outbox模式通过将消息写入本地数据库表并复用同一dbcontext的savechangesasync实现业务数据与消息的原子提交,避免transactionscope跨系统事务失效导致的数据不一致。

不能用 TransactionScope 包住数据库操作和消息发送——这是最常踩的坑,也是 Outbox 模式要解决的根本问题。
为什么TransactionScope在Outbox里会失效
因为 TransactionScope 默认只协调本地资源管理器(如 SQL Server),而 Kafka、RabbitMQ、Redis Stream 这类中间件根本不支持两阶段提交(2PC)。一旦业务事务提交成功,但后续 producer.SendAsync() 失败(网络抖动、Broker 不可用、序列化异常),消息就永远丢失;反过来,如果消息发成功了但 DB 回滚,下游服务又会收到脏事件。
这种不一致不是偶发 bug,而是架构层面的必然结果。Outbox 模式不是“优化发送流程”,而是把“发消息”这个动作从跨系统协作降级为“写本地表”这一可原子操作。
- 别在业务代码里调用
await producer.SendAsync()或bus.Publish() - 别用
TransactionScope跨 DbContext + 消息客户端组合事务 - 所有“待发消息”必须作为实体,和订单、用户等业务数据一起走
SaveChangesAsync()
EF Core 中如何原子写入业务 + Outbox 记录
关键不是“能不能加表”,而是“怎么确保它和业务数据在同一个事务里落库”。最稳妥的做法是复用同一个 DbContext 实例,并在一次 SaveChangesAsync() 中完成全部插入。
推荐结构:
-
OutboxMessage实体必须映射到真实数据库表,字段至少含:Id(Guid)、EventType(全名,如"OrderCreatedEvent")、Payload(NVARCHAR(MAX)+ UTF8 排序规则)、Status(TINYINT,0=Pending)、CreatedAt(用DateTimeOffset.UtcNow) - 在
OnModelCreating中配置索引:HasIndex(x => new { x.Status, x.CreatedAt }),否则轮询查 Pending 会全表扫描 - 业务逻辑中:先
context.Orders.Add(...),再context.OutboxMessages.Add(...),最后只调一次await context.SaveChangesAsync() - 不要手动开新事务、不要分两次
SaveChanges、不要跨DbContext实例操作
后台投递服务怎么避免重复又不丢消息
核心矛盾在于:查出来 → 发送 → 更新状态,这三步必须具备“可见性隔离 + 原子标记”能力,不能靠 SELECT + UPDATE 分两步做(竞态下会重复取同一批记录)。
SQL Server 推荐用带 OUTPUT 的 UPDATE 一次性取出并标记为处理中:
UPDATE TOP (100) OutboxMessages SET Status = -1, ProcessedAt = GETUTCDATE() OUTPUT INSERTED.* WHERE Status = 0 AND CreatedAt <p>这样能保证每条记录最多被一个投递线程拿到。发送成功后再执行一次 <code>UPDATE ... SET Status = 1</code>;失败则设为 <code>Status = 2</code> 并记录错误。</p>
- 加
CreatedAt 是为了跳过刚写入、可能还没刷盘或未被其他连接可见的记录 - 不要用
SELECT FOR UPDATE(SQL Server 不支持该语法) - 投递器必须是独立进程(如 .NET Worker Service),不能和业务 API 同进程——否则崩溃即丢消息
- 每次只取小批量(如 100 条),避免单次处理太久导致超时或阻塞
Outbox 表设计最容易被忽略的三个细节
字段看着简单,但错一个就可能导致重试混乱、调试困难、性能崩塌。
-
Payload类型必须是NVARCHAR(MAX)(SQL Server)或TEXT(MySQL),且排序规则需支持 UTF-8;别用VARBINARY自己序列化——查库时根本看不到内容,出问题只能翻日志 -
Id别用随机NEWID(),优先选NEWSEQUENTIALID()或BIGINT IDENTITY,否则页分裂严重,插入性能断崖下跌 -
Status字段必须存在,且不能只靠ProcessedAt IS NULL判断是否已发——NULL 可能是写入失败、字段未初始化、或迁移遗漏,有明确状态码才方便人工干预和死信归档
真正难的从来不是“怎么写进表”,而是“怎么让这张表在高并发、长时间运行、多次部署迭代后,依然能准确反映每一条消息的生命周期”。











