不能只在入口层做一次redis setnx,因为setnx仅是第一道防线,无法保证业务执行的完整性与原子性,需结合三态状态机、客户端生成幂等键、lua原子脚本及db唯一索引兜底协同实现可靠幂等。

为什么不能只在入口层做一次Redis SetNX
入口层拦截只是第一道防线,不是终点。SetNX成功只代表“允许执行”,不代表“执行完成”;失败也只代表“已被拦截”,不代表“业务已成功”。真实场景中,你可能遇到:
- SetNX成功 → 业务逻辑执行到一半崩溃(比如DB写入成功但Redis缓存失败)→ 下次请求查不到结果,又走一遍流程
- 两个请求几乎同时通过SetNX(窗口极小但存在)→ 都进入业务层 → 唯一索引拦住一个,另一个已完成扣款、发消息等副作用
- Redis故障降级,SetNX跳过 → 全量请求直冲DB → 唯一索引开始高频报错,日志里全是
duplicate key violation
所以必须把“状态记录”和“业务执行”解耦成可验证的三态:首次请求标记pending,成功后原子更新为success,失败则设为failed。不能用布尔字段替代。
idempotency-key必须由客户端生成且带业务上下文
服务端生成key会破坏重试语义——客户端超时后无法确定是否该重发,因为每次key都不同。key必须满足三个条件:全局唯一、业务可追溯、防冲突。
- 禁止用纯
uuid.New()或time.Now().UnixNano()单独作key:前者无业务标识,后者在高并发下易碰撞 - 推荐格式:
pay_{user_id}_{order_id}_{nonce}或create_order:{user_id}:{timestamp_ms}:{rand_6} - 服务端要校验key长度(16–64字符)、字符集(仅
a-z0-9_-),防止注入或Redis key溢出 - 若业务已有天然唯一标识(如
client_order_id),直接复用,不额外拼接
Redis + Lua才是分布式环境下真正可靠的原子校验
先GET再SET有竞态窗口,高并发下必然漏判。必须用Lua脚本把“读状态+写占位+设过期”打包成单次原子操作。
- 示例脚本(适配
github.com/go-redis/redis/v9):if redis.call("GET", KEYS[1]) == false then redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2]) return 1 else return 0 end - KEYS[1]建议含业务类型前缀,例如
idempotent:order:create:{client_order_id},避免不同接口互相污染 - ARGV[2](TTL)要覆盖最大重试窗口,一般设为
15 * time.Minute;太短会误放行,太长会导致key长期残留 - 不要依赖
sync.Map做本地缓存:它只在单实例内有效,分布式节点间完全不可见
数据库唯一索引只是兜底,不是幂等主体
唯一索引能拦住重复插入,但拦不住“插入前的副作用”。比如用户连点两次提交,第一次已扣库存、发MQ、调第三方支付,第二次才被索引拦住,钱已经扣了两次。
- 唯一约束字段必须是业务语义强相关的组合,例如
(user_id, client_order_id),而不是单纯id或uuid - 捕获
ErrDuplicateEntry后,不能直接返回错误,必须查库确认该记录当前状态(是否paid?是否cancelled?) - 查询结果需返回完整响应体(含HTTP status code、body、headers),而不仅是“已存在”的提示——客户端需要知道上次到底成功没
- 如果业务要求强一致(如资金类),状态更新和主业务操作必须在同一个DB事务中完成,不能靠Redis异步补
全链路幂等最难的不是技术选型,而是把“状态机”真正落到每个关键路径上:从HTTP头解析、Redis原子占位、业务执行、DB落库,到最终响应组装,每一步都要能被下游验证。漏掉任意一环,就可能在某个凌晨三点的重试风暴里,多扣一笔用户的余额。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











