仅用 Redis.SetNX 不足以保障幂等性,因其只判断首次写入,无法确保业务逻辑执行成功;需结合状态机、唯一索引与容错策略,应对 panic、Redis 故障、客户端绕过等场景。
直接用 redis.setnx 做原子判断是最稳妥的起点,但只靠它远远不够——必须搭配状态机、唯一索引和容错策略,否则并发漏判、redis 故障或客户端绕过都会导致重复执行。
为什么不能只用 redis.SetNX 就完事
很多人以为调一次 rdb.SetNX(ctx, key, "1", ttl) 就能拦住所有重复请求,结果上线后发现偶发双扣库存、重复发券。核心问题在于:SetNX 只解决“是否首次写入”,不解决“是否真正执行成功”。
- 如果业务 panic 或网络中断,key 写进去了但逻辑没跑完,后续重试会被拒,用户以为失败实际已部分生效
- 如果 Redis 瞬间不可用,你 fallback 到“放行”,幂等语义就彻底崩了
- 如果 key 构造太简单(比如只用
X-Idempotency-Key),不同用户、不同接口会互相污染 - value 存空字符串或时间戳,出问题时没法快速查到是哪次请求、什么参数、返回了什么结果
sync.Map 适合什么场景,又容易在哪翻车
单机部署、QPS 几百以内、不想引入 Redis 依赖时,sync.Map 是个轻量选择,但它不是 Redis 的平替——它不跨进程、无自动过期、全靠你自己管生命周期。
- key 必须带上下文:建议拼成
"idempotent:" + method + ":" + path + ":" + clientID + ":" + idempotencyKey,避免订单接口和退款接口 key 冲突 - value 别存
bool,得是结构体:{status: "success", result: json.RawMessage, timestamp: time.Time},否则“命中缓存”时没法原样返回响应 - 写入前必须先
Load,命中就直接返回;没命中才执行业务逻辑,完成后必须Store,不能只靠 if 判断 - 自己加定时清理:比如每分钟扫一次
timestamp超过 10 分钟的项,或 map size > 10000 就删最老的 10% —— 否则内存越涨越大
数据库唯一索引为什么是兜底而不是主力
唯一索引拦不住中间态副作用,比如两次请求都走到“发短信”那步才卡在 DB 插入上,第一条成功发了短信、第二条报 ERROR: duplicate key value violates unique constraint,但短信已经发了两次。
- 必须建联合唯一索引,例如
UNIQUE (user_id, idempotency_key),字段别选太宽(比如整个 request body),否则写性能拖垮 - 插入失败后,不能直接抛错,要精准识别错误码:
pgx.ErrCodeUniqueViolation(PostgreSQL)或mysql.MySQLError.Number == 1062(MySQL) - 捕获到唯一冲突后,得立刻查一次 DB 确认这条记录是不是真存在、状态是否已完成,再决定返回原始结果还是报错
- 这个查询必须走索引覆盖,不能触发全表扫描,否则高并发下 DB 直接被打满
Redis 故障或并发窗口怎么守住底线
真正的难点不在“怎么设 key”,而在“怎么应对失败”和“怎么避免竞态”。SetNX 不带 NX 和 EX 一起用,等于没设防;用 Lua 脚本把 get+setex 合并成原子操作,是目前最稳的分布式方案。
- 绝对不要拆成
Exists+Set两步,中间有毫秒级窗口,压测时必漏判 - 推荐用 Lua:
if redis.call("GET", KEYS[1]) == false then redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2]) return 1 else return 0 end,保证“查无再写”一步到位 - Redis 连接失败时,必须返回
503 Service Unavailable,而不是静默放行——语义错了比功能挂了更难排查 - key 过期时间得大于接口最长耗时 + 安全余量,比如支付回调超时设 45 秒,TTL 至少 60 秒,否则刚写入就过期,幂等失效
最常被忽略的一点:幂等键里必须包含影响最终状态的所有输入字段。只用 idempotency-key 校验,会导致同一用户两次不同金额的充值被当成重复请求拦掉。真正安全的做法是把关键参数哈希进去,比如 "idempotent:" + userID + ":" + sha256(amount + currency + orderID)。


















