今年帮一个团队复盘线上事故时,我发现一个特别普遍的误区:一提微服务事务,很多人的第一反应就是"上 Seata",而且所有业务都往同一个模式里塞。结果订单这种强一致场景用 AT 模式被全局锁卡死,积分这种能容忍延迟的业务也压着 undo_log 不释放,更别提一堆压根不需要分布式事务的纯查询接口也被套了一层代理。这不是 Seata 的问题,是事务分级没做好。
微服务事务分级治理这件事,本质上是先把业务的一致性需求按强度拆开,再在 Seata 的 AT/TCC/SAGA/XA 四种模式里找到对应的方案,最后把数据库侧的分布式事务能力(比如 TDSQL)当作硬兜底。这篇把我自己从架构设计到线上排障的一整套经验写下来,给准备做分布式事务改造、或者已经在 Seata 里被各种问题折腾过的团队参考。
1. 先想清楚一个核心问题:业务到底需要什么粒度的事务
1.1 微服务拆出来的分布式事务困境
在单机单库时代,一个用户下单的流程,扣库存、写订单、加积分在同一个数据库事务里,ACID 由数据库保证。微服务一拆,问题就来了:这三个操作落在三个服务里,每个服务各自的数据库连接、各自的本地事务,原本"要么全成功、要么全失败"的语义被彻底打破了。
我拿最常见的下单场景说:库存服务扣减成功,订单服务创建失败。从数据库层面看,库存表那条更新已经提交了,订单表没有插入,两边都"成功"或"失败"了,但业务整体上是一个半成功状态。如果再叠加积分、优惠券、物流通知这些环节,失败组合会呈指数级增长:扣库存成功、订单失败、积分成功、通知失败……每一个组合都要写一套对账和补偿逻辑,业务代码根本撑不住。
所以分布式事务解决方案解决的是一个权衡问题:为了跨服务的原子性,你愿意付出多少性能、代码复杂度和运维成本。Seata 也好、TDSQL 也罢,它们不是在"有事务"和"没事务"之间做选择,而是在"什么业务值得开一个全局事务"上做选择。想明白这一点,才会理解为什么不能一套方案打天下。
1.2 一致性需求分级:L1 强一致、L2 最终一致、L3 尽力而为
我在实际项目里习惯把事务需求分成三个等级,这个分级是后续所有方案选型的基础。
| 级别 | 典型业务 | 一致性要求 | 推荐方案 |
|---|---|---|---|
| L1 强一致 | 扣减库存、账户扣款、优惠券锁定 | 秒级内必须一致,失败必须回滚或精确补偿 | Seata AT/TCC/XA,或数据库级分布式事务 |
| L2 最终一致 | 订单状态流转、用户积分、发货通知 | 允许短暂不一致,最终必须收敛到正确状态 | 可靠消息 + 幂等消费,或 Seata SAGA |
| L3 尽力而为 | 操作日志、埋点统计、异步聚合 | 允许丢失或明显延迟 | MQ 异步任务,无需事务中间件 |
为什么 L1 不用消息最终一致?因为"最终一致"意味着从失败到收敛之间存在时间窗口。库存扣减漏了或者扣错了,哪怕只差一秒,资金损失也是实实在在的;但积分少加两条,用户可能过几天才感知,补发完全来得及。业务的容忍度不同,技术方案的等级自然不同。
1.3 分级治理的第一个收益:把成本花在刀刃上
做过 Seata 的人都有体会,全局锁和分支事务快照是有实际开销的。AT 模式一阶段要写 undo_log,注册分支事务到 TC,二阶段提交前全局锁一直持有,热点数据行上的并发会被强制串行化。
我用同样规格的数据库压过同一张订单表:单机本地事务的 TPS 能到 2000+,一旦引入 Seata AT 模式的全局锁和分支注册,热点行并发下 TPS 直接掉到 300~400。当然这是数量级的参考,不同环境和数据分布会有差异,但方向是一致的——分布式事务的开销是真实的、可测量的。
分级治理的意义就在这里:如果把 L2、L3 的业务从 Seata 里请出去,用消息队列和异步任务处理,热点链路上清掉一大半不必要的事务负担,真正把全局事务的代价留给 L1 里那些必须强一致的业务。这不是技术保守,是架构取舍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Seata 模式选型:AT/TCC/SAGA/XA 各自的战场
2.1 AT 模式:无侵入的甜与全局锁的毒
AT 模式是很多团队入门 Seata 的第一站,原因无他:对业务代码侵入极低。你只需要在方法上加一个 @GlobalTransactional 注解,Seata 通过代理数据源自动完成分布式事务的协调。
原理拆开来说并不复杂:
- 一阶段:业务 SQL 和 undo_log 的写入在同一本地事务中提交,同时向 TC(Transaction Coordinator)注册分支事务。undo_log 里记录的是数据变更前后的镜像。
- 二阶段提交:所有分支事务都成功,异步删除 undo_log。
- 二阶段回滚:有分支失败,根据 undo_log 里的前后镜像反向生成补偿 SQL,恢复原数据。
java复制@GlobalTransactional(name = "create-order", timeoutMills = 30000)
public Order createOrder(OrderDTO orderDTO) {
// 扣减库存
stockService.deductStock(orderDTO.getSkuId(), orderDTO.getQuantity());
// 创建订单
orderMapper.insert(buildOrder(orderDTO));
return order;
}
但"无侵入"是双刃剑。因为你感知不到事务的存在,也就感知不到全局锁的存在。一阶段提交到二阶段结束这段时间,所有被修改过的行都会持有全局锁,其他事务对这些行的访问都要等待。热点商品卖 100 件,100 个并发分支事务挤在同一行库存上,全局锁等待就成了吞吐瓶颈。所以我的判断是:AT 模式适合中低并发的跨服务数据一致性场景,比如后台系统、订单状态流转、非热点资源的扣减。超高频热点读写,别想也不想就上 AT。
2.2 TCC:把预留和补偿显式化,适合核心资金链路
TCC 对应 Try、Confirm、Cancel 三个业务方法。Try 阶段做资源预留,Confirm 阶段确认提交,Cancel 阶段释放回滚。它和 AT 最大的不同是:补偿逻辑是业务自己定义的,前置的,不是事后反查出来的。
我用优惠券锁定的例子说明。Try 阶段把优惠券状态改成"锁定中",同时记录锁定的业务单号;Confirm 阶段把状态改成"已使用";Cancel 阶段把状态改回"可使用",同时清掉业务单号。相比 AT 的"二阶段根据日志反查再补偿",TCC 在一开始就确定了资源的所有权和释放路径。
java复制@LocalTCC
public interface StockService {
@TwoPhaseBusinessAction(name = "deductStock", commitMethod = "confirmDeduct", rollbackMethod = "cancelDeduct")
boolean deductStock(@BusinessActionContextParameter(paramName = "skuId") Long skuId,
@BusinessActionContextParameter(paramName = "quantity") Integer quantity);
boolean confirmDeduct(BusinessActionContext context);
boolean cancelDeduct(BusinessActionContext context);
}
TCC 的代价也很明显:三个方法都要自己保证幂等。网络抖动时,Confirm 和 Cancel 都可能被重复执行;预留的资源如果长时间不确认,会造成资源占用。这些都需要在业务表里加状态字段和业务唯一键来控制。但核心资金链路我依然推荐 TCC,因为资源冲突被前置到了 Try 的短事务里,跟 AT 那种把冲突拖到二阶段才暴露的方式相比,可控性完全不同。
2.3 SAGA:长流程编排的理智选择
SAGA 适合业务流程特别长、中间有外部系统参与、不方便用 TCC 三段式切割的场景。Seata 的 SAGA 模式用状态机来编排流程,每个子事务配置一个补偿动作,通过 JSON 定义流转关系。
比如一个订单履约流程:下单、支付、仓库出库、物流发货。这里的每一步都可能涉及外部系统,而且流程可能持续几分钟甚至几小时。TCC 要求的 Try/Confirm/Cancel 三段式设计在这种长流程里非常别扭,SAGA 状态机反而更自然。
json复制{
"stateMachine": {
"name": "orderFulfillment",
"startState": "deductStock",
"states": {
"deductStock": {
"type": "ServiceTask",
"serviceName": "stockService",
"serviceMethod": "doDeduct",
"compensateState": "compensateDeduct",
"next": "createOrder"
},
"createOrder": {
"type": "ServiceTask",
"serviceName": "orderService",
"serviceMethod": "doCreate",
"compensateState": "compensateCreate",
"next": "success"
}
}
}
}
SAGA 有个特性值得留意:它支持向前恢复和向后恢复。向前恢复是从失败点继续往后执行后续步骤,向后恢复是触发前面所有已完成步骤的补偿。这给运维留了很大的弹性空间:有些不可逆的外部调用(比如快递已经揽收)不适合回滚,那就设计一个人工介入点,而不是机械地全量回滚。配置状态机的时候,补偿动作本身也要做好幂等和记录,这是长流程最容易翻车的地方。
2.4 XA:把两阶段提交交还给数据库
Seata 还支持 XA 模式。XA 是数据库原生的分布式事务协议,事务的原子性由数据库自身保证,应用不需要写补偿逻辑,也不需要 undo_log。相比 AT,XA 在事务语义上更干净;相比 TCC,XA 不需要业务代码写三个方法。
但 XA 的硬约束是:所有参与事务的数据库必须原生支持 XA 协议,而且事务期间数据库连接和资源会一直被持有,长事务会导致连接池被占满。这个特点决定了 XA 不太适合长流程,更适合短小精悍的、对强一致和简单性要求高的场景,尤其是底层就是分布式数据库的情况。
四种模式对比下来,选型的核心逻辑可以收敛成一张表:
| 模式 | 侵入性 | 一致性强度 | 性能特征 | 适用场景 |
|---|---|---|---|---|
| AT | 低 | 最终一致(undo_log 补偿) | 全局锁,热点行吞吐下降 | 中低并发跨服务链路 |
| TCC | 高 | 强一致 | 短事务预留,但实现复杂 | 资金、库存等核心热点 |
| SAGA | 中 | 最终一致 | 状态机编排有额外开销 | 长流程、外部系统参与 |
| XA | 低 | 强一致 | 长事务占用数据库连接 | 数据库原生支持、跨分片 |
3. TDSQL 在事务治理中的角色:数据库端的硬约束兜底
3.1 为什么需要数据库参与分布式事务
很多人会忽略一个问题:Seata 解决的是服务间调用的事务一致性,但数据分片间的一致性,Seata 管不到。
举个例子。一张订单表被拆到 TDSQL 的多个分片上,同一个业务事务里需要更新分片 A 的订单数据,再更新分片 B 的库存数据。对应用来说,这是同一个数据库里的两条 SQL,但对底层数据节点来说,这是两个物理分片上的独立操作,必须有一个机制来协调它们的提交和回滚。这件事靠业务代码补偿非常痛苦,而且容易错,最合理的方式是让数据库内核直接处理。
TDSQL 的价值就在这里。它把跨分片的两阶段提交下沉到了存储节点之间的协调层面,应用侧看到的仍然是一个兼容 MySQL 语法和事务语义的"大库"。
3.2 TDSQL 全局事务的落地方式
以我实际接入的经验看,TDSQL 的分布式事务对应用层是相当透明的。你在事务里照常写 BEGIN、COMMIT、ROLLBACK,数据库内部会为每个分布式事务分配全局唯一的事务标识,事务里涉及的每个分片操作都登记在这个标识下。提交时由数据库协调所有分片,任何一个分片失败,整个事务回滚。
这里有一个对应用层体验影响很大的设计:TDSQL 使用全局单调递增的事务标识来保证跨分片的一致性读。也就是说,即使在多个分片上都做了读写操作,应用侧读到的是一个全局一致的快照,不会出现分片 A 已经提交、分片 B 还没提交导致的"中间态"。对有账务类业务的团队来说,这个特性比任何业务层的补偿逻辑都可靠。
接入时的几个注意点:
- 确认你的 TDSQL 实例开启了分布式事务能力,有些默认配置需要显式打开。
- 跨分片事务越短越好。底层协调的代价和数据节点数量、网络延迟正相关,长事务会放大这个开销。
- 分片键的设计决定事务的局部性。同一个事务的多个操作如果能限定在同一个分片内,性能会显著更好;跨分片的事务比例应该控制在一个低位。
3.3 Seata 与 TDSQL 的分工边界
我在一个订单系统里同时用到了 Seata 和 TDSQL,分工是明确的:
| 场景 | 负责方 |
|---|---|
| 跨微服务调用的一致性 | Seata 协调各服务分支事务 |
| 单个应用内跨分片数据操作 | TDSQL 原生分布式事务保证 |
| 既有跨服务调用、又有跨分片操作的混合长链路 | Seata 负责服务编排和补偿,TDSQL 保证库内操作的原子性 |
这里最核心的原则是避免重复治理。如果 Seata 和数据库同时对同一段链路做两阶段提交,事务开销直接翻倍,还可能出现协调冲突。所以要先分级:一段链路只让一个事务框架负责。服务间的一致性交给 Seata,库内的跨分片一致性交给 TDSQL,各管一段,互不越界。
4. 一个订单场景的分级治理落地全过程
4.1 购买链路的事务级别定义
我用一个典型的购买链路来演示完整落地方案:用户下单,涉及库存服务、订单服务、优惠券服务、积分服务、消息通知。
先做分级:
| 链路节点 | 事务级别 | 技术方案 |
|---|---|---|
| 扣减库存 | L1 | TCC,Try 阶段冻结库存 |
| 创建订单 | L1 | Seata AT / TDSQL 本地事务 |
| 锁定优惠券 | L1 | TCC,Try 阶段锁券 |
| 累积积分 | L2 | 可靠消息 + 幂等消费 |
| 推送通知 | L3 | MQ 异步发送 |
这个分级不是拍脑袋定的。库存和优惠券直接涉及资金和关键资源,任何不一致都会在秒级造成损失,所以走 TCC。积分晚几分钟到达用户基本无感,用消息通知积分服务异步处理,配合幂等表去重。通知更是允许失败重试,不需要进事务。
4.2 Seata AT 接入的完整步骤与配置示例
在订单服务里接入 AT 模式,需要做这几件事:
- 引入依赖
xml复制<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>对应你项目的版本</version>
</dependency>
- 配置 Seata 客户端。我这里给一个 Spring Boot 项目的核心配置片段,实际以你的 Seata Server 版本为准:
yaml复制spring:
application:
name: order-service
seata:
enabled: true
application-id: order-service
tx-service-group: my-tx-group
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: public
group: SEATA_GROUP
service:
vgroup-mapping:
my-tx-group: default
data-source-proxy-mode: AT
- 在业务数据库里创建 undo_log 表。这个表是 AT 模式的核心依赖,每个参与事务的业务库都必须有:
sql复制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,
`ext` VARCHAR(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8;
- 在发起全局事务的方法上添加注解。注意,@GlobalTransactional 应该加在整个业务链路的入口方法上,而不是每个服务方法上。很多团队加错位置,导致事务范围失控,分支事务满天飞。
4.3 TCC 接口设计与幂等处理
库存扣减的 TCC 接口,我建议在库存表里加两个字段:frozen_quantity(冻结数量)和 frozen_order_no(冻结业务单号)。Try 阶段增加冻结数量,Confirm 阶段把冻结数量转成实际扣减,Cancel 阶段释放冻结数量。
java复制@LocalTCC
public interface InventoryService {
@TwoPhaseBusinessAction(name = "freezeStock", commitMethod = "confirmFreeze", rollbackMethod = "cancelFreeze")
boolean freezeStock(@BusinessActionContextParameter(paramName = "skuId") Long skuId,
@BusinessActionContextParameter(paramName = "quantity") Integer quantity,
@BusinessActionContextParameter(paramName = "orderNo") String orderNo);
boolean confirmFreeze(BusinessActionContext context);
boolean cancelFreeze(BusinessActionContext context);
}
幂等处理是 TCC 最容易踩的坑。Confirm 和 Cancel 在极端情况下会被重复调用,所以我在实现里会用 frozen_order_no 做唯一约束,重复执行时先查一下状态,已经处理过就直接返回成功。另外还要处理"空回滚":如果 Try 阶段因为超时没执行成功,但 Cancel 被触发了,这时库里根本没有冻结记录,Cancel 要能识别出来并安全跳过。这个细节不做,线上就会出"补偿把自己补偿错了"的诡异问题。
4.4 TDSQL 接入验证与压测观察
订单表在 TDSQL 里按用户维度分片,创建订单这个操作本身不复杂,关键是订单创建和某些状态更新在同一事务里跨了分片。这部分我完全没有走 Seata,而是直接用 TDSQL 的本地事务语法:
sql复制START TRANSACTION;
UPDATE order_info SET status = 'PAID' WHERE order_no = '...';
UPDATE order_pay SET pay_status = 'SUCCESS' WHERE order_no = '...';
COMMIT;
这两条 SQL 如果落在不同分片,TDSQL 会在内部协调提交,业务代码不需要感知分片的存在。
压测时我专门对比过"全链路走 Seata AT"和"库存/优惠券走 TCC + 积分走消息 + 跨分片走 TDSQL"两套方案。同样是 500 并发下单,前者的 TPS 徘徊在 350 左右,经常报全局锁等待超时;后者的 TPS 能稳定到 800 以上,且基本没有分布式事务相关的报错。差距主要来自热点库存行不再被 AT 的全局锁长时间占用了。
5. 高并发下的踩坑与排障:全局锁、死锁、回滚失败
5.1 热点行的全局锁等待:从压测报告反查根因
先说症状:压测一上去,下单接口的 RT 从 50ms 飙升到 3s,TPS 上不去,错误率明显升高。这时候别急着一股脑加机器,先看 Seata 的 TC 日志和业务日志。
我当时的排查链路是这样的:
- 业务日志里看到大量
GlobalLockWaitTimeoutException,说明有分支事务在等待全局锁超时。 - 去 TC 日志里过滤当秒注册的分支事务,发现大量分支事务的
xid都指向同一个库存流水号。 - 到库存表里查这个 SKU,发现它的
frozen_quantity字段一直在波动,说明所有并发下单都在抢同一行的锁。
根因很清楚:库存扣减用了 AT 模式,热点 SKU 的行在事务提交前一直处于全局锁保护下,其他事务全部排队。AT 的全局锁粒度是"行",对热点行来说是致命的。
解法是把库存扣减从 AT 换成 TCC,Try 阶段用短事务冻结库存,Confirm/Cancel 阶段只操作冻结字段,全局锁的持有时间从几百毫秒降到了几十毫秒。换完再压测,同样的 500 并发,秒过。
5.2 回滚失败的常见原因与处理
Seata 最常见的回滚失败原因是"脏写冲突"。原理不复杂:一阶段提交后,undo_log 里记录了数据的前后镜像。如果二阶段回滚前,其他事务已经把这行数据改成别的值了,Seata 按旧镜像反向补偿就会冲突。
我记得有一次线上遇到回滚失败,追查下来是这个链路:库存服务一阶段提交成功,订单服务超时触发全局回滚,但这个时候用户已经在前端发起了一次"取消订单"操作,把库存表里那行的状态改成"已释放"。回滚线程拿着旧镜像想去恢复,发现状态对不上,回滚失败。
处理这种问题的经验,总结成三条:
@GlobalTransactional的timeoutMills要按链路真实耗时来设置,给足余量,避免正常慢请求被误判失败。- 回滚失败不能只靠日志,要配告警和补偿任务。Seata 支持从 TC 端查看到未结束的全局事务,配合业务侧的对账任务,把失败事务捞出来人工处理。
- 业务表尽量保留操作流水,比如 TCC 里我提到的
frozen_order_no。真到需要人工介入的时候,流水是唯一可靠的信息源。
5.3 事务治理需要盯住的四个关键指标
用过一段时间 Seata 和 TDSQL 之后,我让团队在监控大盘上固定了四个指标,每次压测和线上排查都先看它们:
| 指标 | 来源 | 关注原因 |
|---|---|---|
| 活跃全局事务数 | Seata TC 监控 | 数量异常增长说明有事务卡住或超时 |
| 平均全局事务提交耗时 | Seata TC 监控 | 超过阈值说明全局锁竞争或网络开销高 |
| 回滚率 | Seata TC 监控 | 回滚率异常升高说明链路里有不稳定环节 |
| undo_log 表数据量 | 业务库监控 | 积压说明二阶段异步清理跟不上,或者有未完成事务 |
这四个指标不是孤立看的。比如活跃全局事务数和 undo_log 数据量同时涨,基本可以判断分支事务在二阶段提交或回滚上卡住了,这时候去翻 TC 日志,重点看是否有分支事务注册超时或者全局锁等待。TDSQL 侧则主要盯跨分片事务的执行耗时和失败率,这两项能直接反映分片键设计和长事务治理的水平。
最后说几句实在的
踩过几次事务相关的坑之后,我最大的体会是:事务分级这件事,越早做,后面排障越省力。不要追求所有业务都在一套框架下统一处理,每个业务的一致性需求不一样,技术方案就应该不一样。Seata 和 TDSQL 只是工具,真正决定系统稳不稳的,是你有没有在动手之前把"这段链路到底需要什么级别的一致性"想清楚。
再分享一个小技巧:上线前做一次混沌测试,人为制造下游服务的超时和重启,然后观察补偿链路是不是按预期回滚。这个动作花不了多少时间,但能提前暴露大量纸上设计看不出的问题——比如补偿方法本身不幂等、某个外部调用没有处理回滚、状态机某个分支缺少人工介入点。这些坑,压测报告不会告诉你,但事故复盘一定会。
