召回不是多发几条推送
召回活动面向的是「曾经活跃、最近沉默」的用户。它的目标不是把消息发出去,而是让一部分人重新打开应用,并且不因为这次打扰而退订、卸载或投诉。
这两个目标经常冲突。多发几条,短期回流人数可能上升;但每多一条,用户退订和卸载的概率也在上升。案例设定里,消消的召回 7 日回流率是 9.5%,方舟是 6.8%;推送点击率分别是 6.5% 和 9.0%。这些数字说明,召回的效果不是线性的:同样的通道、同样的文案,发给不同频次、不同沉默时长的用户,结果会差很多。
频控和退订合规要解决的就是这个边界:在什么条件下可以发、最多发多少、用户说不收之后怎么立即生效。它不是一个运营策略页,而是一组必须由服务端执行的硬规则。
频控的三个层次
频控至少有三层,混在一起会让口径无法解释:
| 层次 | 约束对象 | 典型口径 | 作用 |
|---|---|---|---|
| 用户级 | 一个用户在一段时间内的召回消息总数 | 每人每周不超过 N 条 | 防止整体打扰 |
| 渠道级 | 同一渠道的发送频率 | 推送每天不超过 1 条 | 防止单通道过载 |
| 活动级 | 同一活动对同一用户的重复触达 | 一个活动只发 1 次 | 防止重复轰炸 |
案例事实表给的是用户级口径:消消每人每周不超过 2 条,方舟不超过 1 条。这个数字只约束召回类消息,不约束支付回执、账号安全、客服通知等事务性消息。如果把事务性消息也算进频控,用户可能因为一笔退款回执而收不到活动召回;反过来,如果把营销消息伪装成事务性消息绕过频控,退订率会很快反映出来。
用户级频控需要一个明确的统计窗口。自然周、滚动 7 天、按用户首次接收时间起算的 7 天,结果都不一样。案例实现用「本周已发送条数」作为输入,把窗口的定义留给上游;函数只负责判断「还能不能发」,不负责定义「一周从哪里开始」。这样同一套逻辑可以适配自然周,也可以适配滚动窗口。
退订与同意状态
退订不是一个按钮,而是一组状态:
- 全局退订:用户明确表示不再接收任何营销消息,所有召回渠道都停止。
- 渠道退订:用户只关闭推送,但仍接受应用内消息或邮件。
- 系统权限关闭:用户没有在应用内退订,但关闭了系统通知权限;推送实际上不可达。
- 渠道未开通:产品没有接入该渠道,或者该渠道不在本次活动的允许列表里。
这四种状态要分开记录,不能合并成一个 subscribed 布尔值。全局退订是最高优先级,命中后不再判断其他条件;渠道退订只影响对应渠道;系统权限关闭应该由通道层反馈,而不是靠召回层猜测;渠道未开通则是配置问题,不是用户意愿。
退订的生效时间必须是「立即」。用户点了退订,下一批计划里就不能再出现他的名字;如果等到第二天批量同步,中间这段时间的发送会直接变成投诉。工程上通常把退订写成一条高优先级事件,召回计划器在读取同意状态时以最新事件为准。
退订入口也要可达。消息里带一个可点击的退订链接,或者应用内有清晰的「消息设置」页;不要要求用户联系客服、填写表单或等待审核。退订越难,用户越可能直接关闭系统权限或卸载,后者对召回能力的伤害更大。
静默时段与跨时区
静默时段解决的是「什么时候发」。同一个用户,晚上 10 点收到召回和下午 3 点收到召回,感受完全不同。案例实现把静默窗口定义为本地时间 [start, end),支持跨午夜,例如 21:00 到次日 09:00。
跨时区是容易被忽略的部分。服务端通常用 UTC 记录时间,但静默时段必须按用户本地时间判断。消消有 7% 的海外用户,方舟有 15% 的 Google Play 用户,如果统一按服务器时区判断,一部分用户会在凌晨被叫醒,另一部分用户在正常时段被误判为静默。
时区偏移要按用户记录,而不是按渠道或地区粗略估算。偏移非法(超出真实时区范围)时应该拒绝,而不是静默回落到 0;静默回落会让一批用户在最差的时间收到消息,而且日志里看不出来。
静默时段的处理结果是「延后」,不是「丢弃」。延后需要知道下一个可用窗口,还要判断延后之后是否还在活动有效期内。如果活动已经结束,延后没有意义,应该转为抑制并记录原因。案例实现只返回 defer,把「延后到什么时候」留给调度层,是为了让判定保持纯函数;调度层可以按小时重跑,也可以在下一次计划时重新判断。
召回对象的选择
召回对象不是「所有不活跃用户」。至少要先排除四类人:
- 已经全局退订或渠道退订的用户。
- 当前渠道未开通、未授权或不在允许列表里的用户。
- 本周已经达到频控上限的用户。
- 仍然活跃、只是短时间未登录的用户。
第四类最容易被忽略。一个用户昨天刚玩过,今天收到「好久不见」的召回,会觉得产品不认识他。案例实现用 inactiveDays 做门槛,只有沉默超过阈值的用户才进入召回候选。阈值应该按产品节奏定:消消每周 2 个活动,召回节奏可以稍快;方舟每周 1 个活动,召回应该更克制。
候选集还要考虑价值分层。付费用户、接近付费门槛的用户、自然量用户,召回成本和收益不同;但分层不能变成「只召回高价值用户」,否则低价值用户会彻底流失,长期影响自然量和口碑。案例里消消的自然量占 55%,方舟占 35%,自然量用户对召回打扰更敏感,频控应该更严而不是更松。
决策顺序
把上述条件排成固定顺序,才能保证同一个用户在多次计划里得到一致的结果:
| 顺序 | 判断 | 结果 | 原因码 |
|---|---|---|---|
| 1 | 全局退订 | suppress | global-opt-out |
| 2 | 渠道退订 | suppress | channel-opt-out |
| 3 | 渠道未允许 | suppress | channel-disabled |
| 4 | 本周已达上限 | suppress | weekly-cap |
| 5 | 仍在活跃窗口 | suppress | recently-active |
| 6 | 处于静默时段 | defer | quiet-hours |
| 7 | 以上都通过 | send | eligible |
顺序不是随便排的。合规判断必须在前,因为退订是用户意愿,不能被频控或活跃度覆盖;频控在活跃度之前,是因为它是硬约束,先判可以让日志口径稳定;静默时段放在最后,因为它只影响「什么时候发」,不影响「能不能发」。
如果顺序反过来,会出现两类问题:先判断活跃度,会把退订用户也计入「最近活跃」的统计;先判断静默时段,会把已经退订的用户放进延后队列,调度层还要再过滤一次。固定顺序的价值不在于哪条规则更重要,而在于所有路径都能解释。
代码:召回计划器
下面的实现把候选用户、活动、策略和时间作为输入,输出每个用户的决策与原因。它不发送消息,不读写数据库,也不处理延后队列;这些都属于调度层。
// file: src/biz/recall.ts
// tier: L1
// verify: node --test test/biz/recall.test.ts
export type RecallChannel = "push" | "sms" | "inapp" | "email";
export type RecallDecision = "send" | "defer" | "suppress";
export interface ConsentState {
readonly push: boolean;
readonly sms: boolean;
readonly inapp: boolean;
readonly email: boolean;
readonly globalOptOut: boolean;
}
export interface RecallCandidate {
readonly userId: string;
readonly lastActiveAtMs: number;
readonly timezoneOffsetMinutes: number;
readonly consent: ConsentState;
readonly sentThisWeek: number;
}
export interface RecallPolicy {
readonly weeklyCap: number;
readonly quietStartHour: number;
readonly quietEndHour: number;
readonly inactiveDays: number;
readonly allowedChannels: readonly RecallChannel[];
}
export interface RecallCampaign {
readonly id: string;
readonly channel: RecallChannel;
}
export interface RecallPlan {
readonly userId: string;
readonly campaign: string;
readonly channel: RecallChannel;
readonly decision: RecallDecision;
readonly reason: string;
readonly localHour: number;
}
export interface RecallSummary {
readonly send: number;
readonly defer: number;
readonly suppress: number;
readonly total: number;
}
const DAY_MS = 86_400_000;
const HOUR_MS = 3_600_000;
const KNOWN_CHANNELS: readonly RecallChannel[] = ["push", "sms", "inapp", "email"];
function assertInteger(name: string, value: number, min: number, max: number): void {
if (!Number.isInteger(value) || value < min || value > max) {
throw new RangeError(`${name} must be an integer between ${min} and ${max}, got ${value}`);
}
}
function assertFinite(name: string, value: number): void {
if (!Number.isFinite(value)) {
throw new RangeError(`${name} must be a finite number, got ${value}`);
}
}
function validatePolicy(policy: RecallPolicy): void {
assertInteger("weeklyCap", policy.weeklyCap, 0, 100);
assertInteger("quietStartHour", policy.quietStartHour, 0, 23);
assertInteger("quietEndHour", policy.quietEndHour, 0, 23);
assertInteger("inactiveDays", policy.inactiveDays, 0, 3_650);
for (const channel of policy.allowedChannels) {
if (!KNOWN_CHANNELS.includes(channel)) {
throw new RangeError(`allowedChannels contains an unknown channel: ${channel}`);
}
}
}
function hasConsent(consent: ConsentState, channel: RecallChannel): boolean {
switch (channel) {
case "push":
return consent.push;
case "sms":
return consent.sms;
case "inapp":
return consent.inapp;
case "email":
return consent.email;
}
}
export function localHourAt(nowMs: number, offsetMinutes: number): number {
assertFinite("nowMs", nowMs);
assertInteger("offsetMinutes", offsetMinutes, -720, 840);
const shifted = nowMs + offsetMinutes * 60_000;
const hours = Math.floor(shifted / HOUR_MS);
return ((hours % 24) + 24) % 24;
}
export function isQuietHour(hour: number, startHour: number, endHour: number): boolean {
assertInteger("hour", hour, 0, 23);
assertInteger("startHour", startHour, 0, 23);
assertInteger("endHour", endHour, 0, 23);
if (startHour === endHour) {
return false;
}
if (startHour < endHour) {
return hour >= startHour && hour < endHour;
}
return hour >= startHour || hour < endHour;
}
export function evaluateCandidate(
candidate: RecallCandidate,
campaign: RecallCampaign,
policy: RecallPolicy,
nowMs: number,
): RecallPlan {
validatePolicy(policy);
assertFinite("nowMs", nowMs);
assertFinite("lastActiveAtMs", candidate.lastActiveAtMs);
if (candidate.lastActiveAtMs > nowMs) {
throw new RangeError("lastActiveAtMs cannot be in the future");
}
assertInteger("sentThisWeek", candidate.sentThisWeek, 0, 10_000);
if (!KNOWN_CHANNELS.includes(campaign.channel)) {
throw new RangeError(`unknown recall channel: ${campaign.channel}`);
}
const localHour = localHourAt(nowMs, candidate.timezoneOffsetMinutes);
const base = {
userId: candidate.userId,
campaign: campaign.id,
channel: campaign.channel,
localHour,
};
if (candidate.consent.globalOptOut) {
return { ...base, decision: "suppress", reason: "global-opt-out" };
}
if (!hasConsent(candidate.consent, campaign.channel)) {
return { ...base, decision: "suppress", reason: "channel-opt-out" };
}
if (!policy.allowedChannels.includes(campaign.channel)) {
return { ...base, decision: "suppress", reason: "channel-disabled" };
}
if (candidate.sentThisWeek >= policy.weeklyCap) {
return { ...base, decision: "suppress", reason: "weekly-cap" };
}
if (nowMs - candidate.lastActiveAtMs < policy.inactiveDays * DAY_MS) {
return { ...base, decision: "suppress", reason: "recently-active" };
}
if (isQuietHour(localHour, policy.quietStartHour, policy.quietEndHour)) {
return { ...base, decision: "defer", reason: "quiet-hours" };
}
return { ...base, decision: "send", reason: "eligible" };
}
export function planRecall(
candidates: readonly RecallCandidate[],
campaign: RecallCampaign,
policy: RecallPolicy,
nowMs: number,
): readonly RecallPlan[] {
return candidates.map((candidate) => evaluateCandidate(candidate, campaign, policy, nowMs));
}
export function summarizeRecall(plans: readonly RecallPlan[]): RecallSummary {
return {
send: plans.filter((plan) => plan.decision === "send").length,
defer: plans.filter((plan) => plan.decision === "defer").length,
suppress: plans.filter((plan) => plan.decision === "suppress").length,
total: plans.length,
};
}
export function formatRecallPlan(
campaign: RecallCampaign,
plans: readonly RecallPlan[],
): string {
const summary = summarizeRecall(plans);
return `campaign=${campaign.id} channel=${campaign.channel} total=${summary.total} send=${summary.send} defer=${summary.defer} suppress=${summary.suppress}`;
}
代码里有几个刻意的边界。
第一,策略参数全部做整数校验。周上限、静默小时、沉默天数都必须是整数;传入小数或负数直接抛 RangeError,而不是四舍五入。频控是合规规则,模糊的参数比没有参数更危险。
第二,时区偏移限制在 -720 到 840 分钟。这个范围覆盖了现实中常见的时区,超出范围的值通常意味着上游传错,应该拒绝而不是回落到 UTC。
第三,静默窗口用 [start, end),结束小时本身不算静默。21:00 到 09:00 的窗口,09:00 可以发,08:59 仍然延后。这个边界要和调度层的重跑节奏对齐,否则会出现「刚好卡在边界」的重复发送。
第四,决策顺序写死在函数里,不通过配置调整。合规判断的顺序不应该成为运营可配置项;如果允许调整,迟早会有人把频控放到退订之前。
第五,defer 和 suppress 分开。延后是「这次不发,下次可能发」,抑制是「这次及后续都不发,直到状态变化」;两者在报表里的含义完全不同,不能合并成一个「未发送」。
双案例对照
消消每人每周不超过 2 条,匹配它每周 2 个活动的节奏;7 日回流率 9.5%,说明高频召回还能带来增量,但退订和卸载要同步监控。它的自然量占 55%,对打扰更敏感,频控不能只看回流。
方舟每人每周不超过 1 条,匹配它每周 1 个活动的节奏;7 日回流率 6.8%,单次召回的价值更高,但用户基数小,一次误发的影响更大。它的付费率 6.5%、ARPPU ¥420,召回对象的价值分层更值得做。
取舍:频控越严,短期召回越少
收紧频控会直接减少发送量,短期回流人数也会下降。消消从每周 2 条降到 1 条,理论上少了一半触达机会;方舟本来就只有 1 条,再收紧等于放弃召回。这个代价是可见的,也是运营最不愿意接受的。
但不收紧的代价更隐蔽:退订率上升、系统权限关闭、卸载率上升,最后连事务性消息都送不到。案例里的折中是「频控 + 分层」:频控保证下限,分层决定把有限的名额给谁。高价值、高沉默、同意状态清晰的用户优先;低价值、刚活跃、近期被触达过的用户延后。
另一个取舍是静默时段。延后会让消息集中在允许窗口的开始时刻,形成新的峰值;如果调度层不做打散,用户可能在 09:00 同时收到多条。案例实现只负责判定,打散和排队属于调度层,这也是为什么 defer 不直接返回具体发送时间。
小结
- 召回频控是服务端硬规则,不是运营页上的建议值;案例口径是消消每周不超过 2 条、方舟不超过 1 条。
- 频控分用户级、渠道级、活动级三层,统计窗口要在上游定义清楚。
- 退订分全局、渠道、系统权限、渠道未开通四类,不能合并成一个布尔值。
- 静默时段按用户本地时间判断,支持跨午夜,非法时区偏移应该拒绝。
- 决策顺序固定为合规、频控、活跃度、静默时段;
defer与suppress必须分开统计。
阅读导航:上一节:11.2 深链路由与冷启动恢复 · 下一节:12.1 隐私政策、权限与数据采集清单 。
常见错误与边界
- 把召回频控当成运营建议值。没有服务端硬约束,活动一多就会突破上限,退订率随之上行。
- 把事务性消息也算进召回频控。支付回执、账号安全被频控挡住,用户会认为产品不可靠。
- 统计窗口定义不清。自然周和滚动 7 天混用,同一个用户可能在两天内被算成两个周期。
- 用一个布尔值表示退订。全局退订、渠道退订、系统权限关闭的处理方式不同,合并后会误发。
- 退订不是立即生效。批量同步的间隙足够发出一条消息,用户会把退订当成无效。
- 退订入口藏得太深。用户找不到退订,就会关闭系统权限或卸载,召回能力损失更大。
- 静默时段按服务器时区判断。海外用户会在凌晨被叫醒,国内用户可能在正常时段被误判。
- 时区偏移非法时静默回落到 UTC。错误被掩盖,一批用户会在最差的时间收到消息。
- 静默窗口边界写成闭区间。结束小时仍然延后,调度层重跑时容易重复发送。
- 把
defer和suppress合并成「未发送」。延后和抑制的后续动作不同,合并后无法解释报表。 - 只召回高价值用户。低价值用户彻底流失,长期影响自然量和口碑。
- 召回对象包含刚活跃的用户。用户刚玩过就收到「好久不见」,会觉得产品不认识他。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。