案例背景
背景:比如两个用户同时批量下发审批操作,审批的是同一批数据,并发调用用一个方法,方法中存在先删除后插入的操作,高并发情况下,可能会引发数据并发问题和死锁问题
public void submitPayOrder(String orderPkId, String operator) {
TenantOrderPay order = tenantOrderPayMapper.selectById(orderPkId);
BizAssert.notNull(order, "订单为空");
tenantOrderAuditMapper.delete(new LambdaQueryWrapper<TenantOrderAudit>().eq(TenantOrderAudit::getOrderPkId, orderPkId));
tenantOrderAuditMapper.insert(new TenantOrderAudit()
.setAuditPkId(IdWorker.getIdStr())
.setProjectPkId(order.getProjectPkId())
.setAuditCode(bizCodeGenerator.getCode(ECommonCode.ORDER_AUDIT))
.setOrderPkId(orderPkId).setOrderType(EOrderType.PAY.getType())
.setCreateTime(new Date())
.setUpdateTime(new Date())
.setAuditRecord(new TenantOrderAuditRecord().submit(operator).toJson())
.setAuditStatus(EOrderAuditStatus.AUDIT.getType()));
}
并发引发的问题
数据库隔离级别
读已提交(RC)
- 可见性规则:事务只能看到其他事务已提交的数据。
- 锁机制:
-
DELETE
操作对符合条件的行加 排他锁(X锁),其他事务需等待锁释放。- 唯一性检查基于已提交数据,允许幻读(Phantom Read)。
可重复读(RR)
- 可见性规则:事务内多次读取同一范围数据的结果一致。
- 锁机制:
-
- 引入 间隙锁(Gap Lock),锁定范围内的间隙,防止其他事务插入新数据。
- 唯一性检查基于当前事务的快照,可能因间隙锁导致死锁。
- 数据库默认隔离级别
重复删除数据
- 根本原因:在RC/RR隔离级别下,DELETE 操作会对符合条件的行加 排他锁(X锁),其他事务在删除操作未提交时会阻塞等待锁释放
- 产生影响:对系统并无影响,多个事务中仅一个能成功删除数据,后续事务的DELETE操作实际影响0行。
- 发生概率:0(无需额外处理)
重复插入数据
- 根本原因:在RC/RR下,若两个事务同时插入相同唯一键值,第一个是事务插入成功,第二个事务会因唯一约束插入失败
- 产生影响:直接报错,系统抛出错误异常
- 发生概率:高,触发条件:并发插入主键冲突
间隙锁产生死锁
- 根本原因:间隙锁在可重复读(RR)级别下默认生效,当使用主键查询,数据不存在则会产生间隙锁,并发删除和写入会有死锁的概率发生
- 产生影响:导致死锁,CPU飙高,资源得不到释放
- 发生的概率:中,触发条件:主键等值查询且数据不存在时,RR级别加间隙锁
解决方案
通用操作
- 强制串行化:使用 select for update 显式锁定主订单行,确保后续操作的原子性。
- 事务边界控制:添加 @Transactional 注解,确保锁持有到事务结束。
- 异常处理:捕获唯一键冲突异常,记录日志并回滚事务。
- 备注:当数据库执行select for update时会获取被select中的数据行的行锁,因此其他并发执行的select for update如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。 select for update获取的行锁会在当前事务结束时自动释放
隔离级别选择
- 推荐使用RC级别,避免间隙锁导致的死锁问题
- 调整数据库隔离级别:SET GLOBAL transaction_isolation = 'READ-COMMITTED';
分布式锁
- 适合集群环境下跨JVM串行运行
- 引入会带来一定的性能损耗
代码优化
正常情况
- 通过主键查询数据的时候,使用select for update加行锁,确保后续删除和插入操作的原子性,避免并发冲突,确保同一时间只有一个事务处理该订单
- 对于删除、插入等多个DML操作,加上@Transactional注解,确保方法在事务中执行,这样锁会保持到事务结束
- 处理可能的唯一约束异常,使用try catch 进行捕获,打印相关日志,并抛出异常,否则事务无法回滚
- 使用Redisson分布式锁,可确保集群环境下,JVM串行化,但也会带来一定的性能损耗(数据库锁和分布式锁二选其一)
@Override
@Transactional(rollbackFor = Exception.class)
public void submitPayOrder(String orderPkId, String operator) {
lockUtil.exec(ESysLockKeyType.SUBMIT_PAY_ORDER.getKey().apply(new Object[]{orderPkId}), ()->{
// 1. 通过 SELECT FOR UPDATE 锁定主订单行,直到事务结束
TenantOrderPay order = tenantOrderPayMapper.selectOne(
new LambdaQueryWrapper<TenantOrderPay>()
.eq(TenantOrderPay::getOrderPkId, orderPkId)
.last("FOR UPDATE")
);
BizAssert.notNull(order, "订单为空");
// 2. 删除审计记录
tenantOrderAuditMapper.delete(new LambdaQueryWrapper<TenantOrderAudit>().eq(TenantOrderAudit::getOrderPkId, orderPkId));
// 3. 生成并插入新审计记录
TenantOrderAudit tenantOrderAudit = new TenantOrderAudit()
.setAuditPkId(IdWorker.getIdStr())
.setProjectPkId(order.getProjectPkId())
.setAuditCode(bizCodeGenerator.getCode(ECommonCode.ORDER_AUDIT))
.setOrderPkId(orderPkId).setOrderType(EOrderType.PAY.getType())
.setCreateTime(new Date())
.setUpdateTime(new Date())
.setAuditRecord(new TenantOrderAuditRecord().submit(operator).toJson())
.setAuditStatus(EOrderAuditStatus.AUDIT.getType());
// 4.插入订单审核记录
try {
tenantOrderAuditMapper.insert(tenantOrderAudit);
} catch (Exception e) {
log.error("订单审核记录插入失败", e);
throw new BusinessException("审批记录冲突");
}
}, ("提交审批订单失败"));
}
数据回滚
在 读已提交(RC) 隔离级别下,事务 B 的回滚 会不会恢复已被事务 A 删除的数据。答案:不会
场景复现与关键点
假设事务 A 和事务 B 并发执行以下操作:
- 事务 A:
-
DELETE
删除数据(加 X 锁)。INSERT
插入新记录(生成唯一主键)。- 提交。
- 事务 B:
-
DELETE
尝试删除同一批数据(等待事务 A 的 X 锁释放)。- 获取锁后,发现数据已被事务 A 删除,实际
DELETE
操作影响 0 行。 INSERT
时因唯一键冲突失败,触发回滚。
关键机制解析
- 锁的获取与等待:
-
- 事务 A 的
DELETE
会锁定符合条件的行(X 锁直到事务结束)。 - 事务 B 的
DELETE
必须等待事务 A 提交或回滚后才能继续。
- 事务 A 的
- 读已提交(RC)的可见性:
-
- 事务 B 在获取锁后,只能看到事务 A 已提交的删除结果。
- 若事务 A 已提交,事务 B 的
DELETE
操作实际 不会删除任何数据(因数据已被事务 A 删除)。
- 事务 B 回滚的影响:
-
- 由于事务 B 的
DELETE
操作实际未删除数据(影响 0 行),回滚时 无数据可恢复。 - 事务 A 的删除和插入操作已提交,数据状态正常。
- 由于事务 B 的
删除场景
- 在RC级别下,事务A删除数据后,事务B看不到事务A未提交的删除,但是一旦事务A提交事务,事务B查询不到已经被事务A删除的数据
- 在RR级别下,事务B在事务A提交后可能仍然看到旧数据,因为可重复读会保持一致性视图
- 再次分析代码:如果数据被事务A删除成功,事务B不应该查出数据,直接抛出异常:订单为空
最后总结
问题 | RC表现 | RR表现 | 解决方案 |
---|---|---|---|
重复删除数据 | 无影响 | 无影响 | 无需处理 |
重复插入数据 | 唯一键冲突异常 | 唯一键冲突异常 | 强制串行化+异常捕获 |
间隙锁导致死锁 | 不发生 | 可能会发生 | 切换为RC级别 |