You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot多线程场景下保险号重复注册的DB事务安全问题咨询

问题分析与解决方案

核心问题

这是典型的先查后写竞态条件:并发请求下,多个线程在各自事务内执行校验时,因数据库事务隔离特性,无法看到其他未提交的插入操作,导致重复校验通过后,多条相同insuranceNumber(且状态为PROSPECTIVE/PARTICIPANT)的数据被插入。


可行解决方案

1. 数据库过滤唯一索引(最优方案)

利用MS SQL支持的过滤唯一索引,直接在数据库层面强制约束,从根源避免重复:

CREATE UNIQUE NONCLUSTERED INDEX IX_Customer_InsuranceNumber_ValidStatus
ON Customer (insuranceNumber)
WHERE status IN ('PROSPECTIVE', 'PARTICIPANT');
  • 优势:数据库原生约束,性能远高于触发器或Java层校验,完全杜绝竞态问题
  • 注意事项:Java层需捕获SQLIntegrityConstraintViolationException,统一封装为业务异常(如“该保险编号已存在”)返回,解决异常不一致问题

2. 细粒度锁控制(针对同一保险编号串行)

不需要全局锁,仅对相同insuranceNumber的请求做并发控制:

单实例应用(本地锁)

用ConcurrentHashMap结合可重入锁实现:

private final ConcurrentHashMap<String, Lock> insuranceLockMap = new ConcurrentHashMap<>();

public void register(Customer customer) {
    String insuranceNum = customer.getInsuranceNumber();
    Lock lock = insuranceLockMap.computeIfAbsent(insuranceNum, k -> new ReentrantLock());
    
    lock.lock();
    try {
        // 执行校验逻辑
        boolean exists = customerRepo.existsByInsuranceNumberAndStatusIn(
            insuranceNum, Arrays.asList(Status.PROSPECTIVE, Status.PARTICIPANT)
        );
        if (exists) {
            throw new BusinessException("该保险编号已被占用");
        }
        // 插入数据
        customerRepo.save(customer);
    } finally {
        lock.unlock();
        // 可选:清理过期锁,避免内存泄漏
        insuranceLockMap.remove(insuranceNum);
    }
}
多实例应用(分布式锁)

用Redis实现跨实例的细粒度锁(示例基于Redisson):

@Autowired
private RedissonClient redissonClient;

public void register(Customer customer) {
    String insuranceNum = customer.getInsuranceNumber();
    String lockKey = "reg_lock:insurance:" + insuranceNum;
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 尝试加锁,等待5秒,持有锁10秒(根据业务调整)
        if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            // 校验+插入逻辑同单实例
            boolean exists = customerRepo.existsByInsuranceNumberAndStatusIn(
                insuranceNum, Arrays.asList(Status.PROSPECTIVE, Status.PARTICIPANT)
            );
            if (exists) {
                throw new BusinessException("该保险编号已被占用");
            }
            customerRepo.save(customer);
        } else {
            throw new BusinessException("注册请求过于频繁,请稍后再试");
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BusinessException("注册中断,请重试");
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
  • 优势:仅对同一保险编号的请求串行,不影响其他用户的注册性能,多实例场景也适用

3. 关于调整数据库隔离级别

此方案无法解决问题:

  • 即使将隔离级别从默认的READ COMMITTED改为REPEATABLE READ,线程仍会通过MVCC快照读获取数据,无法看到其他未提交的插入;
  • 改为SERIALIZABLE会强制加范围锁,虽能阻止重复,但会导致性能急剧下降,还可能引发死锁,完全不适合高并发场景。

现有方案对比

  • 触发器:逻辑重复,性能低于过滤索引,异常处理复杂
  • 全局synchronized:全局串行,严重影响注册性能
  • 前端限制:无法阻止恶意请求,后端必须兜底

内容的提问来源于stack exchange,提问作者timguy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.06 02:25:41