防重复消费必须由消费端显式实现,核心是用redis setnx原子占坑,key需含业务上下文与消息体哈希,失败者直接ack跳过而非重试,ttl应≥业务最大耗时×2.5,数据库唯一索引仅作兜底。

防重复消费不是中间件能“自动加个开关”就解决的,它必须由消费端代码显式实现——无论你用的是 Kafka、RabbitMQ 还是 Redis PubSub,消息队列本身不承诺“只投递一次”,所有重平衡、网络闪断、消费者重启都会导致同一条消息被多次推送给你的 Go 程序。
为什么不能依赖消息队列自带的“幂等”配置
Kafka 的 enable.idempotence=true 只防生产者重复发,不防消费者重复处理;RabbitMQ 的 msg.Redelivered 字段只是提示“这条可能是重发”,但不保证准确(比如连接闪断时可能漏标);Redis PubSub 根本没有重投概念,靠客户端自己兜底。这些机制都只覆盖部分链路,而业务副作用(扣款、发短信、改状态)一旦发生就不可逆。
-
enable.idempotence=true要求单生产者 + 自动分区分配,跨服务或滚动发布时容易失效 -
msg.Redelivered在 RabbitMQ 中可能为 false 却仍是重投(如 channel 关闭后重建连接) - 任何“靠消息体字段去重”的方案都危险:AMQP 的
MessageId常为空,Kafka 的key可能缺失或语义不唯一
必须用 Redis SETNX 做原子占坑,且 key 要带业务上下文
核心动作是:rdb.SetNX(ctx, key, "done", ttl) 一次性完成“检查是否存在 + 设置成功”,避免 GET+SET 的竞态窗口。但 key 不能只是 msg.MessageId 或 msg.DeliveryTag,因为前者可能为空,后者在消费者重启后会变。
- 推荐 key 格式:
idempotent:order_created:20260527-ABC123:sha256(body)[:6] - 其中
order_created是 topic/事件类型,20260527-ABC123是业务主键(如 order_id),sha256(body)[:6]防 body 微小差异导致误判 - ttl 必须 ≥ 业务最大耗时 × 2.5,比如扣库存+写 DB+发通知最长 8s,就设
30*time.Second - 用
github.com/go-redis/redis/v9时,SetNX返回true才执行业务逻辑;返回false就直接msg.Ack()并 return,绝不Nack、不重试、不 panic
并发场景下失败路径必须收敛到“跳过”,而非“重试”
多个 goroutine 或多个消费者实例同时拿到同一条消息时,SETNX 必然只有一个成功。关键是如何处理失败者:
- 成功者继续执行业务逻辑,完成后无需额外清理 key(靠 TTL 自动过期即可)
- 失败者立刻
msg.Ack()并 return —— 这是硬性要求,否则Nack(true, false)会触发新一轮投递,形成循环 - 业务逻辑内部如果 panic,需 recover 后仍调
msg.Ack(),不能让 delivery tag 悬挂 - 不要试图在 defer 里删 Redis key:万一业务 panic 后没来得及执行 defer,key 会残留;更不要依赖“查 DB 是否已存在”来补救,那已发生的副作用无法撤回
数据库唯一索引只能当最后一道防线,不能当主力
建 UNIQUE INDEX idx_msg_id (msg_id) 看似简单,但它拦不住并发窗口里的副作用:
- 两个 goroutine 同时通过 Redis 校验,都拿到
true,都执行了扣库存、发短信 - 其中一条最终因唯一索引报错(如 PostgreSQL 的
unique_violation),但钱已经扣了 - 此时你得额外查库确认该
msg_id是否真成功,再决定返回缓存结果还是重试——这已超出唯一索引能力范围 - 所以唯一索引只用于兜底写入失败,不能替代 Redis 占坑逻辑
真正难的不是写几行 SetNX,而是让整个消费链路接受“失败即跳过”的设计哲学:不重试、不补偿、不告警,只要副作用可重入,系统就天然健壮。这点常被忽略,却决定了方案能否上线。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











