上一篇介绍幂等键留下一些问题,幂等键有了,如果两个请求同时到达,应该如何处理?
未仔细考虑过并发问题的代码可能也试图使用缓存进行存在性检验,但两个请求同时到达时,会发生下面的流程。
请求 A:检查 Redis,不存在
请求 B:检查 Redis,不存在
请求 A:执行兑换
请求 B:执行兑换
请求 A:写入幂等结果
请求 B:写入幂等结果
两个请求都通过了存在性验证,走到业务流程,兑换了两次,问题出在这里:
if !exists(key) {
result := doBusiness()
save(key, result)
}
exists 和 save 是两个独立操作。从单个请求的视角看,这段代码没有问题:
- 检查幂等键是否存在;
- 不存在则执行业务;
- 保存执行结果。
以并发视角出发,第一步和第三步之间则存在一个明显的时间窗口。
请求 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 状态时间会长很多。
问题来了:
- 这个 Key 是已经执行成功了?
- 还是正在执行?
- 还是上一次执行到一半服务挂了?
所以幂等记录至少需要区分 不存在、PROCESSING、SUCCESS
请求第一次到达时,通过 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,但请求参数却不一样,应该怎么办?