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
订单、库存类长链路,能接受秒级延迟 本地消息表或事务消息
已有成熟消息队列,不想维护扫表任务 事务消息
团队小、链路短,短期过渡 先本地消息表,平滑替换成事务消息

延伸阅读