Outbox 模式不能用 TransactionScope 包住 DB 和消息发送,因其仅协调本地资源,而 Kafka 等中间件不支持 2PC,易致数据不一致;正确做法是将消息写入同库 OutboxMessages 表(同一事务),再由轮询或 CDC 投递。

Outbox 模式为什么不能靠 TransactionScope 简单包住 DB 和消息发送
因为 TransactionScope 默认只协调本地资源管理器(如 SQL Server),而 Kafka、RabbitMQ、Redis Stream 这类消息中间件不支持两阶段提交(2PC)。一旦事务提交后发消息失败,或者发消息成功但 DB 回滚,就会出现状态不一致——这是 Outbox 模式要解决的根本问题。
正确做法是:把待发送的消息先写进同库的 OutboxMessages 表(和业务表同一事务),再由独立的后台轮询或 CDC(变更数据捕获)机制读取并投递。
- 必须确保
OutboxMessages表与业务表在同一个数据库、同一个事务中插入 - 不要在业务事务里调用
await producer.SendAsync(...)—— 这是典型误用 - 推荐用
DateTimeOffset.UtcNow作为EnqueuedAt字段,避免时钟漂移导致重试错乱
怎么设计 OutboxMessages 表结构才不容易翻车
字段看似简单,但几个关键点直接影响可靠性:
-
Id推荐用BIGINT IDENTITY或UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID(),避免 GUID 随机写入导致页分裂 -
MessageType存全名(如"OrderCreatedEvent"),别存 assembly 名——部署时可能换程序集 -
Payload必须用NVARCHAR(MAX)+UTF8排序规则(SQL Server 2019+),别用VARBINARY自己序列化,增加调试成本 - 加
Status字段(TINYINT,0=Pending, 1=Sent, 2=Failed),方便人工干预和死信归档 - 务必建复合索引:
IX_OutboxMessages_Status_EnqueuedAt(Status, EnqueuedAt),否则轮询查未发送消息会全表扫
轮询投递服务怎么避免重复发送又不丢消息
核心是「查询 → 更新状态 → 发送 → 再更新」四步原子性,但不能锁整张表。常见错误是用 SELECT ... FOR UPDATE(SQL Server 不支持该语法)或漏掉 WHERE 条件。
推荐做法是用带输出的 UPDATE 语句一次取出一批待处理记录,并原子性标记为 InProgress(比如 Status = -1):
UPDATE TOP (100) OutboxMessages SET Status = -1, ProcessedAt = GETUTCDATE() OUTPUT INSERTED.* WHERE Status = 0 AND EnqueuedAt <= DATEADD(second, -5, GETUTCDATE()) ORDER BY EnqueuedAt;
- 加
EnqueuedAt 是为了跳过刚写入、可能还在事务中的脏数据 - 用
TOP (100)控制每次处理量,防止单次卡太久影响吞吐 - 发送失败后,必须把状态改回
0并延迟重试(比如RetryAfter = DateTimeOffset.UtcNow.AddSeconds(10)),别直接设成2 - 单独启一个
Timer或BackgroundService做死信扫描(Status = 2 且超过 24h),避免堆积
EF Core 怎么在 SaveChanges 里自动写 Outbox 记录
不能依赖 SaveChangesAsync 返回值判断是否成功——它只代表 DB 写入成功,不代表事务已提交。必须 hook 到事务真正提交后的回调点。
- 在
DbContext中重写SaveChangesAsync,用Database.CurrentTransaction?.TransactionId判断是否在事务中 - 收集所有待发布的领域事件(比如通过
DomainEvents集合暂存),但**不立即写表** - 注册
TransactionCommitted事件(.NET 6+)或用DbTransaction的EnlistTransaction+TransactionCompleted(需手动管理生命周期) - 在事务提交回调里批量插入
OutboxMessages,并确保这一批插入也走同一个事务(即复用当前DbTransaction) - 别忘了清理内存里的事件缓存,否则下次
SaveChanges会重复写
这个环节最容易出问题的是事务上下文丢失——比如用了异步 lambda 却没传 CancellationToken,或在回调里 new 了一个新 DbContext 导致跨事务。


















