高鲁棒性 API 设计之 Idempotency Key 幂等键

有这样一个场景,客户端调用服务端 API 兑换奖励:

POST /api/v1/redemptions
{
    "reward_id": "id-123"
}

用户发起一次商品兑换请求,因网络等因素,客户端不清楚是否处理成功,可能会发起重试,假设前一个请求在服务端已经成功,只是在返回途中丢失,客户端重试时,服务端没办法判断是客户端重试还是用户发起的第二次兑换请求。

结合业务场景,以下方式可能在一定程度上缓解问题,如:

  1. 这个商品一个用户生命周期只能兑换一次;
  2. 在一定间隔内,粗暴的根据 Body 请求内容去重,需要琢磨间隔时间,会在一定程度上误伤正常请求;
  3. 躺平、无视小概率场景,用户要是有疑问就找客服同学反馈...

行业内正规的做法是借助 Idempotency Key 幂等键,其解决的问题正是客户端在无法确定上一次请求是否成功时,可以安全重试,而不会重复产生业务副作用。

幂等键由客户端传递,标识客户端的一次业务意图,同一次业务意图的重试必须复用同一个 Idempotency Key。用户再次主动发起同类操作时,则生成新的 Key。

常见的幂等标识携带方式大致有两类。第一类是作为 API 请求参数的一部分:

POST /api/v1/redemptions
{
    "reward_id": "id-123",
    "idempotency_key": "the-idempotency-key-dhfa"
}

例如 Google AIP-155 定义了 request_id,AWS EC2 部分 API 则使用 ClientToken,它们都承担了类似 Idempotency Key 的作用,在 JSON REST API 中,这类参数也经常体现在 Request Body 中。

另一类是放到 Header 中。近年来,更多新设计的 API 选择这种方式,例如 StripePayPalIETF 草案(目前已过期,尚未成为 RFC)

POST /api/v1/redemptions
Idempotency-Key: the-idempotency-key-dhfa
{
    "reward_id": "id-123"
}

我个人感觉通过 Header 的方式更好,对请求体的结构没有侵入性、Header 便于通过中间件统一处理、Idempotency Key 从语义上看也适合作为基础字段。除非业务上完全没有幂等需求,或仅几个接口需要特殊处理才考虑使用 Body 携带,否则新项目设计 API 建议通过 Header 携带幂等键。

幂等键的推荐值

建议使用 UUID v7 或 UUID v4。v7 是时间戳 + 随机数,按时间大致有序,v4 基本纯随机,都比自己生成随机数更稳定通用。

同时应限制 Idempotency Key 的最大长度,比如参考 Stripe 其限制不超过 255 个字符,对于要求提供 Idempotency Key 的接口,缺失、格式非法或超长可以返回 400 Bad Request。

幂等记录的作用范围和时效性

回到服务端的实现,为了识别重复请求,需将首次请求的 Idempotency Key 及其处理状态、请求参数指纹和业务结果记录。可以借助 Redis 缓存存储,一般使用 Authenticated User + HTTP method + Endpoint 组合作为 Idempotency Key 作用范围是比较合理的,以下是 Redis 缓存 Key 的结构示例:

idempotency:{user_id}:{method}:{route}:{idempotency_key}

也就是

idempotency:USER3366:POST:/api/v1/redemptions:01a070a1-33bf-72ae-9e60-95f5e0fc3f82

缓存 Key 直接包含接口路径可能会比较长、不美观也不便于查阅,可以给接口定义 ID,例如 redemption.create,idempotency 也可简化。

idemp:USER3366:redemption.create:01a070a1-33bf-72ae-9e60-95f5e0fc3f82

缓存时间则没有统一的标准,一般建议缓存 24 小时,也有平台缓存 7 天,像是支付、转账、订单等场景更推荐将幂等记录持久化到数据库,并对 “作用域 + Idempotency Key” 建立唯一约束。如果仅仅为了解决短时间重试、重复点击问题,可以将缓存设置为几分钟,但并发场景需要原子化处理,并发问题等下篇再记录。

另外也可以在中间件上做文章,按接口的重要程度设置不同的缓存时间或持久化存储策略。

遇到 Idempotency Key 重复后的返回内容

上方设计了缓存键,能否将 Response Body 保存下来,相同的重复请求正好从缓存直接返回呢?

可行,但需慎重,可以保存首次请求的完整处理结果,包括请求的用户(防止撞到别人的 key 读回别人的响应体)、参数指纹、HTTP 状态码、必要的响应头和响应体,后续相同 Key 且参数一致时,返回相同结果。

当请求的 Idempotency Key 相同,但 Request 不同时,应识别并抛出 422 Unprocessable Content 错误。

# Request Fingerprint 是基于规范化后的、与业务语义相关的请求参数生成的指纹。
fingerprint(new_request) != fingerprint(original_request)

Stripe 也有类似的设计,明确会比较后续请求参数和原请求参数(Docs)防止误用,AWS 也有非常类似的 IdempotentParameterMismatch。

返回错误的响应示例:

{
  "code": "IDEMPOTENCY_KEY_REUSED",
  "message": "Idempotency key has already been used with different request parameters."
}

简单来说,Idempotency Key 判断 “是不是同一次业务意图”,Request Fingerprint 判断 “客户端是不是拿同一个业务意图 ID 偷偷换了请求内容”。

另外的要点

了解以上内容,接口幂等键的设计就不会有大方向上的问题了,但对于幂等键,还有一些需要掌握的。

幂等与幂等键

不要混淆,两者是不同的概念。

  • 幂等:同一个操作执行一次和执行多次,最终效果相同(GET、HEAD、OPTIONS、PUT、DELETE 等方法在 HTTP 语义上是幂等的,而 POST、PATCH 等方法的 HTTP 语义不保证幂等,参考 RFC 9110)。
  • 幂等键:幂等键是为了让原本不幂等的操作获得幂等能力,是实现幂等的手段。

适用的 HTTP 方法

幂等键主要用于 POST、PATCH 等 HTTP 语义本身不保证幂等的方法,GET、HEAD、OPTIONS 通常没有必要使用;PUT、DELETE 按 HTTP 语义本身已经幂等,但具体 API 仍可能使用幂等键提供额外的请求去重、结果重放等能力,应以 API 的具体定义为准。

Stripe 文档有这样的描述:「Don't send idempotency keys in GET and DELETE requests because it has no effect. These requests are idempotent by definition.」(不要在 GET 和 DELETE 请求中发送幂等键,因为这样做不会产生任何效果。这些请求根据定义本身就是幂等的。)

关于幂等键的记录,就先到这里。