《移动游戏产品的架构·商业化与发行工程》11.1 推送通道与到达率

推送到达率 78% 与 85% 不是同一个指标。本节以《潮汐消消》《潮汐方舟》的平台结构与通道差异为案例设定,拆开目标、送达、点击三层口径,给出令牌从活跃到失效的状态机、通道覆盖率与重叠系数的估算方法,并说明多通道叠加的真实代价与通道回执的排障路径。

到达率不是一个数

推送链路上至少有三个不同的比率,很多团队把它们都叫「到达率」:

  • 目标到达率:实际送达到设备的推送数 ÷ 服务端选中的目标数。
  • 有效展示率:被系统展示或写入通知中心的推送数 ÷ 送达数。
  • 点击率:产生点击的推送数 ÷ 送达数。

案例事实表里的 78% 和 85% 指第一种:服务端把消息交给通道商之后,最终确认到达设备的比例。点击率 6.5% 和 9.0% 的分母通常是送达数,而不是目标数。如果拿点击率除以目标数,会得到一个更低的数字,然后误以为「推送没用」;实际上有一部分用户根本没被触达,问题在通道,不在文案。

这三个比率对应三段完全不同的排障路径:

层级主要断点责任方观测手段
目标 → 送达令牌失效、通道限流、系统拦截通道商、OS、客户端通道回执、令牌状态
送达 → 展示通知权限关闭、免打扰、前台抑制OS、用户设置展示回执、权限状态
展示 → 点击文案、时机、落地页运营、产品深链点击、归因参数

工程上最容易犯的错,是把三层混成一个 KPI。到达率掉了就改文案,点击率掉了就换通道,最后两边都解释不清。本节只处理第一层:消息有没有到设备。第二层涉及系统权限与用户设置,第三层涉及深链与召回,分别由 11.2 和 11.3 展开。

四条通道,各自的断点

移动端推送不是一条管道,而是一组能力不同的通道:

通道典型平台优势主要断点
APNsiOS系统级统一,离线可达通知权限、令牌失效、静默推送配额
FCMAndroid 海外、Google Play 设备接入简单,覆盖面广后台限制、厂商省电策略、国内网络可达性
OEM 通道国内安卓厂商系统级保活,到达率通常更高厂商 SDK 差异、配额、审核与包体增量
应用内通道全平台不依赖系统权限,展示可控用户必须已经打开应用,无法承担离线召回

这四条通道不是互斥选项,而是分层组合。一个常见的组合是:iOS 走 APNs,海外安卓走 FCM,国内安卓按厂商接入 OEM 通道,应用内通道作为兜底和运营位。

案例设定里,消消接入 9 个渠道、6 个渠道 SDK(登录、支付、广告、统计、推送、分享),方舟接入 6 个渠道、5 个 SDK。渠道数量多,不等于推送通道多;真正决定到达率的是「目标用户分布在哪些系统上」以及「这些系统上有没有可用的系统级通道」。

消消的平台结构是国内安卓 55%、iOS 30%、TapTap 8%、海外 7%。一半以上用户在国内安卓,OEM 通道的覆盖质量直接决定整体到达率。方舟是 iOS 45%、国内安卓 40%、Google Play 15%,iOS 占比最高,APNs 的令牌管理和通知权限成为主要变量。这能解释为什么案例设定里方舟的到达率 85% 高于消消的 78%:不是方舟的推送写得更好,而是它的用户结构更集中在系统推送更稳定的平台上。这个解释是结构性判断,不是因果结论;要验证还得按平台分层看回执。

令牌不是永久的地址

推送令牌(token)是通道商发给客户端、用来标识「这台设备上的这个应用」的地址。它不是设备 ID,也不是用户 ID,会随重装、恢复备份、系统升级、权限变化而失效。

把令牌当成永久地址,是到达率虚高的常见原因:服务端库里存着一批早就失效的令牌,仍然把它们算进目标数,于是「目标 100 万、送达 78 万」看起来只是丢了 22%,实际上有效目标可能只有 85 万,真实到达率并没有那么差,或者反过来,大量无效目标稀释了有效通道的表现。

工程上把令牌拆成三个状态:

状态含义是否继续发送处置
active最近有刷新或成功回执是正常使用
stale超过一个刷新周期没有动静谨慎降权、减少发送、等待刷新
invalid通道明确拒绝,或长时间无任何回执否停止发送,从目标集移除

状态迁移由三类事件驱动:

  • refreshed:客户端启动或通道回调带来新令牌,状态回到 active,并更新最后活跃时间。
  • aged:按时间推进检查沉默时长,超过一个阈值转 stale,超过两个阈值转 invalid。
  • rejected:通道返回明确的无效令牌错误,直接转 invalid,且不可逆。

这里有一个刻意的选择:invalid 是终态,refreshed 不能把它救回来。原因是「同一个令牌值」被通道拒绝之后,再用它发送只会继续失败;如果客户端真的拿到了新令牌,那应该是一条新记录,而不是在原记录上改状态。把新令牌当成旧记录的刷新,会让失效历史丢失,也会让去重逻辑出错。

stale 则不同。stale 只是「最近没动静」,不代表通道拒绝。直接删掉会损失一部分仍然可达的用户;继续全量发送又会浪费配额。合理的做法是降权:在召回、活动这类非关键消息里跳过 stale,在支付回执、账号安全这类关键消息里仍然尝试。

用覆盖率和重叠估算到达

在没有逐条回执的情况下,可以用两个参数做量级估算:

覆盖率 = 各通道用户占比之和
目标数 = 用户数 × 覆盖率
预计送达 = 目标数 × 通道到达率 × (1 - 重叠系数)

覆盖率解决「有多少用户至少有一个可用通道」,重叠系数解决「同一个用户被多条通道重复计算」。如果 APNs 和 OEM 通道同时可达,按覆盖率相加会把一个人算两次;重叠系数就是对这部分重复的折减。

以消消的 80 万 DAU 为例,假设某次召回的目标人群里,APNs 占 30%、FCM 占 55%、OEM 占 8%,覆盖率就是 93%,目标数约 74.4 万。再按 78% 的到达率和 5% 的重叠系数计算,预计送达约 55.1 万,按全体用户算的有效到达率约 68.9%。

这里的 93% 覆盖结构、5% 重叠系数是示例参数,不是事实表数字;80 万 DAU 和 78% 到达率来自案例设定。参数可以换,但公式和口径不能换:先算覆盖,再算折减,最后才谈点击。

覆盖率低于 100% 的部分,通常来自三种情况:用户从未授权通知、设备没有安装任何通道 SDK、或者用户关闭了系统推送。这部分用户在推送链路上是「不可达」的,不应该反复计入失败,也不应该用短信去补——短信是另一个通道,有自己的同意状态和成本,属于 11.3 的退订与频控范围。

从到达率到点击率

到达率之后才是点击率。案例设定里,消消的推送点击率是 6.5%,方舟是 9.0%。按前面的量级估算:

  • 消消 80 万 DAU × 78% 到达 ≈ 62.4 万送达,按 6.5% 点击 ≈ 4.06 万次点击。
  • 方舟 25 万 DAU × 85% 到达 ≈ 21.25 万送达,按 9.0% 点击 ≈ 1.91 万次点击。

两个产品的点击量级接近,但用户基数差了三倍多。这说明方舟的推送点击率更高,不能简单归因于文案更好;中重度游戏的用户关系更深、推送内容更聚焦、频次更低,单条消息的关注度自然更高。消消是休闲产品,推送频次高、内容轻,点击率低一些是结构性的。

这也是为什么到达率和点击率要分开看:到达率低,先修通道和令牌;点击率低,再修内容、时机和落地页。把两者乘在一起看「每目标点击率」,会让通道问题和内容问题互相掩盖。

代码:可达性估算与令牌状态机

下面的实现把「令牌状态推进」和「人群到达估算」分成两组纯函数。令牌只描述事实,策略提供阈值;估算函数不修改输入,也不调用任何通道接口。

// file: src/biz/push-channel.ts
// tier: L1
// verify: node --test test/biz/push-channel.test.ts

export type PushChannel = "apns" | "fcm" | "oem" | "inapp";
export type TokenStatus = "active" | "stale" | "invalid";
export type TokenEvent = "refreshed" | "aged" | "rejected";

export interface PushToken {
  readonly channel: PushChannel;
  readonly value: string;
  readonly status: TokenStatus;
  readonly lastSeenAtMs: number;
}

export interface ChannelPolicy {
  readonly arrivalRate: number;
  readonly overlapFactor: number;
  readonly maxSilenceMs: number;
  readonly fallbackChannel: PushChannel;
}

export interface AudienceSegment {
  readonly name: string;
  readonly users: number;
  readonly channelMix: Readonly<Partial<Record<PushChannel, number>>>;
}

export interface ReachEstimate {
  readonly segment: string;
  readonly users: number;
  readonly targeted: number;
  readonly expectedDelivered: number;
  readonly untargeted: number;
  readonly undelivered: number;
  readonly arrivalRate: number;
}

export interface ReachReport {
  readonly estimates: readonly ReachEstimate[];
  readonly totalUsers: number;
  readonly totalTargeted: number;
  readonly totalDelivered: number;
  readonly blendedRate: number;
}

const KNOWN_CHANNELS: readonly PushChannel[] = ["apns", "fcm", "oem", "inapp"];

function assertFinite(name: string, value: number): void {
  if (!Number.isFinite(value)) {
    throw new RangeError(`${name} must be a finite number, got ${value}`);
  }
}

function assertRate(name: string, value: number): void {
  assertFinite(name, value);
  if (value < 0 || value > 1) {
    throw new RangeError(`${name} must be between 0 and 1, got ${value}`);
  }
}

function assertChannel(name: string, channel: string): void {
  if (!KNOWN_CHANNELS.includes(channel as PushChannel)) {
    throw new RangeError(`${name} must be a known push channel, got ${channel}`);
  }
}

export function normalizeChannelMix(
  mix: Readonly<Partial<Record<PushChannel, number>>>,
): Readonly<Record<PushChannel, number>> {
  const normalized: Record<PushChannel, number> = { apns: 0, fcm: 0, oem: 0, inapp: 0 };
  let total = 0;
  for (const channel of KNOWN_CHANNELS) {
    const value = mix[channel] ?? 0;
    assertRate(`channelMix.${channel}`, value);
    normalized[channel] = value;
    total += value;
  }
  if (total > 1 + 1e-9) {
    throw new RangeError(`channel mix must not exceed 1, got ${total}`);
  }
  return normalized;
}

export function advanceToken(
  token: PushToken,
  event: TokenEvent,
  nowMs: number,
  policy: ChannelPolicy,
): PushToken {
  if (token.value.trim() === "") {
    throw new RangeError("token value must not be empty");
  }
  assertChannel("token.channel", token.channel);
  assertFinite("nowMs", nowMs);
  assertFinite("token.lastSeenAtMs", token.lastSeenAtMs);
  if (token.lastSeenAtMs < 0) {
    throw new RangeError("token.lastSeenAtMs must not be negative");
  }
  if (nowMs < token.lastSeenAtMs) {
    throw new RangeError("nowMs cannot be earlier than token.lastSeenAtMs");
  }
  if (!Number.isFinite(policy.maxSilenceMs) || policy.maxSilenceMs <= 0) {
    throw new RangeError("maxSilenceMs must be a positive finite number");
  }
  if (token.status === "invalid") {
    return token;
  }
  if (event === "rejected") {
    return { ...token, status: "invalid" };
  }
  if (event === "refreshed") {
    return { ...token, status: "active", lastSeenAtMs: nowMs };
  }
  const silence = nowMs - token.lastSeenAtMs;
  if (silence > policy.maxSilenceMs * 2) {
    return { ...token, status: "invalid" };
  }
  if (silence > policy.maxSilenceMs) {
    return { ...token, status: "stale" };
  }
  return token.status === "stale" ? token : { ...token, status: "active" };
}

export function estimateReach(segment: AudienceSegment, policy: ChannelPolicy): ReachEstimate {
  assertFinite("segment.users", segment.users);
  if (segment.users < 0) {
    throw new RangeError(`segment.users must not be negative, got ${segment.users}`);
  }
  assertRate("arrivalRate", policy.arrivalRate);
  assertRate("overlapFactor", policy.overlapFactor);
  const mix = normalizeChannelMix(segment.channelMix);
  const coverage = KNOWN_CHANNELS.reduce((sum, channel) => sum + mix[channel], 0);
  const targeted = Math.round(segment.users * coverage);
  const expectedDelivered = Math.round(targeted * policy.arrivalRate * (1 - policy.overlapFactor));
  return {
    segment: segment.name,
    users: segment.users,
    targeted,
    expectedDelivered,
    untargeted: segment.users - targeted,
    undelivered: targeted - expectedDelivered,
    arrivalRate: policy.arrivalRate,
  };
}

export function summarizeReach(
  segments: readonly AudienceSegment[],
  policy: ChannelPolicy,
): ReachReport {
  const estimates = segments.map((segment) => estimateReach(segment, policy));
  const totalUsers = estimates.reduce((sum, item) => sum + item.users, 0);
  const totalTargeted = estimates.reduce((sum, item) => sum + item.targeted, 0);
  const totalDelivered = estimates.reduce((sum, item) => sum + item.expectedDelivered, 0);
  return {
    estimates,
    totalUsers,
    totalTargeted,
    totalDelivered,
    blendedRate: totalUsers === 0 ? 0 : totalDelivered / totalUsers,
  };
}

export function chooseFallback(token: PushToken, policy: ChannelPolicy): PushChannel {
  assertChannel("token.channel", token.channel);
  assertChannel("fallbackChannel", policy.fallbackChannel);
  if (token.status === "active") {
    return token.channel;
  }
  return policy.fallbackChannel;
}

export function formatReachReport(report: ReachReport): string {
  const rate = (report.blendedRate * 100).toFixed(1);
  return `segments=${report.estimates.length} users=${report.totalUsers} targeted=${report.totalTargeted} delivered=${report.totalDelivered} rate=${rate}%`;
}

代码里有几个刻意的边界。

第一,通道占比之和不能超过 1。超过 1 意味着同一个人被重复计入覆盖,后续的重叠折减再准也救不回来;这里直接抛 RangeError,而不是静默截断。

第二,令牌沉默阈值是严格大于。沉默正好等于阈值时仍然是 active,超过才转 stale,超过两倍才转 invalid。边界写成 > 而不是 >=,是为了让「恰好一个周期」不被误判为异常;真实系统里时钟抖动很常见,边界过于敏感会产生大量假 stale。

第三,invalid 是终态。advanceToken 遇到 invalid 直接原样返回,rejected 之后即使再来 refreshed 也不会复活。新令牌应该走新记录,旧记录保留失效原因,方便排查通道商或客户端的问题。

第四,summarizeReach 的混合到达率分母是全体用户,不是目标用户。这样得到的数字更接近「这次推送对整体大盘的触达」,也避免覆盖率低的渠道因为分母小而看起来到达率很高。

第五,chooseFallback 只在令牌不活跃时返回兜底通道。active 令牌仍然走原通道,避免为了「统一」而把所有消息都塞进应用内通道,失去离线召回能力。

双案例对照

消消的 80 万 DAU 里,国内安卓占 55%,OEM 通道是到达率的主战场;9 个渠道、6 个 SDK 意味着通道组合更复杂,令牌去重和厂商配额需要单独治理。78% 的到达率不算高,但它的用户盘子大,绝对送达量仍然可观。

方舟的 25 万 DAU 里,iOS 占 45%,APNs 的权限与令牌质量决定上限;6 个渠道、5 个 SDK 让链路更短,85% 的到达率更接近系统推送的稳定水平。它的点击率 9.0% 更高,但用户基数小,不能直接套用消消的召回节奏。

取舍:多通道叠加不是免费

多接一条通道,通常能提高覆盖率,但代价不止是包体。每多一个通道 SDK,就多一份初始化成本、隐私披露项、版本兼容面和线上故障点。案例设定里,消消的第三方 SDK 是 14 个、申请权限 8 项,方舟是 11 个、10 项;通道不是唯一来源,但确实是增长最快的一类。

另一项代价是去重与频控。同一个用户可能同时被 APNs、OEM 和应用内通道触达,如果没有统一的 message id 和去重键,用户会收到重复消息,点击率被稀释,退订率上升。多通道带来的到达率提升,可能被重复打扰抵消掉一部分。

还有一种隐性代价是排障复杂度。一条消息没到,可能是令牌失效、通道限流、厂商审核、系统权限、网络可达性中的任意一个;通道越多,定位路径越长。团队需要接受「到达率不是一个可以单独优化的数字」,而是一组按通道、平台、版本分层的指标。

小结

  • 到达率、展示率、点击率的分母不同,不能混成一个 KPI;案例里的 78% 和 85% 是目标到达率。
  • APNs、FCM、OEM、应用内四条通道各有断点,用户平台结构决定整体到达率的上限。
  • 令牌分 active、stale、invalid 三态,invalid 是终态,新令牌应走新记录。
  • 到达估算先算覆盖率,再用重叠系数折减;覆盖率低于 100% 的用户不应反复计入失败。
  • 多通道叠加提高覆盖,也带来包体、隐私、去重和排障成本,需要按通道分层治理。

阅读导航:上一节:10.3 数据质量:丢失、重复与口径漂移 · 下一节:11.2 深链路由与冷启动恢复 。

常见错误与边界

  1. 把目标到达率、展示率和点击率都叫「到达率」。分母不同,结论会完全相反,排障前必须先确认口径。
  2. 用点击率除以目标数评估推送效果。未送达的用户也被算进分母,会低估真实点击表现。
  3. 把失效令牌继续留在目标集里。目标数虚高、到达率虚低,还会浪费通道配额和发送成本。
  4. 令牌被通道拒绝后仍用原值重试。拒绝通常不可逆,重试只会持续失败,应该换新令牌或移除记录。
  5. 沉默超过一个周期就删除令牌。stale 不等于 invalid,直接删除会损失仍然可达的用户。
  6. 通道占比之和超过 1 仍继续估算。同一个人被重复计入覆盖,重叠折减无法纠正。
  7. 用全体用户做分母比较不同渠道的到达率。渠道覆盖人群不同,应该先分层再比较。
  8. 把方舟 85% 当成消消的目标。两者平台结构不同,直接横比会把结构差异当成执行差距。
  9. 多通道同时发送同一条消息。缺少统一去重键会造成重复打扰,点击率和退订率都会变差。
  10. 只接通道、不做令牌生命周期管理。通道只负责传输,令牌质量决定最终能触达多少人。
  11. 把推送到达率当成用户活跃度指标。到达只说明消息到了设备,不代表用户看见、点击或回流。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查