🔍 登录 注册

分布式事务实战:从 2PC 到 Seata 与本地消息表的完整演进

今 今晚打老虎 2026-10-20 后端 👁 0 ⏱ 4min

分布式事务实战:从 2PC 到 Seata 与本地消息表的完整演进

单体应用里的事务是"本地事务"——一个数据库 ACID 搞定。一旦拆成微服务、数据分散到多个库,跨服务的一致性就成了分布式事务问题。本文梳理分布式事务的经典方案,并给出各自的最佳适用场景。

一、问题的本质:跨资源的一致性

用户下单 → 扣库存(库存服务)→ 扣余额(账户服务)→ 生成订单(订单服务)

任何一个服务失败,都需要让其他服务"撤销"。分布式事务要解决的就是跨服务、跨数据库的原子性。难点在于:没有单一的锁、没有统一的回滚点、网络可能永远不可靠。

二、强一致派:2PC / 3PC

2.1 两阶段提交(2PC)

协调者 → 各参与者:Prepare(准备)
         ← 各参与者:OK / Abort
协调者 → 各参与者:Commit / Rollback(根据投票结果)
  • 阶段一 Prepare:参与者写本地 undo/redo 日志并加锁;
  • 阶段二 Commit:全部同意则提交,任一拒绝则回滚。

缺点:同步阻塞(Prepare 后参与者持有锁等待);协调者单点(挂了则参与者无从决策);极端情况数据不一致。

适用:对强一致要求极高、参与方少且可控的内部系统。Java 中对应 Atomikos / XA。

三、最终一致派:TCC 模式

3.1 Try-Confirm-Cancel

把一次业务操作拆成三个阶段:

// 1. Try:预留资源(冻结库存 10 件)
void try(Order order) { inventoryService.freeze(order.item, 10); }

// 2. Confirm:确认执行(真正扣减冻结库存)
void confirm(Order order) { inventoryService.deduct(order.item, 10); }

// 3. Cancel:取消预留(解冻库存)
void cancel(Order order) { inventoryService.unfreeze(order.item, 10); }

优点:无全局锁、性能优于 2PC、业务控制力强。 代价:每个参与方都要实现 Try/Confirm/Cancel 三方法,侵入性高,代码量翻倍。 适用:资金类、库存类等强资源控制的场景。

四、业务层方案:本地消息表

4.1 核心思路

把"发消息"与"业务操作"放在同一个本地事务里,保证"业务成功则消息一定落库":

BEGIN;
-- 1. 业务操作:写订单表
INSERT INTO orders (...) VALUES (...);
-- 2. 业务操作:写本地消息表(同事务!)
INSERT INTO msg_outbox (biz_key, payload, status)
VALUES ('order_123', '{"items":[...]}', 'PENDING');
COMMIT;
// 后台扫描未投递消息,可靠投递到 MQ
for {
    rows := db.Query(`SELECT * FROM msg_outbox WHERE status='PENDING' LIMIT 100`)
    for _, msg := range rows {
        if err := mq.Publish("order.created", msg.Payload); err == nil {
            db.Exec(`UPDATE msg_outbox SET status='SENT' WHERE id=?`, msg.ID)
        }
    }
    time.Sleep(1 * time.Second)
}

优点:实现简单、无额外框架、与业务模型天然融合。 缺点:消息表与业务表耦合在同一库中,拆库后需配合 binlog 方案(如 Canal 订阅出库)。

五、最终一致派:消息事务 + 幂等消费

经典流程(RocketMQ 事务消息 / Seata AT 模式):

① 发送半消息(半消息暂不可见)
② 本地事务执行
③ 本地事务成功 → 提交半消息;失败 → 回滚半消息
④ 消费方收到消息,幂等消费
// 消费方幂等消费:以业务唯一键去重
func handleOrderCreated(msg *Message) error {
    key := msg.Body.OrderID
    if err := db.Exec(`INSERT IGNORE INTO dedup (biz_key) VALUES (?)`, key).Error; err != nil {
        return nil // 已处理过,直接忽略
    }
    return doBusiness(msg) // 真正的业务逻辑
}

关键:消息的"至少一次投递"意味着消费方必须幂等,这是所有最终一致方案的共同前提。

六、Seata:一站式分布式事务框架

Seata 提供多种模式,其中 AT 模式对业务侵入最小:

全局事务 XID 贯穿调用链
业务 SQL 正常执行(无感)
Seata 自动记录前后镜像,冲突时反向补偿
// 全局事务入口(Go 侧常用 dtm 或 seata-go)
gid := seata.BeginGlobalTx(ctx, "order-service")
defer func() {
    if err == nil { seata.CommitGlobalTx(gid) } else { seata.RollbackGlobalTx(gid) }
}()

orderService.Create(...)   // 参与方自动加入全局事务
paymentService.Pay(...)

适用:已有系统需要分布式事务能力、不想大规模改造业务代码的场景。

七、方案选型决策树

| 业务诉求 | 推荐方案 | |----------|----------| | 强一致、参与方少 | 2PC / XA | | 资金/库存强管控 | TCC | | 追求简单、单库可承载 | 本地消息表 | | 跨多服务、消息驱动 | 事务消息 + 幂等 | | 既有系统少改造 | Seata AT 模式 |

铁律:能不用分布式事务就不用。优先通过业务重设计规避(如把强关联数据放同一服务同一库),分布式事务是最后的武器,不是默认选项。

结语

分布式事务没有银弹:强一致方案牺牲性能与可用性,最终一致方案牺牲实时性,业务方案牺牲开发效率。真正的架构智慧在于识别哪些操作必须强一致,哪些可以最终一致——把必须强一致的操作收敛到最小范围(如资金),把可以异步的操作交给消息与幂等。记住:分布式事务的成本远超你的想象,能在设计阶段拆掉的,绝不在运行时硬扛。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © 今晚打老虎

评论(0)

登录 后参与评论。

  • 还没有评论,来抢沙发。

🔗 相关文章