分库分表后分布式事务回滚失败?Seata AT模式undolog与全局锁冲突解决
大家好,今天我们来聊聊一个在分库分表环境下使用Seata AT模式时,经常遇到的棘手问题:分布式事务回滚失败,特别是undolog与全局锁冲突的情况。
一、背景:分库分表与分布式事务
在业务快速发展过程中,单库单表往往会遇到性能瓶颈。为了提升系统吞吐量和存储能力,我们通常会采用分库分表策略。分库分表虽然解决了单点问题,但也引入了分布式事务的复杂性。
-
分库分表: 将数据分散存储在多个数据库或表中。常见的分片策略包括:
- 水平分片: 将同一张表的数据按照某种规则(如用户ID取模)分散到多个数据库或表中。
- 垂直分片: 将一张表的不同字段拆分到不同的数据库或表中。
-
分布式事务: 指跨越多个数据库的事务。确保多个数据库操作要么全部成功,要么全部失败,保证数据一致性。
二、Seata AT模式及其原理
Seata(Simple Extensible Autonomous Transaction Architecture)提供了多种分布式事务解决方案,其中AT模式(Automatic Transaction)是最常用的模式之一。
AT模式的核心思想是:两阶段提交协议(2PC)的改进版,通过引入undolog来保证事务的一致性。
其主要流程如下:
- Begin: 事务发起方开启全局事务。
- 执行业务SQL:
- 第一阶段(Prepare):执行业务 SQL,同时记录操作前后的镜像数据到
undo_log表中。 - 第二阶段(Commit/Rollback):
- Commit: 删除
undo_log记录 (异步执行)。 - Rollback: 通过
undo_log中的镜像数据,恢复到事务执行前的状态。同时释放全局锁。
- Commit: 删除
- 第一阶段(Prepare):执行业务 SQL,同时记录操作前后的镜像数据到
undo_log 表结构示例:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| branch_id | BIGINT | 分支事务ID |
| xid | VARCHAR | 全局事务ID |
| context | VARCHAR | AT 上下文信息 |
| rollback_info | LONGBLOB | 回滚信息(序列化后的镜像数据) |
| log_status | INT | 状态 (0:正常, 1:待清理) |
| log_created | DATETIME | 创建时间 |
| log_modified | DATETIME | 修改时间 |
三、问题:undolog与全局锁冲突
在分库分表环境下,使用Seata AT模式进行回滚时,可能会遇到undolog与全局锁冲突的问题。
场景描述:
假设有两个事务A和B,都涉及同一行数据(经过分片规则计算后落在同一个数据库的同一张表上)。
- 事务A先执行,更新了数据,并生成了对应的undolog,同时获取了全局锁。
- 事务B尝试更新同一行数据,由于全局锁被事务A持有,事务B需要等待。
- 事务A由于某种原因(例如:业务异常)需要回滚。
- 在回滚过程中,Seata需要根据事务A的undolog来恢复数据。
- 问题来了: 此时,事务B可能还在等待全局锁释放。如果事务A的回滚操作执行速度很快,可能会在事务B获取到全局锁之前,就完成了数据的恢复。然后事务B获取到全局锁,执行更新操作,导致数据不一致。
根本原因:
- 并发更新: 多个事务并发更新同一行数据。
- 全局锁等待: 后续事务需要等待全局锁释放。
- 回滚速度: 回滚速度快于后续事务获取锁的速度。
四、代码示例:模拟冲突场景
为了更好地理解这个问题,我们用一个简单的代码示例来模拟上述场景。
// 假设有两个服务:OrderService 和 AccountService
// 订单服务 OrderService
@Service
public class OrderService {
@Autowired
private JdbcTemplate jdbcTemplate;
@GlobalTransactional(timeoutMills = 300000, name = "order-tx")
public void createOrder(String userId, String productId, int amount) {
// 1. 创建订单
createOrderInner(userId, productId, amount);
// 2. 扣减库存 (调用 AccountService)
accountService.reduceStock(productId, amount);
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // 新的事务,模拟分支事务
public void createOrderInner(String userId, String productId, int amount) {
String sql = "INSERT INTO order_tbl (user_id, product_id, amount) VALUES (?, ?, ?)";
jdbcTemplate.update(sql, userId, productId, amount);
// 模拟异常,触发回滚
if (productId.equals("product-error")) {
throw new RuntimeException("模拟订单创建失败");
}
}
}
// 账户服务 AccountService
@Service
public class AccountService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Transactional(propagation = Propagation.REQUIRES_NEW) // 新的事务,模拟分支事务
public void reduceStock(String productId, int amount) {
String sql = "UPDATE product_tbl SET stock = stock - ? WHERE product_id = ?";
jdbcTemplate.update(sql, amount, productId);
// 模拟延迟,让另一个事务有时间等待全局锁
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
模拟步骤:
- 启动 Seata Server。
- 创建
order_tbl和product_tbl两张表,并进行分库分表配置。 -
运行以下两个事务:
- 事务A:
orderService.createOrder("user1", "product-error", 10)(模拟创建订单失败,触发回滚) - 事务B:
orderService.createOrder("user2", "product1", 5)(在事务A回滚期间,尝试更新product_tbl的库存)
- 事务A:
预期结果:
如果没有解决冲突,事务B可能会成功更新库存,而事务A回滚后,导致订单数据不一致。
五、解决方案:优化回滚流程和控制并发
解决undolog与全局锁冲突问题的关键在于:控制回滚速度,并确保后续事务在回滚完成后才能获取到全局锁。
以下是一些常用的解决方案:
-
延迟释放全局锁:
- 在AT模式的
GlobalLockExecutor中,修改释放全局锁的逻辑。 - 在回滚完成后,不要立即释放全局锁,而是延迟一段时间。
- 这段延迟时间应该足够长,以确保其他等待锁的事务不会在回滚完成之前获取到锁。
代码示例 (修改 Seata 源码中的
GlobalLockExecutor):// 假设在 GlobalLockExecutor 中 @Override public boolean unlockGlobalLock(List<GlobalLockConfig> globalLockConfigs, String xid, Long branchId) { try { // ... 原来的解锁逻辑 // 延迟释放全局锁 Thread.sleep(DELAY_MILLISECONDS); // 设置延迟时间 } catch (InterruptedException e) { // 处理中断异常 } finally { // ... 最终解锁逻辑 } return true; }优点: 简单易行,只需修改 Seata 源码。
缺点: 会增加事务的整体耗时,影响系统吞吐量。延迟时间设置不合理,可能仍然无法解决冲突。 - 在AT模式的
-
基于版本号的乐观锁:
- 在表中增加一个
version字段,每次更新数据时,version字段加1。 - 在执行回滚时,先检查
version字段是否与undolog记录的版本号一致。 - 如果一致,则执行回滚;否则,说明数据已经被其他事务修改,放弃回滚。
SQL示例:
-- 原始更新SQL UPDATE product_tbl SET stock = stock - ? WHERE product_id = ? AND version = ? -- 回滚SQL (带版本号校验) UPDATE product_tbl SET stock = stock + ? WHERE product_id = ? AND version = ?优点: 可以避免因并发更新导致的回滚冲突。
缺点: 需要修改表结构和业务代码,增加开发成本。如果并发更新非常频繁,可能会导致回滚失败率较高。 - 在表中增加一个
-
悲观锁 + 补偿:
- 在回滚之前,先获取当前数据的悲观锁(例如:
SELECT ... FOR UPDATE)。 - 获取到锁之后,再执行回滚操作。
- 如果在获取锁的过程中发生阻塞,说明有其他事务正在修改数据,可以先放弃回滚,稍后通过补偿机制进行重试。
代码示例:
@Transactional(propagation = Propagation.REQUIRES_NEW) public void rollbackWithLock(String productId, int amount, int version) { // 尝试获取悲观锁 String sql = "SELECT stock FROM product_tbl WHERE product_id = ? FOR UPDATE"; try { jdbcTemplate.queryForObject(sql, Integer.class, productId); // 执行回滚操作 String rollbackSql = "UPDATE product_tbl SET stock = stock + ? WHERE product_id = ? AND version = ?"; int rows = jdbcTemplate.update(rollbackSql, amount, productId, version); if (rows == 0) { // 回滚失败,可能数据已被修改,需要进行补偿 // ... 实现补偿逻辑 } } catch (CannotAcquireLockException e) { // 获取锁失败,说明有其他事务正在修改数据,需要进行补偿 // ... 实现补偿逻辑 } }优点: 可以有效避免并发更新导致的回滚冲突。
缺点: 会增加数据库锁的竞争,可能影响系统性能。需要实现复杂的补偿逻辑。 - 在回滚之前,先获取当前数据的悲观锁(例如:
-
基于消息队列的最终一致性:
- 放弃使用AT模式的回滚机制,而是将需要回滚的操作,转换为消息发送到消息队列。
- 由消费者监听消息队列,并执行相应的补偿操作。
流程:
- 事务A执行失败,发送回滚消息到消息队列。
- 消费者接收到回滚消息,执行补偿操作,恢复数据。
优点: 可以实现最终一致性,避免强一致性的性能瓶颈。
缺点: 需要引入消息队列,增加系统复杂度。实现最终一致性需要考虑消息丢失、重复消费等问题。
表格对比:
| 解决方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 延迟释放全局锁 | 简单易行 | 增加事务耗时,延迟时间难设置 | 对事务耗时要求不高,并发量较低的场景 |
| 基于版本号的乐观锁 | 避免并发更新导致的回滚冲突 | 需要修改表结构和业务代码,并发更新频繁时回滚失败率较高 | 并发更新较少,对数据一致性要求较高的场景 |
| 悲观锁 + 补偿 | 有效避免并发更新导致的回滚冲突 | 增加数据库锁的竞争,需要实现补偿逻辑 | 并发更新较多,对数据一致性要求较高的场景 |
| 基于消息队列的最终一致性 | 实现最终一致性,避免强一致性的性能瓶颈 | 引入消息队列,增加系统复杂度,需要考虑消息丢失、重复消费等问题 | 对数据一致性要求不高,允许一定延迟的场景 |
六、总结:选择合适的方案保障数据一致性
解决Seata AT模式下undolog与全局锁冲突问题,需要综合考虑业务场景、数据一致性要求和系统性能等因素。没有一种方案是万能的,需要根据实际情况选择合适的解决方案。 延迟释放全局锁适用于对事务耗时要求不高,并发量较低的场景;乐观锁适用于并发更新较少,对数据一致性要求较高的场景;悲观锁+补偿适用于并发更新较多,对数据一致性要求较高的场景;最终一致性适用于对数据一致性要求不高,允许一定延迟的场景。最终的目的是保障分布式事务的数据一致性,提升系统的稳定性和可靠性。