幂等不是“请求只能执行一次”:从 ModelGate 的 Idempotency-Key 讲重复请求
从 ModelGate 的非流式模型请求出发,解释 Idempotency-Key、请求指纹、pending/completed 状态机、并发冲突与结果重放,以及为什么幂等并不等于 exactly-once。
ModelGate 是我用 Go 实现的 LLM API 网关。
客户端调用一次非流式模型接口:
POST /v1/chat/completions
网关把请求转发给真正的 Provider:
Client
↓
ModelGate
↓
DeepSeek / OpenAI-Compatible Provider
正常情况下,这条链路没有什么特别的。
但如果出现下面这种情况呢?
Client
↓
ModelGate
↓
Provider
↓
模型已经生成完成
↓
ModelGate 已经拿到结果
↓
网络在响应返回客户端之前断开
客户端看到的可能只是:
timeout
connection reset
它无法确定:
这次模型调用到底有没有成功?
最自然的做法就是重新请求。
但如果第一次实际上已经成功,第二次又重新调用一次 Provider,就可能带来:
重复模型调用
重复 Token 消耗
重复计费
甚至得到不同的生成结果
因此 ModelGate 对非流式接口支持:
Idempotency-Key: request-123
客户端重试时继续使用同一个 Key。
但真正实现以后,我发现“幂等”并不是简单地:
见过这个 Key
→ 不执行
还需要回答很多问题:
同一个 Key,但请求内容变了怎么办?
两个相同请求同时到达怎么办?
第一个请求还没执行完,第二个请求应该等待还是报错?
请求成功后,究竟保存什么结果?
Provider 调用失败以后,Key 应该继续占着吗?
Redis 挂了以后,还能不能绕过幂等继续请求模型?
幂等和 Response Cache 又有什么区别?
这篇文章从 ModelGate 当前的 Idempotency-Key 实现出发,整理这些问题之间的关系。
项目地址:theHerta27/ModelGate
幂等首先不是“只允许请求一次”
“幂等”这个词很容易被理解成:
同一个操作只能执行一次。
但更准确地说,它关注的是:
同一个逻辑操作重复提交以后,系统最终不应该因为重复请求产生额外的业务效果。
例如第一次请求:
Idempotency-Key: abc
Provider 返回:
{
"id": "chat-123",
"choices": [...]
}
客户端没收到,于是重新发送完全相同的请求:
Idempotency-Key: abc
ModelGate 希望第二次不要再:
调用 Provider
而是直接重放第一次已经完成的响应:
第一次:
Client
↓
ModelGate
↓
Provider
↓
Response A
↓
保存 Response A
第二次:
Client
↓
ModelGate
↓
发现已完成
↓
直接返回 Response A
所以幂等不是:
请求只能出现一次
而是:
请求可以重试
但同一个逻辑操作
不应该因此重复产生效果
为什么不能只保存 Idempotency-Key
最简单的实现可能是:
Redis 里有 abc
→ 说明做过了
但马上就有一个问题。
客户端第一次请求:
{
"model": "model-a",
"messages": [
{"role": "user", "content": "hello"}
]
}
带:
Idempotency-Key: abc
后来客户端错误地再次使用同一个 Key,却发送:
{
"model": "model-a",
"messages": [
{"role": "user", "content": "write some Go code"}
]
}
如果系统只看:
Key = abc
它可能直接把:
hello
那次请求的旧答案返回给完全不同的新请求。
所以:
Idempotency-Key 只能标识一次逻辑请求,但系统还需要确认“现在这个请求是不是当初那个请求”。
ModelGate 为此计算:
request fingerprint
也就是请求指纹。
Request Fingerprint 是什么
ModelGate 会先把整个 ChatRequest 序列化:
payload, err := json.Marshal(req)
然后计算:
digest := sha256.Sum256(payload)
最终得到:
fingerprint = SHA-256(request JSON)
于是系统判断的不再只有:
Idempotency-Key
而是:
Idempotency-Key
+
Request Fingerprint
同一个 Key:
abc
如果请求内容完全相同:
fingerprint = F1
就认为:
这是同一次逻辑操作的重试。
如果变成:
fingerprint = F2
则说明:
客户端正在试图把同一个 Idempotency-Key 用于不同请求。
ModelGate 会返回冲突,而不是重放错误的旧结果。
为什么 Key 还要带上客户端身份
还有一个问题。
假设两个完全不同的客户端恰好都使用:
Idempotency-Key: request-1
如果 Redis 中直接使用:
modelgate:idempotency:request-1
它们就会互相冲突。
但一个客户端自己的:
request-1
不应该占用全世界所有其他客户端的:
request-1
因此 ModelGate 会把:
ClientIdentity
+
Idempotency-Key
组合后再做 SHA-256:
digest := sha256.Sum256(
[]byte(identity + "\x00" + idempotencyKey),
)
最终 Redis Key 不是:
modelgate:idempotency:client-a:request-1
而是类似:
modelgate:idempotency:
<sha256 digest>
这样做有两个作用。
第一:
不同 Client
+
相同 Idempotency-Key
不会互相冲突。
第二:
Redis Key 中不会直接暴露:
原始客户端身份
Idempotency-Key
也就是说,作用域和数据暴露问题一起解决了。
ModelGate 的幂等不是一个布尔值,而是状态机
如果 Redis 中只保存:
存在
不存在
还不够。
因为一个请求可能处于:
刚刚开始
正在执行
已经完成
不同阶段,重复请求应该有不同处理。
ModelGate 当前主要有四种 Begin 结果:
acquired
pending
completed
conflict
可以画成:
stateDiagram-v2
[*] --> ACQUIRED: 第一次使用 Key
ACQUIRED --> PENDING: 当前请求正在执行
PENDING --> COMPLETED: 请求成功并保存响应
PENDING --> [*]: 请求失败后 Release
COMPLETED --> COMPLETED: 相同 Key + 相同请求,重放结果
PENDING --> CONFLICT: 相同 Key + 不同请求
COMPLETED --> CONFLICT: 相同 Key + 不同请求
严格来说,acquired 是 Begin 返回给当前调用方的结果,而 Redis 中真正保存的执行中状态是:
pending
但从 Gateway 的角度,这四种返回结果刚好对应四条不同处理路径。
第一次请求:ACQUIRED
假设 Redis 中还没有:
这个 Client + Idempotency-Key
ModelGate 会原子写入:
fingerprint = F1
state = pending
并返回:
acquired
Gateway 这才真正继续:
Response Cache
↓
Provider
也就是说:
先获得幂等执行权,再进行真正可能产生费用的模型调用。
这一步非常重要。
如果反过来:
先调用 Provider
↓
再去 Redis 记录 Key
两个并发请求可能已经都调用完 Provider 了。
后面再记录幂等状态已经来不及。
为什么 Begin 必须原子执行
假设用普通 Redis 命令分开写:
GET key
↓
发现不存在
↓
HSET pending
两个请求可以发生:
请求 A:GET → 不存在
请求 B:GET → 不存在
请求 A:HSET
请求 B:HSET
于是两边都认为:
我获得了执行权
随后同时调用 Provider。
这和数据库里的:
先 SELECT
再 INSERT
属于同一种并发问题:
check-then-act。
所以 ModelGate 的 Begin 不是由多条独立 Redis 命令在 Go 中拼接,而是使用 Lua:
local current =
redis.call(
'HMGET',
KEYS[1],
'fingerprint',
'state',
'response'
)
if not current[1] then
redis.call(
'HSET',
KEYS[1],
'fingerprint', ARGV[1],
'state', 'pending'
)
redis.call(
'PEXPIRE',
KEYS[1],
ARGV[2]
)
return {'acquired'}
end
也就是:
检查是否存在
+
创建 pending
+
设置 TTL
在 Redis 脚本中作为一个原子操作完成。
这和 ModelGate 的 Token Bucket 使用 Lua 的原因非常相似:
需要原子的不是某一条 Redis 命令,而是一整段“检查以后再改变状态”的逻辑。
第二个相同请求同时进来:PENDING
假设请求 A 已经:
Begin
→ acquired
Redis 中现在是:
fingerprint = F1
state = pending
但 Provider 还没有返回。
这时请求 B 带着相同:
ClientIdentity
Idempotency-Key
Fingerprint
进入。
Lua 会发现:
Key 已存在
Fingerprint 相同
State 不是 completed
于是返回:
pending
ModelGate 不会让 B:
也调用一次 Provider
而是直接返回:
409 Conflict
错误码:
idempotency_request_in_progress
同时响应:
Retry-After: 1
表示:
相同逻辑请求目前还在执行,稍后再试。
这里 ModelGate 当前选择的是:
拒绝并发重复请求。
而不是:
请求 B 阻塞等待请求 A 完成
为什么不让第二个请求一直等第一个
理论上也可以实现:
请求 B
↓
发现 pending
↓
订阅 / 轮询
↓
等待请求 A 完成
↓
直接返回结果
这样客户端体验可能更顺滑。
但实现复杂度也会提高。
需要继续处理:
B 最多等多久?
A 崩溃怎么办?
谁通知 B?
同时有 100 个 B 怎么办?
Redis Pub/Sub 丢消息怎么办?
等待过程中客户端取消怎么办?
ModelGate 当前采用的是更简单的语义:
pending
→ 409
→ Retry-After: 1
让客户端以后重新请求。
这样:
幂等状态管理
和:
请求等待调度
没有被绑在一起。
同一个 Key,请求内容变了:CONFLICT
假设第一次:
Key = abc
Fingerprint = F1
第二次却:
Key = abc
Fingerprint = F2
Lua 中会直接:
if current[1] ~= ARGV[1] then
return {'conflict'}
end
Gateway 返回:
409 Conflict
错误码:
idempotency_key_conflict
含义是:
这个 Idempotency-Key 已经属于另外一份请求,不能重新解释它。
为什么这里不能:
覆盖旧 Fingerprint
因为那会破坏 Idempotency-Key 最核心的语义:
一个 Key 应该稳定标识同一个逻辑操作。
如果客户端想发新请求,就应该使用新的 Key。
请求成功后:COMPLETED
第一次请求真正成功以后,ModelGate 会把完整的:
provider.ChatResponse
序列化成 JSON。
然后执行 Complete。
Redis 状态从:
fingerprint = F1
state = pending
变成:
fingerprint = F1
state = completed
response = <完整 JSON 响应>
对应 Lua:
local current =
redis.call(
'HMGET',
KEYS[1],
'fingerprint',
'state'
)
if current[1] ~= ARGV[1]
or current[2] ~= 'pending' then
return 0
end
redis.call(
'HSET',
KEYS[1],
'state', 'completed',
'response', ARGV[2]
)
这里 Complete 还会再次确认:
Fingerprint 仍然匹配
State 仍然是 pending
而不是看到 Key 就无条件覆盖。
这相当于保证:
只有真正持有这次 pending 状态的请求,才能把它推进到 completed。
为什么 Complete 也使用 Lua
这里又出现了:
检查当前 fingerprint
检查当前 state
更新 state
写 response
刷新 TTL
如果拆成多个 Redis 命令,就重新产生并发窗口。
所以它同样在 Lua 中完成。
整个状态转换更接近:
Begin Lua
→ 原子地决定谁获得执行权
Complete Lua
→ 原子地把正确的 pending 变成 completed
Release Lua
→ 原子地只删除属于自己的 pending
Redis 在这里承担的已经不是普通缓存,而是一份小型的:
并发状态机。
第二次请求为什么可以完全跳过 Provider
请求成功以后,再发送:
同 Client
同 Idempotency-Key
同 Fingerprint
Begin 会得到:
completed
并同时返回之前保存的:
response
Gateway 直接:
json.Unmarshal(begin.Response, &replay)
然后构造响应。
这个路径不会进入:
Response Cache
更不会进入:
Provider
测试里专门检查:
IdempotencyReplayed = true
Response.ID = replayed
upstream.chatCalls = 0
也就是说:
幂等重放真正跳过了上游调用。
HTTP 响应还会额外带:
Idempotency-Replayed: true
让客户端知道:
这次返回的是已经完成请求的重放结果。
为什么要保存完整 Response,而不只是“这个请求成功过”
假设 Redis 只保存:
state = completed
第二次请求进来以后,系统只能知道:
第一次成功了。
但无法回答:
第一次到底返回了什么?
如果此时重新调用 Provider:
completed
↓
但为了拿答案
再请求模型
幂等就失去了主要意义。
而 LLM 请求还有一个特别明显的问题:
相同 Prompt 再生成一次,不一定得到完全相同的答案。
尤其:
temperature > 0
时,两次模型结果完全可能不同。
所以 Idempotency Replay 保存的是:
第一次真正完成的 Response。
第二次返回:
同一个逻辑结果
而不是:
重新执行一次,希望结果差不多
这也是幂等和 Response Cache 的一个重要区别
ModelGate 同时存在:
Idempotency
和:
Response Cache
它们看起来都可以:
不调用 Provider
直接返回旧 Response
但解决的问题不一样。
Response Cache
ModelGate 当前只缓存明确:
temperature = 0
的非流式成功请求。
它主要回答:
相同的确定性查询以前算过,能不能复用结果来降低延迟和成本?
所以它是一项:
性能优化
Idempotency
Idempotency-Key 不要求:
temperature = 0
它主要回答:
客户端不确定上一次请求是否成功,现在正在重试同一个逻辑操作,能不能避免重复执行?
它是一项:
请求正确性 / 重复执行控制
所以:
Cache
→ “这个结果能不能复用?”
Idempotency
→ “这个逻辑操作是不是已经做过?”
虽然最终都有可能返回保存的 Response,但出发点完全不同。
为什么 Idempotency 在 Cache 之前
ModelGate 当前主链路顺序大致是:
Validate
↓
Rate Limit
↓
Idempotency Begin
↓
Response Cache
↓
Provider
也就是说:
幂等判断
发生在:
缓存查询
之前。
这有一个很实际的效果。
假设第一次带 Idempotency-Key 的请求:
获得 acquired
随后恰好:
Cache HIT
虽然没有真正调用 Provider,但这次逻辑请求仍然可以被标记:
completed
并保存这份 Cache Response。
因此下一次使用同一个 Idempotency-Key:
直接 Idempotency Replay
而不是再次走 Cache。
换句话说:
Idempotency 关注的是整个 Gateway 操作是否已经得到一个确定结果,而不是它到底来自 Provider 还是 Cache。
Rate Limit 为什么反而在 Idempotency 前面
当前顺序还有一个值得注意的地方:
Rate Limit
↓
Idempotency
这意味着:
即使是 Idempotency Replay,请求也先经过限流。
也就是说:
重复请求
不会因为:
最终不调用 Provider
就完全绕过 Gateway 的请求频率控制。
这是当前源码实际采用的策略。
它体现的是:
Rate Limit
→ 控制进入 Gateway 的请求行为
Idempotency
→ 控制重复逻辑操作
两者职责不同。
Provider 失败以后为什么要 RELEASE
假设请求:
Begin
→ acquired
Redis 中:
state = pending
然后 Provider 返回失败。
如果什么都不做:
state
会一直保持:
pending
后续相同请求进来:
Begin
→ pending
→ 409
即使第一次其实已经明确失败。
这样这个 Key 就暂时失去了再次尝试的机会。
所以 ModelGate 在 Provider 调用失败时会:
g.releaseIdempotency(
scopedIdempotencyKey,
fingerprint,
acquiredIdempotency,
)
Release Lua 会检查:
Fingerprint 相同
State = pending
才删除:
if current[1] == ARGV[1]
and current[2] == 'pending' then
return redis.call('DEL', KEYS[1])
end
于是:
第一次请求
→ Provider 明确失败
→ 删除 pending
之后客户端重试
→ 可以重新 acquired
这里表达的是:
失败的请求没有产生可重放的完成结果,因此不应该永久占用 Idempotency-Key。
为什么 Release 也必须检查 Fingerprint 和 State
假设某个很晚才执行的清理逻辑直接:
DEL key
但这个 Key 可能已经被别的状态转换更新。
所以 Release 不能认为:
我以前创建过它,我现在就一定能删。
它必须确认:
Fingerprint 仍然属于自己
并且
State 仍然是 pending
才删除。
这和数据库里:
UPDATE ... WHERE version = ?
那类条件更新有一点相似的思想:
改状态之前,先确认自己看到的前提仍然成立。
Pending 为什么需要 TTL
还有一个更麻烦的情况。
假设请求:
Begin
→ acquired
Redis 已经写入:
pending
但随后整个 ModelGate 进程突然崩溃。
它甚至没有机会执行:
Complete
或者:
Release
如果 pending 永久存在:
这个 Idempotency-Key
就会永远返回:
request in progress
形成死锁式状态。
因此第一次创建 pending 时会立刻设置:
IDEMPOTENCY_LOCK_TTL
当前默认:
30s
也就是说:
pending
↓
如果没有 Complete / Release
↓
最多存活约 30 秒
↓
自动过期
TTL 在这里不是为了:
省 Redis 内存
而是在解决:
持有执行权的请求突然消失以后,锁状态怎样自动释放。
这和分布式锁为什么一定要有租约 / TTL,其实是类似的问题。
Completed 为什么又是另一个 TTL
请求完成以后,ModelGate 会把 Redis TTL 改成:
IDEMPOTENCY_TTL
当前默认:
24h
所以两个 TTL 负责的是不同阶段。
Pending TTL
30s
解决:
正在执行的请求如果崩溃,不能永远占坑。
Completed TTL
24h
解决:
完成结果需要保留多久,供客户端重试时重放。
关系可以写成:
Begin
↓
pending
TTL = 30s
成功
↓
completed
TTL = 24h
这里再次说明:
TTL 不是一个纯粹的 Redis 性能参数,它经常直接参与业务状态语义。
30 秒到了,是不是说明原请求一定失败了
不是。
这是一个非常重要的边界。
假设:
pending TTL = 30s
并不能证明:
30 秒以后第一次模型调用一定已经失败。
理论上可能出现:
第一次请求
→ 已经成功调用 Provider
→ 但 Gateway 卡在某个异常状态
→ pending 最终过期
第二次请求
→ 再次 acquired
→ 又调用一次 Provider
所以 Pending TTL 解决的是:
永久卡死
而不是提供:
exactly-once
保证。
TTL 本质上是一种租约。
过期意味着:
系统不再继续相信原来的执行权仍然有效。
并不意味着:
世界上可以严格证明原操作从未产生任何效果。
Redis Begin 失败为什么要 Fail Closed
这是 ModelGate 里我觉得很值得讲的一个设计。
Response Cache 出故障时,系统可以:
Cache MISS
↓
直接去 Provider
因为缓存只是优化。
但 Idempotency Redis 出故障时:
Begin 无法执行
ModelGate 不会:
算了
直接请求 Provider
而是返回:
503 Service Unavailable
错误码:
idempotency_unavailable
也就是:
Fail Closed。
为什么?
因为客户端已经明确带了:
Idempotency-Key
它表达的是:
我要求这次操作具有幂等保护。
此时 Gateway 如果无法确定:
这个 Key 是否已经 completed
是否仍然 pending
是不是另一个请求内容
却直接继续调用模型,就可能:
重复调用
重复计费
所以:
不知道状态
不应该被解释成:
状态不存在
这两句话完全不同。
“不知道”为什么不能等价于“没有”
Redis 报错:
connection timeout
只说明:
Gateway 无法读取幂等状态。
并不说明:
这个 Key 在 Redis 中不存在
它可能实际上已经是:
completed
如果这时候按:
不存在
处理,就会重复执行。
所以 ModelGate 对:
Cache
和:
Idempotency
采用不同故障策略:
Cache GET 失败
→ 当 MISS 处理
→ 继续 Provider
Idempotency Begin 失败
→ 503
→ 不调用 Provider
这让我觉得是一个很典型的后端设计问题:
依赖故障以后应该 Fail Open 还是 Fail Closed,不取决于“Redis 挂了”这件事本身,而取决于 Redis 在这个功能里承担什么职责。
Complete 失败以后为什么不覆盖成功的模型响应
还有一个更微妙的边界。
假设:
Provider 已成功返回
Gateway 已经拿到完整 Response。
随后执行:
Idempotency Complete
但 Redis 此时突然失败。
ModelGate 当前会:
记录 error
但不会把已经成功的模型结果改成:
503
也就是说:
Provider Success
+
Idempotency Complete Failure
→ 当前请求仍返回模型结果
为什么?
因为业务结果已经真实获得。
如果此时告诉客户端:
请求失败
客户端反而更可能重试。
而 Gateway 已经无法撤销:
刚才那次 Provider 调用
所以当前实现选择:
不用幂等后处理失败覆盖已经获得的模型成功结果。
不过这也带来一个明确的边界:
completed 状态可能没有成功写入
原来的 pending 会继续存在,直到:
30 秒 Lock TTL
过期。
之后客户端再次重试,理论上仍可能重新调用 Provider。
因此当前方案不能提供严格意义上的:
exactly-once execution
它是在现实故障下尽量降低重复执行概率,而不是消除所有重复执行可能。
为什么 Complete 和 Release 使用 Background Context
请求成功以后,客户端可能很快断开。
如果 Idempotency Complete 继续使用原来的:
request ctx
客户端一断开:
ctx canceled
保存幂等结果的操作也会一起取消。
于是:
Provider 已成功
却没有机会写:
completed
所以 ModelGate 的 Complete 使用:
context.WithTimeout(
context.Background(),
g.operationTimeout,
)
Release 也是类似处理。
这里不是无限脱离请求生命周期。
因为仍然有:
operationTimeout
限制。
它表达的是:
主请求虽然已经结束,但这项很短的状态收尾仍然值得给一个独立的小时间预算。
类似的思想也出现在 ModelGate 的 Usage 记录中。
这让我更容易理解:
context.Background()不是“什么都不管”,而是意味着这项操作不应该因为原请求取消而立刻消失;同时仍然应该另外设置自己的 Timeout。
为什么 Idempotency-Key 有格式限制
ModelGate 会先规范化 Header:
value = strings.TrimSpace(value)
然后限制:
最大 128 bytes
允许字符:
a-z
A-Z
0-9
.
_
:
-
为什么要限制?
因为这个值是:
客户端输入
而且会进入系统状态管理逻辑。
即使最终 Redis Key 使用的是 SHA-256,也没有必要接受:
无限长度
任意控制字符
异常编码内容
这种输入。
因此幂等机制除了并发控制,还需要先明确:
什么样的 Key 才算合法协议输入。
为什么流式请求不支持 Idempotency-Key
ModelGate 当前只允许:
Non-streaming
使用 Idempotency-Key。
如果:
{
"stream": true
}
同时携带:
Idempotency-Key: abc
会直接返回:
idempotency_not_supported_for_stream
原因和 SSE 的响应边界有关。
非流式请求可以很自然地定义:
完整 Response
→ completed
→ 保存
→ 以后完整重放
但 SSE 是:
Chunk 1
Chunk 2
Chunk 3
...
[DONE]
如果流到一半断掉:
到底算 completed 还是 failed?
如果保存了一半:
下次要不要从中间继续?
如果重新调用模型:
新流和旧流怎么衔接?
而且模型生成本身也可能不确定。
ModelGate 当前没有实现:
部分 SSE 持久化
断点续传
已发送 Chunk 去重
因此最稳妥的策略就是:
不对流式请求承诺当前这套 Idempotency-Key 语义。
不能因为 HTTP Header 名字一样,就假装流式和非流式可以使用同一套结果重放模型。
幂等是不是 exactly-once
不是。
这是我觉得最容易在面试里说错的一点。
Idempotency-Key 能做到的是:
在系统能够正确读取和保存幂等状态时,相同逻辑请求可以复用完成结果,并阻止明显的并发重复执行。
但真实分布式链路中仍然存在:
Provider 已执行
Gateway 崩溃
Provider 已返回
Redis Complete 失败
Pending TTL 到期
原操作实际效果无法确认
Gateway 到 Provider 之间 Retry
这些不确定窗口。
因此不能说:
有了 Idempotency-Key
=
Provider 一定只被调用一次
更不能直接说:
exactly-once
准确一点是:
通过稳定的操作标识、状态记录和结果重放,使重复请求尽量表现得像同一个逻辑操作,而不是每次都重新产生效果。
这也是为什么很多分布式系统里:
exactly-once
比字面听起来复杂得多。
Idempotency-Key 和 Request ID 也不是一回事
ModelGate 每个请求还有:
X-Request-ID
这两个 ID 很容易混淆。
Request ID
每一次 HTTP 请求都有自己的:
Request ID
例如:
第一次尝试 → request-id-A
第二次重试 → request-id-B
它主要用于:
日志追踪
请求排查
Metrics / Storage 关联
Idempotency-Key
多次 HTTP 请求可以共享同一个:
Idempotency-Key
因为它们表达的是:
同一个逻辑操作
所以:
Request ID
→ 标识一次请求尝试
Idempotency-Key
→ 标识一次逻辑操作
一个逻辑操作可能因为网络重试产生多个 Request ID。
这两个概念不能互相替代。
怎样验证幂等机制真正工作
ModelGate 对 Redis Store 本身和 Gateway 行为分别做了测试。
Begin 的四种状态
Redis Store 单元测试分别模拟:
acquired
pending
conflict
completed
确认 Go 层能够正确解析 Lua 返回值。
Completed Replay 必须跳过 Provider
Gateway 测试预先返回:
StatusCompleted
并提供保存的 Response。
最后要求:
IdempotencyReplayed = true
同时:
upstream.chatCalls = 0
这是一个很重要的断言。
因为只检查:
Response 内容正确
还不能证明幂等真的避免了 Provider 调用。
Pending 和 Conflict 不能访问 Provider
测试分别返回:
pending
和:
conflict
要求 Gateway 返回对应错误。
同时:
upstream.chatCalls = 0
说明冲突请求不会偷偷继续请求上游。
成功必须 Complete
如果第一次:
StatusAcquired
随后 Provider 成功。
测试要求:
completeCalls = 1
releaseCalls = 0
Provider 失败必须 Release
如果 Provider 返回错误:
completeCalls = 0
releaseCalls = 1
说明失败请求不会错误进入 completed。
Scoped Key 不应该暴露原始输入
测试还检查:
ScopedKey("client", "request-key")
结果中不能出现:
client
request-key
并且同样输入必须得到稳定结果,不同客户端必须得到不同结果。
真实 Redis 集成测试还验证了什么
项目的 Integration Test 会使用真实 Redis。
先:
Begin
→ acquired
然后:
Complete
→ 保存 {"ok":true}
再次:
Begin
必须得到:
completed
+
原始 response
这验证的不只是:
fake evaluator 返回值解析
而是实际 Lua Script:
HMGET
HSET
PEXPIRE
确实能在真实 Redis 中完成状态转换。
HTTP 级 Integration Test 还会发三次请求。
第一次:
带 Idempotency-Key
→ 正常执行
→ Cache MISS
第二次:
同 Key
→ Idempotency Replay
第三次:
不带 Idempotency-Key
→ Response Cache HIT
测试最后要求:
三次 Response Body 完全一致
同时第二次响应必须包含:
Idempotency-Replayed: true
这条测试正好把:
Idempotency
和:
Cache
两套看起来相似、实际上职责不同的机制放在了一起验证。
面试时我会怎样回答
如果面试官问:
你的 ModelGate 幂等是怎么做的?
我会回答:
ModelGate 对非流式接口支持 Idempotency-Key。我不会只按 Key 去重,因为同一个 Key 可能被错误用于不同请求,所以会先对完整 ChatRequest 做 SHA-256 请求指纹,同时把 ClientIdentity 和 Idempotency-Key 做哈希得到客户端作用域内的 Redis Key。
Redis 里维护 pending 和 completed 状态。第一次请求通过 Lua 原子创建 pending 并获得执行权;同 Key、同指纹的并发请求如果看到 pending,就返回 409,不再调用 Provider;同 Key 但不同指纹直接 conflict;请求完成后把完整 Response 保存成 completed,后续相同请求直接重放并跳过 Provider。
Provider 失败时会 Release pending,让客户端以后可以重新尝试。Pending 默认只有 30 秒 TTL,避免 Gateway 崩溃后永久占坑;Completed 默认保留 24 小时。
如果 Redis 在 Begin 阶段不可用,我选择 fail closed 返回 503,因为这时无法判断请求是不是已经执行过,如果直接继续调用模型可能产生重复计费。
如果继续问:
为什么幂等还要 Fingerprint?
我会回答:
因为 Idempotency-Key 应该稳定对应一个逻辑请求。如果客户端复用了相同 Key,但修改了 Prompt、Model 或其他请求参数,不能把旧响应重放给新请求,所以保存 Key 的同时还要保存请求指纹,指纹不同直接返回冲突。
如果继续问:
幂等和缓存有什么区别?
我会回答:
Cache 是性能优化,回答“相同的可缓存计算结果能不能复用”;Idempotency 是重复操作控制,回答“这个逻辑操作是不是已经执行过”。ModelGate 的 Response Cache 只针对明确 temperature=0 的非流式成功请求,但 Idempotency 可以保存并重放一次非流式请求的实际结果,即使模型输出本身不是确定性的。
如果继续问:
这能保证 exactly-once 吗?
我会回答:
不能。比如 Provider 已经执行成功但 Gateway 在写 completed 前崩溃,或者 Complete Redis 操作失败,客户端以后仍然可能重新调用 Provider。Idempotency-Key 是通过状态记录和结果重放降低重复执行,并不能把外部 Provider 和 Redis 变成一个原子事务,所以我不会把它描述成 exactly-once。
我从这个问题中理解到的事情
一开始理解幂等时,我比较容易记住一句话:
同一个请求执行多次
结果和执行一次一样
这句话没有错,但放进真实后端以后仍然太抽象。
因为系统真正需要回答的是:
怎么知道两次请求属于同一个逻辑操作?
同一个 Key 但请求内容变了怎么办?
两个相同请求同时到达时谁执行?
正在执行中的请求怎么表示?
成功结果保存多久?
执行方崩溃以后 pending 怎么释放?
失败以后这个 Key 还能不能重新使用?
状态存储不可用时应该继续还是拒绝?
完成结果保存失败以后还能承诺什么?
ModelGate 当前把这些问题组织成:
ClientIdentity + Idempotency-Key
→ 确定 Key 的作用域
Request Fingerprint
→ 确认 Key 对应的请求内容没有变化
Lua Begin
→ 原子获得一次执行权
pending
→ 表示当前逻辑请求仍然在执行
409 in progress
→ 阻止并发重复调用 Provider
conflict
→ 阻止相同 Key 被重新解释成另一个请求
completed + response
→ 保存真正完成的结果
Replay
→ 后续重试直接返回原结果
Release
→ 明确失败后释放执行权
Pending TTL
→ 防止崩溃造成永久占坑
Completed TTL
→ 定义结果可重放时间
Fail Closed
→ 无法确认状态时不冒险重复调用模型
而我觉得这里最重要的边界是:
幂等不是让一个操作在物理世界里绝对只执行一次,而是让系统能够识别同一个逻辑操作,并尽可能让重复请求复用已经确定的结果。
真正的分布式系统里总会存在:
请求发出去了没有?
Provider 执行了没有?
响应是在网络哪一段丢的?
Redis 状态有没有成功写入?
进程在哪一行崩溃?
这些无法仅靠一个 HTTP Header 消除的不确定性。
所以 Idempotency-Key 真正提供的不是魔法般的:
exactly once
而是一套更明确的重复请求协议:
客户端告诉服务端“这些请求其实是同一件事”,服务端再通过状态机、原子操作、结果保存和冲突检测,尽量让这件事只产生一次可见的业务结果。
这也是我在 ModelGate 实现幂等以后,对“重复请求”这个问题真正形成的理解。
延伸阅读
- Stripe API:Idempotent requests
- IETF HTTPAPI:The Idempotency-Key HTTP Header Field
- Redis Documentation:Lua scripting
- Go Documentation:crypto/sha256