返回文章列表

幂等不是“请求只能执行一次”:从 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