Spring Boot REST服务progressiveNr并发校验问题解决方案咨询
并发场景下progressiveNr校验问题的解决方案
问题背景
我有一个Spring Boot项目,包含两个接收同类输入对象的API,分别执行不同业务操作。每个API执行前会校验输入对象,其中progressiveNr字段用于标识特定voucher的请求顺序,要求严格按递增序列提交。
客户端几乎同时调用两个API:
- API1(requestId: 196, voucherId: 1, progressiveNr: 2)
- API2(requestId: 197, voucherId: 1, progressiveNr: 3)
结果API2返回错误“progressiveNr must be 2”,原因是API1仍在校验阶段,尚未完成数据插入,此时数据库中该voucher的progressiveNr仍为1,导致API2的校验逻辑判定需要progressiveNr为2。
现有核心代码如下:
业务服务层代码
@Override @LogExecutionTime(printArgs = true, printResponse = true) public ResponseDTO subve(List<RequestSubveDTO> richieste) { List<ScartoTracciatoConnexa> daScartare = new ArrayList<>(); //requests subve validation richieste.forEach(request -> { ScartoTracciatoConnexa sTC = validateSubve(request); if (sTC != null) daScartare.add(sTC); }); //do other things //do the action asyncService.subve(richieste, daScartare); return new ResponseDTO(EsitiCodes.REQUEST_ACCEPTED); } private ScartoTracciatoConnexa validateSubve(RequestSubveDTO request) { try { validatorService.validateSubve(request); } catch (InvalidRequestException e) { return generaScarto(request, request.getNumVoucher(), SUBVE, e); } return null; } @Override @LogExecutionTime(printArgs = true, printResponse = true) public ResponseDTO renew(List<RequestRenewDTO> richieste) { List<ScartoTracciatoConnexa> daScartare = new ArrayList<>(); //requests renew validation richieste.forEach(request -> { ScartoTracciatoConnexa sTC = validateRenew(request); if (sTC != null) daScartare.add(sTC); }); //do other things //do the action asyncService.renew(richieste, daScartare); return new ResponseDTO(EsitiCodes.REQUEST_ACCEPTED); } private ScartoTracciatoConnexa validateRenew(RequestRenewDTO request) { try { validatorService.validateRenew(request); } catch (InvalidRequestException e) { return generaScarto(request, request.getNumVoucher(), RENEW, e); } return null; }
校验服务层代码
@LogExecutionTime @Transactional(readOnly = true) public void validateRenew(RequestRenewDTO dto) throws InvalidRequestException { //Other validations //progressiveNr validation int lastProgNr = myObjectService.getLastProgNumByVoucherId(dto.getVoucherId()) + 1; if (lastProgNr != dto.getProgressiveNr()) throw new InvalidRequestException(); } //THE SAME FOR validateSubve(RequestSubveDTO dto)
可行解决方案
方案1:调整事务范围+数据库行级锁
问题根源在于校验是独立的只读事务,写入操作异步执行,两者不在同一事务上下文,导致并发时校验读取到旧数据。
修改步骤:
- 移除校验方法的
@Transactional(readOnly = true),将校验与后续写入操作合并到同一个事务中。 - 查询voucher数据时添加排他行级锁,确保其他并发请求必须等待当前事务完成才能读取该行数据。
修改后的校验方法示例:
@LogExecutionTime @Transactional // 去掉readOnly,确保事务覆盖校验+写入全流程 public void validateRenew(RequestRenewDTO dto) throws InvalidRequestException { //Other validations // 加排他行锁查询,阻止并发读取旧数据 VoucherEntity voucher = myObjectService.getVoucherByIdForUpdate(dto.getVoucherId()); int lastProgNr = voucher.getProgressiveNr() + 1; if (lastProgNr != dto.getProgressiveNr()) throw new InvalidRequestException(); // 直接在当前事务内执行写入操作,替代异步调用 myObjectService.updateProgressiveNr(voucher, dto.getProgressiveNr()); }
对应的DAO层查询方法(JPA示例):
@Lock(LockModeType.PESSIMISTIC_WRITE) VoucherEntity getByVoucherId(Long voucherId);
同时修改业务层代码,移除异步调用,改为同步执行:
// 替换asyncService调用为同步业务逻辑 myObjectService.processRenew(richieste, daScartare);
方案2:分布式锁(集群环境适用)
如果系统是多实例部署,数据库行级锁无法跨实例生效,此时需要使用分布式锁(如Redis、ZooKeeper)。
实现思路:
- 以
voucherId作为锁的唯一标识,每个请求执行校验前先获取锁,校验+写入完成后释放锁。 - 设置合理的锁超时时间,避免死锁。
示例代码(Redis锁实现):
@Autowired private RedisLock redisLock; private ScartoTracciatoConnexa validateRenew(RequestRenewDTO request) { String lockKey = "voucher_lock:" + request.getVoucherId(); try { // 尝试获取锁,超时时间30秒 boolean locked = redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS); if (!locked) { return generaScarto(request, request.getVoucherId(), RENEW, new InvalidRequestException("请求过于频繁,请稍后重试")); } // 持有锁期间执行校验和写入 validatorService.validateRenew(request); myObjectService.processRenew(request); } catch (InvalidRequestException | InterruptedException e) { return generaScarto(request, request.getVoucherId(), RENEW, e); } finally { // 释放锁 redisLock.unlock(lockKey); } return null; }
方案3:数据库乐观锁(低并发场景适用)
如果并发量较低,可以使用乐观锁机制,通过版本号控制避免并发冲突。
实现步骤:
- 修改
VoucherEntity,增加版本字段:
@Version private Integer version;
- 校验与写入时,通过版本号确保数据一致性:
@Transactional public void processRenew(RequestRenewDTO dto) throws InvalidRequestException { VoucherEntity voucher = myObjectService.getByVoucherId(dto.getVoucherId()); int lastProgNr = voucher.getProgressiveNr() + 1; if (lastProgNr != dto.getProgressiveNr()) { throw new InvalidRequestException(); } // 更新progressiveNr,JPA会自动校验version字段 voucher.setProgressiveNr(dto.getProgressiveNr()); myObjectService.save(voucher); }
若并发更新,会抛出OptimisticLockingFailureException,捕获后提示用户重试即可。
方案选择建议
- 单实例部署:优先选方案1,行级锁实现简单、性能可靠。
- 多实例集群:优先选方案2,分布式锁跨实例生效。
- 低并发场景:可选方案3,无锁等待开销,实现成本低。
内容的提问来源于stack exchange,提问作者srv
相关产品推荐
相关产品推荐

