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

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:调整事务范围+数据库行级锁

问题根源在于校验是独立的只读事务,写入操作异步执行,两者不在同一事务上下文,导致并发时校验读取到旧数据。

修改步骤:

  1. 移除校验方法的@Transactional(readOnly = true),将校验与后续写入操作合并到同一个事务中。
  2. 查询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:数据库乐观锁(低并发场景适用)

如果并发量较低,可以使用乐观锁机制,通过版本号控制避免并发冲突。

实现步骤:

  1. 修改VoucherEntity,增加版本字段:
@Version
private Integer version;
  1. 校验与写入时,通过版本号确保数据一致性:
@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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.29 16:12:02