离线推送:厂商通道与自建长连接的配合
消息投递的第一选择永远是自建长连接:连接在,消息直接推,延迟最低、边际成本近似为零。但移动端有两件事服务端控制不了——进程被系统回收、网络进入休眠。于是离线推送(push)成为必要补充。本章讲清双通道怎么切换、推送为什么必须依赖"消息已经落库",以及去重合并、token 失效清理这些真正会出事故的地方。
双通道模型
推送不是长连接的替代品,而是它的兜底。判定顺序固定:
logic 处理上行消息
└─ 消息落库成功(消息表 + 会话 seq + 收件箱)
└─ 路由层 Lookup(uid)
├─ 有在线端 → 长连接直推,等 ACK(hold 窗口 5~30s)
└─ 无在线端 或 hold 窗口内无 ACK
└─ 写 push_task → MQ → push worker
└─ 按 uid 聚合合并 → 选通道 → 提交厂商
└─ 厂商回执更新 status
两条纪律:只有"落库成功"才允许产生推送任务,否则用户点开推送却拉不到消息,等于白推;推送只是叫醒闹钟,正文最多带一句摘要,真正的消息永远由客户端按 seq 回源拉取(见 11 章)。
在线判定与切换时机
"路由表里有节点"不等于"用户看得见消息"。半开连接(进电梯、进程被冻结)会让长连接挂着但收不到任何帧。判定要用组合条件:
| 条件 | 判定 | 动作 |
|---|---|---|
| 心跳 90s 内 且 最近一次下行 ACK 成功 | 在线 | 只走长连接 |
| 心跳有效但 hold 窗口内无 ACK | 疑似半开 | 本次转推送,并标记该连接可疑 |
| 路由表无节点 | 离线 | 走推送 |
| 客户端主动上报进入后台/退出 | 离线 | 走推送,且 10s 内不再推第二次 |
避免"在线也推":两端都收到是重复打扰,产品侧通常比技术侧更不能接受。
厂商通道对比
一台设备只维持一条系统级长连接,Android 各自为政是现实,不能指望单一通道全覆盖:
| 通道 | 平台 | 接入方式 | 到达延迟 | 主要限制 |
|---|---|---|---|---|
| APNs | iOS | HTTP/2 + JWT | <1s | 静默推送(content-available)有频次配额,超限只降优先级不报错 |
| FCM | Android 海外 | HTTP v1 + OAuth2 | 1~3s | 依赖 Google 服务,国内机型不可用 |
| HMS Push(华为) | 华为设备 | HTTPS + OAuth2 | 1~3s | 需设备带 HMS Core,旧机型要回退其他通道 |
| 小米推送 | MIUI | HTTPS | 1~3s | 透传消息受应用后台存活限制,通知栏消息更可靠 |
| OPPO PUSH | ColorOS | HTTPS | 1~5s | 按应用评级给每日配额(普通应用常见 5 万条/日量级),需提前申请提升 |
| vivo Push | OriginOS | HTTPS | 1~5s | 配额与消息分类(系统消息/运营消息)绑定,超量会被丢弃 |
工程结论:多通道并行 + 优先级映射。同一 uid 多端在线时逐端选通道(iOS→APNs、华为机型→HMS、其余按厂商映射表落位),单通道失败不影响其他端。所有厂商共用一个 push worker 抽象,接口只保留 Submit 与 ParseReceipt 两个方法,厂商差异全部收敛在适配层。
推送任务表与幂等
推送要能被重放、统计、审计,就必须先落表,不能"直接调 SDK":
CREATE TABLE im_push_task (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
task_id VARCHAR(40) NOT NULL COMMENT '幂等键:uid+conv_id+seq 的 hash',
uid BIGINT UNSIGNED NOT NULL,
conv_id BIGINT UNSIGNED NOT NULL,
seq BIGINT UNSIGNED NOT NULL COMMENT '该会话最新消息序号',
unread_cnt INT NOT NULL COMMENT '合并后的未读数,红点用',
collapse_key VARCHAR(64) NOT NULL COMMENT '通常 = conv_id,交厂商侧做替换合并',
payload VARBINARY(1024) NOT NULL COMMENT '仅标题+摘要,不含完整正文',
vendor VARCHAR(16) NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发 1已提交 2已送达 3失败 4已合并',
create_time DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_task (task_id),
KEY idx_uid_status (uid, status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
uk_task 是幂等的核心:MQ 至少一次投递下,同一个 (uid, conv_id, seq) 只会推一次。该表按 uid 分片、只保留 7 天热数据,历史进归档表而非长期堆在主库。
去重与合并
推送的最主要投诉不是"收不到",而是"被刷屏"。合并要分两层做:
| 层级 | 手段 | 说明 |
|---|---|---|
| 服务端合并窗口 | 同一 uid+conv_id 在 5s 内的多次触发只保留一条,unread_cnt 取最新 | 大群刷屏时效果最明显,可裁掉 70% 以上分量 |
| 厂商侧替换 | APNs 的 apns-collapse-id、FCM 的 collapse_key 填同一个值 | 新消息在通知栏替换旧消息,而不是叠加 |
| 客户端侧 | 收到摘要后按 conv_id 归并红点 | 避免"5 条推送 5 个红点" |
合并窗口不要拉太长:超过 10s 会出现"用户已经点开消息了,推送才到"的尴尬。
token 失效清理
厂商 token(device token / registration id)会因重装、系统升级、长时间不活跃而变化,不清理就会持续静默失败。三件事必做:
- 回执驱动:厂商回执里出现
Unregistered、BadDeviceToken、InvalidRegistration之类错误码,立即把该 token 置为失效,而不是重试; - 累计失败淘汰:同一 token 连续失败 3 次或超过 30 天未刷新,标记失效并停止投递;
- 启动上报:客户端每次启动上报 token,服务端对比后才写库,并按
device_id唯一键更新,避免同一设备堆积多条 token。
CREATE TABLE im_push_token (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
uid BIGINT UNSIGNED NOT NULL,
device_id VARCHAR(64) NOT NULL COMMENT '设备指纹,见 05 章',
platform TINYINT NOT NULL COMMENT '1=iOS 2=Android 3=Web',
vendor VARCHAR(16) NOT NULL COMMENT 'apns/fcm/hms/mi/oppo/vivo',
token VARCHAR(256) NOT NULL,
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=有效 0=失效',
fail_count INT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_device_vendor (device_id, vendor),
KEY idx_uid_status (uid, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
到达率口径与容量估算
到达率必须写清分子分母,否则不同团队能算出两倍的差距:
到达率 = 厂商回执"已接受/已送达"条数 ÷ 提交厂商条数
(不含客户端点击率,点击率是产品指标,不是投递指标)
DAU 1000 万,人均上行 40 条/日 = 4 亿条/日
合并后人均收到推送 8 条/日 → 推送量 8000 万条/日 ≈ 930 条/s
峰值系数 5(早晚高峰)→ 约 4700 条/s,推送是异步链路,可削峰处理
push_task 单条约 600B:8000 万 × 600B ≈ 48 GB/日
保留 7 天热数据 ≈ 340 GB,按 uid 分 8 库;30 天以上转归档或对象存储
厂商配额核算:某厂商日配额 5 万条、而该机型当日推送量 30 万条时,多出的 25 万条
必须在选通道阶段降级(转其他通道或按策略丢弃),而不是重试打爆接口
常见坑
- 推送早于落库:异步落库后再推送看似更快,但会造成"点开无消息",一定要以落库成功为触发点(09 章的幂等设计也依赖这个顺序)。
- 推送正文带敏感内容:锁屏预览会泄露,敏感会话与私密模式只推"你有一条新消息"。
- 把客户端在线状态当唯一依据:客户端上报的"我在线"不可信,以服务端心跳与 ACK 为准。
- 忽略厂商限流:收到 429 或配额超限要做指数退避并切换通道,连续重试会被厂商拉黑应用。
- 无视用户开关,或多端重复推:夜间静默与单会话免打扰必须在推送前过滤(事后撤回做不到);同一 uid 三端在线时也不能只按 uid 聚合后推一条,而要按端选通道、按端判断在线状态。
小结:离线推送是长连接的兜底通道,触发条件必须挂在"消息落库成功"之后,并按心跳与 ACK 组合判定在线以免重复打扰;实现上以 push_task 的 uk_task 保证幂等,用服务端合并窗口加厂商 collapse key 两层去重,用回执错误码加累计失败淘汰持续清理失效 token;衡量它的是口径明确的到达率与厂商配额,而不是"接口调用成功"。