
为什么直接用 Redis SETNX 做幂等校验会漏判
因为 SETNX 只能保证“写入时唯一”,但无法覆盖“请求已处理完、结果已返回、但客户端超时重试”这种典型场景。这时候你查不到 key,却不能放行——它可能已在上一轮成功执行并落库,只是响应丢了。
真正的幂等校验必须区分三个状态:processing(正在执行)、success(已成功)、failed(已失败)。只靠一个 key 存在与否,撑不起这三态。
-
SETNX+ 过期时间:只能防并发,不防重放 - 没存返回值或状态码:重试时无法原样返回历史结果
- 没绑定请求唯一标识(如
X-Request-ID):跨服务链路无法对齐上下文
Go 里怎么用 Redis 实现带状态的幂等 Key
核心是把一次 RPC 调用的生命周期映射成 Redis 的一个 Hash 结构,比如 idempotent:<request_id></request_id>,里面存 status、result、expire_at 三个字段。用 HSET 写入,HGETALL 读取,配合 Lua 脚本做原子状态跃迁。
别用 SET + JSON 字符串拼接——反序列化失败会导致整个 key 不可读;也别用多个独立 key——并发下 GET + SET 非原子,状态会错乱。
立即学习“go语言免费学习笔记(深入)”;
- key 名建议固定前缀 + 请求 ID,例如
idempotent:abc123 - status 字段只接受
"pending"/"success"/"failed"三种值,拒绝其他写入 - result 字段用
json.RawMessage直接存原始响应体,避免重复编解码开销 - 务必设置
EXPIRE,且过期时间要大于业务最大耗时(比如 5s 业务,设 30s)
在 gRPC middleware 里怎么安全拦截和复用结果
不是所有 RPC 方法都适合加幂等——查询类(GetUser)通常不需要,而创建/支付类(CreateOrder、PayInvoice)必须加。关键在于:拦截器得在真正 handler 执行前就判断是否可跳过。
用 grpc.UnaryServerInterceptor 拦截,从 ctx 中提取 X-Request-ID(或从 metadata 里取),再查 Redis。如果状态是 success 或 failed,直接构造 response 返回,不进业务逻辑。
- 拦截器里查 Redis 必须带超时(如
context.WithTimeout(ctx, 100 * time.Millisecond)),否则拖垮整个链路 - 复用 result 时注意:gRPC response 是 proto struct,需提前约定好序列化格式(推荐 JSON),并在 client 侧兼容解析
- 不要在拦截器里做重试逻辑——那是 client 层的事;server 只负责“这次请求我认不认识”
- 如果 Redis 不可用,建议 fallback 到放行(fail-open),而不是拒掉请求(fail-closed)
容易被忽略的边界:分布式锁续期与状态漂移
长耗时操作(比如上传后触发转码)可能超过 Redis key 的 TTL,导致 key 过期、后续重试被当成新请求。这不是锁失效的问题,而是“状态生命周期”和“业务执行周期”没对齐。
解决方案不是无脑延长 TTL,而是启动一个 goroutine 在后台定期用 EXPIRE 续期,条件是:当前 status 仍是 pending,且距离上次更新已过半 TTL。一旦 status 变为 success 或 failed,立刻停止续期。
- 续期 goroutine 必须绑定到本次请求的 context,随 handler 结束自动 cancel
- 不要用
WATCH/MULTI做状态检查——Redis Cluster 下不支持,且性能差 - status 字段更新必须用 Lua 脚本保证原子性,例如只允许从
pending → success,禁止success → pending
最麻烦的从来不是第一次实现,而是当某个服务升级了响应结构、但老版本幂等 key 还在 Redis 里躺着的时候——那个 result 字段根本解析不了。所以,key 的 version 字段不能省,哪怕只是个数字。










