redis pub/sub本身不防重复,是广播模型必然结果;必须由业务层用set nx ex+全局唯一msg_id做前置幂等校验,且校验失败直接跳过,不可重试或ack。

Redis Pub/Sub 本身不防重复,多实例、重连、网络抖动都会导致同一条消息被多次收到——这不是缺陷,是广播模型的必然结果。防重复必须由业务层兜底,核心就一条:用 SET 原子指令 + 全局唯一 msg_id 做前置校验。
为什么 SUBSCRIBE 和 PSUBSCRIBE 都无法解决重复消费
有人误以为换用 PSUBSCRIBE(模式订阅)就能避开重复,其实它只改变“哪些频道的消息能进当前连接”,不改变“每条匹配消息进几次”。比如你启动 3 个进程都 PSUBSCRIBE order.*,而发布端发了 PUBLISH order.created "...",这 3 个进程各自都会收到一份副本。
更关键的是:SUBSCRIBE 和 PSUBSCRIBE 都没有消费确认机制,Redis 不记录谁收到了、谁处理失败了、谁卡在中间。所以无论你怎么切订阅方式,重复问题照旧。
必须由发布方生成并透传 msg_id
消费者不能自己拼 msg_id(比如用时间戳+随机数),因为不同进程可能在同一毫秒生成相同字符串;也不能依赖 Redis 自增 ID(INCR),因为 Pub/Sub 没有事务上下文,ID 和消息不是原子绑定的。
- 推荐用
uuid4()或 Snowflake ID,在发布时就塞进 payload,例如:{"msg_id": "a1b2c3...", "data": {...}} - 如果用 Spring Data Redis,
convertAndSend()发送前先序列化好带msg_id的对象 - 前端或上游服务调用发布接口时,也应统一生成并透传该 ID,避免下游再补
消费者端用 SET NX EX 做原子幂等校验
校验动作必须在真正执行业务逻辑之前完成,且失败时直接返回,不要重试、不要 ACK——因为 Pub/Sub 根本不支持 ACK。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
正确写法是单条命令完成“不存在才写”+“自动过期”,例如:
if not redis_client.set(f"seen:{msg_id}", "1", nx=True, ex=3600):
return # 已处理过,跳过
注意以下三点:
- 别拆成
SETNX+EXPIRE:两步非原子,高并发下可能写入成功但过期失败,key 永久残留 - TTL 时间要合理:太短(如 60 秒)可能业务还没跑完 key 就过期;太长(如 7 天)会占内存,建议按业务最长处理耗时 × 2~3 倍设置
- key 命名带上业务域前缀,比如
order:seen:{msg_id},避免跨业务冲突
Redis 故障时幂等会失效,这点必须接受
如果 Redis 实例宕机或网络分区,SET 操作失败,消费者会认为“没存过”,继续执行业务逻辑——此时重复就真发生了。这不是设计漏洞,而是权衡:为强一致性引入 ZooKeeper 或数据库去重,会拖慢整个 Pub/Sub 流程,违背其轻量广播的定位。
生产环境真正要做的,是把这种失效当成已知边界来设计:比如订单场景,Redis 去重失败后,后续仍走数据库唯一索引兜底;日志类场景,可容忍少量重复,不做额外防护。别指望一个方案吃掉所有风险。










