消息推送防重系统:多渠道消息不重不漏

业务里最常见的需求:“订单发货了,提醒一下用户”——但这条提醒可能要走短信、公众号、App 推送三个渠道,还可能被定时任务重复触发。怎么保证用户收到一次,且一定收到一次? 这是多渠道消息防重系统的核心问题。

一、问题拆解

1 2 3 . . . / 3 t o / " k e n / A " p p

目标同一事件同一用户,每个渠道恰好一条;失败的渠道可重试,但重试不重发。

二、架构

1 2 3 4 5 . . . . . / 线 / R a b A b / P i I / t A M I p Q D p + / + +

三、核心设计

1. 幂等键(防重的钥匙)

1
2
3
4
5
6
7
8
{
  "biz_type": "ORDER_SHIPPED",
  "biz_id": "order-20260907-001",      // 业务唯一
  "user_id": "u-123",
  "channel": "SMS"                     // 渠道维度
}
// 幂等键 = biz_type + biz_id + user_id + channel
// 同一键只允许一条成功记录

关键:幂等键必须确定性生成(重试时用同一个键),不能随机。

2. 渠道去重(DB 唯一索引)

1
2
3
4
5
6
7
8
9
CREATE TABLE push_task (
  id BIGINT PRIMARY KEY,
  idempotent_key VARCHAR(128) NOT NULL,
  channel VARCHAR(32) NOT NULL,
  status TINYINT,          -- 0待发/1成功/2失败/3终态
  ...
  UNIQUE KEY uk_idempotent (idempotent_key)   -- 防重兜底
);
-- 插入冲突(重复任务)→ 跳过,不重发

DB 唯一索引是防重的最后防线(哪怕代码层漏了,DB 也能拦)。

3. 渠道抽象

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Channel interface {
    Send(ctx, msg) error
    Name() string
}

type SMSChannel struct{}      // 短信渠道
type WechatChannel struct{}   // 公众号渠道
type AppChannel struct{}      // App 推送

// 新渠道 = 实现接口 + 注册(开闭原则)

4. 失败重试与补偿

" / / " 退 + 5 / A p p

四、踩坑记录

  1. 幂等键不含渠道:三个渠道共用一个键 → 发完短信,公众号被去重了 → 键必须含渠道维度
  2. 重试不换键:失败重试时重新生成键 → 每条重试都"看起来是新消息" → 重试必须复用原幂等键
  3. DB 唯一索引冲突不处理:插入冲突直接报错 → 业务炸 → 冲突捕获后按"已存在"处理(幂等跳过)
  4. 渠道 API 返回"成功"但没发出去:渠道半成功(状态不确定)→ 用对账任务定期核对渠道回执

五、监控与指标

S L A /

总结

消息推送防重的核心认知:

  1. 幂等键 = biz_type + biz_id + user_id + channel:确定性生成,含渠道维度
  2. DB 唯一索引是最后防线:代码漏了,DB 拦
  3. 失败要分可重试/不可重试:退避重试 + 告警 + 降级补偿
  4. 对账是"不遗漏"的保证:渠道回执核对,确认送达

推送系统的口碑 = 不打扰(不重)+ 不缺席(不漏)。 幂等防重 + 失败补偿 + 对账,三件套齐了才敢说"推送可靠"。