go消费端必须自行实现幂等,因消息队列不保证“只消费一次”;redis setnx通过原子性占坑防并发重复执行,需结合带业务上下文的可复现msgid、合理过期时间及失败即ack策略。

Go 消费端必须自己实现幂等,消息队列不保证“只消费一次”——RabbitMQ 的 msg.Redelivered、Kafka 的 enable.idempotence=true、Redis PubSub 的订阅机制,全都不解决消费者重复处理问题。
为什么数据库唯一索引不能当幂等主力
唯一索引(如 UNIQUE INDEX idx_msg_id (msg_id))只能拦住第二次写入,但拦不住已发生的副作用:
- 两个 goroutine 同时通过 Redis 校验(窗口极小),都拿到
true,都执行了扣库存、发通知、调第三方支付接口 - 其中一条最终因唯一索引报错(PostgreSQL 的
unique_violation或 MySQL 的ErrCodeDuplicateEntry),但钱已经扣了、短信已经发了 - 你得额外查库确认该
msg_id是否真成功,再决定是返回缓存结果还是重试——这已超出唯一索引能力范围
Redis SETNX 是去重表方案的核心原子操作
用 SET key value EX seconds NX 一次性完成「检查是否存在 + 设置成功」,避免 GET+SET 的竞态。关键点:
-
key必须带业务上下文,例如"idempotent:order_created:20260527-ABC123",不能只用msg.MessageId(AMQP 不强制设,客户端常为空)或msg.DeliveryTag(消费者重启后会变) - 过期时间
EX要大于业务最大耗时的 2–3 倍;比如扣库存+写 DB+发 MQ 最长 12s,就设EX 40 - 用
github.com/go-redis/redis/v9时,直接调rdb.SetNX(ctx, key, "done", 40*time.Second),返回true才执行业务逻辑 - 若返回
false,说明已被处理,直接msg.Ack()并 return,**不要 panic、不要重试、不要 Nack**,否则触发新一轮投递
消息 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,否则去重失效
并发场景下失败处理要收敛到“跳过”,而非“重试”
多个消费者实例或单实例多 goroutine 同时处理同一条消息时,SETNX 必然只有一个成功:
- 成功者继续执行业务逻辑;失败者必须立刻
msg.Ack()并退出,不能msg.Nack(false, true)把消息扔回队列 - 失败路径日志要明确标记为重复跳过,例如:
log.Warn("duplicate message skipped", "key", key, "msg_id", msgID) - 别试图在失败时读取 Redis 中的旧结果并返回——除非你同时做了结果缓存(如
idempotent:result:{key}),否则没意义
真正容易被忽略的是:消息 ID 的构造逻辑必须与业务语义对齐,而不是技术字段堆砌;还有,SETNX 失败后的 Ack 动作不是可选项,它是防止雪崩的关键开关。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











