Ohhnews

分类导航

$ cd ..
foojay原文

告别样板代码:用一条注解实现幂等 REST 端点

#幂等性#spring boot#rest api#redis#支付系统

别再向用户重复收费:一个注解实现幂等 REST 端点

作者:Youssef Ben Henda

没人会在生产环境之前谈论的问题

用户点击“支付”。请求超时。应用重试。你的服务器扣了两次款。

你并没有写出 bug。是网络的问题。但你的用户并不在乎这个区别。

幂等问题出现在比支付更多的地方。负载均衡器重放一个超时请求。移动客户端双击提交按钮。Kubernetes Pod 在重启后重试。从服务器视角来看,这些都一模一样:在第一个 POST 已经执行后,又来了一个相同的 POST。

标准修复方式是让客户端发送 Idempotency-Key 请求头。服务器记住自己已经做过什么。Stripe 这样做,PayPal 也这样做。大多数需要这个能力的团队,最终都会为每个重要端点重复编写同样的样板代码。

我厌倦了写这些样板代码,所以构建了一个幂等 Spring Boot starter,让它一次处理所有端点。

幂等 Spring Boot starter 做了什么

idempotency-spring-boot-starter 只增加了一个注解:

[LOADING...]

就这样。携带相同 Idempotency-Key 请求头的重复请求,会得到原始响应的重放,既不会重新执行,也不会报错。

[LOADING...]

开箱即用提供两种存储:Redis(检测到时默认使用)和 JDBC/Postgres。你可以通过一个配置属性在两者之间切换。

当出错时会发生什么

正常路径很直接。真正有意思的设计问题是:处理器抛出异常时应该怎么做。

这是状态机:

[LOADING...]

5xx 失败会释放 key。 第一个请求可能因为临时性问题失败:数据库抖动、依赖变慢。如果保留 key,合法的重试会永远拿到缓存的 500。那比重复请求更糟。所以我们删除 key,让重试重新执行。

4xx 响应会被保留并重放。 400 Bad Request409 Conflict 是确定性的。同样的错误输入每次都会产生同样的错误输出。重放它是正确的。它还能防止两个并发请求同时竞争发现同一个冲突的竞态条件。

如果你的需求不同,可以通过 idempotency.release-on 配置这个行为。

另外还有请求体指纹检查。如果客户端用同一个 key 发送不同的请求体,那是客户端 bug。库会返回 422,绝不会静默执行错误的载荷。

幂等 Spring Boot 与并发请求:人们容易忽略的情形

顺序重复是简单场景。真正有趣的是两个相同请求同时到达,而两者都尚未完成。

认领(claim)步骤是原子的:在 Redis 中是单个 SETNX,在 Postgres 中是 INSERT ... ON CONFLICT DO NOTHING。一个请求赢得认领并执行处理器。另一个看到 IN_PROGRESS,然后等待。

默认情况下(idempotency.on-conflict=wait),输掉认领的请求会轮询,直到赢家完成。然后它重放结果。如果轮询超时,返回 409

你也可以设置 on-conflict=fail_fast。这会跳过轮询,立即返回 409。如果你的客户端反正都会重试,而你宁愿更早释放线程,这会很有用。

测试中一个值得注意的点:20 个并发重复请求打到 8 线程线程池上,会导致无关端点的延迟从低于 50ms 飙升至超过 1 秒。没有请求失败。但如果你的线程池受限,并且预期会频繁出现重复突发流量,fail_fast 是更安全的选择。

我选择明确声明的 exactly-once 限制

这个领域的大多数库要么完全跳过这一点,要么把它埋在小字说明里。我不想那样做。

两种存储开箱即用提供的都是**至少一次(at-least-once)**语义,而不是精确一次(exactly-once)。

原因如下:AOP 切面包裹在你的处理器任何 @Transactional 边界之外。当切面调用 complete() 时,你的处理器事务已经提交。完成写入是另一个独立事务。

这意味着在业务事务提交和幂等记录写入之间有一个很小的窗口。如果进程在这个窗口内崩溃,key 会一直保持 IN_PROGRESS,直到 TTL 过期。重试会重新执行处理器。

对于 JDBC 存储,精确一次是可以实现的。在你自己的已打开事务内调用 IdempotencyStore.complete()。完成写入会加入该事务,并与你的业务数据原子提交。

但同一个方法上的 @Idempotent + @Transactional 不会自动给你这个保证。“幂等”和“精确一次”不是同一个主张。要做到后者,远不只是换一个依赖那么简单。

幂等 Spring Boot 端点的快速配置

Redis(大多数场景推荐):

[LOADING...]

Redis 在 classpath 中时自动配置生效。不需要额外属性。

JDBC/Postgres:

[LOADING...]

[LOADING...]

schema 文件随 idempotency-store-jdbc 一起提供。无论使用哪种存储,注解和行为都是相同的。

值得了解的配置

大多数默认值都是合理的。有几个属性值得特别说明:

属性默认值何时修改
idempotency.require-keyfalse设为 true,用 400 拒绝不带 key 的请求
idempotency.scopeglobalusertenant 按主体(principal)隔离 key 命名空间(需要 Spring Security)
idempotency.on-conflictwait在受限线程池下切换为 fail_fast
idempotency.on-store-failureproceed如果存储不可达,fail 返回 503
idempotency.release-onfive_xx,timeoutSpring 需要 five_xx,而不是 5xx

scope 属性值得简单说明。

global 表示所有用户共享同一个 key 命名空间。user 按已认证主体隔离 key。两个不同用户就可以使用同一个 UUID 而不会冲突。tenant 从 JWT 中读取一个声明。你也可以自己实现 ScopeResolver,并将其注册为 @Bean

实测开销

我通过 Testcontainers 在 Docker Desktop 上对真实 Redis 和 Postgres 做了基准测试:200 个请求,20 次预热迭代:

  • p50:相比未加注解的相同端点,增加约 5 到 6.4ms 延迟
  • p99:最高约 40ms
  • 内存存储对照组:p50 约 0.5ms,p99 为负值(在测量噪声范围内)

切面自身的开销低于 1ms。指纹计算、key 组合、JSON 序列化:都很快。其余是两次网络往返:claim 和 complete。Docker Desktop 的数字反映的是容器网络,而不是这个库本身。

扩展它

IdempotencyStore 是一个包含四个方法的接口:claimcompletereleasefind。注册你自己的 @Bean,由于 @ConditionalOnMissingBean,你的实现会胜出:

[LOADING...]

ScopeResolver(自定义 key 命名空间)和 IdempotencyMetrics(接入 Micrometer 或任何其他工具)也是同样的模式。还有 IdempotencyObjectMapperCustomizer,可以用它注册你的 Jackson 模块,而不动应用自己的 ObjectMapper

它刻意不做的事

范围蔓延会毁掉小型库。以下是非目标:

  • 不是重试库。 它不会帮你重试出站调用。
  • 不是限流器。 那是不同的问题,需要不同的工具。
  • 不是 Kafka 去重层。 消费者幂等是另一类问题。
  • 目前不支持 WebFlux。 v0.1 仅支持 Servlet 栈;WebFlux 在路线图上。
  • 不支持流式或 SSE。 直接写入 HttpServletResponse 的内容不会被捕获。
  • JDBC 目前只支持 Postgres; MySQL 已列入计划。

在哪里找到它

Maven Central: io.github.benhendayoussef:idempotency-spring-boot-starter:0.1.0

GitHub: github.com/benhendayoussef/idempotency-spring-boot-starter

可运行示例位于 samples/sample-orders-api。运行 docker compose up -d && ./gradlew :samples:sample-orders-api:bootRun,然后执行 ./demo.sh,即可实时看到重放行为。

这是 v0.1。我非常希望能从那些在生产环境发布过幂等 Spring Boot API 的人那里获得反馈,尤其是关于失败策略和精确一次限制的反馈。Issue 和 PR 都非常欢迎。

该文章 无需样板代码的幂等 REST 端点 首发于 foojay