先问业务能不能接受“最终”
分布式事务没有银弹。强一致方案(XA、Seata AT)能保证回滚,但会带来全局锁、性能下降和实现限制;最终一致方案接受一段时间的数据不一致,换取吞吐和可用性。选型的第一步不是比技术,而是问业务:这笔订单晚 3 秒看到积分,能不能接受?如果答案是可以,就不该为它上全局锁。
这篇讲最终一致性里最常用、也最容易被做错的一条路:本地消息表。核心思想只有一句话——把“发消息”变成和业务数据同一次本地事务里的写入。
四种方案怎么选
| 方案 | 一致性 | 业务侵入 | 适用场景 |
|---|---|---|---|
| Seata AT | 准实时,隔离性弱 | 低 | 短事务、冲突少的跨库写 |
| TCC | 最终一致 | 高,要写 Try/Confirm/Cancel | 资金、库存等需要精细控制的场景 |
| 本地消息表 | 最终一致 | 中 | 已有 MQ,跨服务状态通知 |
| Saga / 补偿 | 最终一致 | 中高 | 长流程、跨多个服务 |
本地消息表的优势是简单、可控、不依赖特定中间件:业务和消息在同一个库里,一次本地事务保证两者要么都成功、要么都失败。缺点是消息表和业务表耦合在同一个库,需要自己做投递、重试、清理和对账。
本地消息表:把发消息变成一次本地写
表结构要能支撑“待发送、已发送、失败重试”三种状态,并且按状态和时间建索引:
CREATE TABLE outbox_message (
id BIGINT NOT NULL AUTO_INCREMENT,
biz_key VARCHAR(64) NOT NULL COMMENT '业务唯一键,用于幂等和排查',
topic VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2失败待人工',
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_status_retry (status, next_retry_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
业务代码里,订单写入和消息写入必须在同一个 @Transactional 方法内完成:
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderMapper.insert(command.toOrder());
outboxMapper.insert(OutboxMessage.pending(
command.getOrderNo(), "order-created", command.toPayload()));
}
这里不能先发 MQ 再写库,也不能用 REQUIRES_NEW 单独提交消息表——前者会在事务回滚时发出错误消息,后者会在业务失败时留下幽灵消息。
接下来是一个独立的投递任务。它只做三件事:捞待发送的消息、发出去、更新状态。捞取时用 SKIP LOCKED 避免多实例重复处理:
SELECT id, topic, payload, retry_count
FROM outbox_message
WHERE status = 0 AND next_retry_at <= NOW(3)
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
发送成功后把 status 置为 1;失败则 retry_count + 1,并按指数退避更新 next_retry_at,超过上限置为 2,交给人工或补偿任务。这里要接受一个事实:投递是至少一次,消息可能重复,所以消费端必须幂等。
消费端幂等与对账
幂等最可靠的做法是数据库唯一约束,而不是“先查再插”。建一张消费记录表,用 (consumer_group, biz_key) 做唯一索引,消费时先插入记录,冲突就说明处理过,直接返回成功:
INSERT INTO consume_record (consumer_group, biz_key, created_at)
VALUES (?, ?, NOW(3));
插入成功才继续执行业务;业务和这条记录放在同一个本地事务里,失败一起回滚。Redis 的 SETNX 可以作为前置拦截,减少数据库压力,但不能替代唯一约束——Redis 会丢数据,最终防线还是数据库。
再往下是核对。消息表只能保证“发出去了”,不能保证“消费结果正确”。对账任务每天比对订单状态和下游状态,找出“订单已支付、库存未扣减”这类差异,生成补偿任务。对账不是可选项,它是最终一致方案的最后一道保险。
常见坑
坑一:在事务提交前发消息。 这是最典型的错误。下游收到消息去查订单,查不到,于是当作无效消息丢弃;或者反过来,本地事务回滚了,消息已经发出去。必须用“本地事务同时写消息表 + 异步投递”的方式。
坑二:只监听 @TransactionalEventListener 就以为万事大吉。 这个注解默认在事务提交后执行,但进程在提交后、发送前崩溃,消息就永久丢了。它适合做非关键的副作用(发通知、清缓存),不能替代消息表。
坑三:重试没有上限和退避。 下游故障时,每秒重试一次会把下游彻底打垮。重试间隔按 1s、2s、4s、8s 递增,设上限并进入死信,才是可运维的做法。
坑四:消息表无限增长。 已发送的消息要定期归档或删除,否则单表几千万行后,扫描待发送消息的查询会越来越慢。按 created_at 分区或定期迁移到历史表。
坑五:补偿只做正向,不考虑补偿失败。 退款失败、库存回补失败都需要二次重试和人工介入入口。设计时要假设“补偿也可能失败”,而不是假设一次补偿一定成功。
排查步骤
- 按业务单号查
outbox_message,看status和retry_count,确认消息是没写、没发还是发送失败。 - 看 MQ 的消费位点和堆积,确认消费者是否在线、是否在重复失败。
- 按
biz_key查消费记录表,确认是否已处理,排除重复消费。 - 对比业务库和下游库的关键字段,判断是消息丢失还是业务逻辑错误。
- 最后看对账任务的差异清单,多数“偶发不一致”都能在这里被收敛。
评论