Redis 原子防重:多渠道消息推送去重实战
招聘平台有一条典型的推送链路:候选人的简历被查看 → 系统要发站内信 + 短信 + 公众号模板消息。同一个业务事件触发多路推送,一旦有重试、延迟任务、多实例并发,用户就会收到重复消息——比漏推更烦人。
一、防重难的根源
消息推送链路是异步的,重复的来源有:
- 生产者重试:调用推送服务超时,重试一次,实际已成功
- 多实例并发:同一个事件被多个消费实例同时处理(Kafka 的 at-least-once)
- 延迟任务补发:定时任务扫描「未推送成功」的补偿,判断条件写得不严谨
传统的查库判断(SELECT ... WHERE status='pending')在高并发下根本不是原子操作:两个请求同时查到 pending,都去推。
二、核心方案:Redis SETNX + Lua 原子脚本
去重的本质是:同一个业务事件 ID,只允许一个执行者拿到「推」的许可。
|
|
幂等键怎么设计
防重不是拿「消息内容」当 key,而是拿业务事件的稳定标识:
biz_type:业务类型(简历被查看/投递成功/面试提醒)biz_id:业务主键(简历 ID / 投递单 ID)action:动作(notify / sms / wechat)
同一事件的多路推送各占一个 key——站内信和短信是不同通道,各自独立防重,避免互相覆盖。
为什么必须 Lua
SETNX 单条命令本身是原子的,但「判断+加锁」组合需要保证原子性。用 EVAL 把整个逻辑塞进 Redis 服务端执行,杜绝了「先 GET 后 SET」之间的并发窗口。
超时兜底
Lua 里给 key 加了 EX 过期时间(默认 24h)。过期后允许重复推——这是有意的权衡:防重窗口覆盖业务推送的完整生命周期,远大于任何重试间隔。
三、完整链路
补偿队列再进来时,同一个幂等键已被占用 → 自动去重,不会二次推送。
四、效果与代价
- 重复推送率从可观测的偶发重复降到 0(线上 3 个月无重复投诉)
- 每条消息的成本:1 次 Redis 内存读写(微秒级),远低于查库
- key 自动过期,不会无限膨胀(24h 窗口内的活跃事件量级可控)
五、踩坑补充
- 别用 INCR 代替:
INCR key首次返回 1 看似能当锁,但语义是「计数」,误用会造成同一个 key 被计数污染 - 释放锁要验持有者:执行者完成后要 DEL key,但必须确认是自己(Lua 里 GET + 比对 + DEL 一起做),防止 A 超时 B 拿到锁,A 的完成动作把 B 的锁删了
- key 设计要带版本:业务规则调整(比如「同一用户同一天只推一次」改为「同一用户同一职位只推一次」)时,key 的语义会变,命名时带上规则版本便于切换
总结
推送防重是典型的「分布式一致性」问题,解法就一句话:给每个业务事件一个稳定幂等键,用 Redis 的原子性做一次「谁先到谁推」的裁决。
这套模式不止用于推送——接口幂等、任务防重、优惠券发放,都是同一个套路。