消息推送平台设计

本文系统设计一个消息推送平台:需求澄清与量级估算、多通道接入与降级(APNs/FCM/厂商通道/短信/邮件/站内信)、去重与频控(幂等键、用户级配额、免打扰时段)、定时与延迟投递(延迟队列、时区本地化)、送达率统计与回执(投递回执、失败重试、漏斗指标),并给出架构图、数据表、推送伪代码与容量规划。

推送是「一次触发、多渠道分发、要求最终送达」的系统:一条营销消息要同时能走 APNs、FCM、国内厂商通道、短信、邮件、站内信;用户装了 App 但关了通知,就要降级到短信;用户设置了免打扰,就要延迟到第二天早上;发出去之后还要知道有多少人真的看到了。它和普通消息队列的区别在于——通道是外部的、不可靠的、有各自配额的,平台必须自己承担重试、降级、去重、统计的全部复杂度。本文按照系统设计面试的标准答题结构,设计一个生产级的消息推送平台。

一句话:推送平台的本质是「把一条业务事件翻译成 N 个通道的投递任务,并保证不重不漏不乱」——通道会挂、会限流、会超时,平台的价值就在于把这些不确定性挡在业务之外。

一、需求澄清与量级估算

1.1 需求澄清

面试官给出题目「设计一个消息推送平台」后,先通过提问明确边界:

  • 消息类型:只做「营销推送」(可延迟、可丢弃)还是也做「事务通知」(验证码、订单状态、支付结果,必须送达)?事务通知要求强可靠,营销通知可以尽力而为。
  • 通道范围:移动推送(APNs/FCM/华为/小米/OPPO/vivo)、短信、邮件、站内信、微信模板消息、Web Push 各支持哪些?
  • 送达要求:是否需要「已送达/已点击」回执?回执是统计送达率的基础,但很多通道只给有限的回执能力。
  • 去重粒度:同一用户短时间内的相同消息要去重吗?跨通道去重(推了 App 就不再发短信)吗?
  • 频控维度:单用户每天最多几条?单通道每天几条?营销与事务是否分开配额?
  • 免打扰:是否支持用户设置免打扰时段、按类型退订?
  • 时效:营销消息可以「定时发」,事务消息必须「立即发」,两者调度路径不同。

1.2 量级估算

以一个千万日活的 App 为例:

日活用户:          1000 万
日均推送条数:      5000 万条(人均 5 条)
峰值 QPS:          营销活动集中下发,峰值 10 万条/秒(持续几十秒)
通道配额:
  APNs:            无硬性配额,但连接数受限,需长连接复用
  FCM:             免费但有速率限制,超限会 429
  厂商通道:         华为/小米等通常按应用分级配额,如 10 万条/天
  短信:             运营商限速,且成本高(约 0.03~0.05 元/条)
  邮件:             SMTP 有连接数与发送速率限制
存储:
  推送任务:         5000 万行/天,热数据 7 天
  投递明细:         5000 万 × 平均 1.3 通道 ≈ 6500 万行/天
  回执:             仅成功/失败状态,可压缩

关键结论:推送量(5000 万/天)远小于日活用户的可能触达量(1000 万 × N),真正的瓶颈是外部通道的配额与速率。平台的核心任务不是「发得快」,而是「在通道配额内,把最重要的消息优先发出去,并优雅处理超限与失败」。

二、高层架构设计

  业务系统 ──▶ ┌────────────────┐
  (下单/活动)   │ 推送 API 网关    │  鉴权 / 参数校验 / 幂等
               └───────┬────────┘
                       ▼
               ┌────────────────┐
               │ 消息接入服务     │  模板渲染 / 变量替换
               │ (template)      │  生成标准推送事件
               └───────┬────────┘
                       ▼
               ┌────────────────┐
               │ 规则引擎         │  去重 / 频控 / 免打扰
               │ (filter)        │  通道选择 / 优先级
               └───────┬────────┘
                       ▼
               ┌────────────────┐      ┌──────────────┐
               │ 调度器           │─────▶│ 延迟队列      │
               │ (立即/定时分流)   │      │ (定时/重试)   │
               └───────┬────────┘      └──────┬───────┘
                       ▼                      │
               ┌────────────────┐             │
               │ 通道队列 (按通道)│◀────────────┘
               │ APNs/FCM/SMS... │
               └───────┬────────┘
        ┌──────────────┼──────────────┬──────────────┐
        ▼              ▼              ▼              ▼
   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐
   │ APNs    │   │ FCM     │   │ 厂商通道 │   │ 短信/邮件│
   │ 连接池   │   │ 连接池   │   │ SDK      │   │ 网关     │
   └────┬────┘   └────┬────┘   └────┬────┘   └────┬────┘
        └──────────────┴──────────────┴──────────────┘
                       ▼
               ┌────────────────┐
               │ 回执收集 + 统计  │  送达率 / 点击率 / 失败分析
               └────────────────┘

五条关键链路:

  1. 接入:业务系统调用统一推送 API,携带业务幂等键、目标用户、模板 ID、变量、期望通道与优先级。
  2. 渲染:模板引擎把变量替换成最终文案,生成标准推送事件。
  3. 规则过滤:去重、频控、免打扰、退订检查,决定「发不发、走哪个通道、什么时候发」。
  4. 调度投递:立即消息进通道队列,定时消息进延迟队列,到点后转投递。
  5. 回执统计:收集通道回执,更新投递状态,产出送达率漏斗。

三、核心组件设计

3.1 多通道接入与降级

通道抽象:所有通道对上层暴露统一的接口,屏蔽各自协议的差异。

class Channel(ABC):
    @abstractmethod
    def send(self, msg: PushMessage) -> SendResult: ...

    @abstractmethod
    def supports(self, user: User) -> bool:
        """该用户是否有此通道的可达标识(如 APNs token)"""
        ...

    @abstractmethod
    def quota(self) -> Quota:
        """通道配额与当前余量"""
        ...

class APNsChannel(Channel):
    def send(self, msg):
        # 复用 HTTP/2 长连接,携带 JWT 鉴权
        resp = self.session.post(
            f"https://api.push.apple.com/3/device/{msg.device_token}",
            json={"aps": {"alert": msg.title, "sound": "default"},
                  "biz_id": msg.idempotent_key},
            headers={"apns-topic": self.bundle_id,
                     "apns-push-type": "alert",
                     "apns-priority": "10" if msg.urgent else "5"},
            timeout=10)
        return SendResult(
            success=resp.status_code == 200,
            channel="apns",
            # APNs 的 id 可用于后续查回执
            receipt_id=resp.headers.get("apns-id"))

降级策略(Fallback Chain):一条消息按优先级依次尝试通道,前一个失败或不可达则降级到下一个。

营销消息降级链:
  厂商通道(华为/小米/OPPO/vivo,送达率高、免费)
    → FCM/APNs(海外或未覆盖机型)
    → 站内信(App 打开时可见)→ 短信(仅高价值用户,成本高)

事务消息降级链(要求强送达):
  APNs/FCM(秒级)→ 厂商通道(并行发送,取先到)→ 短信(30 秒未送达则发)

降级触发的判定:

  • 通道不可达:用户没有该通道的 token(未装 App、未授权通知)。
  • 通道报错:DeviceTokenNotForTopic(token 失效)、Unregistered(已卸载)等永久错误 → 标记 token 失效并降级。
  • 通道超限:429/配额耗尽 → 降级或延迟重试。
  • 超时未回执:事务消息发出去 30 秒无回执 → 触发短信降级。

Token 生命周期管理:设备 token 会因重装、换机、系统更新而失效,必须维护一份「用户 → 有效 token 列表」的映射,定期清理失效 token。

CREATE TABLE device_token (
  user_id      BIGINT       NOT NULL,
  platform     VARCHAR(16)  NOT NULL,  -- ios/android/harmony
  channel      VARCHAR(16)  NOT NULL,  -- apns/fcm/huawei/xiaomi...
  token        VARCHAR(256) NOT NULL,
  status       TINYINT      NOT NULL,  -- 1有效 0失效
  last_active  DATETIME     NOT NULL,
  updated_at   DATETIME     NOT NULL,
  PRIMARY KEY (user_id, platform, channel),
  UNIQUE KEY uk_token (token),
  INDEX idx_status_active (status, last_active)
);

UNIQUE KEY (token) 保证一个 token 只属于一个用户(换绑时更新),失效 token 定期归档删除。

3.2 去重与频控

去重(Dedup):同一业务事件可能被重复触发(上游重试、消息队列重复投递),必须保证用户只收到一条。

-- KEYS[1] = dedup:<biz_id>:<user_id>   ARGV[1] = ttl
-- 返回 1 首次(应发送),0 重复(应丢弃)
if redis.call('SET', KEYS[1], '1', 'NX', 'EX', ARGV[1]) then
  return 1
end
return 0

去重键的设计是关键:按什么维度去重决定了误伤率。

过粗:dedup:<user_id>              → 用户 1 小时内任何消息都只发一条(误伤严重)
适中:dedup:<biz_id>:<user_id>     → 同一业务事件只发一次(推荐)
过细:dedup:<biz_id>:<user_id>:<通道>  → 同事件可跨通道重复发(可能骚扰)

推荐「业务幂等键 + 用户」组合,TTL 取业务去重窗口(营销 24 小时,事务 5 分钟)。

频控(Rate Limiting):防止对用户过度打扰。多维度的配额叠加:

维度 1:单用户全通道总配额    如 5 条/天、2 条/小时
维度 2:单用户单通道配额      如 短信 2 条/天、营销推送 3 条/天
维度 3:单用户单业务类型配额   如 促销类 1 条/天、内容类 3 条/天
维度 4:全局通道配额          如 短信全平台 100 万条/天(成本控制)
维度 5:退订与免打扰          用户主动关闭某类型则直接拦截

用 Redis 的滑动窗口或令牌桶实现:

-- 用户级频控:滑动窗口计数
-- KEYS[1] = freq:<user_id>:<type>:<window>   ARGV[1]=now_ms ARGV[2]=window_ms ARGV[3]=limit
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2]))
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[3]) then
  return 0                      -- 超限,拦截
end
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. ':' .. math.random(1000000))
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1

频控的取舍:

  • 拦截还是排队?营销消息超限可以「排队到明天」,事务消息超限必须「立即放行」(事务优先级高于频控)。
  • 频控失败怎么办?如果 Redis 挂了,是「宁可多发」还是「宁可不发」?营销场景宁可少发(保护体验),事务场景宁可多发(保证送达)。所以频控要有「降级开关」:Redis 不可用时按配置决定放行还是拦截。
  • 跨通道合并计数:用户可能同时在 App、短信、邮件收到同一条营销,需要「跨通道合并计数」,即一条营销消息无论走几个通道,对用户只算 1 次。

免打扰:用户设置的静默时段(如 22:00-08:00)内,营销消息不投递、延迟到次日;事务消息可配置「是否突破免打扰」(验证码必须突破)。

3.3 定时与延迟投递

立即 vs 定时:接入时根据消息的 schedule_time 分流。

def route(msg):
    if msg.schedule_time is None or msg.schedule_time <= now():
        return immediate_queue(msg)                     # 立即投递
    return delay_queue(msg, msg.schedule_time)          # 延迟投递

延迟队列的三种实现:

  1. Redis ZSet:score = 到期时间戳,定时任务每秒 ZRANGEBYSCORE 0 now 取出到期任务。简单,适合中小规模(百万级)。
  2. 消息队列延迟消息:RocketMQ 支持固定延迟级别、RabbitMQ 有延迟插件、Kafka 需自建时间轮。适合大规模且需要持久化。
  3. 时间轮(Timing Wheel):内存中维护多级时间轮,适合海量短延迟任务(如秒级重试),但不持久化,重启会丢。
Redis ZSet 延迟队列:
  ZADD delay:push <ready_at_ms> <task_json>
  扫描循环(每 100ms):
    tasks = ZRANGEBYSCORE delay:push 0 <now_ms> LIMIT 0 1000
    for t in tasks:
        if ZREM(delay:push, t):        # 原子取出,防多消费者重复处理
            enqueue_channel_queue(t)

时区本地化:定时营销消息常要「按用户所在时区的早上 9 点发」。用户表里存时区偏移,投递时按用户时区换算绝对时间。

def schedule_local(msg, user):
    tz = ZoneInfo(user.timezone or "Asia/Shanghai")
    local = datetime.combine(msg.local_date, msg.local_time, tzinfo=tz)
    return local.astimezone(timezone.utc).timestamp() * 1000

注意夏令时(DST)地区的边界——某些日子「凌晨 2 点」不存在或出现两次,换算要用 ZoneInfo 而非固定偏移量。

延迟任务的幂等:延迟队列至少要保证「至少一次」投递,所以到点执行时必须再查一次去重键与消息状态,避免重复发送。

3.4 送达率统计与回执

投递状态机:

CREATED ──▶ QUEUED ──▶ SENT ──▶ DELIVERED ──▶ CLICKED
                │        │
                │        └──▶ FAILED(永久失败:token 失效)
                └──▶ DROPPED(被频控/去重/免打扰拦截)
                └──▶ EXPIRED(超过有效期仍未送达)

回执的获取方式因通道而异:

  • APNs:通过 HTTP/2 响应头拿到 apns-id,或用 APNs 的反馈服务(旧版 binary 协议)查未送达。新版主要靠客户端上报「收到通知」。
  • FCM:响应里有 message_id,投递失败会通过 onMessageSent/上游回执返回。
  • 厂商通道:各家有各自的回执接口(如小米的回执回调、华为的送达回执)。
  • 短信:运营商提供状态报告(status report),通过短信网关回调。
  • 站内信:客户端拉取时上报「已读」。

统一的回执模型:

CREATE TABLE push_receipt (
  receipt_id   VARCHAR(64) PRIMARY KEY,   -- 平台生成的投递 ID
  msg_id       VARCHAR(64) NOT NULL,      -- 业务消息 ID
  user_id      BIGINT      NOT NULL,
  channel      VARCHAR(16) NOT NULL,
  status       VARCHAR(16) NOT NULL,      -- SENT/DELIVERED/FAILED/CLICKED
  error_code   VARCHAR(32),               -- 失败原因
  event_time   DATETIME    NOT NULL,      -- 回执时间
  created_at   DATETIME    NOT NULL,
  INDEX idx_msg (msg_id),
  INDEX idx_user_time (user_id, event_time)
);

送达率漏斗(Funnel):这是推送平台最重要的运营指标。

触达漏斗:
  计划发送 100%  → 通过规则过滤(去重/频控/免打扰)→ 实际投递 85%
  实际投递 85%   → 通道接受(SENT)→ 80%
  SENT 80%      → 送达(DELIVERED)→ 65%
  DELIVERED 65% → 点击(CLICKED)→ 8%

每一层的流失都要能归因:过滤流失 15%(去重 5% / 频控 7% / 免打扰 3%)、通道流失 5%(token 失效 3% / 超时 1.5%)、送达流失 15%(关闭通知权限 10% / 系统休眠限制 5%)

失败重试策略:区分可重试与不可重试。

不可重试(直接失败 + 降级):token 失效 / 参数错误 / 模板不存在 / 用户已退订

可重试(指数退避):通道 5xx / 超时 / 429 限流 / 网络抖动

重试参数:最多 3 次,退避 1s / 5s / 30s,超过则标记 EXPIRED

统计的实时性:回执量级与投递量同阶(每天数千万),统计不能实时精确聚合(成本高)。做法是「实时近似 + 离线精确」:实时用 Redis HyperLogLog 估算送达 UV、用计数器估算总量;离线用数仓做精确漏斗与归因。这属于典型的 可观测性 与埋点体系范畴。

四、深入权衡

1. 并行发送 vs 顺序降级:事务消息可以「并行发 APNs 与厂商通道,取先到的」,缩短送达时间;营销消息应「顺序降级」,避免用户被多渠道重复轰炸。选择取决于「时效 vs 打扰」的权衡。

2. 推送 vs 拉取:推送的送达率受系统限制(iOS 后台限制、Android 保活限制)影响,实测送达率可能只有 60%~70%。所以重要消息应「推拉结合」——推送只是「提醒」,真正的消息内容靠客户端拉取。这与 即时通讯系统设计 里「推送唤醒 + 拉取同步」的模式一致。

3. 去重窗口的长短:窗口太短(如 1 分钟),上游重试间隔超过窗口就会重复发;窗口太长(如 7 天),正常的「再发一次」需求会被误拦。通常按业务事件粒度定窗口,事务类短(5 分钟)、营销类长(24 小时)。

4. 频控的严格程度:频控太松,用户体验受损、卸载率上升;太紧,重要营销触达不到用户、GMV 受损。实践中会做「分层频控」:高价值用户配额更高、高价值消息(如订单)不受营销配额限制。这与 优惠券营销系统设计 里的触达策略同源。

5. 自建通道 vs 用第三方:自建要维护 APNs 长连接池、各家厂商 SDK、短信网关,成本高但可控;用第三方推送服务(如个推、极光)省事但多一层依赖与费用。规模大到一定程度(千万 DAU 以上)自建更划算。

五、总结

消息推送平台的设计可以浓缩成四条主线:

  1. 多通道抽象与降级:把 APNs/FCM/厂商/短信/邮件统一成 Channel 接口,用降级链保证「通道挂了消息还能到」。
  2. 去重与频控守住体验:业务幂等键去重 + 多维频控配额 + 免打扰,让用户不被过度打扰,且频控要能优雅降级。
  3. 定时投递靠延迟队列:Redis ZSet / MQ 延迟消息 / 时间轮各有适用规模,时区本地化要处理夏令时边界。
  4. 回执与漏斗驱动优化:从「计划发送」到「点击」的每一层流失都要能归因,才能持续提升送达率。

延伸阅读:通知系统的通用抽象与通道选择见 通知系统设计 ;通道之间的异步解耦与削峰依赖 消息队列系统设计 ;定时与重试任务的调度机制见 分布式任务调度系统设计 ;推送唤醒与消息同步的配合见 即时通讯系统设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个视频会议系统(WebRTC SFU)
  2. 设计一个 A/B 测试与实验平台
  3. 设计一个分布式锁服务