4.2 分布式事务:从 2PC 到本地消息表
场景:一笔订单牵动两个库
拆库之后第一个找上门的问题就是下单流程。用户点下结算按钮,系统要做两件事:在 order_db 插入订单记录,在 product_db 扣减对应 SKU 的库存。拆库之前这是一个本地事务,一条 BEGIN 到 COMMIT,要么全成要么全败。拆完之后这两步分别落在两个连接上,中间任何一步失败,数据就对不上了。
我们最先遇到的真实故障就是这样:扣库存成功了,但插入订单后调用积分服务超时,订单接口整体返回失败,用户重试又扣了一次库存。那天晚上对账发现超扣了四十多单,一单一单手工回滚,从此团队里没人敢再说分布式事务是纸上概念。
业务上强一致不可妥协的时候,方案选择其实不多。先把主流方案摆出来对比:
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC(XA) | 强一致 | 差 | 中 | 传统金融,对性能不敏感 |
| TCC | 强一致 | 中 | 高,需写三套接口 | 资金类核心链路 |
| 本地消息表 | 最终一致 | 好 | 低 | 互联网长链路,可容忍秒级延迟 |
| Seata AT | 准实时一致 | 中 | 中 | 中间件支持完善的栈 |
| 事务消息 | 最终一致 | 好 | 低 | 已有成熟消息队列时 |
方案:为什么不是 2PC
2PC 原理不复杂:协调者问所有参与者能不能提交,大家都说能就发提交,否则回滚。听起来美好,但有两个致命问题。第一是同步阻塞,参与者锁住资源等协调者指令,协调者一挂,所有参与者全部卡死。第二是性能,我们的秒杀峰值要顶住五千 TPS,XA 事务的耗时是本地事务的好几倍,直接出局。
TCC 也被我们排除了:try、confirm、cancel 三个接口,库存、订单、优惠券每个业务都要改一遍,开发成本太高,五人小团队耗不起。最后落在两个候选:本地消息表和事务消息。考虑到4.3 节引入 RocketMQ 是后面的事,拆库当口消息队列还没铺开,于是先上了本地消息表,逻辑简单、不依赖任何中间件。
落地:本地消息表长什么样
核心思想一句话:把跨库操作拆成本地事务加一条消息记录。订单服务在本地事务里干两件事:写订单表,写消息表。这两个动作在同一个库的同一个事务里,天然原子。然后由一个后台任务轮询消息表,把没投递成功的消息发到消息队列,库存服务消费消息完成扣减。
CREATE TABLE transactional_message (
id BIGINT PRIMARY KEY COMMENT '雪花 ID',
business_key VARCHAR(64) NOT NULL COMMENT '业务键,如订单号',
topic VARCHAR(64) NOT NULL COMMENT '投递目标 topic',
payload TEXT NOT NULL COMMENT '业务报文',
retry_count INT DEFAULT 0 COMMENT '已重试次数',
status TINYINT DEFAULT 0 COMMENT '0 待投递,1 已投递,2 已废弃',
next_retry_at DATETIME COMMENT '下次重试时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_status_retry (status, next_retry_at)
);
订单服务的下单逻辑改造后长这样:
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err
}
msg := &Message{
BusinessKey: order.No,
Topic: "stock_deduct",
Payload: order.ToJSON(),
NextRetryAt: time.Now(),
}
return tx.Create(msg).Error
})
if err != nil {
return err
}
// 事务提交后立即触发一次投递,减少轮询延迟
publisher.Notify()
消费端(库存服务)拿到消息后必须做幂等:先查自己的扣减流水表,同一个 business_key 处理过就直接跳过。这一步不能省,因为消息投递是 at-least-once 语义,重试必然带来重复。
扫表的后台任务每秒跑一次,只捞状态为待投递且到期的数据。重试采用指数退避,第一次 1 秒、第二次 5 秒、第三次 30 秒,超过次数阈值就标记废弃并告警,人工介入。这样既能尽快重试,又不会在下游故障时把它压垮。扫描任务的实现也很短:
func (w *Scanner) Run(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := w.scanOnce(ctx); err != nil {
log.Errorf("scan failed: %v", err)
}
}
}
}
scanOnce 每次只捞一百条,按 next_retry_at 升序,用当前时间做过滤条件,避免一次性把列表撑爆。投递成功后更新状态为已投递,失败的累加 retry_count 并算出下一次重试时间。
我们给这套流程定了几条硬指标,挂在监控面板上天天看:
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| 消息表待投递积压 | 还没发出去的消息数 | 超过 2000 条 |
| 平均投递延迟 | 生成到投递成功的时间差 | 超过 5 秒 |
| 重试占比 | 需要重试的消息比例 | 超过百分之五 |
| 废弃消息数 | 进入人工干预队列的消息 | 出现即告警 |
踩坑:三个我们替你踩过的坑
第一个是消息顺序。同一笔订单的消息如果投递顺序乱了,先发的取消消息被后发的扣减消息覆盖,数据就错了。解决办法是把同一 business_key 的消息路由到同一个队列分片,单分区内严格有序。选好分片键是关键,我们选的是订单号,保证同一订单的全部消息进同一队列。
第二个是事务悬挂。订单在本地事务里其实已经回滚了,但某种异常路径下消费者还是收到了对应消息。我们的兜底是消费者每次都回查订单状态,不存在的订单直接丢弃消息,不做任何扣减。
第三个是消息表本身成为瓶颈。大促时每秒几千笔订单,消息表的写入压力不小。后来加了两个优化:消息表按天分表,历史数据定期归档;投递成功的记录及时转移,保持热表足够小。扫描查询一定要走索引,否则一次全表扫就能把主库 CPU 顶满——这个坑我们就真实踩过,云监控告警就是在那次事故之后才补上的。
什么时候该上更强的方案
本地消息表好归好,但它只保证最终一致,用户点完结算到库存扣完之间有几秒的窗口。如果你的业务是扣钱,这个窗口不可接受,那该上 TCC 或者 Seata 的 AT 模式。我们的原则很清晰:钱要强一致,其他都可以最终一致。后来 RocketMQ 用顺了之后,新链路直接用事务消息替代本地消息表,省掉扫表任务,思路完全一样,只是把可靠投递的活交给了中间件。
消息队列与配套服务部署在 CVM 集群上,实例规格按消息吞吐预估,新购机器走的团队账号活动价,有需要的可以看腾讯云 CVM 特惠入口。
小结
分布式事务没有银弹。2PC 太慢,TCC 太贵,本地消息表用最终一致性换来了简单和性能,对绝大多数互联网业务足够了。记住三条经验:业务能接受最终一致就别上强一致;消费者一定要做幂等;重试配合退避,否则你就是下一个把下游压垮的人。
最后补一张我们方案的选型流程,给同样纠结的朋友一个参照:
| 你的场景 | 推荐方案 |
|---|---|
| 扣钱、扣款,窗口不可接受 | TCC 或 Seata AT |
| 订单、库存类长链路,能接受秒级延迟 | 本地消息表或事务消息 |
| 已有成熟消息队列,不想维护扫表任务 | 事务消息 |
| 团队小、链路短,短期过渡 | 先本地消息表,平滑替换成事务消息 |
