金融支付、订单库存这类业务只要一聊到“分布式事务一致性”Seata 基本是绕不开的名字。而 Seata 的 AT 模式又是使用最广、争议也最多的方案——它用起来确实像魔法业务代码几乎零侵入一个GlobalTransactional就能搞定跨库一致性但很多人只停留在“会用”的层面一遇到全局锁等待超时、回滚失败、热点行锁冲突就完全不知道从哪排查。这篇文章我就把 Seata AT 模式从内核到实战完整捋一遍重点拆解两阶段提交的底层流程、全局锁机制到底锁的是什么、为什么它会成为金融级场景的瓶颈以及如何在订单与库存这种典型场景里正确落地。内容偏深适合已经用过 Seata、想彻底搞懂原理的读者也适合正在做技术选型、想评估 AT 模式能不能上生产的人参考。1. 为什么金融级场景我选 Seata AT 模式1.1 分布式事务的问题本质先从一个最基础的业务说起用户在电商平台下单订单服务要插入一条订单记录库存服务要扣减库存账户服务要扣减余额。这三个操作分布在三个独立的服务、三个独立的数据库里任何一步失败都会造成钱付了没扣库存、或者库存扣了订单没生成这种问题。单机时代我们用本地事务就能解决BEGIN到COMMIT之间数据库的 ACID 保证一切要么全成功、要么全失败。但服务拆分和数据库拆分之后一个业务操作横跨多个物理资源本地事务就管不住了。这时候就需要分布式事务让多个独立的本地事务组合成一个逻辑上的“全局事务”从外部看它仍然具备原子性。Seata 的处理思路很有意思它不强求像 XA 那样让各个数据库直接参与到同一个两阶段提交协议中而是引入了一个事务协调器用“补偿”的思路协调各参与方。这种设计让 Seata 对业务的侵入性极小也正因如此它成了国内微服务架构里普及度最高的分布式事务中间件。1.2 AT 模式与 XA、TCC、SAGA 的取舍很多人刚开始选型时都会纠结到底用哪种模式。我直接说我的结论在金融级订单、库存、账户这类对一致性要求极高、事务链路清晰、中间件相对可控的场景里AT 模式是最平衡的选择。XA 模式依赖数据库层面的 XA 协议Oracle、MySQL 都支持但性能和兼容性一直是老大难问题尤其是 MySQL 的 XA 在跨行跨表高并发场景下表现并不理想而且所有连接在整个事务期间要一直持锁资源占用太严重。TCC 模式性能好、可控性强但业务方必须为每个操作实现 try、confirm、cancel 三套逻辑对代码的侵入极大开发成本至少翻一倍。SAGA 模式适合长事务、无谓“中间状态可见”的编排型业务但它没有隔离性保护中间态对外可见金融场景接受不了。AT 模式的核心优势在于你只需要在业务方法上加一个GlobalTransactional注解Seata 自动帮你完成分支事务的注册、数据快照的保存、回滚时反向补偿。业务代码里只需要写正常的INSERT、UPDATE、DELETESeata 在底层通过代理数据源拦截 SQL帮你生成 before image 和 after image。这套机制让落地成本极低同时它的数据一致性和隔离性又有底层机制兜底非常适合订单、库存这类以单库单行写为主、事务链路可控的场景。2. Seata AT 模式内核机制拆解2.1 两阶段提交与角色分工Seata 的 AT 模式本质上还是两阶段提交2PC的变体但它做得更轻量。整个架构里有三个角色TCTransaction Coordinator事务协调器、TMTransaction Manager事务管理器、RMResource Manager资源管理器。TM 是发起全局事务的入口也就是你加了GlobalTransactional的那个业务方法RM 负责管理各个分库的本地事务向 TC 注册分支事务并上报执行结果TC 是独立部署的 Seata Server负责全局事务的调度。第一阶段TM 向 TC 发起全局事务拿到全局事务 IDXID然后通过微服务调用链路把 XID 往下游传递。每个服务的 RM 执行本地事务时在业务 SQL 执行前给数据拍一张快照before image执行业务 SQL 后再拍一张after image连同回滚日志一起写入undo_log表。这一步最关键的地方在于一阶段直接提交本地事务不会一直持有数据库连接资源。第二阶段如果所有分支事务都成功了TC 通知所有 RM 异步删除各自的undo_log完成全局提交。如果任意分支失败TC 会向所有 RM 发送回滚指令RM 根据undo_log里的前后镜像生成反向 SQL 把数据恢复到 before image 的状态。这样做的好处很明显事务过程中普通读操作读到的可能是各分支已提交的中间状态但最终结果一定是全局一致的不会出现“永远不一致”的数据残留。2.2 快照镜像与 undo_log 的生成回滚细节很多资料只停留在“AT 模式会生成快照”这一步但实际业务开发中至少要把快照生成和回滚的细节吃透否则你写出来的 SQL 可能跟 Seata 的机制相冲突导致无法回滚。undo_log表的表结构是固定模板主要字段包括branch_id分支事务 ID、xid全局事务 ID、rollback_info序列化后的回滚信息包含了 before image 和 after image、log_status状态0 表示正常待删除1 表示已全局提交、log_created和log_modified时间字段。当 Seata 代理数据源拦截到一条UPDATE时会先解析 SQL找出条件语句命中的记录主键执行一次查询生成 before image如id1, stock100然后执行原始 SQL 更新数据更新完成后再次查询生成 after image如id1, stock90。两个镜像序列化后跟随分支注册请求一起发给 TC。回滚时RM 根据镜像判断 after image 数据是否已经被其他事务再次修改如果没被修改就用 before image 反算 UPDATE 语句直接覆盖回来如果已经被其他事务修改了说明发生了脏写回滚会失败并抛出异常。所以这里有个硬性要求被 Seata 管理的表必须有主键SQL 必须能保证生成的镜像能唯一定位到数据行否则 Seata 无法判断数据是否被篡改也就无法保证回滚的正确性。实践中常见的问题是开发者在没有主键的视图表上执行 SQL或者用多表关联写复杂更新Seata 都会直接报错或者拒绝拦截。3. 全局锁机制AT 模式最大的“核武器”与“紧箍咒”3.1 写隔离和读隔离如何靠全局锁实现AT 模式的写隔离机制本质上就是靠全局锁实现的。所谓全局锁是 TC 端维护的一张分布式锁记录表锁的粒度是“资源 行数据主键”不是整个数据库表。当一个分支事务要更新某一行数据时RM 在本地数据库执行更新的前提下会先向 TC 申请对应数据行的全局锁。只有拿到全局锁这个分支事务才算真正拥有这行数据的修改权。如果拿不到锁RM 会陷入等待循环默认间隔 10 毫秒重新尝试一次直到超时。这里要特别留意AT 模式的全局锁和数据库的本地锁不一样。本地锁是数据库自己加的保护的是同一数据库内并发的行级写操作全局锁是 Seata Server 层维护的目的是对跨库的分支事务进行行级串行化。也就是说即使两个服务操作的是不同数据库里的同一业务主键数据Seata 也能通过全局锁让它们按顺序执行避免数据被交叉覆盖。读隔离方面AT 模式默认是读未提交级别因为一阶段本地事务已经提交其他事务可以读到这个中间状态。如果业务不能容忍脏读就需要在查询 SQL 上面加SELECT ... FOR UPDATESeata 会把这个查询也代理成加锁操作读前先拿全局锁等写事务提交或回滚后再读从而实现读已提交级别的隔离。这也是文档里常说“AT 模式读隔离需要业务方通过 for update 主动控制”的原因。3.2 锁等待超时、热点行冲突与应对策略全局锁给一致性兜了底但也给高并发场景埋了一个大坑它本质上把不同数据库的行锁冲突提升到了 TC 层面。假设你有 1000 个并发请求同时在扣同一个商品的库存Seata 会在 TC 上维护同一行的全局锁竞争后到的事务全部阻塞等待直到最先持锁的分支事务全局提交或回滚。这意味着你很可能在应用日志里看到大量Global lock wait timeout或者Data exists in the local transaction lock table异常。应对策略一般分三个方向。第一个方向是调参数适当增加client.rm.lock.retryTimes和client.rm.lock.retryInterval的值让事务等待更久再判断失败。第二个方向是避免跨服务的“长事务”把尽可能多的操作收敛到一次本地事务里减少全局锁持有时间。第三个方向是优化业务设计比如把热点商品的库存拆分成多个子库存节点每个节点单独库存扣减时随机选取节点把单行热点打散成多行从源头上避免锁竞争。另外AT 模式里还存在一个非常容易被忽略的死锁场景本地事务先更新了表 A 的行再在同一个事务里申请表 B 的全局锁同时另一个事务正好先持有 B 行的全局锁、又等待 A 行的本地锁两边互相等待。这种问题靠调参数解决不了只能通过严格约定事务内所有分支的访问顺序避免出现交叉加锁。4. 金融级场景实战订单与库存的分布式事务落地4.1 场景拆解订单、库存、余额三端的一致性用最经典的订单与库存场景来讲。用户下单时订单服务创建订单库存服务扣减库存账户服务扣减余额。三个服务、三个数据库必须保证同生共死。业务上要特别注意不要把订单创建过程做成一个大到包罗万象的分布式事务。我的建议是核心写链路订单插入、库存扣减、余额扣减这三个必须放入同一个全局事务保证原子性。至于发送消息通知、写操作流水、更新推荐系统的数据这些可以放入事务后异步执行或者依赖最终一致性的消息队列强行塞进全局事务只会让全局锁的持有时间变长大幅增加锁冲突和性能风险。这个案例最终落地时还涉及一个重要的“幂等”设计。全局事务执行过程中如果 TC 端反复重试提交或回滚业务系统可能会收到重复的请求所以订单号、扣减流水号要设计成全局唯一所有 UPDATE 语句必须带幂等条件比如WHERE order_status待支付避免同一单据被重复扣减。4.2 关键配置与代码实现第一个要干的事是搭建 Seata Server。官方支持多种配置中心生产环境通常用 Nacos 作为配置和注册中心。Seata Server 启动后事务协调器的能力就具备了下面重点看业务侧怎么接入。依赖层面Spring Boot 项目主要引入spring-cloud-starter-alibaba-seata。配置文件里关键要设置事务组和 TC 地址例如seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP client: rm: lock: retry-interval: 10 retry-times: 30每个数据库都要建undo_log表这是 AT 模式回滚的基础设施漏掉的话业务跑起来会报找不到undo_log的错误。建表 SQL 官方文档有标准模板直接复制创建即可。数据源代理这步最容易被忽略。Seata 必须通过代理数据源才能拦截 SQL 生成前后镜像所以业务方的数据源要用DataSourceProxy包装一层Configuration public class SeataDataSourceConfig { Bean Primary public DataSource dataSource(DataSource dataSource) { return new DataSourceProxy(dataSource); } }核心事务入口的代码其实很简单订单服务的方法加一个GlobalTransactional(name create_order_tx, rollbackFor Exception.class)注解即可。方法内部依次调用订单插入、库存服务扣减、账户服务扣减。下游服务的Transactional照常写Seata 会自动把 XID 透传下去下游作为分支事务参与全局事务。注意一个细节全局事务方法内部如果调用了本地异步线程或者通过 HTTP 不带 XID 透传那么下游服务不会感知到全局事务该分支事务会独立提交造成一致性被破坏。跨服务调用时XID 需要通过 HTTP Header 或者 RPC 上下文透传Spring Cloud Alibaba 的组件已经自动做了这件事但如果你们公司用的是自研 RPC需要手动把 XID 放到请求头里。4.3 如何设计事务边界与异常兜底事务边界的设计直接决定全局锁竞争状况和故障恢复能力。我在生产环境的建议是全局事务内部只放强一致性的核心写操作尽量控制在 3 到 5 个分支事务以内。事务执行时间要严格压测必要时要限制在几百毫秒级别超过 1 秒就要焦虑了因为这意味着一行数据被锁了整整 1 秒并发场景下等待队列会非常恐怖。异常兜底方面虽然 Seata 能在回滚时自动恢复数据但你不能完全依赖它。线上出现批量脏数据、回滚失败等情况时一定要有数据核对和补偿任务兜底。比如说订单服务可以定期扫描一段时间内的异常单据对未终态的单据发起查询和补偿用“对账程序 人工接口”的方式保证最终一致。另外全局事务执行期间还会遇到一种情况某分支事务已经执行完但 TC 通知它回滚时下游服务已经宕机或者undo_log数据被人为删除这时候 Seata 会一直重试回滚直到成功。运维和开发都要清楚这个机制千万不要在全局事务执行期间手动清理undo_log表否则会留下无法回滚的脏数据。5. 常见问题与排查技巧实录5.1 全局锁等待超时的排查这是 AT 模式上线后最典型的故障。现象就是业务日志里出现Global lock wait timeout接口响应变慢甚至堆积。排查的思路分三步。第一步到 Seata Server 的日志或 TC 管理端查看当前哪些资源在等待锁。第二步定位持锁的事务是什么是哪个全局事务、哪个分支事务、在哪一行数据上然后去业务方确认这个持锁事务是否卡住了。第三步查看持锁事务的代码确认是不是有人在全局事务里调用了远程接口或者睡眠等待导致持锁时间过长。定位到具体原因后大部分情况都是事务边界设计不合理。处理手段最有效的有两个一个是把非核心分支移出全局事务缩短锁的持有时间另一个是设置合理的重试参数不要把retryTimes调得太大否则等锁时间过长反而拖垮服务。5.2 回滚失败与 undo_log 异常回滚失败的典型报错是Rollback finished with error原因通常是 after image 和当前数据不一致也就是行数据在全局事务执行期间又被其他事务改写了。这种情况大多不是 Seata 的 bug而是业务代码里混用了非代理数据源部分 SQL 绕过了全局锁机制在 Seata 还没提交时直接把数据改了。排查时先检查业务服务里有没有手动获取数据库连接绕过代理数据源的地方比如原生 JdbcTemplate、多数据源配置里没有全部包DataSourceProxy。再看是不是存在两个不同的全局事务同时改同一行数据的并发逻辑。最后检查undo_log表的数据量是否异常膨胀如果大量历史日志没被清理回滚阶段读取效率会明显下降。5.3 热点数据与长事务的优化建议金融场景里最容易出问题的就是热点账户、热点商品库存、热点积分账户。全局锁会让这些数据的并发能力被大幅削弱。我现在常用的方案是热点账户的扣减不走 Seata AT 全局锁而是先把请求写入待结算队列通过异步 Batch 处理顺序扣减用本地事务保证多笔扣减的原子性同时业务上做余额不足的校验和冲正。库存数据则用分片库存设计每个库存分片是一个独立记录扣减时路由到某个分片分片内部再用本地事务完成。如果业务实在没法拆分就要考虑从模式层面降级这个场景下用 TCC 反而更合适因为 try 阶段不持有全局锁每个分支只需预留资源confirm 阶段快速提交锁的持有时间大大缩短。所以说AT 模式不是万能钥匙技术选型一定是结合业务场景来的。下面整理了一张常见问题速查表可以直接保存下来挂在团队 wiki 里问题现象可能原因排查入口解决方向Global lock wait timeout全局锁竞争激烈或持锁事务过长TC 日志、持锁事务 XID缩小事务边界、拆分热点键、调大重试次数回滚时数据被篡改部分 SQL 绕过了代理数据源数据源代理配置、undo_log 镜像对比统一使用代理数据源、给表加主键找不到 undo_log 表数据库漏建回滚日志表各业务库表结构检查执行官方建表 SQL子事务未注册成功XID 未在调用链路透传请求头 XID、RPC 上下文检查拦截器或 RPC 过滤器事务执行成功后数据不一致全局事务内部混用了异步线程业务代码线程使用异步操作统一放到事务后发送undo_log 异常增长高频分支事务提交后清理不及时关注 undo_log 行数和存储大小定期归档清理结合压测评估这几种问题我在生产环境里全踩过尤其是第一个每次大促前主动压测基本都能爆出来。我的应急预案通常是在压测阶段就把事务边界和锁冲突问题当第一优先级处理而不是上线后靠监控救火。最后再分享一个实操层面的小技巧。如果你有条件可以在 Seata 的 TC 端把事务执行时间指标接入监控大屏设置阈值告警一旦某类事务执行时间明显上升大概率就是全局锁竞争在抬头。这个信号往往比业务侧的错误日志来得更早能让你在故障影响扩大之前就把问题摁住。分布式事务本身就属于“不出事则已一出事就是大问题”的范畴前置的压测、监控、边界设计比事后任何补救手段都更有价值。
