应使用客户端提供的稳定标识(如X-Idempotency-Key)拼接业务上下文生成Redis幂等key,配合SET NX EX原子写入;避免直接哈希body,禁用INCR单独限频,ID生成需Lua保障原子性,幂等结果缓存须区分pending/success/failure状态。

用 Redis SETNX 实现请求幂等性,但别直接存 raw body
直接把整个 HTTP 请求体(比如 json.RawMessage)当 key-value 存 Redis,看似简单,实际会踩三个坑:body 顺序不一致导致哈希不同、空格/换行差异、gzip 或编码层干扰。真正该存的是客户端可控且稳定的标识。
推荐做法是让前端在请求头带一个 X-Idempotency-Key(比如 UUID v4),后端用它拼接业务上下文生成 Redis key:
key := fmt.Sprintf("idempotent:%s:%s", userID, idempotencyKey)
然后用 SET key value EX 3600 NX 原子写入。注意:NX 是必须的,EX 时间要覆盖业务最长处理周期(比如支付回调可能卡住 1 小时,就不能只设 5 分钟)。
- 不要依赖
Content-MD5或Body Hash—— 客户端可能没算,或中间代理改了 body - 如果用 Gin,可在 middleware 里统一校验:
c.GetHeader("X-Idempotency-Key"),为空则拒绝 - Redis 写失败(如连接断)不能直接 fallback 到“放行”,得返回 503,否则破坏幂等语义
用 Redis INCR + 过期时间防重复提交,但别只靠 INCR
INCR 看似能计数防重,但它不带过期逻辑,单靠 INCR 会导致 key 永久残留,内存泄漏。正确组合是 INCR + EXPIRE,但要注意竞态:INCR 成功后 EXPIRE 失败,key 就没过期。
立即学习“go语言免费学习笔记(深入)”;
Golang 推荐用 Lua 脚本一次执行:
script := `
if redis.call("INCR", KEYS[1]) == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return redis.call("GET", KEYS[1])
`
调用时传入 key 和 expireSeconds。这样原子性有保障,且只在首次递增时设过期。
- 别对每个请求都 INCR —— 只对明确需限频的场景(如短信发送、密码重置)才用
- 如果业务要求“同一用户 1 分钟最多提交 3 次”,key 应含用户维度:
rate:uid:12345 - 注意 Redis 集群下 Lua 脚本的 key 必须落在同一 slot,所以 key 设计要有 hash tag,比如
rate:{uid}:12345
生成全局唯一 ID 时,Snowflake 不是银弹,Redis INCR 也得加锁
Go 生态常用 github.com/bwmarrin/snowflake,但它依赖机器时间回拨 —— 一旦 NTP 校正或虚拟机暂停,可能生成重复 ID。更稳的方式是用 Redis INCR,但直接 INCR 在并发高时仍可能因网络延迟导致多个请求拿到同一个值(Redis 响应未返回前,客户端已发下一轮)。
安全做法是:先 INCR,再用 GET 确认值,且整个过程用 Lua 包裹,避免中间被其他客户端插队:
script := "local v = redis.call('INCR', KEYS[1]); redis.call('EXPIRE', KEYS[1], ARGV[1]); return v"
同时,ID 生成服务本身要做降级:Redis 不可用时,切到本地 LRU cache + 时间戳 + 自增 counter 组合(精度降到毫秒级,短时可接受)。
- Snowflake 节点 ID(nodeID)别硬编码,从配置或 Consul 动态获取,避免部署多实例时冲突
- 如果用
redis.Incr()方法,确保调用后检查 error 是否为redis.Nil(key 不存在)或连接错误,别忽略 - 生成的 ID 别直接暴露给前端做分页参数 —— 容易被枚举,中间加一层映射或加密
幂等结果缓存必须区分「成功」和「处理中」状态
只存最终结果(比如订单创建成功后的 orderID)不够。如果请求超时重试,而第一次还在执行中,第二次进来发现 key 已存在,就该返回 409 Conflict 或等待中状态,而不是直接返回旧结果 —— 否则用户看到“下单成功”但实际没扣款。
建议用 Redis Hash 存三个字段:status(pending / success / failed)、result(JSON 字符串)、updated_at(时间戳)。用 HSETNX 初始化,后续用 HGETALL 读取判断。
- status 为 pending 时,可配合长轮询或 WebSocket 通知前端,而不是立刻返回 503
- 不要用单个 string key 存 JSON 结构体 —— 修改部分字段得反序列化再序列化,开销大且不原子
- 缓存清理时机很重要:成功后保留 24 小时,失败后保留 2 小时,避免误重试;定时任务扫描过期 pending 记录并标记为 failed
最麻烦的不是实现,而是边界:比如支付回调里,银行通知到了但 DB 更新失败,此时幂等 key 已写,但业务状态不一致。这种 case 得靠异步对账兜底,代码里再怎么锁也没用。










