做开发的都知道,推送是留住用户的手段,也是赶跑用户的手段。同一条业务事件触发了三次提醒,用户一分钟里收到三条一模一样的消息,体验直接就崩塌了。我接手过一个老项目,告警推送没有去重,一个接口抖动,值班群一个晚上被两百多条重复消息刷屏,真正要紧的那一条反倒被淹没在里头。后来我给这套推送链路加上去重,重复消息从每天几千条降到了个位数。这一篇就把这套去重方案的设计和实现完整地讲一遍,代码可以直接拿去参考。
一、为什么重复推送这么常见
1.1 重复的三个来源
重复消息不是平台抽风,是链路结构决定的。头一个来源是事件重试:上游系统为了保证消息不丢,普遍采用至少一次的投递策略,同一条事件可能被投递多回,下游不做处理就会重复消费。第二个来源是多入口触发:同一个业务动作,定时任务扫一遍、用户操作触发一遍、数据变更回调再触发一遍,三路都汇到推送层,各发各的,谁也不让谁。第三个来源是规则重叠:用户同时命中了两条推送规则,两条规则各生成一条内容相近的消息,用户视角看就是重复打扰。
1.2 去重要在哪一层做
结论先给:在推送网关这一层做,统一收口。在各个业务系统里各自做去重,口径对不齐,A系统认为重复,B系统认为不重复,用户照样收两条。所有推送都经过网关,网关手里拿着全局视角做判断,这是唯一能把住关口的位置。我们的架构是业务方调网关的统一推送接口,网关负责去重判断、频控、合并、下发,职责从头到尾一条线。
二、去重键的设计:判断"同一事件"的标准
2.1 业务指纹:去重的灵魂
去重的头一步是定义什么叫"同一事件"。我们的做法是让业务方在调推送接口时带一个去重键,这个键由业务方来生成,规则定死:同一事件必然生成相同的键,不同事件几乎不可能碰撞。比如订单的状态变更,用订单号加状态值拼接起来;比如库存告警,用仓库编号加货品编号加告警级别拼接。业务方对自己领域里什么算同一件事,门儿清,这个键必须交给他们定,网关不猜。
2.2 时间窗口:去重的边界
只有键还不够,得配一个时间窗。同一个库存告警,今天触发一次、明天又触发一次,这两条该不该都发出去?该发,因为它们是两个独立事件。但一小时内的三次抖动,就该合并成一条。我们的默认策略是一小时的窗口:窗口内同键只发头一条,后续的记个数不发送;窗口结束后如果又来同键事件,视为新事件正常发。窗口长短按业务类型可配,紧急告警的窗口短一些,内容通知的窗口长一些。
三、实现:一个键的完整旅程
3.1 存储选型
去重判断是典型的高频读写场景,每条推送进来都要查一回键、写一回键。我们选的是内存缓存外加持久层的双层结构:热数据放在内存里,判断毫秒级就能完成;持久层再留一份,防止网关重启后窗口期内重复放行。单机内存不够用的场景,把缓存换成集中式的就行,判断逻辑一个字不用改。
3.2 核心代码
实现的骨架不长,关键是判断和写入要做成原子操作,查完再写中间有并发缝隙,两个请求同时查到键不存在,各自都会放行。用带过期时间的键值写入,写入成功才算抢到了发送权,天然就是原子操作:
python
import hashlib, time
def try_dedup(event_key, window_sec=3600):
"""同一事件窗口内只放行一次"""
fp = hashlib.md5(event_key.encode()).hexdigest()
cache_key = f"push:dedup:{fp}"
# set-if-not-exist, 带过期时间, 原子操作
won = cache.setnx(cache_key, int(time.time()), ttl=window_sec)
return won # True=首次,放行; False=窗口内重复,拦截
def push(event_key, user_id, content):
if not try_dedup(event_key):
counters.incr(f"push:dup:{event_key}") # 记重复次数,供合并参考
return "duplicated"
return sender.send(user_id, content)
这段代码里有两个细节值得展开。头一个是键要加哈希:业务键里可能夹着中文和特殊字符,统一做一遍哈希再当存储键,长度可控,也不会污染缓存的键空间。第二个是拦截不是丢弃:被拦下的重复要计数,窗口结束的时候如果攒下了 N 次重复,可以发一条事件升级的提醒,重复本身就是个信号,说明情况还在持续,不能闷声吞掉了事。
3.3 并发下的一个坑
上线压测时撞过一个坑:两个机房同时活着,各揣一份本地缓存,同一事件一边放行一条,用户收两条,去重等于白做。解法是把判断收口到缓存层的主节点上,本地只做无状态的请求转发;或者干脆上一把分布式锁,按着键加锁,拿到锁的实例查缓存决定放行与否。我们选的前一种,结构简单,牺牲几毫秒的延迟,换来判断的绝对一致。
四、去重之外的组合拳
4.1 频控:按人设上限
去重管同一事件,频控管总量。一个用户一天能收几条,一条触达渠道一分钟能发几条,这些上限必须在网关里配死,不留口子。我们去重上线同期配上的频控:普通用户日上限二十条,紧急通道五十条,超限的进次日汇总摘要。上线头一周,人均日推送量掉了七成,点开率反倒翻了一倍还多。消息少了下来,每一条才值钱。
4.2 合并:同一事件的多次重复变成一条升级
窗口期结束时的重复计数,前面提过一笔,这里展开说说用法。一小时内同一接口告警触发了四十回,被拦下三十九条,窗口结束看一眼计数,补发一条:该告警一小时内重复四十次,仍在持续,建议关注。用户收到的是一条带着信息量的升级提醒,而不是四十条复读机。合并逻辑让去重从单纯的防御变成了增值,拦下来的数据反过来服务了通知质量。
4.3 兜底:哑掉的告警比吵闹的更险
去重做狠了会引入新风险:万一头一条消息发丢了,后面的全被拦下,用户一条都收不到。所以发送一旦失败要删掉去重键,让下一条重复事件能够重新放行;再加一道对账,每天核对事件总量和发送总量,拦利率一旦突变就告警。去重系统的可靠性要靠监控来兜底,不能让它裸奔。
五、小结
消息去重看着是个小功能,做透了是三层功夫:业务层把去重键定义清楚,网关层用原子写入把窗口判断做实,运营层用频控和合并把打扰降下来、把价值提上去,一层都不能缺。我们这套上线后,重复消息降到个位数,用户点开率翻了倍,值班群的消息流终于能看了。做推送的同学不妨先自查一遍自家系统的重复率,这个数字往往比想象中难看,而修好它带来的收益,也比想象中大得多。
常见问题
问:去重窗口设多长合适?有没有通用的推荐值?
答:没有放之四海皆准的值,按业务场景定。告警类的窗口短,五分钟到半小时,因为告警讲究时效,压太久会误事;内容通知类的窗口长,一到二十四小时都常见,反正晚一点用户不介意。给个起步建议:先按一小时上线,跑两周看数据,看拦下的重复里有多少是该拦的、有多少拦狠了,再回调。窗口长短是调出来的,不是拍出来的。
问:业务方不配合传去重键怎么办?
答:分两步走。起先一步,网关兜底自动生成键:拿接口名加消息内容做指纹,能兜住完全相同的消息,虽然拦不住内容略有差异的重复,但先能救急。第二步,拿着重复率数据找业务方谈:你们接口的重复率是百分之多少、用户投诉了多少,把键的生成规则给到他们,一个字段的事,一般都肯配合。数据摆在面前,比空口提需求有说服力得多。
问:去重会不会把该发的消息也拦掉?
答:会,这就是为什么必须配兜底。三道保险:发送失败删键重放,这是头一道;窗口结束的重复计数补发升级提醒,这是第二道;每日对账总量突变告警,这是第三道。去重拦的是噪音,不是信息,只要兜底在,该到用户手里的消息一条也少不了。怕的不是拦,是拦了没人知道。
问:这套方案在消息量小的时候是不是杀鸡用牛刀?
答:看多小。一天几千条以内,单机内存字典加个定时清理就够,上面那套架构确实重了。但量这个东西是长的,今天几千,活动一来就是几十万。我的建议是判断逻辑按可迁移的方式写,起步用单机实现,量上来了把存储换成集中式缓存,代码一行不用大改。架构可以后补,判断收口这个原则要从头守住。