首页 > 解决方案 > 如何避免重复请求的竞争条件?

问题描述

假设我收到两个具有相同有效负载的并发请求。但我必须 (1) 执行单笔支付交易(使用第三方 API)和 (2) 以某种方式对两个请求返回相同的响应。正是第二个要求使事情变得复杂。否则,我可能只是对重复请求返回错误响应。

我有两个实体:SessionPayment(通过@OneToOne关系相关)。Session有两个字段来跟踪整体状态:( PaymentStatus, NONE, OK) ERROR, SessionStatus( CHECKED_IN, CHECKED_OUT)。初始条件是NONECHECKED_IN

请求负载确实包含一个唯一的会话号,我用它来获取相关会话。现在,假设支付服务对于一个唯一的订单 id 是一种“幂等的”:它只对给定的订单 id 执行一次交易。订单 ID 也出现在请求负载中(双请求的值相同)。

我想到的流程是这样的:

  1. 获取会话
  2. 如果session.getPaymentStatus() == OK,则找到付款并返回成功响应。
  3. 执行付款
  4. 将付款保存到 DB。Session具有从请求有效负载生成的具有唯一约束的字段。因此,如果其中一个线程尝试插入重复项,DataIntegrityViolationException则会抛出 a。我抓住它,找到已经插入的付款,并根据它返回响应。
  5. 如果 4 没有抛出异常,则返回相应的响应。

在这个流程中,似乎至少存在一种情况,尽管支付交易已成功完成,但我可能不得不对两个请求都返回错误响应!例如,假设“第一个”请求发生错误,付款未完成,并返回错误响应。但是对于“第二个”请求,恰好处理时间稍长,支付完成,但插入DB时,发现已经插入的支付记录,并在此基础上形成错误响应。

我想避免所有这些类似比赛条件的情况。我有一种感觉,我在这里遗漏了一些非常明显的东西。本质上,问题在于以某种方式发出一个请求以等待另一个请求完成。有没有办法可以利用数据库事务和锁来顺利处理这个问题?

上面我假设支付服务对于给定的订单 ID 是幂等的。如果不是,我必须绝对避免向它发送重复请求怎么办?

这是服务方法的相关部分:

Session session = sessionRepo.findById(sessionId)
        .orElseThrow(SessionNotFoundException::new);

Payment payment = paymentManager.pay(session, req.getReference(), req.getAmount());

Payment saved;
try {
    saved = paymentRepo.save(payment);
} catch (DataIntegrityViolationException ex) {
    saved = paymentRepo.findByOrderId(req.getReference())
            .orElseThrow(PaymentNotFoundException::new);
}

PaymentStatus status = saved.getSession().getPaymentStatus();
PaymentStage stage = saved.getSession().getPaymentStage();

if (stage == COMPLETION && status == OK)
    return CheckOutResponse.success(req.getTerminalId(), req.getReference(), 
            req.getPlateNumber(), saved.getAmount(), saved.getRrn());

return CheckOutResponse.error(req.getTerminalId(), req.getReference(),
            "Unable to complete transaction.");

标签: javaspringspring-mvcconcurrencyarchitecture

解决方案


我想避免所有这些类似比赛条件的情况。我有一种感觉,我在这里遗漏了一些非常明显的东西。本质上,问题在于以某种方式发出一个请求以等待另一个请求完成。有没有办法可以利用数据库事务和锁来顺利处理这个问题?

我倾向于认为,尽管付款已成功处理,但没有办法消除返回错误响应的所有可能性,因为有太多地方可能发生损坏,包括您自己的代码之外。但是,是的,您可以通过应用一些锁定来消除一些不一致响应的机会。

例如,

  1. 获取会话
  2. 获取会话PaymentStatus对其进行悲观锁定。您还必须包含代码以确保在请求处理完成之前释放此锁,即使在错误情况下也是如此(我对此不再赘述)。
  3. 如果session.getPaymentStatus() != NONE,则返回相应的响应。
  4. 执行付款
  5. 将付款保存到数据库,我想这包括将 更新PaymentStatusOKERROR。由于锁定,预计不会尝试插入副本。如果发生这种情况,则需要通知管理员,并返回不同的响应,可能是 501。
  6. 返回适当的响应。

请注意,成功支付的幂等性在这方面对您没有帮助,但如果幂等性扩展到支付失败的情况,那么您的原始工作流程将不会受到问题中描述的不一致响应问题的影响。


推荐阅读