高并发幂等必须用redis setnx+ex原子命令卡入口,再以数据库唯一索引兜底;拆分命令、单靠缓存或索引均会导致双扣款或订单裂变。

高并发下做幂等,不是加个缓存或唯一索引就能糊弄过去——必须用 SetNX + EX 原子命令卡在入口,再靠数据库唯一索引兜底,否则双扣款、订单裂变是分分钟的事。
Redis SetNX 必须带 EX 且不能拆成两条命令
很多人写成先 EXISTS 再 SET,或者 SETNX 成功后单独调 EXPIRE,这会留竞态窗口:两个请求同时查到 key 不存在,都写入;或者 EXPIRE 失败导致 key 永久残留,后续所有重试全被误拒。
- 必须用原子命令:
rdb.SetNX(ctx, key, value, ttl)(Go-Redis v9),它底层自动拼NX EX,不依赖手动组装 -
key格式要带业务上下文,比如"idempotent:pay:u123:abc456",不能只用X-Idempotency-Key -
value别存空字符串,填trace_id或原始幂等键,方便日志对齐和人工排查 -
ttl设为「接口最长耗时 + 安全余量」,支付类建议 ≥ 3600 秒,普通操作 ≥ 900 秒;太短会过期误放行,太长会堆积无效 key - Redis 故障时不能静默放行,
err != nil必须返回503 Service Unavailable,否则幂等语义彻底失效
数据库唯一索引只是兜底,不是主防线
唯一索引拦不住中间态重复:两个请求都通过 Redis 校验,一起走到 INSERT 阶段,其中一个成功、另一个报 1062(MySQL)或 pgx.ErrCodeUniqueViolation(PostgreSQL),但前者可能已经发了 MQ、扣了余额。
- 建联合唯一索引,字段必须覆盖业务关键维度,例如
UNIQUE (user_id, idempotency_key),不是单列idempotency_key - INSERT 前必须把幂等记录(如
idempotent_logs表)与主业务逻辑放在同一事务中 - 捕获唯一冲突后,不能直接返回错误,要再
SELECT一次确认状态是否已完成,再决定返回 200 + 原始结果,还是 400 + 业务错误 - 索引字段别选太宽的列(比如整个请求 body),用客户端传的短 key 最稳妥,写性能也更好
“先查后改”类操作必须用 SELECT FOR UPDATE 加行锁
当业务依赖当前状态(比如“只有 pending 订单才能转 paid”),光靠缓存或唯一索引不够,得靠数据库行级锁堵住竞态窗口。
- 必须在同一个事务里完成:
SELECT ... FOR UPDATE→ 状态判断 →UPDATE ... WHERE id = ? -
WHERE条件必须命中索引,否则会升级为表锁,拖垮并发 - 事务里禁止 HTTP 调用、
time.Sleep、复杂计算,锁持有时间越短越好 - 别用
SELECT ... FOR UPDATE NOWAIT简单报错,应配合有限重试(最多 3 次,间隔递增)提升成功率
别用 time.Now().UnixNano() 当幂等 key
纳秒级时间戳在高并发或容器环境下极易重复,且完全脱离业务上下文——用户刷新、网关重发、前端连点都会生成新时间戳,导致本该幂等的请求被当成新请求处理。
- 正确做法是用业务唯一标识拼接,比如
"order_create:" + userID + ":" + orderID - 避免把
X-Request-ID直接当幂等 key——它由客户端或网关生成,不可信;只可作为日志追踪字段 - 如果非要用时间,至少取到秒级 + 用户 ID + 操作类型,例如
"pay_retry:" + userID + ":" + strconv.FormatInt(time.Now().Unix(), 10),但依然不如业务 ID 可靠 - sync.Map 在分布式场景下完全无效,每个实例都有自己的 Map,无法协同;幂等要求强一致性,必须以 Redis 这类分布式存储为唯一事实源
真正难的不是写对某一行代码,而是让 Redis、DB、锁、状态机四层防御在故障、重试、网络抖动下仍保持行为一致——任何一层松动,都可能在凌晨三点爆出双扣款告警。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











