单纯用 redis setnx 不足以保证 rpc 幂等性,因其仅能防止重复进入,无法处理“已成功执行但响应丢失”场景,导致重试时漏执行;强幂等需确保相同请求参数下业务状态与返回结果完全一致,必须记录“是否已成功完成”而非仅“是否正在处理”,并借助 lua 脚本原子实现存结果与防重一体化。

为什么单纯用 Redis SETNX 不足以保证 RPC 幂等性
因为 SETNX 只能防止“同一请求重复进入”,但无法处理「请求已成功执行、但响应丢失」的场景。客户端重试时,服务端若只看 key 是否存在,会直接返回「已存在」而跳过业务逻辑,导致漏执行——这不是幂等,是丢操作。
真正强幂等要求:相同请求参数 → 无论调用几次,业务状态和返回结果都完全一致(含成功响应体)。
- 必须记录「请求是否已成功完成」,不只是「是否正在处理」
- 不能依赖客户端传来的临时 ID(如 UUID)做唯一判断,要绑定业务语义(如
order_id+action=pay) - Redis 单命令无法原子读+写+回填结果,得靠 Lua 脚本兜底
用 Lua 脚本实现「存结果+防重」一体化
核心思路:在一次 Redis 原子操作中,检查是否存在已完成的结果;若无,则写入「处理中」标记并返回空;若有,则直接返回缓存结果。Lua 脚本能规避网络往返和竞态。
示例脚本(用于支付类接口):
立即学习“go语言免费学习笔记(深入)”;
if redis.call("EXISTS", KEYS[1]) == 1 then
local res = redis.call("HGETALL", KEYS[1])
if #res > 0 then
return res
end
end
redis.call("HSET", KEYS[1], "status", "processing")
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[1]))
return {}
Go 中调用:
使用 redis.NewScript 加载,script.Eval 执行,KEYS[1] 是幂等键(如 "idempotent:pay:20240501:ORD123"),ARGV[1] 是过期秒数(建议 24h 内,避免脏数据长期占内存)。
- 不要把整个响应 JSON 存进 Hash,只存关键字段(
"code","msg","data_id"),避免 Redis 内存膨胀 - 如果业务需要返回动态时间戳(如
created_at),得在 Go 层补全,Lua 里不生成 - 脚本返回空表
{}表示需继续执行业务逻辑;返回非空表示可直接返回
RPC 接口层如何安全集成幂等逻辑
关键不是「加个中间件就完事」,而是把幂等校验点卡在「业务执行前、参数校验后」——太早(如反序列化前)拿不到业务 ID;太晚(如 DB 写完后)已产生副作用。
- 从
context.Context或 HTTP Header 提取X-Idempotency-Key,拼出 Redis key,注意过滤非法字符(用url.PathEscape或白名单) - 若 Lua 返回非空结果,直接构造响应并 return,**绝不能继续调用下游 service**
- 业务执行成功后,必须用另一个 Lua 脚本「原子覆写结果」:
HSET+EXPIRE,且设置比处理中更长的 TTL(例如处理中 30s,结果缓存 2h) - 遇到 panic 或超时,要主动删掉
processing状态(或靠 TTL 自动清理),否则会永久阻塞该请求
容易被忽略的边界情况
最常翻车的地方不在主流程,而在这些细节:
-
redis.DialTimeout和redis.ReadTimeout必须显式设小(如 300ms),否则网络抖动会导致幂等 key 长期处于processing状态 - 同一个幂等键,不同版本 API 返回结构变化时,旧结果缓存仍会被返回——需在 key 中加入 API 版本号,如
"idempotent:v2:pay:ORD123" - Redis 故障时,不能 fallback 到「跳过幂等」,而应返回
503 Service Unavailable,由客户端控制是否降级重试 - 测试时用
time.Sleep模拟慢请求,观察并发调用下是否真能返回一致结果,而不是只测单次
强幂等从来不是加一段脚本就结束的事,它要求你对每个重试路径、每种失败类型、每处缓存生命周期都有明确决策。没覆盖到的角落,往往就是资损发生的起点。










