Seata AT 模式原理
问题背景
2024年618大促前夕,我们团队将订单服务迁移到 Seata AT 模式来处理分布式事务。灰度第一天晚上,运维告警发现订单服务的 RT(响应时间)从 20ms 飙升到 800ms,DBA 报告数据库出现了大量锁等待。
更诡异的是:数据库里只有 3 个表,总共不到 100 个连接池,但锁等待的数量超过了 500。
排查了一整夜才发现根因:两个用户同时下单,触发了跨库的分布式事务。A 分支事务持有表 A 的某行锁,B 分支事务持有表 B 的某行锁,但两个事务互相等待对方的全局锁——经典的死锁,但这次死的是全局锁,而不是数据库本地锁。
// 事故代码
@Transactional(rollbackFor = Exception.class)
public void placeOrder(OrderDTO dto) {
orderService.create(dto); // 分支事务 1: 操作 order 表
inventoryService.deduct(dto); // 分支事务 2: 操作 inventory 表
paymentService.charge(dto); // 分支事务 3: 操作 payment 表
}
三个分支事务分别持有各自数据库的本地锁,同时等待 TC(Transaction Coordinator)颁发的全局锁。由于 Seata AT 模式的全局锁是 First-Begin-Wins 策略,两个并发事务都在等待对方的锁,形成了循环等待,最终死锁。
这次事故让我们彻底重新审视了 Seata AT 模式的设计原理和局限性。
【架构权衡】
AT 模式最大的卖点是"业务无感知"——你像写普通 SQL 一样写代码,Seata 自动帮你管理分布式事务。但代价是全局锁。这把双刃剑在高并发场景下会严重限制吞吐量。理解 AT 模式的本质,是正确使用它的前提。
问题定义
Seata AT 模式解决的是"如何在不改变业务代码的情况下,实现分布式事务的一致性"。
传统的分布式事务方案(如 2PC/TCC)对业务代码侵入严重——你需要显式编写分支事务的提交/回滚逻辑。而 AT 模式通过解析 SQL + 自动生成 UndoLog 的方式,让业务代码完全不用改。
但 AT 模式不是 2PC!这是面试和实际使用中最容易混淆的点。
AT 模式与 2PC 的本质区别
2PC(两阶段提交)的事务流程是:
flowchart LR
A[TM 向 TC 发起全局事务] --> B[阶段一:Prepare<br/>所有分支事务执行<br/>但不提交<br/>持有数据库锁]
B --> C{所有分支 Prepare 成功?}
C -->|是| D[阶段二:Commit<br/>所有分支提交]
C -->|否| E[阶段二:Rollback<br/>所有分支回滚]
D --> F[事务结束]
E --> F
style B fill:#ffcccc
2PC 的核心问题是:阶段一(Prepare)会持有数据库锁直到阶段二结束。如果 TC 在阶段一和阶段二之间挂了,所有分支事务的数据库锁都会悬挂,系统hang住。
AT 模式的本质是一阶段执行 + 二阶段异步删除 UndoLog:
flowchart TD
A[TM 发起全局事务] --> B[一阶段:执行 SQL + 生成 UndoLog<br/>本地事务提交,释放 DB 锁]
B --> C[注册分支到 TC<br/>TC 记录全局事务状态]
C --> D{全局事务结果}
D -->|COMMIT| E[二阶段:异步删除 UndoLog<br/>不持有任何锁]
D -->|ROLLBACK| F[二阶段:根据 UndoLog<br/>生成反向 SQL 回滚]
style B fill:#e1f5ff
style E fill:#e1f5ff
style F fill:#e1f5ff
关键区别:AT 模式在一阶段就提交了本地事务、释放了数据库锁。二阶段只是善后操作(删 UndoLog 或执行回滚),不需要再持有数据库锁。
💡
这就是 AT 模式比传统 2PC 性能好的根本原因:数据库本地锁的持有时间极短(只有 SQL 执行的时间,通常毫秒级),大部分时间锁是在 TC 的协调器层面管理的,而不是在数据库层面。
核心设计
分支事务的执行流程
AT 模式的分支事务执行分为四个步骤:
sequenceDiagram
participant App as Application
participant RC as ResourceManager<br/>(Seata JDBC Driver)
participant DB as Database
participant TC as Transaction Coordinator
App->>RC: 执行 SQL
RC->>DB: 解析 SQL,查询原数据
Note over RC: 根据 SQL 类型生成<br/>UndoLog(前置镜像 + 后置镜像)
RC->>DB: 执行 SQL
Note over RC: UndoLog 已在同一事务中<br/>写入 seata 的 undo_log 表
DB-->>RC: SQL 执行结果
RC->>TC: 注册分支事务<br/>携带 Undolog 信息
RC->>DB: COMMIT 本地事务
Note over RC: 本地事务提交后<br/>DB 锁立即释放
RC-->>App: 执行完成
具体来看一条 Update SQL 的 UndoLog 是怎么生成的:
// Seata AT 模式会自动拦截你的 SQL
// 假设你执行了这条 SQL:
UPDATE order SET status = 'PAID' WHERE id = 100;
// Seata 的 DataSourceProxy 会:
// ① 执行前查询:SELECT * FROM order WHERE id = 100;
// → 生成前置镜像 (before_image)
// ② 执行这条 UPDATE
// ③ 执行后查询:SELECT * FROM order WHERE id = 100;
// → 生成后置镜像 (after_image)
// ④ 将两个镜像写入 undo_log 表(在同一个本地事务中)
生成的 UndoLog 结构:
{
"id": "uuid-xxx",
"transactionId": "global-tx-id",
"branchId": "branch-1",
"undoItems": [
{
"tableName": "order",
"beforeImage": {
"id": 100,
"status": "PENDING",
"amount": 100.00
},
"afterImage": {
"id": 100,
"status": "PAID",
"amount": 100.00
}
}
]
}
当全局事务需要回滚时,Seata 会根据 beforeImage 生成反向 SQL:
-- Seata 自动生成的反向 SQL
UPDATE order SET status = 'PENDING' WHERE id = 100;
全局锁的设计
Seata AT 模式引入了一个全局锁的概念,这是理解 AT 模式性能问题的关键。
全局锁由 TC(Transaction Coordinator)维护,不是数据库的本地锁。TC 维护了一个全局锁表:
-- 全局锁表(Seata Server 侧)
lock_table (
row_key, -- 锁定行的唯一标识: 表名:主键值 (如 order:100)
xid, -- 全局事务 ID
branch_id, -- 分支事务 ID
resource_id, -- 数据库资源 ID
table_name, -- 表名
pk -- 主键值
)
flowchart TD
A[事务 A: UPDATE order<br/>WHERE id = 100] --> B[向 TC 申请全局锁<br/>order:100]
B --> C{TC 返回结果}
C -->|获取成功| D[执行 SQL<br/>写入 UndoLog<br/>提交本地事务]
C -->|获取失败| E[本地重试<br/>等待 TC 释放锁]
F[事务 B: UPDATE order<br/>WHERE id = 100] --> G[向 TC 申请全局锁<br/>order:100]
G --> H{TC 返回结果}
H -->|获取失败| I[本地重试<br/>轮询 TC]
H -->|获取成功| J[执行 SQL...]
style E fill:#fff3cd
style I fill:#fff3cd
全局锁的获取发生在一阶段执行 SQL 之前。这意味着:即使数据库本地锁在一阶段结束后就释放了,全局锁仍然被持有,直到全局事务结束。
⚠️
这就是我们团队618翻车的根因:两个并发事务分别持有不同表(order 和 inventory)的全局锁,但它们各自还需要获取对方的全局锁才能继续——形成了循环等待。Seata 的全局锁不检测死锁,只按申请顺序排队,导致死锁发生。
全局锁的隔离级别:AT 模式提供的是读已提交(Read Committed) 级别的全局一致性,而不是可串行化(Serializable)。不同全局事务可以同时修改不同行的数据,但同一行数据在同一时刻只能被一个全局事务修改。
回滚机制
AT 模式的回滚完全依赖 UndoLog,不需要网络通信去其他节点协调:
flowchart TD
A[全局事务回滚指令] --> B[TC 向分支事务发送<br/>Rollback 指令]
B --> C[分支事务从本地 undo_log 表<br/>读取 UndoLog 记录]
C --> D[根据 beforeImage<br/>生成反向 SQL]
D --> E[执行反向 SQL<br/>数据恢复到前置镜像状态]
E --> F[删除本地 undo_log 记录]
F --> G[向 TC 确认回滚完成]
G --> H[TC 释放全局锁]
回滚是纯本地操作,不需要访问其他数据库或服务。这使得 AT 模式的回滚速度非常快,通常在毫秒级完成。
AT 模式 vs TCC 模式
很多团队在选型时会纠结用 AT 还是 TCC。来看对比:
【架构权衡】
AT 模式的本质是"用全局锁换业务无感知"。如果你追求极致的性能和跨语言支持,选择 TCC。如果你追求开发效率和代码简洁,且业务场景不涉及极端高并发,选择 AT。需要注意的是,AT 模式不支持以下场景:跨库事务(两个不同数据库实例的事务)、非 DB 资源(Redis、MQ 等)、不支持的 SQL 语法(不支持触发器、存储过程、批量条件更新)。
生产避坑
坑一:全局锁导致的并发退化
这是 AT 模式最常见的问题。在高并发场景下,全局锁会成为系统的性能瓶颈。
症状:数据库连接池使用正常,但 RT 飙升,大量 SQL 执行时间正常但整体响应极慢。
根因:全局锁排队。QPS 越高,等待全局锁的事务就越多。
解决方案:
- 降低全局事务的复杂度——减少单个全局事务涉及的分支数量
- 使用低粒度的主键——避免范围查询(如
WHERE status = 'PENDING')锁定大量行
- 开启分支事务异步执行——Seata 支持 AT 模式的分支事务异步注册,但需要评估数据一致性风险
- 切到 TCC 模式——对于性能要求极高的场景
// ❌ 低效:范围查询会锁定大量行
@GlobalTransactional
public void batchUpdate() {
orderMapper.updateStatusByCondition("PENDING", "PROCESSING");
}
// ✅ 高效:逐行更新,每次只锁一行
@GlobalTransactional
public void batchUpdate() {
List<Order> orders = orderMapper.selectByStatus("PENDING");
for (Order order : orders) {
order.setStatus("PROCESSING");
orderMapper.update(order);
}
}
坑二:undo_log 表膨胀
在高频写入场景下,undo_log 表会快速膨胀。如果清理策略不当,会占用大量存储空间。
# seata server 配置
store:
db:
globalTable: global_table
branchTable: branch_table
lockTable: lock_table
undolog:
undoLogTableName: undo_log
undoLogDeletePeriod: 86400000 # 24 小时清理一次 UndoLog
坑三:与读写分离数据源冲突
如果你的数据库配置了读写分离(主库写入、从库读取),Seata AT 模式必须使用 DataSourceProxy,且所有事务操作必须路由到主库。否则会导致读取不到已写入的数据(主从延迟)或 UndoLog 和业务数据不在同一个数据库实例中。
// ✅ 正确:使用 DataSourceProxy 确保所有操作走主库
public DataSourceProxy dataSourceProxy(DataSource dataSource) {
return new DataSourceProxy(dataSource);
}
// ❌ 错误:读写分离的数据源没有包裹 DataSourceProxy
@Bean
public DataSource routingDataSource(...) {
// 这个会被 Seata 拦截,但读写分离逻辑可能失效
}
工程代价
落地 Checklist