Go 重试策略:幂等性保证比重试次数更重要 — 方向3 Go 重试策略幂等性保证比重试次数更重要 — 方向3一、重试三次的默认策略在生产环境里是什么后果几乎每个 Go 开发者都写过这样的代码for i : 0; i 3; i { if err : doSomething(); err nil { break } }这段代码在大多数时候都能工作直到遇到以下场景推理服务调用向量数据库写入 embedding第一次请求网络超时。你重试了第二次写入成功了。但第一次请求其实也到达了数据库并成功写入只是响应在网络层丢失。结果同一条 embedding 被写入了两次下游检索时出现了重复结果。这个问题的根源不是重试次数太多而是被重试的操作不具备幂等性。在 AI 推理链路的中间件层重试策略是绕不开的。GPU 节点的偶发性 OOM、网络分区导致的连接断开、vLLM 推理引擎的内部超时——这些都是分布式系统中虽然低频但必然会发生的事件。但重试不是多发一次请求这么简单。它引入了一个根本性的问题如果第一次请求其实已经成功了第二次重试会发生什么。二、请求的去重与幂等键的实现解决重试带来的副作用核心手段是幂等键。每一个需要重试的请求都携带一个全局唯一的幂等键下游服务基于这个键去重——同一个幂等键的多次请求只执行一次。关键设计点在于幂等缓存不仅存储请求是否已处理的布尔值还存储处理结果。这样当重试请求到达时不仅避免了重复执行还能直接返回第一次请求的结果。对于推理请求返回结果的大小可能很大完整的 embedding 向量或生成的文本此时可以只缓存结果摘要并在幂等缓存过期后从下游重新获取。三、幂等中间件的生产实现// IdempotencyMiddleware 幂等中间件 type IdempotencyMiddleware struct { store IdempotencyStore // 幂等缓存存储 ttl time.Duration // 幂等键过期时间 waitMax time.Duration // 等待进行中请求的最长时间 } type IdempotencyStore interface { // TryClaim 尝试声明幂等键返回是否首次声明 TryClaim(ctx context.Context, key string, ttl time.Duration) (bool, error) // SetResult 存储处理结果 SetResult(ctx context.Context, key string, result []byte, ttl time.Duration) error // GetResult 获取处理结果nil表示处理中或未处理 GetResult(ctx context.Context, key string) ([]byte, error) } // Wrap 包装一个处理函数增加幂等保护 func (m *IdempotencyMiddleware) Wrap( fn func(ctx context.Context, req Request) (Response, error), ) func(ctx context.Context, req Request) (Response, error) { return func(ctx context.Context, req Request) (Response, error) { // 从请求中提取或生成幂等键 idemKey : req.IdempotencyKey if idemKey { // 没有幂等键的操作不做幂等保护直接执行 // 这类操作应该被标记为不允许重试 req.Metadata[retriable] false return fn(ctx, req) } // 尝试声明幂等键 claimed, err : m.store.TryClaim(ctx, idemKey, m.ttl) if err ! nil { return Response{}, fmt.Errorf(idempotency claim failed: %w, err) } if !claimed { // 幂等键已存在可能正在处理或已处理完成 return m.waitOrGetResult(ctx, idemKey) } // 首次请求执行实际处理 resp, err : fn(ctx, req) // 将结果写入幂等缓存 result : encodeResult(resp, err) if setErr : m.store.SetResult(ctx, idemKey, result, m.ttl); setErr ! nil { // 存储失败记录日志但不影响返回——幂等是尽力保证 log.Printf(idempotency store result failed for key%s: %v, idemKey, setErr) } return resp, err } } func (m *IdempotencyMiddleware) waitOrGetResult( ctx context.Context, key string, ) (Response, error) { deadline : time.Now().Add(m.waitMax) for time.Now().Before(deadline) { result, err : m.store.GetResult(ctx, key) if err ! nil { return Response{}, fmt.Errorf(idempotency get result: %w, err) } if result ! nil { // 已处理完成返回缓存结果 return decodeResult(result) } // 结果尚未就绪前一请求还在处理中等待后重试 select { case -ctx.Done(): return Response{}, ctx.Err() case -time.After(100 * time.Millisecond): continue } } return Response{}, fmt.Errorf(timeout waiting for in-flight request: key%s, key) }幂等缓存的 TTL 设置为 60-300 秒足以覆盖重试窗口。幂等键的生成由调用方负责推荐使用{业务类型}/{操作ID}/{内容Hash}的格式确保不同场景下的幂等键不冲突。四、幂等性的边界不是所有操作都能重试幂等中间件虽然强大但有几个不可回避的边界条件幂等存储的可用性幂等缓存本身也可能不可用。如果 Redis 宕机是否应该拒绝所有请求不应该。合理的策略是降级——当幂等缓存不可用时允许请求通过但不做幂等保护。代价是重复请求可能产生副作用。这个代价是否可接受取决于业务场景。幂等键的过期与冲突TTL 过期后同一个幂等键可以被重新声明。如果有恶意请求或错误重放携带了已过期的密钥可能导致重复操作。需要合理设置 TTL使其足够覆盖正常的重试窗口但又不会过长。只读操作不需要幂等保护GET 请求天然幂等不需要幂等中间件。只对写操作POST、PUT、DELETE或推理任务提交进行幂等保护。适用边界适合所有可能被重试的写操作和推理任务提交。适合有明确请求标识符如 traceID、clientRequestID的链路。不适合调用方无法生成稳定唯一键的场景如前端 Ajax 请求无业务 ID。不适合幂等缓存本身不可用且副作用不可接受的场景。五、总结重试策略的价值由幂等性保证而非重试次数。没有幂等性的重试是在赌第二次请求不会成功但第一次请求其实已经成功了。落地建议在推理调度层实现幂等中间件基于 Redis 或 etcd 做幂等缓存。关键是将幂等检查与请求处理解耦——即使幂等存储不可用也不中断正常请求。同时区分可重试操作与不可重试操作对没有幂等键的请求禁止自动重试。基础设施不需要漂亮话需要的是重试三次后数据库里不会多出两条重复的 embedding 记录。