outbox模式通过将消息与业务操作写入同一数据库事务来解决消息丢失问题:先在本地数据库的outboxmessages表中保存消息(含messageid、messagetype、payload等字段),再由后台服务轮询投递并更新processedat字段,确保至少一次投递。

为什么直接发消息容易丢?
数据库事务提交了,但消息没发出去——这是典型的消息与业务不同步问题。比如用 RabbitMQ 或 Kafka 发送订单创建事件,如果在事务后调用 channel.BasicPublish() 时网络抖动或服务宕机,消息就永远丢失了。Outbox 模式的核心思路是:把要发的消息先写进本地数据库(和业务操作同一事务),再由独立的后台进程读取、投递、标记为已发送。
如何设计 Outbox 表结构?
关键字段必须包含:Id(主键)、MessageId(全局唯一,推荐用 Guid.NewGuid())、MessageType(如 "OrderCreated")、Payload(JSON 字符串,用 System.Text.Json 序列化)、CreatedAt、ProcessedAt(null 表示未发送)。表名建议叫 OutboxMessages,且必须和业务表在同一数据库、同一事务中。
常见错误:把 Payload 设为 TEXT 类型却没设足够长度(MySQL 默认 65535 字节不够),或用 XML 存储导致序列化/反序列化不一致;还有人漏掉 ProcessedAt 字段,导致无法判断是否已投递成功。
怎么在 EF Core 里原子写入业务 + 消息?
不能手动拼 SQL,也不能用两个 SaveChanges()。正确做法是:在同一个 DbContext 实例中,先 Add() 业务实体(如 Order),再 Add() 一个 OutboxMessage 实体,最后调用一次 SaveChanges()。
using var transaction = context.Database.BeginTransaction();
try
{
context.Orders.Add(new Order { ... });
context.OutboxMessages.Add(new OutboxMessage
{
MessageId = Guid.NewGuid(),
MessageType = "OrderCreated",
Payload = JsonSerializer.Serialize(orderEvent),
CreatedAt = DateTime.UtcNow
});
await context.SaveChangesAsync(); // 同一事务提交
await transaction.CommitAsync();
}
注意点:
• 必须用同一个 DbContext 实例,跨上下文会失败
• 不要用 SaveChangesAsync() + await 后再开新事务——那就不原子了
• 如果用的是 EF Core 7+,可考虑 ExecuteSqlInterpolated 批量插入,但需确保参数绑定安全
后台轮询投递器怎么避免重复和漏发?
启动一个 BackgroundService,定期(如每 2 秒)查 ProcessedAt IS NULL 的记录,按 CreatedAt 升序取前 100 条,逐条发送。成功后用 UPDATE OutboxMessages SET ProcessedAt = GETUTCDATE() WHERE Id = @id AND ProcessedAt IS NULL 更新——这个 AND ProcessedAt IS NULL 是关键,防止并发时重复处理。
容易被忽略的细节:
• 投递失败不能直接跳过,要记录日志并重试(最多 3 次),否则永久卡住
• 查询时加 WITH (READPAST)(SQL Server)或 SKIP LOCKED(PostgreSQL)避免行锁阻塞
• 不要用 SELECT * FROM OutboxMessages WHERE ProcessedAt IS NULL ORDER BY Id——ID 不代表时间顺序,可能漏发早创建但 ID 小的记录
Outbox 表本身不是银弹:它把“消息可靠性”问题转成了“投递器高可用”问题,所以投递服务必须能自愈、支持多实例(靠数据库行锁或分布式锁控制并发),而且监控 ProcessedAt 延迟比监控消息队列积压更直接。










