outbox模式是解决“业务提交成功但消息丢失”这一架构级不一致问题的方案,核心是将消息写入本地数据库表并与业务数据共用同一dbcontext和savechangesasync()实现原子提交,禁用transactionscope跨系统事务。

Outbox 模式在 C# 中不是“让发消息更稳一点”的技巧,而是解决“业务已提交、消息却丢了”这一必然不一致问题的架构级方案。直接在 SaveChangesAsync() 后调用 producer.SendAsync() 或 bus.Publish(),无论怎么加重试,都不可靠。
为什么 TransactionScope 包不住消息发送
TransactionScope 默认只协调本地资源管理器(比如 SQL Server),而 Kafka、RabbitMQ、Redis Stream 等中间件根本不支持两阶段提交(2PC)。结果就是:
- 事务提交成功 → 消息发送失败 → 数据库有状态、下游没事件
- 消息发送成功 → 事务回滚 → 下游收到脏事件,状态错乱
这不是偶发异常,是跨资源事务模型不匹配导致的确定性缺陷。别在业务层写 await producer.SendAsync(),这是最常踩的坑。
EF Core 中必须复用同一个 DbContext 实例
OutboxMessage 实体必须和 Order、User 等业务实体共用同一个 DbContext 实例,并在一次 SaveChangesAsync() 中全部提交。否则原子性就破了。
- 不要手动开新事务:
context.Database.BeginTransaction()不必要,反而容易漏 commit - 不要分两次
SaveChangesAsync():先存订单、再存消息 → 中间崩溃就丢消息 - 不要跨 DbContext:比如用独立的
OutboxContext→ 事务隔离,无法保证同步落库 - 推荐做法:在业务逻辑中调用
context.OutboxMessages.Add(new OutboxMessage { ... }),然后只执行一次await context.SaveChangesAsync()
OutboxMessages 表结构设计关键点
字段看着简单,但几个细节不处理好,轮询会慢、调试会懵、上线会翻车:
-
Id推荐用UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID(),避免随机 GUID 引发页分裂 -
Payload必须用NVARCHAR(MAX)+ UTF8 排序规则(SQL Server 2019+),别用VARBINARY自己序列化 —— 调试时看不到明文,排查成本翻倍 -
Status用TINYINT(0=Pending, -1=InProgress, 1=Sent, 2=Failed),方便人工干预和死信归档 - 必须建复合索引:
IX_OutboxMessages_Status_EnqueuedAt(Status,EnqueuedAt),否则轮询查Status = 0会全表扫描 -
EnqueuedAt用DateTimeOffset.UtcNow,别用DateTime.Now—— 避免服务器时钟漂移导致重试窗口错乱
后台投递服务怎么避免重复又不丢消息
核心不是“查出来再更新”,而是用一条带 OUTPUT 的 UPDATE 原子取出并标记为处理中:
UPDATE TOP (100) OutboxMessages SET Status = -1, ProcessedAt = GETUTCDATE() OUTPUT INSERTED.* WHERE Status = 0 AND EnqueuedAt <p>这条语句同时完成三件事:锁定待处理记录、标记为 InProgress、返回数据给投递逻辑。注意 <code>EnqueuedAt 是为了跳过刚写入、可能还在事务中的脏数据 —— 这个时间偏移量容易被忽略,但少了它,高并发下大概率重复投递。</code></p>










