先删除后插入的并发问题

230 阅读6分钟

案例背景

背景:比如两个用户同时批量下发审批操作,审批的是同一批数据,并发调用用一个方法,方法中存在先删除后插入的操作,高并发情况下,可能会引发数据并发问题和死锁问题

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),锁定范围内的间隙,防止其他事务插入新数据。
    • 唯一性检查基于当前事务的快照,可能因间隙锁导致死锁。
  • 数据库默认隔离级别

重复删除数据

  1. 根本原因:在RC/RR隔离级别下,DELETE 操作会对符合条件的行加 排他锁(X锁),其他事务在删除操作未提交时会阻塞等待锁释放
  2. 产生影响:对系统并无影响,多个事务中仅一个能成功删除数据,后续事务的DELETE操作实际影响0行。
  3. 发生概率:0(无需额外处理)

重复插入数据

  1. 根本原因:在RC/RR下,若两个事务同时插入相同唯一键值,第一个是事务插入成功,第二个事务会因唯一约束插入失败
  2. 产生影响:直接报错,系统抛出错误异常
  3. 发生概率:高,触发条件:并发插入主键冲突

间隙锁产生死锁

  1. 根本原因:间隙锁在可重复读(RR)级别下默认生效,当使用主键查询,数据不存在则会产生间隙锁,并发删除和写入会有死锁的概率发生
  2. 产生影响:导致死锁,CPU飙高,资源得不到释放
  3. 发生的概率:中,触发条件:主键等值查询且数据不存在时,RR级别加间隙锁

解决方案

通用操作

  1. 强制串行化:使用 select for update 显式锁定主订单行,确保后续操作的原子性。
  2. 事务边界控制:添加 @Transactional 注解,确保锁持有到事务结束。
  3. 异常处理:捕获唯一键冲突异常,记录日志并回滚事务。
  4. 备注:当数据库执行select for update时会获取被select中的数据行的行锁,因此其他并发执行的select for update如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。 select for update获取的行锁会在当前事务结束时自动释放

隔离级别选择

  1. 推荐使用RC级别,避免间隙锁导致的死锁问题
  2. 调整数据库隔离级别:SET GLOBAL transaction_isolation = 'READ-COMMITTED';

分布式锁

  1. 适合集群环境下跨JVM串行运行
  2. 引入会带来一定的性能损耗

代码优化

正常情况

  1. 通过主键查询数据的时候,使用select for update加行锁,确保后续删除和插入操作的原子性,避免并发冲突,确保同一时间只有一个事务处理该订单
  2. 对于删除、插入等多个DML操作,加上@Transactional注解,确保方法在事务中执行,这样锁会保持到事务结束
  3. 处理可能的唯一约束异常,使用try catch 进行捕获,打印相关日志,并抛出异常,否则事务无法回滚
  4. 使用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 并发执行以下操作:

  1. 事务 A:
    • DELETE 删除数据(加 X 锁)。
    • INSERT 插入新记录(生成唯一主键)。
    • 提交。
  1. 事务 B:
    • DELETE 尝试删除同一批数据(等待事务 A 的 X 锁释放)。
    • 获取锁后,发现数据已被事务 A 删除,实际 DELETE 操作影响 0 行。
    • INSERT 时因唯一键冲突失败,触发回滚。

关键机制解析

  1. 锁的获取与等待:
    • 事务 A 的 DELETE 会锁定符合条件的行(X 锁直到事务结束)。
    • 事务 B 的 DELETE 必须等待事务 A 提交或回滚后才能继续。
  1. 读已提交(RC)的可见性:
    • 事务 B 在获取锁后,只能看到事务 A 已提交的删除结果。
    • 若事务 A 已提交,事务 B 的 DELETE 操作实际 不会删除任何数据(因数据已被事务 A 删除)。
  1. 事务 B 回滚的影响:
    • 由于事务 B 的 DELETE 操作实际未删除数据(影响 0 行),回滚时 无数据可恢复。
    • 事务 A 的删除和插入操作已提交,数据状态正常。

删除场景

  1. 在RC级别下,事务A删除数据后,事务B看不到事务A未提交的删除,但是一旦事务A提交事务,事务B查询不到已经被事务A删除的数据
  2. 在RR级别下,事务B在事务A提交后可能仍然看到旧数据,因为可重复读会保持一致性视图
  3. 再次分析代码:如果数据被事务A删除成功,事务B不应该查出数据,直接抛出异常:订单为空

最后总结

问题RC表现RR表现解决方案
重复删除数据无影响无影响无需处理
重复插入数据唯一键冲突异常唯一键冲突异常强制串行化+异常捕获
间隙锁导致死锁不发生可能会发生切换为RC级别