Redis 原子防重:多渠道消息推送去重实战

招聘平台有一条典型的推送链路:候选人的简历被查看 → 系统要发站内信 + 短信 + 公众号模板消息。同一个业务事件触发多路推送,一旦有重试、延迟任务、多实例并发,用户就会收到重复消息——比漏推更烦人。

一、防重难的根源

消息推送链路是异步的,重复的来源有:

  1. 生产者重试:调用推送服务超时,重试一次,实际已成功
  2. 多实例并发:同一个事件被多个消费实例同时处理(Kafka 的 at-least-once)
  3. 延迟任务补发:定时任务扫描「未推送成功」的补偿,判断条件写得不严谨

传统的查库判断(SELECT ... WHERE status='pending')在高并发下根本不是原子操作:两个请求同时查到 pending,都去推。

二、核心方案:Redis SETNX + Lua 原子脚本

去重的本质是:同一个业务事件 ID,只允许一个执行者拿到「推」的许可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- push_dedup.lua
-- KEYS[1]: 防重 key(业务事件幂等键)
-- ARGV[1]: 执行者标识(随机 uuid)
-- ARGV[2]: 过期时间(秒)
-- 返回值:1=拿到许可可以推,0=已被推过
local ok = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2])
if ok then
    return 1
end
-- 加锁后还要验证持有者,防超时后误删他人锁
local owner = redis.call('GET', KEYS[1])
if owner == ARGV[1] then
    return 1
end
return 0

幂等键怎么设计

防重不是拿「消息内容」当 key,而是拿业务事件的稳定标识

p u s p h u : s d h e : d d u e p d : u { p b : i r z e _ s t u y m p e e _ } v : i { e b w i e z d _ : i 1 d 0 } 2 : 4 { : a n c o t t i i o f n y }
  • biz_type:业务类型(简历被查看/投递成功/面试提醒)
  • biz_id:业务主键(简历 ID / 投递单 ID)
  • action:动作(notify / sms / wechat)

同一事件的多路推送各占一个 key——站内信和短信是不同通道,各自独立防重,避免互相覆盖。

为什么必须 Lua

SETNX 单条命令本身是原子的,但「判断+加锁」组合需要保证原子性。用 EVAL 把整个逻辑塞进 Redis 服务端执行,杜绝了「先 GET 后 SET」之间的并发窗口

超时兜底

Lua 里给 key 加了 EX 过期时间(默认 24h)。过期后允许重复推——这是有意的权衡:防重窗口覆盖业务推送的完整生命周期,远大于任何重试间隔。

三、完整链路

K E a V f A k L a p u s s h t a _ a t d / t - e u l d s e u / : a p s . s t l e - u n o a t n / c f e a i l e d

补偿队列再进来时,同一个幂等键已被占用 → 自动去重,不会二次推送。

四、效果与代价

  • 重复推送率从可观测的偶发重复降到 0(线上 3 个月无重复投诉)
  • 每条消息的成本:1 次 Redis 内存读写(微秒级),远低于查库
  • key 自动过期,不会无限膨胀(24h 窗口内的活跃事件量级可控)

五、踩坑补充

  1. 别用 INCR 代替INCR key 首次返回 1 看似能当锁,但语义是「计数」,误用会造成同一个 key 被计数污染
  2. 释放锁要验持有者:执行者完成后要 DEL key,但必须确认是自己(Lua 里 GET + 比对 + DEL 一起做),防止 A 超时 B 拿到锁,A 的完成动作把 B 的锁删了
  3. key 设计要带版本:业务规则调整(比如「同一用户同一天只推一次」改为「同一用户同一职位只推一次」)时,key 的语义会变,命名时带上规则版本便于切换

总结

推送防重是典型的「分布式一致性」问题,解法就一句话:给每个业务事件一个稳定幂等键,用 Redis 的原子性做一次「谁先到谁推」的裁决

这套模式不止用于推送——接口幂等、任务防重、优惠券发放,都是同一个套路。