东东's Blog

高鲁棒性 API 设计之 Idempotency Key:并发请求与执行一致性

上一篇介绍幂等键留下一些问题,幂等键有了,如果两个请求同时到达,应该如何处理?

未仔细考虑过并发问题的代码可能也试图使用缓存进行存在性检验,但两个请求同时到达时,会发生下面的流程。

请求 A:检查 Redis,不存在
请求 B:检查 Redis,不存在

请求 A:执行兑换
请求 B:执行兑换

请求 A:写入幂等结果
请求 B:写入幂等结果

两个请求都通过了存在性验证,走到业务流程,兑换了两次,问题出在这里:

if !exists(key) {
    result := doBusiness()
    save(key, result)
}

existssave 是两个独立操作。从单个请求的视角看,这段代码没有问题:

  1. 检查幂等键是否存在;
  2. 不存在则执行业务;
  3. 保存执行结果。

以并发视角出发,第一步和第三步之间则存在一个明显的时间窗口。

请求 A 检查完成后,请求 B 完全可以在请求 A 写入结果之前,也完成一次检查。于是两个请求都认为「我是第一次请求」。

所以面对并发问题,真正需要保证的是对于同一个 Idempotency Key,同一时刻只能有一个请求获得业务执行权

原子地 “抢占” 执行权

Redis 的 SET ... NX 很适合完成这个动作。

例如:

SET idempotency:{key} processing NX EX 300

其中:

  • NX:Key 不存在时才写入;
  • EX 300:设置 5 分钟过期时间;
  • processing:表示该请求正在处理中。

这个操作在 Redis 中是原子的,于是两个请求同时到达时:

请求 A:SET ... NX -> 成功
请求 B:SET ... NX -> 失败

只有请求 A 获得执行权,请求 B 不再进入业务流程。

伪代码大概变成:

// 1. 原子抢占执行权
ok := setNX(key, "PROCESSING", 5*time.Minute)
if !ok {
    return handleDuplicateRequest(key)
}

result, err := doBusiness()
if err != nil {
    // 只有能够确认业务没有产生副作用时,
    // 才可以安全释放 PROCESSING。
    //
    // 如果业务结果处于“不确定”状态,
    // 不能简单 DEL,否则重试可能导致业务再次执行。
    return handleBusinessFailure(key, err)
}

// 2. 保存成功结果,并设置更长的结果保留时间
saveResultWithTTL(key, result, 24*time.Hour)

return result

相比之前:

if !exists(key) {
    result := doBusiness()
    save(key, result)
}

原来的 “先检查、再决定是否执行” 存在竞争窗口,而 SET ... NX 将 “检查是否不存在 + 抢占执行权” 合并成了一个原子操作。

但事情还没有结束

如果只做到这里,又会马上遇到几个新的问题,假设请求 A 抢占成功:

SET key processing NX EX 300

然后开始执行业务。

这时请求 B 带着相同的 Idempotency Key 过来了,它发现 Processing Key 已经存在。

注意:这里的 Processing 状态和 SUCCESS 状态,数据 TTL 是不同的,Processing 根据业务可能只有几分钟,而产生结果的 SUCCESS 状态时间会长很多。

问题来了:

  1. 这个 Key 是已经执行成功了?
  2. 还是正在执行?
  3. 还是上一次执行到一半服务挂了?

所以幂等记录至少需要区分 不存在PROCESSINGSUCCESS

请求第一次到达时,通过 SET ... NX 原子地把状态从 “不存在” 变成 PROCESSING

业务执行成功后,再把状态更新为 SUCCESS,并保存原请求对应的响应结果。

例如:

{
  "status": "success",
  "http_status": 200,
  "response": {
    "code": 0,
    "message": "success"
  }
}

这样后续相同请求再次到达时,就不需要重新执行业务,可以直接返回第一次请求的结果。

并发请求应该返回什么?

假设请求 A 仍然处于 PROCESSING

请求 A:PROCESSING
请求 B:再次请求

请求 B 此时有几种处理方式。

一种方式是立即返回:

HTTP/1.1 409 Conflict

例如:

{
  "code": "request_in_progress",
  "message": "A request with the same Idempotency-Key is still being processed"
}

客户端稍后重试即可。另一种方案是服务端短暂等待,请求 A 执行完成后,把 A 的结果直接返回给 B。

例如等待几十到几百毫秒,并轮询 Redis:

B  -> 发现 PROCESSING
   -> 等待
   -> 查询
   -> 等待
   -> 查询
   -> 发现 SUCCESS
   -> 返回 A 的结果

这种方式客户端体验更好,但服务端实现也更复杂,而且会占用连接和计算资源。

实际项目中,更推荐这样判断:

短业务:可短暂等待
长业务:直接返回 processing/conflict,让客户端重试

缓存键一定要设置过期时间

前面的:

SET key processing NX EX 300

必须设置 TTL,因为服务可能在设置完 PROCESSING 后的处理过程中崩溃,如果缓存永不过期,那么这个 Idempotency Key 就永久处于处理中,没人知道它是不是还在运行。

之后所有重试都会被拒绝,本质上也是一种故障恢复机制。

然后就会出现新的问题:如果业务执行时间超过了 PROCESSING 的 TTL 怎么办?

例如 TTL 是 5 分钟:

00:00 请求 A 获得执行权
00:05 Redis Key 过期
00:06 请求 B 到达,重新 SET ... NX 成功
00:07 请求 A 仍然在执行业务

此时 A、B 又同时开始执行了。

所以 TTL 不能简单地拍脑袋设置。

对于执行时间可控的普通 HTTP API,可以让 TTL 明显大于业务正常执行时间。

对于耗时可能非常长的任务,则需要考虑:

  • 延长租约;
  • 定期续期;
  • 将任务转换成异步任务;
  • 或者直接把幂等约束放到数据库事务和唯一约束中。

本文使用 SET ... NX,只是为了原子地确定:谁是这个 Idempotency Key 对应请求的第一次执行者。

Redis 原子,不等于业务原子

还有一个更加重要的问题。

即使 SET ... NX 本身是原子的,也不意味着整个业务流程是原子的。

例如兑换业务:

1. Redis 抢占 Idempotency Key
2. 数据库事务:扣减积分 + 创建兑换记录
3. 数据库 COMMIT 成功
4. Redis 保存 SUCCESS

假设数据库事务已经 COMMIT 成功,但服务在 Redis 保存 SUCCESS 之前崩溃,此时可能出现:

Redis:PROCESSING
数据库:兑换已经成功
客户端:没有收到成功响应

过一段时间 PROCESSING 过期,请求重新执行。

如果业务本身没有其他保护机制,就可能再次扣积分。

所以 Idempotency Key 能解决的是 HTTP 请求级别的重复提交问题,搭配 SET ... NX 能看住服务的大门,内部系统的业务 ‘幂等建设’ 仍需要仔细考量服务可能在任何阶段挂掉后的恢复机制,数据库层增加最终约束进行兜底。

下一篇,仍是关于 Idempotency Key ,还需要继续学习解决一个问题:

如果两个请求使用了相同的 Idempotency Key,但请求参数却不一样,应该怎么办?