结论:用客户端生成的x-request-id做redis setnx前置去重(带ex、nx、返回值校验)+ 数据库select for update事务兜底,是最稳、最易落地的组合。

直接说结论:用客户端生成的 X-Request-ID(或 Idempotency-Key)做 Redis 去重 + 数据库唯一约束兜底,是最稳、最易落地的组合。别信“前端防抖就够了”,也别把幂等逻辑塞进 context 里传——它根本过不了 gRPC 或 HTTP 代理。
为什么不能只靠数据库唯一索引?
唯一索引(比如 UNIQUE (client_order_id))是兜底手段,不是第一道防线。它只能在写入时拦截重复,但无法阻止并发请求同时闯过校验、一起走到 INSERT 那一步——这时会触发竞态,至少一个请求拿到 ERROR: duplicate key value violates unique constraint,返回 500 或 409 是其次,更麻烦的是:日志里看到两个 goroutine 几乎同时执行了扣款前的状态检查,结果一个成功一个失败,业务状态已经不一致了。
常见错误现象包括:
- 用户点一次按钮,后台创建了两笔订单,第二笔因唯一索引报错,但第一笔已扣款
- 日志显示相同
X-Request-ID出现两次,但服务端没在入口拦截,而是放行到业务层才冲突
所以必须前置拦截:在真正执行业务逻辑前,就确认“这个请求我见过”。
Redis SetNX 做去重时三个必填参数
SET 命令本身不安全,SETNX 才是原子性去重的核心。但光用 SETNX key value 还远远不够,必须带齐以下三项:
-
EX seconds:设置过期时间,覆盖最大重试窗口(比如 10 分钟)。不设就等于埋雷——服务宕机后锁永远不释放 -
NX:确保只在 key 不存在时才设值。漏掉它,等于每次请求都覆盖旧值,完全失去去重意义 - 显式检查返回值:
ok, err := rdb.SetNX(ctx, key, "1", ttl).Result()。ok == false不代表出错,只是 key 已存在;err != nil才是 Redis 连接或命令异常
示例(Go + go-redis/v8):
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
key := "idempotent:" + r.Header.Get("X-Request-ID")
ok, err := rdb.SetNX(ctx, key, "1", 10*time.Minute).Result()
if err != nil {
// 处理 Redis 故障,如降级为只走 DB 约束
}
if !ok {
http.Error(w, "duplicate request", http.StatusConflict)
return
}
幂等键不能只用 Request-ID,得加业务维度
纯 X-Request-ID 校验会导致“合法不同请求被误拒”。比如用户连续发起两笔不同金额的充值,如果都用同一个 ID(比如前端误复用),第二笔会被拦住——这不是幂等,这是阻塞。
正确做法是构造业务指纹,例如:
"idempotent:" + userID + ":" + sha256(fmt.Sprintf("%s:%s:%d", orderID, payMethod, amount))- 或更轻量:
"idempotent:" + userID + ":pay:" + strconv.FormatInt(time.Now().Unix(), 10)(配合短 TTL)
关键点:
- 避免含非幂等字段:时间戳、随机数、traceID 若未绑定业务上下文,就别往里塞
- 不要依赖客户端传的
Idempotency-Key原样当 key——它可能被恶意构造为固定值,导致整个用户被锁死 - 如果业务允许返回历史结果(如查单接口),需额外缓存响应体和状态码,而不仅是“已处理”标记
数据库兜底必须配 SELECT FOR UPDATE + 事务边界
Redis 挂了怎么办?网络分区了怎么办?这时候唯一约束是最后一道墙,但得让它真正起作用。单纯 INSERT ... ON CONFLICT DO NOTHING 不够——它不阻塞并发写,两个请求仍可能同时通过 Redis 检查,然后一起 INSERT,最终一个成功一个失败。
更稳妥的做法是“先查后改”+ 行锁:
- 在事务内执行
SELECT status FROM orders WHERE client_order_id = $1 FOR UPDATE,命中索引(否则升级表锁) - 查到记录且状态合法(如
pending),再执行UPDATE;否则拒绝 - 整个事务必须短:禁止在事务里调外部 HTTP、sleep、或做复杂计算,锁持有时间越长,并发越卡
容易被忽略的一点:FOR UPDATE 只对当前事务有效,如果业务逻辑分散在多个函数里,忘了用同一个 *sql.Tx,锁就形同虚设。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










