2. 从痛点出发:为什么订单库存场景需要分布式事务
我这两年处理过不少分布式事务相关的故障,印象最深的一次是线上促活动态调整库存后,订单表和库存表数据对不上,财务对账出了问题,最后靠人工补单才收场。事后复盘,问题根源就是订单服务和库存服务跨了两个数据库,本地事务管不住全局一致性。
所谓分布式事务,本质上是解决“多个独立数据源之间的数据一致性”问题。拿最常见的电商下单流程来说:用户下单要扣库存、写订单、可能还要加积分,这三个操作分别落在订单库、库存库、积分库。如果扣库存成功但订单创建失败,库存就被白扣了;如果订单创建成功但扣库存失败,超卖就发生了。传统方案里,要么用TCC手工补偿,要么靠消息对账,但代码侵入都太重。
Seata在这种背景下是比较实用的一套方案,尤其AT模式,对业务代码的侵入比TCC小很多。我最早接触Seata是在一个订单中台项目里,当时面临的技术选型就是阿里的Seata和开源的TCC框架二选一。对比之后我选了Seata,后面在十几个生产环境里落地,也踩了不少坑,今天把整套优化和落地经验整理出来。
这套内容适合谁看?如果你负责的业务涉及跨库操作,比如订单库存、账户余额、积分流水这类场景;如果你正在选型分布式事务方案,或者已经引入Seata但性能不理想、时常出现数据不一致报警,那这篇内容能帮你省不少排查时间。
3. Seata AT模式核心机制拆解
3.1 AT模式到底做了什么
AT模式全称是Automatic Transaction,核心思想是“业务无侵入”。业务代码里只需要加一个@GlobalTransactional注解,Seata就会接管整个调用的全局事务。
它的工作原理可以分三步理解:
第一,事务发起方(TM)开启全局事务,生成全局事务ID(XID),通过Dubbo或Spring Cloud的调用链传递下去。
第二,每个参与事务的分支事务(RM)在自己的本地库中执行SQL,同时Seata会拦截SQL,生成前后镜像(before image和after image),并写入一张undo_log表。这个undo_log表就是回滚的依据。
第三,全局事务提交时,所有分支都执行成功,TM通知RM异步删除undo_log记录;如果任何一步失败,RM根据undo_log里的前后镜像生成反向SQL,把数据恢复到事务开始前的状态。
这套机制带了一个很重要的概念叫全局锁。AT模式里,RM在执行本地SQL时,会对涉及的主键记录加全局锁,锁信息记录在Seata服务端的global_table和lock_table表中。目的是防止两个全局事务同时修改同一条记录,保证写冲突可控。
我刚开始接触AT模式时,直觉上担心它性能不行,理由是每笔SQL都要做镜像、写undo_log,还要和TC(事务协调器)通信。后来压测下来发现,只要参数调对,AT模式对业务RT的影响能控制在10%以内,这在绝大多数业务场景里是可以接受的。
3.2 AT模式和TCC、消息队列对比
AT模式能火起来,不是因为性能最好,而是因为它在“代码侵入”和“一致性保障”之间找到了一个相对平衡的位置。
TCC(Try-Confirm-Cancel)模式要求业务方自己实现三个接口,比如库存服务要写Try扣减预留库存、Confirm确认扣减、Cancel回滚预留库存。这套逻辑写起来非常痛苦,而且幂等、悬挂、空回滚这三个问题都得自己处理,开发成本很高。
消息队列方案适合最终一致性场景,比如下单成功后发一条MQ消息去异步扣库存。它的问题在于,如果MQ消息丢了,或者消费端逻辑没做好幂等,数据就会一直对不上,且问题暴露有延迟。
AT模式的选择逻辑其实很简单:它把回滚逻辑用镜像SQL自动生成,业务方只需要关注自己的SQL是否正确,同时保证了事务的原子性和一致性。它不适合极端高并发写热点场景,但覆盖了90%以上的跨库事务业务。
下面这张对比表,是我在给团队做技术选型时常用的参考:
| 方案 | 代码侵入 | 一致性类型 | 性能损耗 | 维护成本 | 适用场景 |
|---|---|---|---|---|---|
| AT模式 | 低 | 强一致(最终一致由回滚保证) | 中等,可调优 | 低 | 跨库、跨服务常规事务场景 |
| TCC | 高 | 强一致 | 低 | 高 | 高并发、资源预扣场景 |
| 消息队列 | 低 | 最终一致 | 低 | 中 | 异步削峰、可容忍短暂不一致场景 |
| XA | 极低 | 强一致 | 高 | 低 | 数据库原生支持、低频场景 |
实际落地时,AT模式最适合订单、库存、账户这类短事务场景,尤其是事务内嵌套调用不超过三层、单事务耗时少于5秒的场景。事务时间越长,全局锁持有时间越长,冲突概率和锁等待放大效应就越明显。
4. 生产落地前的关键配置与参数调优
4.1 部署架构与基本配置
Seata生产部署建议采用高可用模式。TC(Transaction Coordinator)至少部署两个节点,注册到Nacos或Consul上,客户端通过注册中心感知TC地址。如果只有单机TC,TC一旦挂掉,所有进行中的全局事务都会卡在“待处理”状态,业务直接瘫痪。
我第一套生产环境就是图省事部署了单机TC,结果一次发布重启期间,正好有上百个全局事务处于中间态,重启后部分事务状态丢失,最后靠手工查库才恢复。从那以后,我定的规矩是TC至少双节点,且独立于业务应用部署。
TC的JVM参数需要根据全局事务量估算。通常4C8G的机器可以支撑每秒几百到上千个全局事务,但要注意事务分支数。单个全局事务平均5个分支的话,TC的内存开销会明显增大。建议配置如下:
# application.yml(Seata Server端) server: port: 7091 spring: datasource: url: jdbc:mysql://localhost:3306/seata_server?useSSL=false username: seata password: seata seata: registry: type: nacos nacos: server-addr: 127.0.0.1:8848 application: seata-server group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: "" group: SEATA_GROUP store: mode: db db: datasource: druid db-type: mysql driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/seata_server?useUnicode=true&characterEncoding=utf8 user: seata password: seata max-active: 20 min-idle: 5 server: max-queue-size: 20000 max-commit-retry-timeout: -1 max-rollback-retry-timeout: -1重点是store.mode一定要配置成db模式。默认的文件模式存不了多少事务日志,生产环境必须用数据库存储,便于追溯和清理。
4.2 客户端关键参数配置详解
客户端(业务应用)里的Seata参数,直接决定了性能上限和异常行为。这里列出我实测过最关键的几个:
seata.tm.degrade.check:事务降级开关。开启后,当TC不可用或全局事务处理超时时,Seata会降级为本地事务执行。这个参数在压测和演练时特别有用,但生产环境我建议谨慎使用,因为降级会牺牲全局一致性。我一般只在TC故障演练时临时打开。
client.rm.report.retry.count:分支事务注册上报重试次数,默认是5。如果网络有抖动,调大到10会提高事务注册成功率。代价是故障时感知变慢,所以不要无限调大。
client.tm.commit.retry.count和client.tm.rollback.retry.count:全局提交和回滚的重试次数。默认值通常够用,但是如果日志里出现Retry commit字样,说明网络确实不稳定,这时候调大重试次数比调大超时时间更有效。
client.rm.lock.retry.internal:分支事务获取全局锁的重试间隔,默认10毫秒。client.rm.lock.retry.times:默认30次,意味着锁冲突时最多等待300毫秒。对于秒杀、热卖品这类写热点集中场景,300毫秒很可能不够,我通常改成3秒等待,把retry.times调整成300。
下面是客户端侧配置文件的参考模板:
# application.yml(业务客户端) seata: enabled: true application-id: ${spring.application.name} tx-service-group: my_test_tx_group # 事务分组与TC集群的映射关系,需要在Nacos中配置 service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091 # TC集群开关,生产用注册中心时自动发现 registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: "" group: SEATA_GROUP username: nacos password: nacos client: rm: lock: retry-interval: 10 retry-times: 30 report-retry-count: 5 table-meta-check-enable: false report-success-enable: true async-commit-buffer-limit: 10000 tm: commit-retry-count: 5 rollback-retry-count: 5这里有个容易踩的坑:tx-service-group名称必须和Nacos里配置的vgroup-mapping保持一致,否则客户端报错“no available service”,而且这个错不会第一时间上报,只会在运行时日志里刷,很容易遗漏。
4.3 undo_log表与全局锁表的设计
Seata AT模式依赖业务库里的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`), UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;这张表的本质,是业务库里的“补偿日志”。Seata在执行SQL前后读取数据快照存入其中,回滚时把镜像SQL反推执行。它有两个我走了弯路才注意到的点:
第一,xid和branch_id必须建唯一索引,这个索引在回滚时用来精确定位分支记录。如果不建,回滚时全表扫一遍,一旦数据量大,全局事务的失败恢复时间会成倍增长。
第二,rollback_info存储的是序列化后的镜像数据,默认用的是Java序列化。如果业务实体类没有实现Serializable,回滚时会抛异常。我遇到过一次实体类没实现序列化,导致回滚失败,事后检查代码才加上。如果你的应用用了Kryo或Protobuf做序列化,可以在客户端配置client.rm.undo.serialization改成对应实现。
全局锁的管理在TC服务端自动完成,业务方不直接感知。锁的粒度是“主键值”级别,不是行级也不是表级。这意味着一个全局事务如果操作了多条记录,就会持有多个全局锁。锁的超时时间由client.rm.lock.retry.times和retry-internal相乘决定,超过等待时间仍拿不到锁,就会抛LockConflictException。
5. 从订单库存场景出发的完整实操
5.1 场景设计与代码示例
为了讲清楚整个落地过程,我以一个标准的订单库存场景为例。假设有两个微服务:订单服务(order-service)和库存服务(inventory-service),分别使用独立的数据库。业务流程是:创建订单时同步扣减库存,如果库存不足则订单创建失败,库存不扣。
传统写法下,这两个操作分散在两个服务里,各自有本地事务,无法保证一致性。引入Seata后,流程变成:
订单服务作为全局事务发起方,标注@GlobalTransactional注解。内部先创建订单,然后通过Dubbo或OpenFeign调用库存服务扣库存。整个调用链路上只要有一个分支失败,全局事务就自动回滚,包括订单插入和库存扣减。
代码示例(订单服务):
@Service public class OrderService { @Resource private OrderDAO orderDAO; @Resource private InventoryFeignClient inventoryClient; @GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class) public boolean createOrder(OrderDTO orderDTO) { // 1. 创建订单,本地事务提交 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(orderDTO.getUserId()); order.setProductId(orderDTO.getProductId()); order.setCount(orderDTO.getCount()); orderDAO.insert(order); // 2. 远程扣减库存 InventoryDTO inventoryDTO = new InventoryDTO(); inventoryDTO.setProductId(orderDTO.getProductId()); inventoryDTO.setCount(orderDTO.getCount()); // 远程调用库存服务 InventoryResponse response = inventoryClient.deduct(inventoryDTO); if (!response.isSuccess()) { // 注意:这里直接抛出异常,让Seata拦截回滚 throw new RuntimeException("库存不足,订单创建失败"); } return true; } }库存服务代码:
@Service public class InventoryService { @Resource private InventoryDAO inventoryDAO; /** * 扣减库存,本地分支事务 */ @Transactional(rollbackFor = Exception.class) public boolean deduct(InventoryDTO inventoryDTO) { // 执行扣减,SQL由Seata自动生成镜像 int updates = inventoryDAO.deductStock(inventoryDTO.getProductId(), inventoryDTO.getCount()); if (updates == 0) { throw new RuntimeException("库存数据不存在"); } // 检查扣减后是否剩余为负数 int currentStock = inventoryDAO.getStock(inventoryDTO.getProductId()); if (currentStock < 0) { throw new RuntimeException("扣减后库存为负,拒绝操作"); } return true; } }这里有个细节值得注意:库存服务里的@Transactional注解保留着,Seata AT模式的分支事务并不会替代本地事务,两者是叠加的。本地事务保证单库内的ACID,全局事务保证跨库的一致性。我在很多项目里看到有人把本地@Transactional去掉,这是不正确的做法。
5.2 全局事务ID的传递链路
全局事务ID的传递是AT模式能跨服务生效的基础,但也是最容易出问题的地方。
当@GlobalTransactional方法被调用时,Seata会通过拦截器生成XID,存放在RootContext里,默认是一个ThreadLocal变量。接下来发生远程调用时,Seata的集成组件(如Dubbo Filter、OpenFeign拦截器)会把XID从当前线程的ThreadLocal中取出,放入RPC协议的attachment或header中传递出去。
在订单服务中调用库存服务:
// OpenFeign客户端 @FeignClient(name = "inventory-service") public interface InventoryFeignClient { @PostMapping("/inventory/deduct") InventoryResponse deduct(@RequestBody InventoryDTO inventoryDTO); }Seata的SeataFeignInterceptor会拦截Feign请求,把XID放到请求头里,库存服务接受到请求后,又通过SeataHandlerInterceptor从请求头取回XID,设置到库存服务对应线程的RootContext中。整个过程对业务代码透明。
这里最常见的问题是异步线程丢失XID。比如createOrder方法内部用异步线程池去扣库存,子线程的ThreadLocal是全新的,XID带不过去,库存服务那边的分支事务就无法加入当前全局事务,最终结果就是订单创建成功但库存没扣。解决方法是手动传递XID,或者把扣库存改成同步调用。我在项目里明确要求:全局事务链路中禁用异步调用,除非能保证XID传递完整。
另外一个坑出现在线程池复用场景。如果线程池里的线程在处理完一个事务后,ThreadLocal里残留了上一个XID,下一次任务会错误地引入旧的全局事务。处理办法是使用Seata提供的TransactionTemplate或者在使用线程池执行任务前主动清空RootContext.unbind()。
5.3 分支事务注册与全局提交回滚流程
要真正理解AT模式落地后的运行机制,需要对提交和回滚的时序有清晰认识。我把一次完整的全局事务执行流程拆开来看:
第一步,TM(订单服务)调用@GlobalTransactional方法,向TC注册全局事务,拿到XID。
第二步,订单服务执行的第一个本地SQL(插入订单)被Seata数据源代理拦截,生成前后镜像,写入undo_log表,同时向TC注册分支事务,TC记录分支和全局的关系。
第三步,订单服务通过Feign调用库存服务,XID随请求头传递。库存服务里的扣减SQL同样被代理拦截,写入undo_log并注册分支事务。
第四步,全局事务方法执行完毕,TM向TC发送全局提交请求。
第五步,TC检查所有分支都注册完成且存在,通知各RM进行分支提交。分支提交本质上就是删除undo_log记录,业务库的改动已经是定局。
第六步,如果有任何分支执行失败,异常向上抛到@GlobalTransactional拦截器,TM向TC发送全局回滚请求。TC通知所有RM执行回滚,RM读取undo_log,根据前后镜像生成反向SQL执行,把数据恢复到修改前。
提交和回滚流程中,TC会记录每个分支的状态。如果某个RM在提交过程中宕机,TC会不停重试,直到确认该分支提交成功为止。所以生产环境中,TC的重试机制是保证最终一致性的核心防线。
这里有一个我反复跟团队强调的点:undo_log表里的数据不代表“事务失败”了,它在成功提交后也会短暂存在,然后被定时任务异步删除。日志里看到undo_log有数据,不要下意识认为是回滚,要先看分支状态是提交还是回滚。
6. 生产环境踩坑实录与性能优化经验
6.1 性能瓶颈定位与优化手法
AT模式在生产环境跑了一段时间后,性能问题会逐渐暴露。常见的瓶颈有三个方向:全局锁等待、undo_log写入开销、TC处理能力。
全局锁等待是写热点场景最突出的问题。比如秒杀商品,所有用户都在扣减同一个SKU的库存,第二个事务必须等第一个事务提交释放全局锁,否则一直重试。这里的核心优化思路,是把“写热点”转成“写热点+扩展点”。例如库存表可以拆成库存主表和库存流水扩展表,扣减逻辑修改扩展表记录,库存主表只做总额核对。全局锁只锁扩展记录,而不是锁唯一的库存主记录。
我实测过一组数据:单SKU并发扣减从每秒200次提升到每秒2000次,关键不是把Seata参数调到多大,而是改了扣减策略,让每次扣减不再直接更新同一行库存记录,先写流水,再用异步任务汇总。
undo_log写入开销在事务量大时也不可忽视。每一条被代理的SQL都伴随两次镜像查询和一次undo_log插入,事务响应时间平均增加1到3毫秒。优化方法有两个:一是精简不必要的分支事务,尽量把多个本地SQL合并成一个事务块;二是如果业务上允许,把不关键的查询SQL排除在Seata管控范围外,比如查询操作不加@GlobalTransactional,只对写操作开启。
TC处理能力方面,我建议监控TC的线程池指标。netty-tc-thread线程数如果持续打满,说明TC需要扩容,或者事务分支数太多。从业务侧优化分支数比给TC堆机器更有效。
6.2 网络抖动与超时问题处理
分布式事务对网络抖动非常敏感。我和团队在压测阶段遇到过:模拟网络延迟增大后,分支事务能够成功执行,但TC回调确认信息迟迟没收到,TM侧就判断超时并触发回滚。结果就是业务数据已经被改掉(本地已提交),但全局事务判定回滚,最终数据不一致。
这套问题的根因在于Seata的全局事务状态与分支本地事务状态不是同时刻一致的,中间存在一个窗口期。要降低这个窗口期的影响,有几个实践方法:
一是把client.tm.commit.retry.count和client.tm.rollback.retry.count调大,网络抖动时多试几次,而不是立即判定失败。
二是确保TC的重试任务没有超时时间限制。server.max-commit-retry-timeout和server.max-rollback-retry-timeout配置为-1,表示不限制重试时间。如果限制了,TC重试到超时就会放弃,这个分支会一直处于中间状态。
三是明确幂等要求。如果全局事务提交阶段失败,TC会重试分支提交,分支提交对应的业务SQL必须幂等。在库存场景,扣减SQL本身不是幂等的,但Seata AT模式在分支提交时只是删undo_log,不会重放业务SQL,所以这个场景相对安全。但是TCC模式就完全不同,Confirm和Cancel必须你自己实现幂等。
还有一类网络问题:TC和RM之间的连接用了长连接,长连接长时间空闲会被服务端或中间设备断开,导致下次通信报错。解决办法是定期做心跳检查,或者把网络空闲超时配置调大。Seata的Netty基础配置里,connect.timeout默认是5秒,我第一次在云环境部署时遇到堆外内存增长,排查下来就是心跳周期和中间设备空闲断连冲突导致的。
6.3 高危故障场景:TC宕机与数据库死锁
TC宕机是最严重的故障场景,处理不好会造成全局事务状态悬挂。我梳理过一套标准应对流程:
第一步,启动备节点TC,确认注册中心中TC服务恢复可用。
第二步,查看业务库中undo_log表的数据。凡是日志状态为“正常”且关联的全局事务处于“未提交”状态的,都需要人工介入。
第三步,从TC的全局事务表(global_table)中查询未完成事务,判断它们是否所有分支都已完成。如果都完成了,手动提交对应事务;如果有分支失败,触发回滚。
第四步,清理悬挂事务记录,恢复业务流量。
整个流程里最关键的是第二步和第三步,这需要业务开发、DBA、架构组三方配合。我们的经验是,TC宕机所在时间段的事务尽量不要自动清理,而是通过核对业务数据判断是否需要补账。比如订单创建了但库存没扣,业务侧要补偿扣减,这个判断算法比任何自动化工具都可靠。
数据库死锁则是另一类高频故障。AT模式引入全局锁后,实际上存在两把锁:本地数据库行锁和Seata全局锁。如果两个事务对不同记录加锁的顺序相反,就会产生死锁。举例来说,事务A先锁库存再加积分,事务B先加积分再锁库存,当两个事务在全局锁和本地锁之间嵌套等待时,死锁就发生了。
规避死锁的手段:一是在业务层面统一加锁顺序,例如所有事务先锁库存再锁订单再锁积分,全局统一;二是调整数据库的innodb_lock_wait_timeout,不能让死锁检测等太长时间,一般设置成2到3秒;三是合理控制单事务操作记录数量,文档里建议单事务不超过100条主键,实践中我一般控制在20条以内。
6.4 事务分组与灰度发布策略
生产环境里不可能只服务一个业务线。我维护的Seata集群里跑着订单、支付、会员、积分四个业务线,每个业务线都有独立的tx-service-group。
这样做的优势很明显:每个业务线的全局事务是隔离的,一个业务线的事务风暴不会拖垮另一个。比如大促时订单服务的全局事务量骤增,支付线的TC性能不受影响,因为TC上不同分组的事务处理是并行的。
事务分组还和灰度发布强绑定。新版本代码上线前,可以只让部分流量走新事务组,其余流量走老事务组。做法是:在注册中心里给同一个TC虚拟集群配一个旧分组和一个新分组,再通过客户端的service.vgroup-mapping控制流量比例。
我常用的灰度策略是这样:先在Nacos配置中心里新建一个tx-service-group: order-tx-group-gray,映射到同一套TC集群。然后只让一台实例使用灰度的group配置,其他实例保持旧配置。灰度实例上线观察半天,确认无异常后,把剩余实例都切到新配置,最后删掉旧group。
这套方案比直接升级TC版本更稳。因为TC版本升级涉及协议兼容性,万一新旧版本不兼容,影响面是全部业务线。通过分组隔离,我可以在TC集群里混跑多个版本,逐个验证后再迁移。
7. 常见问题速查与排障技巧
7.1 典型报错场景与解决方案
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 客户端启动报“no available service” | 事务分组未正确匹配 | 检查tx-service-group和vgroup-mapping配置 | 修正映射关系,确认注册中心中TC服务存在 |
| 全局事务一直卡住不提交不回滚 | TC宕机或事务状态丢失 | 查询TC的global_table状态 | 重启TC,人工核对中间态事务 |
商品库存扣减时出现LockConflictException | 全局锁冲突等待超时 | 查看日志确认冲突的SQL和锁时间 | 调大lock.retry.times或优化热点写策略 |
| 回滚时报找不到undo_log记录 | 本地事务已提交但undo_log被清理 | 核对分支状态和本地表数据 | 确认是否有定时任务清理了undo_log,调整清理策略 |
| 分支事务执行成功但全局回滚 | 本地提交成功但TC回调超时 | 查看TM和TC之间的网络日志 | 调大重试次数,检查网络稳定性 |
| 数据库死锁导致全局事务失败 | 本地锁与全局锁嵌套等待 | 查看数据库死锁日志 | 统一加锁顺序,降低事务锁记录数 |
7.2 日志排查的关键关键字
Seata的日志量比较大,生产环境排查时不要全量看。我通常会按这几个关键字过滤:
Global transaction:看到这行说明TM发起了全局事务,后面跟着的是成功或失败的最终状态。
Branch transaction:分支事务注册、提交、回滚的日志入口,能定位到具体哪个服务哪个SQL报错。
retry commit或retry rollback:说明TC在重复尝试提交或回滚,通常表示网络或RM侧暂时不可用。
LockConflict:全局锁冲突,日志会附带冲突的SQL和当前持有锁的事务ID,可以辅助定位写热点。
undo_log delete:分支提交成功,undo_log清理的日志,说明事务正常结束。
排查时我会把seata相关的logger级别临时调整到DEBUG,定位完立刻调回INFO,避免大促期间日志量影响性能。生产环境高峰期不建议开DEBUG,曾经有一次排查问题开了DEBUG,结果日志侧把磁盘写满了,业务直接IO阻塞,教训很惨。
7.3 undo_log表数据膨胀怎么办
undo_log表只存进行中的事务镜像,正常提交后会被异步删除,所以理论上不会膨胀。但生产环境仍然可能积累数据,原因通常是:全局事务长时间未结束,或TC宕机后RM侧分支事务悬挂未清理。
处理方法分两步:先查全局事务状态,如果对应的global_table里事务已经结束,那undo_log里的记录就是残留,可以安全删除;如果事务还在进行中,则不能手动删,否则会破坏回滚能力。
长期预防方案是加一张定时清理任务,每天凌晨执行以下SQL时,先核对存在性:
-- 先查询残留记录 SELECT xid, branch_id, log_status, log_created FROM undo_log WHERE log_created < DATE_SUB(NOW(), INTERVAL 1 DAY) AND log_status IN (0, 1); -- 确认这些业务对应的xid在seata_server库中不存在有效事务后,再执行删除 DELETE FROM undo_log WHERE log_created < DATE_SUB(NOW(), INTERVAL 1 DAY) AND log_status IN (0, 1);注意这个删除操作必须在业务低峰期执行,并且要确认数据对应的全局事务已经终结。如果业务上允许,也可以考虑在事务结束后由业务侧主动调用清理接口,但通用性不如定时任务。
8. 长期运维与演进建议
Seata落地不是装完配置就完事,长期运维有几个方向值得投入。
第一,监控体系要补全。与Seata相关的关键指标包括:全局事务提交成功率、全局事务回滚率、全局锁等待时间、undo_log残留量、TC线程池活跃度。这些指标对接Prometheus后,可以在Grafana上建一个专门面板。我设定的报警阈值是:全局事务回滚率连续5分钟超过10%就报警;全局锁等待超过1秒就报警;TC线程池活跃度超过80%持续5分钟就扩容。
第二,定期做故障演练。每季度做一次TC宕机演练、一次网络分区演练、一次数据库死锁演练。演练的作用是验证人工介入流程是否可执行。我有一次演练发现,人工提交悬挂事务时,由于DBA和开发没有统一的SQL脚本,光确认事务状态就花了两小时。后来我整理了一套标准的“悬挂事务处理脚本包”,分发到DBA和值班开发手里,演练时间缩短到20分钟。
第三,版本升级策略。Seata迭代速度不慢,小版本修复很多坑,大版本则会引入协议变化。我的原则是:不在大促前升级;升级顺序是先升级TC,再升级客户端;升级前必须跑一遍完整的订单库存压测脚本,确认内存、锁、回滚三个维度没有异常。
第四,归档旧数据。TC的global_table和lock_table会随时间增长,尤其事务量大的业务线。建议每天归档超过7天的已完成事务记录,归档到历史表,防止单表数据量过大影响TC查询性能。
如果团队有余力,还可以把Seata的分支事务状态同步到业务系统的审计表里,形成一条完整的“事务审计链路”。这样当最终对账发现数据不一致时,可以从业务侧直接定位到具体是哪个事务出现了问题,而不需要翻遍TC日志。
9. 结尾
最后聊一点我个人体会。Seata AT模式最吸引人的地方是它把分布式事务的门槛降得很低,但这也意味着团队容易忽视它背后的运行机制。我见过不少项目上线半年后,出了问题才回去翻undo_log表结构,才知道有global_table这张表的存在。
如果你正在规划分布式事务的落地,我的建议是把精力前置:先梳理清楚有哪些跨库操作、单事务分支数量、写热点分布、网络质量如何,再决定要不要引入Seata。引入之后,一定要保留至少一场故障演练,因为分布式事务的隐患多半不在正常路径上,而在故障路径上。
再分享一个小技巧:Seata的全局事务和本地事务叠加使用时,建议在代码注释里写明“这里为什么需要本地事务”“如果去掉本地事务会发生什么”。我看到过有人把@Transactional误删后,全局事务虽然能回滚,但本地查询在事务内看到的数据就是错的,排查起来非常绕。
希望这篇基于订单库存场景的完整落地记录,能帮你少走一些弯路。如果你们也有Seata AT模式的生产经验,欢迎多交流,尤其是锁冲突和事务悬挂这两块,不同业务形态下的处理方式差异还是挺大的。