离线推送:厂商通道与自建长连接的配合

消息投递的第一选择永远是自建长连接:连接在,消息直接推,延迟最低、边际成本近似为零。但移动端有两件事服务端控制不了——进程被系统回收、网络进入休眠。于是离线推送(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 各自为政是现实,不能指望单一通道全覆盖:

通道平台接入方式到达延迟主要限制
APNsiOSHTTP/2 + JWT<1s静默推送(content-available)有频次配额,超限只降优先级不报错
FCMAndroid 海外HTTP v1 + OAuth21~3s依赖 Google 服务,国内机型不可用
HMS Push(华为)华为设备HTTPS + OAuth21~3s需设备带 HMS Core,旧机型要回退其他通道
小米推送MIUIHTTPS1~3s透传消息受应用后台存活限制,通知栏消息更可靠
OPPO PUSHColorOSHTTPS1~5s按应用评级给每日配额(普通应用常见 5 万条/日量级),需提前申请提升
vivo PushOriginOSHTTPS1~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;衡量它的是口径明确的到达率与厂商配额,而不是"接口调用成功"。

笔记加载中…