go消费端必须自己实现幂等,因消息队列不保证“只消费一次”;redis setnx是去重核心,需带nx/ex原子操作;消息id须防碰撞且可复现;db唯一索引是不可绕过的兜底。

Go消费端必须自己实现幂等,消息队列不保证“只消费一次”
RabbitMQ 的 msg.Redelivered、Kafka 的 enable.idempotence=true、Redis PubSub 的订阅机制,全都不解决消费者重复处理问题。这些能力只作用于传输链路或生产者侧,消费端拿到消息后是否执行、执行几次,完全由你代码控制。一旦漏掉幂等校验,扣库存、发通知、调第三方支付接口这些副作用就可能重复发生。
Redis SETNX 是去重表方案的核心原子操作
用 SET key value EX seconds NX 一次性完成「检查是否存在 + 设置成功」,避免 GET+SET 的竞态。关键点:
-
rdb.SetNX(ctx, key, "done", 40*time.Second)必须带NX和EX,不能拆成两步;否则两个 goroutine 同时查到 key 不存在,都会写入并执行业务逻辑 - 过期时间要大于业务最大耗时的 2–3 倍;比如扣库存+写 DB+发 MQ 最长 12s,就设
EX 40 - 返回
false时,必须立刻msg.Ack()并退出,**不要Nack、不要重试、不要 panic**——否则触发新一轮投递,形成死循环 - 日志要明确标记为跳过,例如:
log.Warn("duplicate message skipped", "key", key, "msg_id", msgID)
消息 ID 构造必须防碰撞且可复现
不能依赖原始 msg.Body(含时间戳、随机 ID 就会哈希不一致),也不能只拼 msg.ExchangeName + msg.RoutingKey(不同消息可能路由到同一位置)。推荐组合:
- 业务标识(如
"order_created") - 语义主键(如
order_id或user_id) -
sha256(msg.Body).Sum(nil)前 12 字节(足够区分,又不会太长)
示例函数:func calcID(topic string, orderID string, body []byte) string { h := sha256.Sum256(body); return fmt.Sprintf("idempotent:%s:%s:%x", topic, orderID, h[:6]) }。生产者和消费者必须用同一套规则生成 ID,否则去重失效。
数据库唯一索引不是可选项,而是不可绕过的兜底
所有中间层(Redis、内存缓存、消息队列去重)都可能失效:缓存穿透、Redis 故障、客户端绕过 header、网络重传……DB 层才是最后一道防线。但要注意:
- 唯一索引只能拦住第二次写入,拦不住已发生的副作用;两个 goroutine 同时通过 Redis 校验,都执行了扣库存、发短信,其中一条最终因唯一索引报错,但钱已经扣了
- 插入失败后,不能直接抛错或静默返回 success;必须精准识别冲突错误:
mysql.MySQLError.Number == 1062或pgx.ErrCodeUniqueViolation - 捕获到唯一冲突后,要查一次 DB 确认是否真已成功——因为 DB 插入失败 ≠ 业务未执行(比如发了短信、扣了库存但最后一步写库崩了)
真正难的不是写对 SETNX,而是当 Redis 失效、DB 冲突、业务逻辑中途 panic 时,还能让结果可追溯、状态可对齐。这需要把幂等 key、trace_id、状态机三者在日志、监控、存储里始终绑在一起。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











