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
相关产品推荐
相关产品推荐

