REST 幂等性实现 - 如何在已处理请求时回滚?

REST Idempotence implementation - How to rollback when a request has already been processed?

我想要达到的目标

我们有一个 REST API 使用 Spring Boot、JPA 和 Hibernate 构建。 使用 API 的客户端无法可靠地访问网络。为了避免最终用户出现太多错误,我们让客户端重试不成功的请求(例如,在发生超时后)。

由于我们无法确定请求再次发送时是否已经被服务器处理过,所以我们需要使POST请求幂等。即发送两次相同的POST请求一定不能两次创建相同的资源。

到目前为止我做了什么

为了实现这一点,我做了以下工作:

到目前为止一切顺利。

问题

我有多个服务器实例在同一个数据库上工作,并且请求是负载平衡的。因此,任何实例都可以处理请求。

在我当前的实现中,可能会出现以下情况:

  1. 请求由实例1处理,耗时较长
  2. 因为耗时太长,客户端中止连接,重新发送相同的请求
  3. 第二个请求由实例2处理
  4. 第一个请求处理完成,结果由实例1存入数据库
  5. 第二个请求处理完成。当实例 2 尝试将结果存储到数据库中时,结果已经存在于数据库中。

在这个场景中,请求被处理了两次,这是我想要避免的。

我想到了两种可能的解决方案:

  1. 当相同请求的结果已经存储时回滚请求2,并将保存的响应发送给客户端。
  2. 通过在实例 1 开始处理请求 2 时立即将请求 ID 保存在数据库中来防止请求 2 被处理。该解决方案将不起作用,因为客户端和实例 1 之间的连接因超时而关闭,从而使客户端无法实际接收到实例 1 处理的响应。

尝试解决方案 1

我正在使用 Filter 来检索和存储响应。我的过滤器大致如下所示:

@Component
public class IdempotentRequestFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException     {

        String requestId = getRequestId(request);


        if(requestId != null) { 

            ResponseCache existingResponse = getExistingResponse(requestId);

            if(existingResponse != null) {
                serveExistingResponse(response, existingResponse);
            }
            else {

                filterChain.doFilter(request, response);

                try {
                    saveResponse(requestId, response);
                    serve(response);
                }
                catch (DataIntegrityViolationException e) {

                    // Here perform rollback somehow

                    existingResponse = getExistingResponse(requestId);
                    serveExistingResponse(response, existingResponse);
                }
            }
        }
        else {
            filterChain.doFilter(request, response);
        }

    }

    ...

然后我的请求是这样处理的:

@Controller 
public class UserController {

    @Autowired 
    UserManager userManager; 

    @RequestMapping(value = "/user", method = RequestMethod.POST)
    @ResponseBody
    public User createUser(@RequestBody User newUser)  {
        return userManager.create(newUser);
    }
}

@Component
@Lazy
public class UserManager {

    @Transactional("transactionManager")
    public User create(User user) {
        userRepository.save(user); 
        return user; 
    }

}

问题

注意:我对 spring、hibernate 和 JPA 比较陌生,对事务和过滤器背后的机制了解有限。

The request is processed by instance 1 and takes a long time

考虑将流程分成两步。

第 1 步存储请求,第 2 步处理请求。在第一个请求中,您只需将所有请求数据存储在某个地方(可以是数据库或队列)。在这里你可以介绍一个状态,例如'new'、'in progress'、'ready'。 无论如何,您都可以使它们同步或异步。 因此,在第二次尝试处理相同的请求时,您检查它是否已经存储和状态。在这里你可以回复状态或者等到状态变为 'ready'。 因此,在过滤器中,您只需检查请求是否已存在(之前已存储),如果是,则获取状态和结果(如果已准备好)以发送给响应。

您可以将自定义验证注释 - @UniqueRequest 添加到 RequestDTO 并添加 @Valid 以检查数据库(参见 the example)。无需在 Filter 中执行此操作,而是将逻辑移至 Controller(实际上它是验证的一部分)。 在这种情况下如何响应取决于您 - 只需检查 BindingResult。

基于

To avoid having too many errors for the end user, we made the client retry unsuccessful requests

您似乎可以完全控制客户端代码(太棒了!)以及服务器。

但是,尚不清楚客户端网络的问题是不稳定(连接经常随机断开并且请求被中止)还是缓慢(超时),因为您已经提到了两者。那么让我们来分析一下!

超时

我首先推荐的是:

  1. 正在调整服务器上的连接超时,以便在服务器完成操作之前不会关闭它;
  2. 调整客户端上的请求超时以解决服务器上的缓慢操作客户端网络的缓慢问题。

但是:

  • 如果服务器运行真的慢,最大连接超时时间(120s,是吗?)不够;
  • 或者如果您还发送大 requests/responses 并且最大客户端超时时间不够;
  • 或者如果您出于任何原因不想增加超时,[​​=62=]

那么标准的请求-响应方案可能不适合。

在这种情况下,与其让客户端等待响应,不如直接发回确认 Request received 并通过某个 TCP 套接字发送实际响应?如果操作完成,任何后续尝试都会收到一条消息说 Request is being processed 或最终响应(这是您的操作的幂等性将有所帮助的地方)。

客户端网络故障

如果客户端网络不稳定并且容易出现频繁故障,上面提出的请求和响应解耦的解决方案也应该有效!

  1. 首先,如果你立即发回确认,你会立即让客户知道发生了什么;快速响应时间还应该使客户更有可能收到响应。
  2. 其次,每当任何请求由于网络故障而中止时,您可以简单地等待适当的时间(基本上,有足够的时间让服务器完成操作),然后再重试,而不是重试离开。这样你会 显着 增加服务器完成相关操作的机会,你应该得到你的响应(同样,这是使用幂等请求的关键)。
  3. 如果您不想调整超时, 以防重试操作后您收到 Request in progress 的响应,您可以尝试监听套接字再次.

最后的想法

如果无法使用套接字,您可以使用轮询。轮询不是很好,但就个人而言,我很可能仍然会使用轮询而不是回滚,尤其是在服务器操作缓慢的情况下 - 这将允许在重试之前有适当的暂停。

回滚的问题在于他们会尝试使用代码从故障中恢复,而这本身从来都不是万无一失的。如果回滚时出现问题怎么办?您能否确保回滚是原子的和幂等的,并且在任何情况下都不会使系统处于未定义状态?除此之外,它们的实现可能并不简单,并且会引入额外的复杂性和用于测试和维护的额外代码。

如果您没有客户端代码

如果您不拥有客户端代码,您将遇到更多麻烦,因为您的 API 的消费者可以随意对您的服务器进行大量任意调用。在这种情况下,我会肯定锁定幂等操作和return响应说请求正在处理而不是尝试使用回滚来恢复任何东西。想象一下有多个并发请求和回滚!如果你不满意The queue will get longer and longer, making the whole system slower, reducing the capacity of the system to serve requests.),我相信这种情况会更糟。