到达率不是一个数
推送链路上至少有三个不同的比率,很多团队把它们都叫「到达率」:
- 目标到达率:实际送达到设备的推送数 ÷ 服务端选中的目标数。
- 有效展示率:被系统展示或写入通知中心的推送数 ÷ 送达数。
- 点击率:产生点击的推送数 ÷ 送达数。
案例事实表里的 78% 和 85% 指第一种:服务端把消息交给通道商之后,最终确认到达设备的比例。点击率 6.5% 和 9.0% 的分母通常是送达数,而不是目标数。如果拿点击率除以目标数,会得到一个更低的数字,然后误以为「推送没用」;实际上有一部分用户根本没被触达,问题在通道,不在文案。
这三个比率对应三段完全不同的排障路径:
| 层级 | 主要断点 | 责任方 | 观测手段 |
|---|---|---|---|
| 目标 → 送达 | 令牌失效、通道限流、系统拦截 | 通道商、OS、客户端 | 通道回执、令牌状态 |
| 送达 → 展示 | 通知权限关闭、免打扰、前台抑制 | OS、用户设置 | 展示回执、权限状态 |
| 展示 → 点击 | 文案、时机、落地页 | 运营、产品 | 深链点击、归因参数 |
工程上最容易犯的错,是把三层混成一个 KPI。到达率掉了就改文案,点击率掉了就换通道,最后两边都解释不清。本节只处理第一层:消息有没有到设备。第二层涉及系统权限与用户设置,第三层涉及深链与召回,分别由 11.2 和 11.3 展开。
四条通道,各自的断点
移动端推送不是一条管道,而是一组能力不同的通道:
| 通道 | 典型平台 | 优势 | 主要断点 |
|---|---|---|---|
| APNs | iOS | 系统级统一,离线可达 | 通知权限、令牌失效、静默推送配额 |
| FCM | Android 海外、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 深链路由与冷启动恢复 。
常见错误与边界
- 把目标到达率、展示率和点击率都叫「到达率」。分母不同,结论会完全相反,排障前必须先确认口径。
- 用点击率除以目标数评估推送效果。未送达的用户也被算进分母,会低估真实点击表现。
- 把失效令牌继续留在目标集里。目标数虚高、到达率虚低,还会浪费通道配额和发送成本。
- 令牌被通道拒绝后仍用原值重试。拒绝通常不可逆,重试只会持续失败,应该换新令牌或移除记录。
- 沉默超过一个周期就删除令牌。stale 不等于 invalid,直接删除会损失仍然可达的用户。
- 通道占比之和超过 1 仍继续估算。同一个人被重复计入覆盖,重叠折减无法纠正。
- 用全体用户做分母比较不同渠道的到达率。渠道覆盖人群不同,应该先分层再比较。
- 把方舟 85% 当成消消的目标。两者平台结构不同,直接横比会把结构差异当成执行差距。
- 多通道同时发送同一条消息。缺少统一去重键会造成重复打扰,点击率和退订率都会变差。
- 只接通道、不做令牌生命周期管理。通道只负责传输,令牌质量决定最终能触达多少人。
- 把推送到达率当成用户活跃度指标。到达只说明消息到了设备,不代表用户看见、点击或回流。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。