Go标准做法是用自定义类型key(如reqIDKey{})将唯一请求ID注入context.Context并透传,Redis幂等需用带TTL的原子Set操作,HTTP应返回409 Conflict状态码,数据库唯一索引仅作兜底。

怎么用 context.Context 透传唯一请求 ID 到 handler 和 service 层
不靠中间件硬塞全局变量,也不靠函数多加一个 reqID string 参数——Go 的标准做法是把唯一 ID 塞进 context.Context,一路向下传递。这样既干净,又不会污染业务函数签名。
常见错误是直接用 context.WithValue(context.Background(), key, val),但 key 类型要是自定义类型(不是 string),否则容易被其他模块误覆盖。
- 定义 key:
type reqIDKey struct{},然后用ctx = context.WithValue(r.Context(), reqIDKey{}, id) - 在 handler 中生成 ID:推荐用
uuid.New().String()或nanoid.Generate("abcdef0123456789", 16)(轻量无依赖) - 务必在日志、DB 插入、缓存写入等所有关键路径里都取出来打点,否则 ID 失效
- 别在 goroutine 里直接用原始
r.Context(),要显式ctx := r.Context()再传进去,否则可能 panic
Redis 实现幂等 Key 的正确 setnx + expire 组合
只用 SETNX 不设过期时间?那是线上事故预备动作。幂等 Key 必须带 TTL,否则失败请求卡住 Key,后续所有合法请求全被拦。
Go 标准库 redis.Client 没有原子的「set if not exists + expire」单命令封装,得用 SetNX 配合 Expire,但要注意竞态:如果 SetNX 成功但 Expire 失败,Key 就永久存在。
立即学习“go语言免费学习笔记(深入)”;
- 推荐用
client.Set(ctx, key, "1", 5*time.Minute).Err():现代 Redis 客户端(如 github.com/redis/go-redis/v9)的Set默认带SET NX EX原子语义 - key 拼接格式建议:
"idempotent:" + userID + ":" + reqID,避免不同用户冲突 - 如果业务要求强一致性(比如支付),
SET返回redis.Nil表示已存在,直接返回成功响应,不要重试或报错 - 别用
time.Now().Unix()做 TTL,用5 * time.Minute这种字面量,避免时区或系统时间跳变干扰
HTTP handler 怎么拦截重复提交并返回合适状态码
前端点了两次“提交订单”,后端不能返回 500 或静默吞掉第二次——得明确告诉客户端:“你这次没干成,但上次已经成功了”。关键是状态码和响应体设计。
常见错误是统一返回 200 + {"code":409,"msg":"已处理"},这会让前端无法区分“真成功”和“幂等成功”,导致 UI 状态错乱。
- 推荐返回
409 Conflict状态码,符合 HTTP 语义,且前端可单独捕获处理 - 响应体保持和正常成功一致(比如同样字段、同样结构),只改状态码和加个
"idempotent": true字段 - 别在 middleware 里直接
return,要通过http.Hijacker或自定义 responseWriter 控制写出时机,否则 panic - 如果用了 Gin/Echo,务必在中间件末尾加
c.Abort()或return,防止继续执行下游 handler
为什么不能只靠数据库唯一索引做幂等
唯一索引能拦住重复插入,但拦不住重复更新、重复扣款、重复发消息这些操作。而且它暴露的是数据库层错误(ERROR: duplicate key value violates unique constraint),前端根本没法友好提示。
更麻烦的是性能:每次请求都走 DB 写入再回滚,对 PostgreSQL 是 INSERT ... ON CONFLICT DO NOTHING,对 MySQL 是 INSERT IGNORE,但无论哪种,都是高开销路径,远不如 Redis 先拦。
- 唯一索引适合兜底,不适合主控逻辑;幂等控制必须前置到缓存或内存层
- 如果业务允许“最终一致”,可以 Redis 拦 + DB 异步落库;但金融类必须 Redis 拦 + DB 同步写 + 事务包裹
- 注意 MySQL 的
REPLACE INTO会触发 DELETE + INSERT,可能误删关联数据,别用 - PostgreSQL 的
ON CONFLICT要显式指定WHERE条件,否则可能命中错误行,尤其在软删除场景下
真正难的不是生成 ID 或写 Redis 命令,而是所有中间件、service、dao 层都得对这个 reqID 敏感——漏一处,幂等就破防。最常被忽略的是异步任务:比如下单成功后发 Kafka,那个 goroutine 里 Context 丢了,ID 就断了。


















