推送是「一次触发、多渠道分发、要求最终送达」的系统:一条营销消息要同时能走 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 │ │ 网关 │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
└──────────────┴──────────────┴──────────────┘
▼
┌────────────────┐
│ 回执收集 + 统计 │ 送达率 / 点击率 / 失败分析
└────────────────┘
五条关键链路:
- 接入:业务系统调用统一推送 API,携带业务幂等键、目标用户、模板 ID、变量、期望通道与优先级。
- 渲染:模板引擎把变量替换成最终文案,生成标准推送事件。
- 规则过滤:去重、频控、免打扰、退订检查,决定「发不发、走哪个通道、什么时候发」。
- 调度投递:立即消息进通道队列,定时消息进延迟队列,到点后转投递。
- 回执统计:收集通道回执,更新投递状态,产出送达率漏斗。
三、核心组件设计
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) # 延迟投递
延迟队列的三种实现:
- Redis ZSet:
score = 到期时间戳,定时任务每秒ZRANGEBYSCORE 0 now取出到期任务。简单,适合中小规模(百万级)。 - 消息队列延迟消息:RocketMQ 支持固定延迟级别、RabbitMQ 有延迟插件、Kafka 需自建时间轮。适合大规模且需要持久化。
- 时间轮(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 以上)自建更划算。
五、总结
消息推送平台的设计可以浓缩成四条主线:
- 多通道抽象与降级:把 APNs/FCM/厂商/短信/邮件统一成
Channel接口,用降级链保证「通道挂了消息还能到」。 - 去重与频控守住体验:业务幂等键去重 + 多维频控配额 + 免打扰,让用户不被过度打扰,且频控要能优雅降级。
- 定时投递靠延迟队列:Redis ZSet / MQ 延迟消息 / 时间轮各有适用规模,时区本地化要处理夏令时边界。
- 回执与漏斗驱动优化:从「计划发送」到「点击」的每一层流失都要能归因,才能持续提升送达率。
延伸阅读:通知系统的通用抽象与通道选择见 通知系统设计 ;通道之间的异步解耦与削峰依赖 消息队列系统设计 ;定时与重试任务的调度机制见 分布式任务调度系统设计 ;推送唤醒与消息同步的配合见 即时通讯系统设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。