跳到正文

分布式事务 ​

一句话:分布式事务是在「多个独立数据库各自提交」的前提下,用补偿或预留把一致性补回来;它换来的是一致性,代价是性能、复杂度和一段可见的中间状态。

适用版本: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. 缩短全局事务边界:查询、发消息、调第三方一律移出 @GlobalTransactional
properties
# 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 用资源预留换隔离性,但每个接口要写三套逻辑。选型顺序是:能不用就不用,能用本地消息表就不用分布式事务。

把一门技术讲透到既懂也会用