分库分表后,分布式事务怎么选:本地消息表真的过时了吗,Seata 又踩了哪些坑
本地消息表没过时,但它的适用边界在分库分表后急剧收缩。Seata AT 模式看似完美,实际部署中踩的坑比文档里写的多得多。这事儿没有银弹,选型取决于你到底是“分库不分服务”还是“分库又分了服务”。
两年前我给一个订单系统做分库分表改造,128 库、每库 16 表,从单库 MySQL 5.7 硬切过来。当时的噩梦不是切库,而是切完之后分布式事务怎么搞。原来一个下单流程里,扣库存、生成订单、发积分三个操作在同一个数据库事务里一把梭,现在库存表在库 A,订单表在库 B,积分表在库 C,一个 Spring @Transactional 直接报废。
那时候我们第一个方案就是本地消息表。不是说这方案有多好,而是它最不挑环境,不用引入额外中间件。具体做法是,在订单库的同一个事务里,先写订单主表,同时往一张 outbox_message 表里插入一条“待发送积分”的消息,状态标为 PENDING。然后起一个定时任务,每分钟扫这张表,把未发送的消息投递到 RocketMQ,投递成功后再把状态改成 SENT。积分服务消费消息,本地落库,用订单 ID 做幂等。
这个方案跑了三个月,问题开始冒出来。最头疼的是定时扫表的延迟,业务那边投诉说积分到账太慢,最差情况能到一分钟。我们试着把扫描间隔调到 10 秒,结果 QPS 上来之后,outbox_message 表成了热点,2000 万条数据时扫描时间超过 3 秒,长事务锁等待把订单主表的插入都给拖慢了。而且这玩意儿还有个隐性问题:消息投递和状态更新的原子性没法保证。虽然我们用的是先发消息再改状态的逻辑,但 RocketMQ 发送超时的时候,你不知道消息到底发没发出去,重发就重复消费,不发就可能丢消息。幂等处理不好就是灾难。
后来我们换了一版方案,用 RocketMQ 的事务消息替代本地定时扫表。生产者先发一条 half 消息,然后执行本地事务(写订单+写 outbox),提交成功后 commit 消息,MQ 才会投递给消费者。如果本地事务回滚,就 rollback 消息。这解决了扫表延迟和热点问题,但引入了新依赖——RocketMQ 的事务回查机制。有一次 Broker 和 NameServer 之间网络抖动,大量 half 消息卡在待确认状态,回查线程池被打满,整个消息投递延迟了 40 分钟。那是我职业生涯里最漫长的 40 分钟。
这时候团队有人提 Seata。说实话我对这东西第一印象并不好,2020 年 Seata 1.2 的时候我在另一个项目试过,当时 AT 模式对 MySQL 8.0 的某些 SQL 解析有 bug,undo_log 表膨胀到 300GB 直接把磁盘撑爆了。但到了 2022 年,Seata 1.5.2 已经稳定很多,加上阿里自己也在大规模用,我觉得可以再给它一次机会。
Seata AT 模式的原理其实不复杂。它在每个参与分布式事务的数据库里创建一张 undo_log 表,业务 SQL 执行前先解析成前后镜像,写入 undo_log。一阶段全部提交,二阶段如果收到 TC(事务协调器)的 rollback 指令,就根据 undo_log 生成逆向 SQL 回滚。这个设计的好处是业务代码零侵入,只加一个 @GlobalTransactional 注解就行。
但真正在生产环境跑起来,踩的坑比预期多。第一个坑是 TC 的高可用。Seata Server 本身是个无状态服务,但它的全局事务状态存在哪里?官方推荐用数据库或 Redis,我们选了 MySQL。问题来了:TC 写全局事务日志是同步的,每次 begin、commit、rollback 都要落库。我们压测的时候发现,当全局事务 TPS 超过 2000 时,TC 的数据库连接池被打满,事务超时率飙升到 15%。解决办法是换成了 Redis 存储模式,但 Redis 没有持久化保障,主从切换时会丢事务状态,导致部分分支事务悬挂,undo_log 永远不会被清理。
第二个坑是 undo_log 的清理。Seata 1.5 默认的清理策略是每次全局事务结束后异步删除 undo_log,但这在高并发下根本来不及清。我们有个库的 undo_log 表一天能涨 800 万行,虽然 Seata 有定时清理线程,但默认 24 小时清一次,加上 MySQL 的 delete 操作会产生大量 undo 和锁竞争,稍不注意就把从库同步给拖慢了。最后我们改成了按小时清理,并且用 pt-archiver 工具低优先级归档删除,才把这个定时炸弹拆掉。
第三个坑最隐蔽:跨服务调用的超时配置。Seata 的全局事务默认超时时间是 60 秒,但我们的下单流程涉及 7 个微服务,每个服务有各自的超时和重试机制。有一次积分服务因为 GC 停顿响应了 8 秒,上游订单服务的 Feign 超时设的是 5 秒,触发重试。Seata 这边还在等积分服务的一阶段提交,结果收到了两条重复的分支注册请求。TC 直接抛了异常,整个全局事务回滚,但订单已经在本地提交了,undo_log 的逆 SQL 执行时发现数据已经被后续的另一个事务修改过,回滚失败,留下脏数据。这个问题的根源是 Seata 的隔离级别是读未提交,两个全局事务之间会互相看到对方的中间状态,加上重试机制,脏写概率大幅增加。
所以回到标题的问题:本地消息表过时了吗?在分库分表但不拆分服务的场景下,它仍然是最可控的方案。因为所有表都在同一个应用里操作,你可以用数据库事务保证业务表和消息表的原子写入,后续的投递问题可以通过引入消息队列的事务消息来解决。这种方案没有额外的协调器依赖,运维成本最低。
但一旦分库又拆分服务,本地消息表就捉襟见肘了。服务间的调用链变长,一个业务操作要协调三个不同的应用,每个应用有自己的数据库。这时候你没法用单库事务保证原子性,只能上分布式事务协调器。Seata AT 模式在代码侵入性上确实做到了极致,但代价是把复杂性转移到了运维层面:你要维护 TC 集群、监控 undo_log 膨胀、处理悬挂事务、理解它那套读未提交隔离级别下的各种奇怪行为。
我的实际建议是:如果你的系统 QPS 在 1000 以下、服务数量不超过 5 个,用 Seata AT 模式可以快速落地,但一定要做好监控和 undo_log 的自动化清理。如果 QPS 超过 5000 或者对一致性要求极高(比如资金类业务),老老实实用 TCC 模式或者 Saga 模式,业务代码多写点,但至少事务边界是明确的,不会出现 AT 模式那种隐式的读写异常。如果只是异步解耦的场景(比如发积分、发通知),RocketMQ 事务消息加本地幂等表是最稳的方案,别为了技术一致性硬上分布式事务。
常见问题
Seata AT 模式和 TCC 模式到底怎么选?
AT 模式对业务零侵入,但隔离级别是读未提交,两个全局事务之间可能读到脏数据。如果你的业务流程里存在对同一行数据的并发修改(比如扣库存),AT 模式有概率出现脏写导致回滚失败。TCC 模式需要你自己实现 try/confirm/cancel 三个接口,代码量大,但隔离性由业务自己控制,适合资金扣减这类强一致性场景。一般建议:无并发冲突用 AT,有并发冲突用 TCC,别在资金链路上用 AT。
本地消息表的 outbox 表一直增长怎么清理?
不要直接在业务库里 delete 历史数据,会锁表影响在线业务。用 pt-archiver 或 DataX 把一个月前的已发送消息归档到冷库,然后批量删除。另外 outbox 表一定要按时间分片,搞个定时任务每天自动建下一天的分表,查询的时候只扫当天表,避免全表扫描。我见过一个团队没做分表,outbox 涨到 5000 万行后,扫表查询直接打挂了主库。
Seata 的 undo_log 膨胀到几百 GB 怎么办?
先检查全局事务是否有悬挂(二阶段一直没收到 commit/rollback),悬挂事务的 undo_log 不会被自动清理。查 seata 的 branch_table 表,状态为 PhaseOne_Done 且超过超时时间的,手动调 TC 的 API 做 rollback。日常清理建议用 pt-archiver 按 gmt_create 归档三天前的数据,别用 delete 语句硬删。另外 Seata 1.6 开始支持 undo_log 的压缩存储,可以开起来,能减少 60% 左右的磁盘占用。