Seata AT 模式下是如何解决全局事务并发导致的脏写(Dirty Write)问题的?
在 Seata AT 模式下,解决全局事务并发导致的脏写(Dirty Write)问题的核心机制是:全局锁(Global Lock) + 本地数据校验(Undo Log Image 对比)。
要理解这个机制,首先需要明白为什么在 AT 模式下会产生脏写风险。
一、 为什么 AT 模式会有脏写风险?
Seata AT 模式是一个改进版的两阶段提交(2PC):
- 一阶段(Phase 1): 执行业务 SQL,生成 Undo Log,然后直接提交本地数据库事务,释放本地数据库的行锁。
- 二阶段(Phase 2): 如果全局事务提交,则异步清理 Undo Log;如果全局事务回滚,则通过 Undo Log 反向补偿数据。
风险点在于: 一阶段提交后,本地数据库锁已经释放。此时,如果另一个事务(全局或本地)来修改同一行数据,并在当前全局事务的二阶段回滚之前提交了修改。当当前全局事务需要回滚时,就会把别人的修改覆盖掉,从而产生脏写。
二、 Seata 是如何解决脏写的?
Seata 主要通过以下三层防线来彻底解决或拦截脏写问题:
第一层防线:全局锁(Global Lock)—— 针对“全局事务 vs 全局事务”
这是 Seata 防止脏写最核心的机制。全局锁由 Seata 的 TC(Transaction Coordinator,事务协调器)统一维护。
- 一阶段:获取全局锁
- 当 RM(资源管理器)执行完本地 SQL,准备提交本地数据库事务之前,它必须先向 TC 申请该行数据的全局锁(锁的标识通常是:
表名 + 主键)。 - 如果申请成功: 提交本地事务,释放本地数据库行锁。
- 如果申请失败: 说明有其他全局事务正在操作这行数据,当前事务会不断重试,直到超时(抛出异常,触发全局回滚)。
- 当 RM(资源管理器)执行完本地 SQL,准备提交本地数据库事务之前,它必须先向 TC 申请该行数据的全局锁(锁的标识通常是:
- 二阶段:释放全局锁
- 全局提交(Commit): TC 收到全局提交指令,释放该行数据的全局锁,并异步通知 RM 清理 Undo Log。
- 全局回滚(Rollback): TC 通知 RM 进行数据补偿(执行 Undo Log),补偿完成后,TC 再释放全局锁。
工作示例:
假设全局事务 A 和 全局事务 B 并发修改同一行记录(ID=1):
- 事务 A 先拿到本地锁,执行 SQL,向 TC 申请到
ID=1的全局锁,然后提交本地事务,释放本地锁。 - 事务 B 此时可以拿到本地锁,执行 SQL。但在提交本地事务前,B 必须向 TC 申请
ID=1的全局锁。 - 因为全局锁在 A 手里(A 的二阶段还没结束),B 申请全局锁失败,B 只能等待。
- 这样就严格防止了 B 覆盖 A 的数据,避免了并发全局事务下的脏写。
第二层防线:Undo Log 数据校验 —— 针对“全局事务 vs 纯本地事务”
如果系统中存在绕过 Seata TC 的纯本地事务(即普通的 Spring @Transactional,不带 @GlobalTransactional),纯本地事务是不会去 TC 申请全局锁的,它直接修改数据库并提交。
这种情况下,全局锁防不住纯本地事务,脏写可能发生。Seata 通过二阶段回滚时的校验机制来兜底:
- 在一阶段,Seata 会生成数据的
before_image(修改前的数据)和after_image(修改后的数据),保存在undo_log表中。 - 在二阶段如果发生回滚,Seata 不会盲目地用
before_image恢复数据,而是会先进行数据校验。 - 校验规则:对比当前数据库中的实际数据 与
after_image是否一致。- 如果一致: 说明这期间没有其他本地事务修改过这行数据,允许安全回滚(用
before_image覆盖回去)。 - 如果不一致: 说明发生了脏写(被纯本地事务偷偷改了)。此时 Seata 拒绝回滚,因为如果回滚就会把本地事务的修改覆盖掉。Seata 会抛出异常,并转为人工介入处理(通常需要开发者通过告警发现,并手动修复数据)。
- 如果一致: 说明这期间没有其他本地事务修改过这行数据,允许安全回滚(用
第三层防线:@GlobalLock 注解 —— 优雅处理本地事务
为了解决第二层防线中“纯本地事务修改导致全局事务无法回滚,只能人工介入”的痛点,Seata 提供了 @GlobalLock 注解。
如果你的系统中有一部分业务是只需要操作单库的本地事务,但它操作的表又会参与到其他的全局事务中,你应该在这个本地事务的方法上加上 @GlobalLock 注解。
- 作用: 加了
@GlobalLock的本地事务,虽然不会开启全局事务,但在提交本地事务前,它会去 TC 检查该行记录是否被其他全局事务加了全局锁。 - 效果: 如果有全局锁,本地事务会等待/重试,从而避免了它去破坏正在进行中的全局事务的数据,从根源上消除了“全局事务 vs 本地事务”导致的脏写问题。
总结
Seata AT 模式解决脏写(并发写)问题的核心逻辑如下:
| 并发场景 | 防御机制 | 结果 |
|---|---|---|
| 全局事务 vs 全局事务 | TC 全局锁互斥排队 | 后一个事务等待前一个事务的二阶段结束,完全避免脏写。 |
全局事务 vs 本地事务(带 @GlobalLock) |
TC 全局锁互斥排队 | 本地事务等待全局事务的二阶段结束,完全避免脏写。 |
| 全局事务 vs 普通本地事务 | Undo Log after_image 校验 |
拦截非法回滚,拒绝覆盖别人的修改,抛出异常交由人工处理。 |
获取锁的顺序(非常关键以避免死锁):
一阶段执行时:先获取本地数据库锁 -> 执行业务 SQL -> 获取 Seata 全局锁 -> 释放本地数据库锁。