主题
分布式事务
一句话:分布式事务是在「多个独立数据库各自提交」的前提下,用补偿或预留把一致性补回来;它换来的是一致性,代价是性能、复杂度和一段可见的中间状态。
适用版本:Seata 2.x(AT/TCC/Saga)。Spring Cloud Alibaba 2023.0.x 对应的 Seata 版本以官方兼容矩阵为准。
为什么需要
单体应用里,「创建订单 + 扣库存」在同一个数据库、同一个本地事务里,要么都成功,要么都回滚。拆成两个服务后,两个库各自提交:订单库提交成功、库存库扣减失败,订单已经存在,库存没扣,数据对不上。
分布式事务要解决的就是这个「部分成功」。但 CAP 决定了它不可能既强一致又高可用:所有方案都在一致性、可用性、性能之间做取舍。
核心机制
三个角色
| 角色 | 职责 |
|---|---|
| TM(Transaction Manager) | 定义全局事务边界:Begin、Commit、Rollback |
| TC(Transaction Coordinator) | 维护全局/分支事务状态,协调提交或回滚 |
| RM(Resource Manager) | 管理一个数据库资源,执行本地 SQL、注册分支、上报状态 |
TC 不执行业务 SQL;它只维护状态和全局锁。TC 挂了,已提交的本地事务不会自动回滚,但新全局事务无法开启。
AT 模式:阶段一到底做了什么
这是最容易讲错的部分。AT 的阶段一不是「先提交,失败再想办法」,而是一个有明确顺序的本地事务:镜像、undo_log、全局锁三件事都在本地提交之前完成。
第 5 步拿不到全局锁时不会走到本地提交:RM 按重试策略等待,超过上限就回滚这个本地事务,所以热点行上的全局锁竞争是 AT 高并发下的主要瓶颈。
关键点:
undo_log和业务 SQL 在同一个本地事务里,保证「业务写了,回滚信息一定在」。- 全局锁在 TC 侧,不是业务表上的
SELECT ... FOR UPDATE;它是lock_table里的一行记录,锁的 key 是「表名 + 主键」,用来阻止其他全局事务写同一行。 - 本地事务提交后,本地数据库锁释放,但全局锁要等到阶段二才释放。
阶段二:提交与回滚
- 提交:TC 通知 RM 删除对应
undo_log,释放全局锁。删除可以异步,失败会重试。 - 回滚:RM 先校验 after image 与当前数据是否一致(不一致说明被脏写,需人工介入),再用 before image 生成反向 SQL 执行,最后删除
undo_log、释放全局锁。
undo_log 的 rollback_info 存的是 before + after 两份镜像,不只是 before;log_status 0 表示正常,1 表示全局事务已结束、可清理(具体取值以 Seata 版本为准)。
隔离性:AT 默认是读未提交
AT 的阶段一已经提交,其他事务可能读到「已提交但可能被回滚」的数据。Seata 的全局锁只阻止其他全局事务写同一行,不阻止本地事务读。
需要读已提交语义时:
- 读方也开启全局事务,或用
@GlobalLock+SELECT ... FOR UPDATE,让读操作参与全局锁。 - 或者业务上接受短暂脏读,靠对账/补偿兜底。
这是 AT 的已知代价,不是 bug。
为什么不用 XA
XA 在数据库驱动层做两阶段提交:准备阶段锁定资源,等协调者决策,提交/回滚后才释放。锁持有时间长,长事务会拖垮吞吐。
AT 把「锁」从数据库行锁换成了 TC 的全局锁:阶段一尽早提交、释放本地锁,阶段二再补偿。代价是隔离性变弱、依赖 undo_log、回滚可能遇到脏写。两者不是谁更先进,是取舍不同。
TCC 与 Saga
- TCC:Try 预留资源、Confirm 确认、Cancel 释放。没有全局锁,性能好,隔离性强;代价是每个接口写三套逻辑,Confirm/Cancel 必须幂等。
- Saga:一串本地事务 + 反向补偿,适合长流程、跨外部系统;代价是没有隔离性,补偿可能失败,需要人工兜底。
选型顺序:能不用分布式事务就不用 → 本地消息表 + 补偿 → AT → TCC/Saga。
实战场景
一、热点库存行:全局锁把大促变成排队
现象:秒杀活动开始 30 秒内,下单接口 P99 从 200ms 涨到 4 秒,日志出现 LockKeyConflict: ...stock:10086,回滚率 12%(经验值,需校准);单看数据库,innodb_trx 里几乎没有长事务,行锁等待也不高。
排查:AT 的锁在 TC 侧,数据库监控看不到它。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 全局锁冲突 | 应用日志 lock key conflict;TC 侧 lock_table 按 row_key 聚合计数 | 同一 row_key(表名+主键)高频 → 热点行 |
| 等待发生处 | trace 里耗时集中在“分支注册/申请全局锁”还是业务 SQL | 集中在申请全局锁 → 不是 SQL 慢 |
| 本地锁与全局锁 | performance_schema.data_lock_waits 无等待,但分支注册慢 | 印证瓶颈在 TC,不在 InnoDB |
| 重试配置 | RM 侧全局锁重试次数与间隔 | 超过上限就回滚本地事务,表现为“偶发失败率上升” |
| 并发形态 | 同一 SKU 的下单是否集中在少数实例 | 并发越集中,锁队列越长 |
根因是 AT 的结构性代价:全局锁按“表名 + 主键”逐行申请,热点行上所有全局事务被迫串行,且阶段二才释放。
对症做法:
sql
-- 1. 把一行库存拆成 N 行,把锁竞争摊薄(最直接的解法)
-- stock(id, sku, bucket_no, qty),扣减时随机选一个桶
update stock set qty = qty - ? where sku = ? and bucket_no = ? and qty >= ?;java
// 2. 热点写改 TCC:Try 冻结、Confirm 扣减、Cancel 解冻,不依赖全局锁串行
@TwoPhaseBusinessAction(name = "stockTry", commitMethod = "confirm", rollbackMethod = "cancel")
BusinessActionContext tryDeduct(BusinessActionContext ctx, @BusinessActionContextParameter(paramName = "sku") String sku, int qty);
// 3. 或者放弃跨库强一致:扣库存本地事务 + 本地消息表驱动订单状态,最终一致
// 4. 缩短全局事务边界:查询、发消息、调第三方一律移出 @GlobalTransactionalproperties
# 5. 全局锁等待上限要显式,避免无限排队拖垮调用方线程
# 具体键名以当前 Seata 版本配置项为准(client.rm.lock.retry.* 一族)选择顺序仍是:能不用分布式事务就不用 → 本地消息表 → AT → TCC/Saga。热点行是 AT 的已知瓶颈,不是可以调参数绕开的偶发问题。
复盘要点:容量评估要按“热点行的锁吞吐”算一次:单个 row_key 的全局锁串行化,理论上限就是每秒几个全局事务(经验值,需校准,必须实测)。秒杀类需求在设计阶段就要拆桶或用 TCC,压测里要专门造一个单 SKU 高并发的用例,而不是只测多 SKU 均匀分布。
二、读到“已提交但会被回滚”的数据:AT 的默认隔离级别
现象:一次超卖投诉。详情页展示了“仅剩 1 件”,用户下单成功后支付时提示库存不足;对账发现某笔库存回滚发生在页面读取之后 200ms。数据库隔离级别是 REPEATABLE READ,看起来“不该读到脏数据”。
排查:AT 的阶段一已经本地提交,全局锁只挡其他全局事务写同一行,不挡本地事务读。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 读方是否在 XID 内 | trace 里读请求是否有全局事务上下文 | 没有 → 读不参与全局锁 |
| 读方用什么读 | 是普通 select 还是 @GlobalLock + SELECT ... FOR UPDATE | 普通 select 会读到待回滚数据 |
| 是否发生过回滚 | undo_log 中该 xid 的记录与 log_status;TC 全局事务状态 | 已回滚但读发生在回滚前 → 脏读确认 |
| 数据库隔离级别 | 该库的 transaction_isolation | 无关,隔离级别管不了跨库补偿 |
| 业务后果 | 是否以展示值做了扣减判定 | 展示值当事实用,才是真正的问题 |
这不是 bug,是 AT 换性能付出的代价,文档里必须写清“默认读未提交”。
对症做法:
java
// 1. 需要读已提交语义时,让读也参与全局锁
@GlobalLock // 只参与全局锁,不开全局事务
@Transactional(readOnly = true)
public StockView queryWithGlobalLock(long skuId) {
return stockMapper.selectForUpdate(skuId); // SELECT ... FOR UPDATE,阻塞等待全局锁释放
}
// 2. 关键判定不用“读展示值”,用受控写:扣减依赖数据库原子条件与影响行数
int n = stockMapper.deductIfEnough(skuId, qty, requestId); // where qty >= ?
if (n == 0) return Result.soldOut();
// 3. 展示层明确标注“库存以结算为准”,或给展示值加短时 TTL业务上接受短暂脏读、靠对账兜底也是一种合理选择,但要写成显式决定,而不是默认状态被当成强一致。
复盘要点:跨服务读一致性要按“谁可能处于待回滚状态”来设计。规则可以简化成一句:判定用写、展示用读——凡是要据此改变状态的数值,必须走带条件的原子写,不能依赖前一次查询结果。前端展示要能标出数据时点,避免用户按过期数值做决策。
三、回滚卡住:after image 校验失败意味着脏写
现象:全局事务回滚后库存没有恢复。日志里 undo dirty data、rollback failed because of after image mismatch;undo_log 里那条记录一直存在,TC 侧全局事务停在 Commiting/Rollbacking 状态,人工删了 undo_log 之后数据彻底对不上。
排查:回滚前先比对 after image 与当前数据,不一致说明这条行在阶段一之后被别人改过。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 校验失败的具体列 | 日志里的 after image 与当前行数据逐列比对 | 差异列即被改动的位置 |
| 谁改的 | 该行的 binlog(where id = ? + 时间窗)+ 各服务的写日志 | 找到非 Seata 通道的写 |
| 是否绕过全局锁 | 改动方是否是本地事务/脚本/DBA 手工 | 本地写不受 TC 全局锁约束,这是最常见来源 |
| 是否有脏数据被覆盖 | 其他全局事务是否也在同一行上 | 多事务交叉时后果不可预期 |
| 删除记录的影响 | undo_log 是否被手工清理 | 删了就等于放弃自动回滚能力 |
对症做法:
text
1. 约束写通道:AT 管理的表只允许通过带 XID 的服务写入
- 数据库账号权限收回给运维/脚本的直连写;变更走工单
- 定时任务、修数脚本必须走服务接口,或临时纳入全局事务
2. 回滚失败的正确处置:
- 先确认是 TC/RM 连接问题还是镜像校验失败,处置完全不同
- 镜像不一致时不要手工删 undo_log;按差异做数据修复并留记录
3. 兜底机制:日级对账(订单数 vs 库存扣减流水),差异进入待处理队列而不是直接改库阶段二会持续重试直到成功,所以“回滚慢”通常是连通性或锁的问题,不是要清理记录的理由。
复盘要点:脏写的根源几乎都是“同表有多个写入口”,这与 03 篇里跨 namespace 写同一张表是同一类问题在不同层的投影。把“AT 管理的表清单 + 唯一写服务”做成架构资产并定期扫描 binlog 来源;对账口径要覆盖“回滚未完成的 XID”,让这类故障在小时级被发现,而不是等用户投诉超卖。更完整的方案对比见 微服务治理与架构决策。
最小上手
起一个 Seata Server(2.x,单机 file 模式仅用于验证):
bash
docker run -d --name seata-server -p 8091:8091 -p 7091:7091 \
-e STORE_MODE=file seataio/seata-server:2.0.0
# 7091 是控制台,8091 是 TC 端口;生产用 db 模式并集群部署每个业务库建 undo_log:
sql
CREATE TABLE undo_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME(6) NOT NULL,
log_modified DATETIME(6) NOT NULL,
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;应用配置(2.x):
yaml
seata:
enabled: true
application-id: order-service
tx-service-group: my_tx_group
service:
vgroup-mapping:
my_tx_group: default
grouplist:
default: 127.0.0.1:8091
registry:
type: file入口方法加 @GlobalTransactional,远程调用用 Feign/Dubbo 正常写。验证:调用后查 undo_log 是否有记录、日志里是否有 XID、失败时库存是否回滚。生产把 registry.type/config.type 换成 Nacos,store.mode 换成 db。
生产动作
- undo_log 必须建在每个业务库:漏建会在分支注册时报错;表结构随 Seata 版本升级,升级前先看 release notes。
- TC 高可用:多实例 + 共享数据库(
store.mode=db);TC 的lock_table/global_table是恢复依据,不能丢。 - 大字段:BLOB/TEXT 会让
undo_log膨胀,尽量不要放进 AT 管理的表;必须放时评估磁盘和回滚耗时。 - 超时与重试:阶段二会持续重试,直到成功;不要因为「回滚慢」手工删
undo_log,先恢复 RM 与 TC 的连通性。 - 端口:TC 8091、控制台 7091;容器/防火墙要放通,RM 连不上 TC 时表现为分支注册失败。
- 排障:回滚不结束 → 查 TC 与 RM 连接、
undo_log是否完整、全局锁是否被占;数据被改过 → 查 after image 校验失败日志。 - 长事务:
@Transactional里调 Feign 会把本地事务拖长;远程调用放事务外,或改本地消息表。
边界
- 能不用就不用:跨库写入如果能通过业务设计避免(合并库、异步补偿、幂等重试),优先避免。
- 不适合长事务/人工审批流:全局锁持有时间长,吞吐会崩;长流程用 Saga 或状态机。
- 不适合跨外部系统:银行、第三方支付没有
undo_log,只能用 TCC/Saga/对账。 - AT 不是强隔离:默认读未提交;需要读已提交要额外加全局锁,性能和复杂度都上升。
- TCC 的成本在业务:三套接口、幂等、悬挂/空回滚都要处理,不是换个注解就能上。
讲清楚
分布式事务解决的是「多个库各自提交,部分成功怎么办」。Seata AT 的阶段一是一个本地事务:查 before image、执行业务 SQL、查 after image、写 undo_log、向 TC 注册分支并申请全局锁,然后提交本地事务;全局锁在 TC 的 lock_table 里,不是业务表的 SELECT FOR UPDATE。阶段二提交就删 undo_log,回滚就用 before image 生成反向 SQL,并先校验 after image 有没有被改过。它的代价是默认读未提交、依赖 undo_log、回滚可能遇到脏写。XA 是「锁着等判决」,AT 是「先提交再补偿」;TCC 用资源预留换隔离性,但每个接口要写三套逻辑。选型顺序是:能不用就不用,能用本地消息表就不用分布式事务。