前段时间有位做电商系统的朋友问我一个特别经典的问题:商城下单,库存扣减成功了,但订单创建却失败了,用户手里没有订单,库存却少了,这怎么解释?我告诉他,这就是典型的分布式事务一致性问题。放在单体应用里,一个本地事务就能把订单、库存、账户打包搞定;一旦拆成多个微服务、多个数据库,原来那套事务保证直接失效,数据一致性的账就得从架构层面重新算了。
很多人一听到"分布式事务"就本能地想到 Seata、2PC、消息队列这些名词,然后在网上翻了一堆文章,看完还是不知道自己的订单系统该选哪个方案。这篇文章不打算再复述一遍理论,而是从实际决策的角度,把强一致和最终一致几个主流方案摆在一起对比,说清楚它们各自解决什么问题、代价是什么、在你的项目里怎么选。如果你正在为订单、库存、账户这类跨服务数据一致性发愁,这篇文章应该能帮你少走弯路。
1. 从一次下单事故说起:分布式事务到底在解决什么
1.1 一个最典型的分布式事务场景拆解:下单链路涉及哪些状态一致性
先别急着聊方案,把问题本身拆透。假设你有一个电商系统,订单服务管订单数据,库存服务管库存数据,账户服务管余额扣减,这三个服务各自拥有独立数据库。用户下单的完整动作如下:订单服务创建一条待支付订单,库存服务扣减对应商品库存,账户服务扣减用户余额或生成待支付账单。这三个动作之间相互依赖,且任何一步失败,前面的成功操作都必须回滚,否则就会出现"有订单没库存""扣了库存没订单""扣了钱没订单"这类脏数据。
真正的难点在于,这三个动作已经不是同一个数据库连接上的三条 SQL 了,而是三个独立进程、三套数据库、三次网络调用。传统数据库提供的 ACID 事务要求所有操作都发生在同一个事务边界内,比如我在 A 库里执行 INSERT,在 B 库里执行 UPDATE,本地事务根本管不住 B 库。一旦中途某个服务挂掉、网络超时或者数据冲突,你无法像单体应用那样执行一个 ROLLBACK 就把所有痕迹清掉。
这就引出了分布式事务的核心目标:让多个独立资源上的操作,在逻辑上保持原子性,或者至少在最终状态上保持一致。这个"保持一致"有不同的强度,直接决定你选哪套方案。
1.2 为什么不能用"扣库存失败就回滚订单"这种粗暴方案
初次接触这个问题的人最容易想到的方案是:先创建订单,再调库存服务扣库存,如果扣库存接口返回失败,就在订单服务里把刚才创建的订单删掉/标记为取消。这个方案听起来很合理,但仔细一推敲就发现漏洞百出。
比如扣库存接口实际已经扣成功了,但返回响应时网络超时。订单服务收到的是"调用异常",于是执行本地回滚,取消了订单。而库存服务那边扣减成功,并没有接到任何补偿指令,于是库存少了、订单不存在。这还不算完,如果此时你用一个定时任务去扫描超时订单,发现某个订单既未支付也未取消,又去补发取消请求,可能正好赶上用户重新提交了相同商品的下单请求,两个操作互相覆盖,数据彻底乱套。
所以问题不只是"异常时回滚",还包括:网络抖动造成的"结果不确定"、回滚操作本身可能失败、并发请求下补偿动作相互干扰。这些靠一个简单的 try-catch 是永远搞不定的,必须有一套显式的事务协议来约定:什么时候提交、什么时候回滚、回滚失败怎么办、接口重复到达怎么识别。
1.3 一致性强弱的分类:从强一致到最终一致的谱系
分布式事务领域的方案五花八门,但归纳起来就是沿着一条谱系从强一致走向最终一致。强一致端的代表是 2PC/XA 以及基于它做的 Seata AT 模式,核心思路是让所有参与方在同一时刻对外呈现相同的数据状态;最终一致端的代表是本地消息表、事务消息、SAGA,核心思路是允许短暂的不一致,但通过补偿和重试保证最终所有服务的数据收敛到一致状态。
我不建议一上来就给自己扣"必须强一致"的帽子,而是先接受一个事实:分布式环境里不存在免费的一致性。你要求的实时性越高,系统付出的代价就越大。下面给出一个大致分类表,后面两章会逐个展开分析:
| 方案类型 | 代表性技术 | 一致性强度 | 业务停顿/效果 | 实现成本 | 典型场景 |
|---|---|---|---|---|---|
| 强一致 | XA/2PC | 同步强一致 | 有全局锁,吞吐受限 | 高 | 金融转账、短事务、跨库且并发低 |
| 强一致(优化) | Seata AT | 逻辑强一致(带补偿) | 全局锁冲突仍存在 | 中高 | 尖刺并发低的中小型微服务 |
| 最终一致 | 本地消息表 | 异步最终一致 | 无阻塞,吞吐高 | 中 | 订单状态、积分、通知类 |
| 最终一致 | 事务消息 | 异步最终一致 | 无阻塞,依赖MQ | 中 | 核心订单+库存异步化 |
| 最终一致 | SAGA | 异步/同步补偿 | 无锁,流程长 | 中高 | 长流程、多服务编排 |
下面逐个说透。
2. 强一致阵营的底牌:2PC/XA与Seata AT模式
2.1 2PC/XA 的原理与真正的坑
2PC 两阶段提交是分布式事务的老祖宗,前面提到的 XA 就是它在数据库层面的实现标准。整个过程由一个协调者(通常叫事务管理器)和多个参与者(每个数据库)组成。第一阶段叫准备阶段:协调者问所有参与者,"你们能不能提交?"参与者各自执行事务内操作、写入 undo/redo 日志,但暂不提交,然后返回"准备好了"。第二阶段叫提交阶段:协调者收到所有参与者的"准备好了"之后,广播"提交",参与者才真正提交。只要有一个参与者说"不行",协调者就广播"回滚"。
听着挺严谨,但这套方案落到生产环境,问题一个比一个致命。第一个是阻塞问题:准备阶段所有参与者都要持有数据库行锁,直到第二阶段提交或回滚才能释放。如果某个参与者迟迟不响应,其他参与者的锁就一直挂着,数据库连接池被耗尽,后台任务全部卡死。第二个是协调者单点问题:协调者宕机后,所有参与者都停在"已准备"状态,没人能通知它们提交还是回滚,事务只能僵在那里,需要人工介入。第三个是网络分区问题:准备阶段大家都通过了,但最终提交阶段网络把协调者和某个参与者隔开了,导致部分提交成功、部分没有提交,所谓强一致直接破功。
这些坑在单机数据库环境里不那么明显,一旦跨机房、跨地域,网络延迟和故障概率成倍放大,2PC/XA 的可用性就很脆弱了。我把话放这儿:如果你要跨机房部署,最好直接放弃 XA,除非你的业务能接受数据库长时间锁等待和随时可能人工捞数据。
2.2 Seata AT模式:对2PC的改良与自动补偿
Seata 的 AT 模式本质上是对传统 2PC 的一次工程化改良,理解它之前先理解为什么不用 XA:XA 要求参与者数据库真的去 prepare 一份事务,锁时间太长,而且对数据库的事务隔离级别有严格要求。AT 模式换了个思路:不要求数据库提前 prepare,而是利用数据源代理,在业务 SQL 执行前后记录数据快照,事务提交前通过全局锁来防止其他事务修改同一行数据,提交后如果发现需要回滚,就根据快照自动生成反向 SQL 把数据还原。
具体落地时,你需要在业务数据库里建一张 undo_log 表:
CREATE TABLE `undo_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `branch_id` bigint(20) NOT NULL, `xid` varchar(100) NOT NULL, `context` varchar(128) NOT NULL, `rollback_info` longblob NOT NULL, `log_status` int(11) NOT NULL, `log_created` datetime NOT NULL, `log_modified` datetime NOT NULL, PRIMARY KEY (`id`), KEY `ux_branch_id` (`branch_id`), KEY `ux_xid` (`xid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;业务代码看起来和本地事务几乎没有区别,核心逻辑里加上 @GlobalTransactional 注解,Seata 客户端会拦截方法,开启一个全局事务,所有参与的分支事务都自动纳入管理。这相比 XA 的最大好处是:参与者不需要提前锁资源,而是通过记录前后镜像做到"按需回滚",数据库层面的锁等待时间大大缩短。
但 AT 模式并非银弹。它引入了全局锁的概念:在全局事务提交前,Seata TC 会锁住涉及的数据行,其他本地事务如果也想修改同一行,会被阻塞或等待。如果系统并发很高,热点商品频繁被下单,全局锁冲突会非常明显,延迟飙升。另外 undo_log 表会持续积累历史回滚记录,需要定期清理,回滚事件本身也要做监控,否则 undo_log 膨胀会影响数据库性能。
我的建议是:AT 模式适合并发量要求不极端的内部系统、后台运营系统,比如给商家后台做库存同步、给客服做退款审批,这类系统一天几万笔事务,用 AT 模式很舒服。如果是面向用户的高并发交易链路,想清楚自己是否能接受全局锁带来的排队问题。
2.3 强一致方案的适用范围与"看起来可靠"的陷阱
说句容易得罪人的话:大部分业务根本就不需要强一致,只是被"数据一致性"这四个字吓住了,才拼命往强一致方案上靠。强一致方案的可靠性是有条件的,条件不满足时,它比异步补偿方案更容易翻车。
强一致的适用条件大概是:并发量可控、事务链路短(只涉及两三个服务)、数据敏感度极高且必须实时一致、团队有能力维护协调者和数据库锁监控。比如银行核心转账,A 账户扣钱和 B 账户加钱必须同步成功或同步失败,这个用 TCC 或 XA 都有道理。但你做一个电商订单,用户下单后看到"订单已提交"和库存实际扣减之间有 100 毫秒的延迟,完全不影响体验,就没必要付出全局锁的代价。
还有一个常见的误解是"AT 模式有自动回滚,所以很安全"。实际上自动回滚只保证已记录的前后镜像能还原,如果镜像记录本身因为数据库问题没有写入,或者业务代码里混了不支持的类型,回滚会出现异常。而且全局事务过程中如果忘记设置超时时间,异常情况下分支事务可能长期挂起,把所有连接占满。强一致方案不是"不用想一致性了",而是"你要想的事情更多了"。
3. 最终一致阵营的主流套路:消息事务、本地消息表和SAGA
3.1 本地消息表与定时任务:最朴素但最稳的最终一致方案
如果你不想依赖 Seata 这类协调者,又不具备引入复杂业务消息队列的条件,本地消息表几乎是最稳妥的选择,可能没有之一。它的核心思想是:把"发送消息"和"业务操作"放在同一个本地事务里,然后再由异步任务把消息可靠地发给下游服务。
具体流程先拆成五步:
- 在订单服务开启本地事务:写订单表,同时写一张消息表,消息状态为"待发送"。
- 本地事务提交后,立即由后台线程或定时任务扫描消息表,把未发送消息投递到下游服务接口(Mq 或 HTTP)。
- 下游服务收到消息后执行库存扣减,执行成功调用确认接口或发送 ack,订单服务把消息状态更新为"已发送"。
- 如果投递或执行失败,消息状态还是"待发送",定时任务继续重试,直到成功。
- 为防一些下游永远无法处理的消息积压,可以设置最大重试次数,超过后进入"死信"状态,由告警人工处理。
消息表不能随便设计,它至少要包含:
CREATE TABLE `t_message_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `biz_type` varchar(32) NOT NULL COMMENT '业务类型,比如ORDER_CREATED', `biz_id` varchar(64) NOT NULL COMMENT '业务唯一ID,比如订单号', `content` text NOT NULL COMMENT '投递内容,JSON', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待发送 1已发送 2死信', `retry_count` int(11) NOT NULL DEFAULT '0', `next_retry_time` datetime NOT NULL, `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz_type_biz_id` (`biz_type`,`biz_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里唯一键 uk_biz_type_biz_id 非常重要,它可以防止同一笔业务被重复插入消息记录,保证消息只会产生一条。下游投递时要配合消费幂等,靠业务唯一键去重,比如库存扣减表对 order_no 建唯一索引。
这个方案看下来可能很多人觉得"太土了,没有技术含量"。但实际上它很皮实:不依赖额外中间件的高可靠特性,数据库本身就是消息的持久化存储,只要数据库不丢,消息就不会丢。当然它的缺点是业务表与消息表耦合在一起,对数据库有一点额外压力,并且定时任务有秒级延迟,不是实时性要求高的场景。
3.2 RocketMQ事务消息:半消息与回查机制
如果说本地消息表是把消息存在自己库里,那么 RocketMQ 事务消息就是把消息的可靠性存放托管到 MQ,同时通过"半消息 + 回查"机制解决本地事务和消息发送的原子性问题。
事务消息的执行流程与本地消息表神似:生产者先发送一条"半消息"(half message)到 Broker,Broker 能收到但对消费端不可见;随后生产者执行本地事务;根据本地事务执行结果,向 Broker 发送 commit 或 rollback,只有 commit 之后这条消息才真正对消费者可见。如果生产者迟迟没有上报结果,Broker 会主动调用生产者的回查接口去确认本地事务到底成没成,再决定将半消息放行或删除。
这段逻辑落到代码里,大致是:
@Transactional public void createOrderAndSendMessage(OrderDTO orderDTO) { // 1. 本地事务创建订单 orderMapper.insert(orderDTO); // 2. 发送事务消息 TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction( "order-topic", MessageBuilder.withPayload(orderDTO).build(), orderDTO.getOrderNo() ); } // 事务消息监听器,执行本地事务 @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { // 这里实际上已经在上面的@Transactional里执行业务了 return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } // 回查接口:Broker没收到commit/rollback时调用 @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { String orderNo = msg.getUserProperty("orderNo"); OrderDO order = orderMapper.selectByOrderNo(orderNo); if (order != null) { return LocalTransactionState.COMMIT_MESSAGE; } return LocalTransactionState.ROLLBACK_MESSAGE; }事务消息对比本地消息表,省去你自己维护消息表和定时任务,也让业务表更干净。但代价是你必须把 RocketMQ 自身的可用性当回事:如果 Broker 集群挂了,消息发不出去,本地事务就无法正常结束。为了降低这个风险,你在 RabbitMQ/Kafka 里可能找不到现成的事务消息机制,Kafka 的 EOS 只能保证单分区有序,无法直接用于跨服务的分布式事务。如果团队还没引入 RocketMQ,为了做事务消息而专门上线一套新中间件,这个成本要好好掂量。
3.3 SAGA:长流程与编排/协同模式
SAGA 和前面几种方案最大的区别在于:它不追求"不留中间态",而是把一个长事务拆成一组有序的本地短事务,每个短事务都配一个反向补偿事务。执行过程像多级台阶,成功就逐级往下走,任何一步失败就沿着已走过的台阶反向回退。
比如一个完整的订单履约链路:创建订单 -> 扣库存 -> 冻结优惠券 -> 调用支付。如果调用支付失败,就依次执行解冻优惠券、加回库存、取消订单。每个步骤都是独立本地事务,不需要全局锁,长时间运行的流程也不会占用数据库资源。
SAGA 有两种落地形态:编排(Orchestration)和协同(Choreography)。编排模式里有一个专门的流程控制器,它负责告诉每个服务"现在执行哪一步""上一步失败,请补偿",所有流程状态都集中在控制器里,可控性高,但控制器本身可能成为单点或复杂逻辑重灾区。协同模式则完全靠事件驱动:每个服务完成自己的本地事务后,往消息中间件发一个事件,后续服务监听事件继续执行;失败也通过事件触发前序服务的补偿方法。协同没有中心控制,扩展性好,但流程分布式地隐藏在事件链路里,排查问题很费劲。
下面给一个比较模板化的实现思路,假设用一个流程编排器:
@SagaTransaction public void executeOrderProcess(OrderContext ctx) { // 依次调用各参与者的正向服务 orderService.createOrder(ctx); // 失败则跳转到补偿流程 inventoryService.deduct(ctx); // 失败则调用 orderService.cancelOrder couponService.freeze(ctx); // 失败则调用 inventoryService.addBack paymentService.pay(ctx); // 失败则调用 couponService.unfreeze }落到生产环境,SAGA 必须处理三个经典问题:幂等、空补偿、悬挂。幂等要求每个服务对同一个流程重复调用,结果一致;空补偿是指补偿动作发生时,正向事务压根就没执行成功,你必须能识别这种情况;悬挂是指正向请求和补偿请求到达顺序错乱,导致服务端出现了不该补偿却补偿的尴尬状态。这三点都要通过状态机加唯一流程 ID 来控制。SAGA 适合的业务有旅行预订(订酒店、订机票、租车)、长周期订单履约这类天生可以分成多步、且不强求同步一致的长链路。
3.4 最终一致方案的可靠性闭环:幂等表、对账任务与状态机
很多人实现最终一致方案时只关注"消息能不能发出去",结果忽略了更关键的一层:消费端和补偿端怎么保证不重复、不遗漏、不搞错时序。最终一致不是一个异步调用就完事了,它是一个闭环。
首先是消费幂等。库存扣减、积分增加、账户变更这类操作,必须允许同一笔消息被重复投递而不能重复生效。最简单的做法是在消费端状态表里对业务唯一号建唯一索引,比如扣减库存表里对 order_no 建唯一键,第一次插入成功,重复插入直接 throws DuplicateKey,业务理解为已处理即可。也可以用状态表单独记录消费过的消息 ID,每次消费前先查状态表,但唯一索引的方式更不容易受并发影响。
其次是对账任务。消息表里一堆消息处于"已发送"状态不代表下游真的处理成功了,因为 ack 可能丢失。所以必须设计一个对账定时任务:每隔几分钟扫描订单库和库存库,找出那些在订单侧显示成功、但在库存侧没有扣减记录的订单,补偿库存操作。对账是真实生产里让一致性"最终"收敛的关键,没有对账,消息一旦丢失就是永久丢。
最后是状态机。订单状态至少要有待支付、已支付、已取消、已完成、关闭,每个状态间的流转必须由明确的动作触发。分布式环境下状态不是由一个事务改的,而是多个服务异步改的,所以需要用状态机约束非法跳转,比如"已取消"订单不能再执行"扣库存"动作。状态机可以简单写成一个枚举加一个合法流转表,但一定要在代码里硬性校验,不能只靠约定。
4. 真实决策复盘:订单与库存这类场景我最后怎么选
4.1 不同体量的三种选择思路
很多技术纠结"到底选哪个方案",其实答案取决于你的系统处在什么阶段,没有绝对的好,只有适合不适合。我把团队常见的三种体量拿出来说。
第一种:还是单体应用,刚刚拆了库或者拆了模块,但业务量一天几千单。这时候引入 Seata 或者自己写的分布式事务协调器,反而增加复杂度。对这类系统,我倾向于用本地消息表 + 下单主流程同步降级的方式,先把核心链路保住。你甚至可以把订单和库存放同一个物理库的不同 schema,还是走本地事务,等到真的必须拆服务了再迁移。
第二种:微服务改造完成,订单、库存、账户分属三个团队,单量几万到几十万一天。这个阶段事务消息是最顺手的方案,配合消费幂等和对账任务,能把一致性问题控制在可接受范围。如果你已经用了 RocketMQ,直接用事务消息,没必要再重复造一个消息表轮询。如果没用 RocketMQ,用本地消息表 + HTTP 回调也能做到同样效果,成本更低。
第三种:流量很大、链路很长的电商平台,或者明显需要强一致且低并发的特定业务域。这种情况下建议分域处理:交易主链路用事务消息 + 对账,支付/退款这类资金操作单独接 SAGA 或 TCC 保证资金安全。不要让全站统一到一种方案,每个业务域根据数据敏感度差别对待,才是大型系统的常态。
4.2 选型清单与验证步骤
我给自己做技术选型时不太看网上那些"XX方案最牛"的争论,而是拿一张清单挨个过。你可以照抄:
- 一致性要求:业务能否接受"订单下了但库存几百毫秒后才看到少了"?能接受就选最终一致,不能就直接淘汰消息类方案。
- 链路长度:涉及服务是否超过三个?如果只有一个下游,甚至没必要引入分布式事务框架,本地事务加可靠消息就够了。
- 并发与热点:是否有高并发抢购、秒杀类热点扣减?有热点响应优先考虑异步削峰和库存预占,不要碰全局锁方案。
- 中间件现状:团队是否已运维 RocketMQ?没有的话,不建议只为事务消息强行引入,本地消息表这样只依赖数据库的方案可能更划算。
- 故障恢复能力:假设消息队列故障 30 分钟,业务是否可接受?不可接受就得多设计降级策略,而不是单纯依赖一个中间件保命。
- 团队维护成本:Seata 又装服务端又管 undo_log,事务消息也要监控消息积压,问问自己有没有人值班处理这些告警。
按这张清单走一遍,70% 的情况下答案已经出来了。如果还是拿不准,我建议做一次小规模压测:照着目标方案搭一套最小链路,模拟下游服务 50% 超时,看系统能否在 10 分钟内恢复一致。能恢复、不丢数据的方案才值得往生产推。
4.3 我在下单-扣库存场景落地的具体配置与踩坑
聊点实际的,我在一个中等体量的电商系统里的选择是:RocketMQ 事务消息 + 库存扣减幂等表 + 对账任务。下单接口伪代码如下:
- 订单服务创建订单,状态为"待支付"。
- 发送事务消息"ORDER_CREATED"到 RocketMQ,消息内容包含订单号、商品 SKU、扣减数量、本次扣减唯一流水 ID。
- 库存服务消费消息,先检查 inventory_flow 表里是否存在相同的流水 ID,存在直接返回;不存在则扣减库存并写入流水记录。
- 如果库存不足或商品已下架,消费逻辑直接抛出异常并通知告警,但消息不能简单 reconsume 无限重试,而是经过几次重试后进入死信队列,由对账任务处理。
- 对账任务每 5 分钟扫描订单表最近 30 分钟创建且状态为"待支付/已支付"的订单,与库存流水比对,发现缺失的扣减记录则补扣或标记异常。
这中间我踩过几个比较典型的坑。第一个是消费者重试导致库存重复扣减,虽然我设计了幂等表,但最初漏掉了"扣减库存 SQL 和插入流水表 SQL 必须放在同一个本地事务里"这一点,导致偶尔出现库存扣了、流水没插进去,重试又扣一次。后来把两个操作包进一个 @Transactional 才解决。
第二个是事务消息回查慢导致订单发送流程被拖住。RocketMQ 半消息发出后,如果本地事务执行很快但 commit 回执因为 GC 或者网络抖动延迟了,Broker 就会触发回查,回查又要查数据库,极端情况下多个线程同时回查同一订单号。我在回查接口里忘了加幂等控制,导致数据库压力瞬时变大。后来在回查逻辑里做了缓存和唯一约束,问题才缓解。
第三个是对账任务的时间偏移问题。最开始对账任务只查"创建时间在最近 5 分钟"的订单,结果因为应用服务器和数据库时钟存在两秒偏差,部分订单被漏扫,库存缺口没有及时补上。最终改为"按照订单 ID 增量 + 状态筛选"的方式去扫,不依赖时间字符串,稳定很多。
4.4 如果必须强一致:低并发场景下的Seata AT配置建议
如果你评估下来确实需要强一致,比如做退款审批、资金冻结,并发又不高,可以考虑 Seata AT 模式。我见过不少团队把 Seata 默认配置丢上去就跑,后来出现一堆超时和锁等待问题,所以给几个实操建议。
Seata Server 端先定好存储模式:生产环境不要用 file 模式做集群,要使用 DB 模式,全局事务记录会写到 seata 库。配置大致如下:
store.mode=db store.db.datasource=druid store.db.db-type=mysql store.db.url=jdbc:mysql://your-seata-db:3306/seata?useUnicode=true&characterEncoding=utf8 store.db.user=your_user store.db.password=your_password业务侧的几个参数更重要。客户端需要设置合理的全局事务超时时间,短事务建议 30 秒以内;如果超过 1 分钟还没结束,基本是业务逻辑混入了远程慢调用,不应该靠加超时解决,而是把远程调用拆出去。数据库连接池大小也要放大一些,因为 AT 模式在事务期间对一个连接占用时间变长,连接池太小容易排队。另外 undo_log 表按天做分区或者定期清理,不要让回滚日志只增不减。
还有一个很多人踩过的点:被 Seata AT 管理的数据库表必须有主键,否则无法生成准确的回滚 SQL;字段类型也尽量规整,某些数据库函数比如 NOW() 可能导致前后镜像不一致。初始化阶段最好自己在测试环境做一轮故障演练:手动 kill 掉一个分支事务进程,观察全局事务是否能正确判超时回滚,数据是否完整恢复。
5. 终局:分布式事务没有银弹,只有取舍
回到开头的那个问题,扣了库存但订单创建失败,这属于一致性被破坏后的典型表现。但我现在会告诉你:比起追求一套完美的分布式事务方案,更值得花时间的是把"一致性需求是什么"想清楚,以及为最终一致方案配上幂等、重试、对账这套兜底体系。很多时候,高可用和强一致无法兼得,必须根据业务价值做取舍。
我在实际项目里反复体会到一个原则:让应该异步的流程异步化,让必须同步的步骤尽量短。用事务消息解耦订单和库存,用户看到的是快速下单成功,后台通过消息和对账把库存数据收敛到一致,比在链路上强加全局锁要优雅得多。如果你的业务必须强一致,那就老老实实接受全局锁的约束并做好连接池、超时、undo_log 的运维;如果你能接受秒级最终一致,异步消息加对账是我更推荐的方向。
最后再分享一个小技巧:无论用哪种方案,先把核心表的主键、业务唯一号、状态字段设计好,分布式事务里 80% 的重复和乱序问题都能通过唯一约束和状态机拦截掉。模型扎实了,方案选哪个都不会太难看。