接入 IM 机器人后,你几乎一定会遇到这个问题:同一个事件,被推了两次。 渠道为了保证”别丢消息”,往往选择至少一次投递——也就是说,你的 webhook 可能因为超时、网络抖动、渠道侧重试,而收到同一个事件的重复回调。

这时候如果你不做处理,就会重复回复、重复建会话、重复触发动作,用户收到两遍消息,或者后台跑了两次同一件事。这篇文章讲我把”去重”落在哪。

先接受一个前提:默认至少一次

别指望渠道”只推一次”。”至少一次”是很多消息系统的默认,你要做的不是祈祷不重,而是让重复变得无害。

所以第一件事是:把”可能重复”当成常态来设计。读回调的代码,默认就是”这可能是我见过的,也可能是新的”。

用什么做幂等键

去重的核心是幂等键——每一条消息/事件,都用一个稳定、唯一的标识来代表”这是我”。常见来源:

  • 渠道给的事件/消息 ID:比如 message_id、event_id、msgId。这是最可靠的,因为它代表”渠道眼里这一条是什么”;
  • 会话 + 时间戳:如果渠道不给稳定 ID,只能用(会话标识, 时间)去凑,但这样会有撞键风险;
  • 自己生成:如果上面都没有,就得用回调里的内容去算一个指纹,但要小心不同渠道的内容结构不一样。

关键是用渠道自己给的 ID,别自己发明。因为渠道重试时,会带着同一个 ID 回来,你才能认出”这条我见过”。

去重放哪里:先查再写,还是写时判重

有两种做法:

  1. 先查再写:收到回调,查一下这个 ID 处理过没有,处理过就跳过。简单,但有两个坑——并发下两个人同时查到”没处理过”,都会执行;以及”查和写”不是原子的。
  2. 写时判重(用唯一约束):在存储层把一个字段设为唯一,处理前先写入这个 ID;如果插入冲突,说明已经处理过了,直接跳过。这个更稳,因为唯一约束在数据库层面保证了原子性。

我更推荐第 2 种:用一个唯一约束兜底,而不是只靠”先查”。单机下可以用内存 set,但要考虑进程重启会丢;有一个持久化、带唯一约束的地方(比如 SQLite 的唯一索引),才真正靠得住。

别忘了去重之外的两个点

  • 顺序:重试不一定是按顺序来的。如果业务对顺序敏感(比如”先建会话再发消息”),要去重之外还要处理乱序,否则可能”先处理了后面的”。
  • 可观测:你要能看到”到底处理了多少条、重复了多少条、跳过了多少条”。因为如果渠道疯狂重试,说明你的处理有 bug,或者超时了。重复率陡增,往往是你处理慢或坏掉的信号。

最后几条

  • 默认”至少一次投递”,把重复当常态设计。
  • 用渠道给的稳定 ID 做幂等键,别自己发明。
  • 用唯一约束 + 写时判重兜底,别只靠”先查再写”。
  • 关注顺序和可观测:重复率上升 = 有 bug 或慢了。
  • 目标不是”绝对不重”,而是“重了也没事”。

把幂等做好,你就能坦然面对渠道的每一次重试,而不用提心吊胆。