漏斗不是一个图,是一份查询契约
同一款游戏,运营说付费率是 2.8%,财务说是 2.6%,渠道后台又给出第三个数字。差异通常不在数据本身,而在四个人对「付费用户」「活跃」「成功支付」的定义不同。漏斗的价值不是画出一张逐渐收窄的图,而是把定义固定下来,让不同团队查同一个数。
一份可执行的漏斗契约至少要说清四件事:
- 步骤是什么,顺序是什么。
- 主体是谁,是用户、订单还是设备。
- 每一步允许多长时间,从哪一步开始计时。
- 同一主体重复触发同一步时,算一次还是多次。
这四件事没有写清,漏斗就只是一张好看的图。写清之后,它才是一个可以被测试、被复现、被质疑的查询。
四要素缺一不可
| 要素 | 必须回答 | 常见错误 |
|---|---|---|
| 步骤 | 哪几步,是否允许跳过 | 把「浏览」和「加购」合成一步,之后无法定位流失 |
| 主体 | 用户、订单、会话还是设备 | 上半段按用户、下半段按订单,转化率超过 100% |
| 顺序 | 严格先后,还是只要求都发生 | 不要求顺序,先付款后浏览也算转化 |
| 窗口 | 从第一步起算,还是每步之间单独计时 | 窗口定义不一致,两个团队的结果永远对不上 |
顺序与窗口是最容易被忽略的两项。严格顺序会过滤掉「先点广告再回首页再进入商店」的真实路径,导致转化率偏低;不要求顺序又会把「先付款、后曝光」的异常数据算成转化,导致转化率偏高。没有绝对正确的选择,只有写清楚的选择。
窗口同理。从第一步起算 7 天,和每一步之间 7 天,是两个完全不同的口径。前者适合衡量一次活动带来的完整转化,后者适合衡量每一步的产品体验。两者都能用,但不能混用。
付费漏斗:从下单到结算
以《潮汐消消》为例,月订单 42 万笔,掉单率 0.35%,退款率 1.2%。把订单作为主体,可以得到一条不依赖用户行为的交易漏斗:
| 步骤 | 消消 | 方舟 | 说明 |
|---|---|---|---|
order_created | 420,000 | 150,000 | 客户端发起下单 |
channel_confirmed | 420,000 | 150,000 | 渠道回调确认 |
granted | 418,530 | 149,700 | 发货成功,扣除掉单 |
settled_7d | 413,508 | 148,502 | 7 日内未退款 |
| 下单到结算 | 98.45% | 99.00% | 两端损耗合并 |
这条漏斗的每一步都对应一个可核对的系统状态:下单有订单号,确认有渠道流水号,发货有资产流水,结算有退款单。它不依赖客户端埋点是否完整,因此适合财务与客服使用。客户端埋点在这条链路里的作用,是解释「哪一步丢的」,而不是替代订单系统成为事实源。
把交易漏斗和用户漏斗混在一起,是付费分析最常见的错误。订单可以一个人下很多笔,用户不行;一旦上半段按用户、下半段按订单,就会出现转化率超过 100% 的荒谬结果。正确做法是分开算:用户漏斗回答「多少人愿意付费」,订单漏斗回答「付了之后链路是否健康」。
分母决定结论
付费率、ARPPU、ARPU 这三个指标经常被混用,因为它们的分母不同:
| 指标 | 公式 | 消消 | 方舟 |
|---|---|---|---|
| 付费率 | 付费用户 / 活跃用户 | 2.8% | 6.5% |
| ARPPU | 内购流水 / 付费用户 | ¥68 | ¥420 |
| ARPU | 总流水 / 活跃用户 | ≈¥3.1 | ≈¥28.2 |
| 单付费用户订单 | 订单数 / 付费用户 | 4.69 | 2.31 |
消消月活 320 万、付费用户 8.96 万,付费率 2.8%;方舟月活 100 万、付费用户 6.5 万,付费率 6.5%。方舟的付费率是消消的两倍多,但它的 ARPPU 是消消的六倍,两个数字共同解释了为什么方舟月流水 2,820 万元、消消 987 万元。
分母选错,结论会完全反过来。用 DAU 做分母,付费率会显得很高,因为分子是按月累计的付费用户;用新增用户做分母,付费率又会显得很低,因为新增用户还没有经历完整的付费决策周期。行业里说「付费率」时,默认分母通常是 MAU,但必须在报表里写出来,而不是靠默契。
单付费用户订单数也值得单独看。消消是 4.69,方舟是 2.31,说明消消靠高频小额,方舟靠低频大额。这个差异会直接影响后面所有分析:消消的漏斗要关注「第一次付费后如何复购」,方舟的漏斗要关注「第一次大额付费前的信任建立」。
留存:队列、观察窗与口径
留存不是「今天有多少人回来」,而是「某一批人在第 N 天还回来多少」。三个要素必须同时固定:
- 队列:按首次活跃日、首次付费日还是首次安装日分组。三者回答的问题不同,不能互换。
- 观察窗:D1、D7、D30 是自然日还是 24 小时滚动窗口。跨时区产品尤其容易在这里出错。
- 回访定义:启动算回访,还是必须完成一次核心行为。启动口径会显著高估留存。
案例里两个产品的 DAU/MAU 都是 25%:消消 80 万 / 320 万,方舟 25 万 / 100 万。这个比值衡量的是整体活跃黏性,不是队列留存;把它当成 D30 留存会得出完全错误的结论。DAU/MAU 高,可能只是用户每天打开领奖励;队列留存低,才说明新增用户没有形成长期习惯。
留存曲线还要区分「自然回落」和「产品问题」。新用户在前三天快速流失是普遍现象,D1 到 D7 的斜率比绝对值更有信息量;如果 D7 到 D30 还在快速下降,通常说明中期目标缺失,而不是新手引导做得不好。
回流不是留存
留存看的是同一批用户的连续行为,回流看的是沉默用户是否被重新激活。两者经常被混在一张报表里,导致「留存提升」的结论其实来自一次推送活动。
《潮汐消消》的召回 7 日回流率是 9.5%,《潮汐方舟》是 6.8%。消消的回流率更高,和它的召回频控更宽(每人每周不超过 2 条)以及休闲品类的低门槛有关;方舟的召回频控是每人每周不超过 1 条,回流率更低,但单次回流的价值更高。
回流分析的坑在于分母。用「发送量」做分母,得到的是推送效率;用「沉默用户数」做分母,得到的才是召回能力。两个数字都叫「回流率」,含义却完全不同。案例里统一用「触达的沉默用户」做分母,并在报表中注明频控上限。
可运行的漏斗计算器
下面的实现把四要素落成代码:步骤与顺序由数组表达,主体由 subjectId 表达,窗口由 windowMs 表达,重复触发由「每步只记一次」处理。它同时提供按事件计算和按已聚合数量计算两条路径。
// file: src/biz/funnel.ts
// tier: L1
// verify: node --test test/biz/funnel.test.ts
export interface FunnelEvent {
readonly subjectId: string;
readonly step: string;
readonly atMs: number;
}
export interface FunnelStepResult {
readonly step: string;
readonly subjects: number;
readonly fromPrevPct: number | null;
readonly fromStartPct: number;
}
export type FunnelCode = "empty-steps" | "duplicate-step" | "bad-window" | "count-length" | "count-order";
export type FunnelResult =
| { readonly ok: true; readonly steps: readonly FunnelStepResult[]; readonly windowMs: number; readonly subjects: number }
| { readonly ok: false; readonly code: FunnelCode; readonly detail: string };
export type RateCode = "bad-active" | "paid-exceeds-active" | "bad-cohort" | "retained-exceeds-cohort";
export type RateResult =
| { readonly ok: true; readonly pct: number }
| { readonly ok: false; readonly code: RateCode; readonly detail: string };
export interface RetentionPoint {
readonly day: number;
readonly ratePct: number;
}
export interface CaseAggregates {
readonly mau: number;
readonly paidUsers: number;
readonly orders: number;
readonly dropRatePct: number;
readonly refundRatePct: number;
readonly revenueCents: number;
}
export const CASE: Readonly<Record<"xiaoxiao" | "fangzhou", CaseAggregates>> = {
xiaoxiao: { mau: 3_200_000, paidUsers: 89_600, orders: 420_000, dropRatePct: 0.35, refundRatePct: 1.2, revenueCents: 987_000_000 },
fangzhou: { mau: 1_000_000, paidUsers: 65_000, orders: 150_000, dropRatePct: 0.2, refundRatePct: 0.8, revenueCents: 2_820_000_000 },
};
export function computeFunnel(
events: readonly FunnelEvent[],
steps: readonly string[],
windowMs: number,
): FunnelResult {
if (steps.length === 0) {
return { ok: false, code: "empty-steps", detail: "a funnel needs at least one step" };
}
const seen = new Set<string>();
for (const step of steps) {
if (step.length === 0 || seen.has(step)) {
return { ok: false, code: "duplicate-step", detail: `step ${JSON.stringify(step)} is empty or repeated` };
}
seen.add(step);
}
if (!Number.isFinite(windowMs) || windowMs < 0) {
return { ok: false, code: "bad-window", detail: `${windowMs} is not a valid window` };
}
const bySubject = new Map<string, FunnelEvent[]>();
for (const event of events) {
const list = bySubject.get(event.subjectId);
if (list === undefined) {
bySubject.set(event.subjectId, [event]);
} else {
list.push(event);
}
}
const reached = new Array<number>(steps.length).fill(0);
for (const list of bySubject.values()) {
const ordered = [...list].sort((left, right) => {
if (left.atMs !== right.atMs) {
return left.atMs - right.atMs;
}
return steps.indexOf(left.step) - steps.indexOf(right.step);
});
let cursor = Number.NEGATIVE_INFINITY;
let start: number | null = null;
let depth = 0;
for (let index = 0; index < steps.length; index += 1) {
const wanted = steps[index] as string;
let matched: FunnelEvent | null = null;
for (const event of ordered) {
if (event.step !== wanted || event.atMs < cursor) {
continue;
}
if (start !== null && event.atMs - start > windowMs) {
continue;
}
matched = event;
break;
}
if (matched === null) {
break;
}
if (index === 0) {
start = matched.atMs;
}
cursor = matched.atMs;
depth = index + 1;
}
for (let index = 0; index < depth; index += 1) {
reached[index] = (reached[index] as number) + 1;
}
}
const subjects = reached[0] as number;
const result: FunnelStepResult[] = steps.map((step, index) => {
const count = reached[index] as number;
const previous = index === 0 ? null : (reached[index - 1] as number);
return {
step,
subjects: count,
fromPrevPct: previous === null ? null : rate(count, previous),
fromStartPct: subjects === 0 ? 0 : rate(count, subjects),
};
});
return { ok: true, steps: result, windowMs, subjects };
}
export function funnelFromCounts(counts: readonly number[], steps: readonly string[]): FunnelResult {
if (counts.length !== steps.length || steps.length === 0) {
return { ok: false, code: "count-length", detail: `${counts.length} counts for ${steps.length} steps` };
}
for (let index = 0; index < counts.length; index += 1) {
const count = counts[index] as number;
if (!Number.isInteger(count) || count < 0) {
return { ok: false, code: "count-order", detail: `count ${count} is not a non-negative integer` };
}
const previous = counts[index - 1];
if (previous !== undefined && count > previous) {
return { ok: false, code: "count-order", detail: `step ${steps[index]} grows from ${previous} to ${count}` };
}
}
const subjects = counts[0] as number;
const result: FunnelStepResult[] = steps.map((step, index) => {
const count = counts[index] as number;
const previous = index === 0 ? null : (counts[index - 1] as number);
return {
step,
subjects: count,
fromPrevPct: previous === null ? null : rate(count, previous),
fromStartPct: subjects === 0 ? 0 : rate(count, subjects),
};
});
return { ok: true, steps: result, windowMs: 0, subjects };
}
export function paidConversion(activeUsers: number, paidUsers: number): RateResult {
if (!Number.isInteger(activeUsers) || activeUsers <= 0) {
return { ok: false, code: "bad-active", detail: `${activeUsers} is not a positive user count` };
}
if (!Number.isInteger(paidUsers) || paidUsers < 0 || paidUsers > activeUsers) {
return { ok: false, code: "paid-exceeds-active", detail: `paid ${paidUsers} must be between 0 and ${activeUsers}` };
}
return { ok: true, pct: rate(paidUsers, activeUsers) };
}
export function retentionRate(cohortUsers: number, retainedUsers: number): RateResult {
if (!Number.isInteger(cohortUsers) || cohortUsers <= 0) {
return { ok: false, code: "bad-cohort", detail: `${cohortUsers} is not a positive cohort` };
}
if (!Number.isInteger(retainedUsers) || retainedUsers < 0 || retainedUsers > cohortUsers) {
return { ok: false, code: "retained-exceeds-cohort", detail: `retained ${retainedUsers} must be between 0 and ${cohortUsers}` };
}
return { ok: true, pct: rate(retainedUsers, cohortUsers) };
}
export function validateRetentionCurve(points: readonly RetentionPoint[]): { readonly ok: boolean; readonly detail: string } {
let previousDay = -1;
for (const point of points) {
if (!Number.isInteger(point.day) || point.day <= previousDay) {
return { ok: false, detail: `day ${point.day} must be a strictly increasing integer` };
}
if (!Number.isFinite(point.ratePct) || point.ratePct < 0 || point.ratePct > 100) {
return { ok: false, detail: `rate ${point.ratePct} must be within 0..100` };
}
previousDay = point.day;
}
return { ok: points.length > 0, detail: `${points.length} points checked` };
}
export function arppuCents(revenueCents: number, payers: number): number | null {
if (!Number.isFinite(revenueCents) || revenueCents < 0 || !Number.isInteger(payers) || payers <= 0) {
return null;
}
return Math.round(revenueCents / payers);
}
export function arpuCents(revenueCents: number, activeUsers: number): number | null {
if (!Number.isFinite(revenueCents) || revenueCents < 0 || !Number.isInteger(activeUsers) || activeUsers <= 0) {
return null;
}
return Math.round((revenueCents / activeUsers) * 100) / 100;
}
export function formatFunnel(result: FunnelResult): string {
if (!result.ok) {
return `funnel error=${result.code} detail=${result.detail}`;
}
const parts = result.steps.map((step) => {
const prev = step.fromPrevPct === null ? "n/a" : `${step.fromPrevPct}%`;
return `${step.step} users=${step.subjects} start=${step.fromStartPct}% prev=${prev}`;
});
return `funnel window=${result.windowMs}ms subjects=${result.subjects} ${parts.join(" ")}`;
}
function rate(part: number, whole: number): number {
if (whole === 0) {
return 0;
}
return Math.round((part / whole) * 10000) / 100;
}
代码里有几个刻意的设计。
第一,computeFunnel 按主体分组后,对每个主体做一次有序扫描。每一步只取「上一步之后、窗口之内」的最早事件,取到就前进,取不到就停止。这样既保证顺序,也保证窗口从第一步起算。
第二,同一步的重复事件不会增加计数。用户在页面里反复曝光同一张卡片,只算一次曝光;否则漏斗会被人为放大,而且放大倍数取决于 UI 的刷新频率。
第三,funnelFromCounts 面向已经聚合好的报表。它要求数量非负、整数且单调不增,任何一步比上一步多都会直接报错。这个检查能拦住「把不同口径的分子分母拼在一张表里」的低级错误。
第四,paidConversion 与 retentionRate 都把分母校验写进函数。活跃用户必须为正,付费用户不能超过活跃用户,留存人数不能超过队列人数;非法输入返回错误码,而不是静默算出负数或超过 100% 的比率。
第五,CASE 常量只放事实表里的聚合值,测试用它复现 42 万笔订单的漏斗,而不是在代码里另造一套业务数字。代码算出的 418,530 与 413,508,分别对应 0.35% 掉单和 1.2% 退款。
双案例:高频小额与低频大额
| 维度 | 消消 | 方舟 | 分析含义 |
|---|---|---|---|
| 月订单 | 42 万笔 | 15 万笔 | 消消的漏斗样本更密 |
| 付费率 | 2.8% | 6.5% | 方舟的付费决策更集中 |
| ARPPU | ¥68 | ¥420 | 方舟单笔价值更高 |
| 单付费用户订单 | 4.69 | 2.31 | 消消靠复购,方舟靠单笔 |
| 掉单率 | 0.35% | 0.20% | 方舟链路更稳,但单笔损失更大 |
| 退款率 | 1.2% | 0.8% | 消消的小额退款更频繁 |
消消的漏斗应该按「首次付费 → 二次付费 → 三次付费」拆开看。它的付费率不高,但付费用户的订单密度高,任何一次首付体验的损耗都会影响后续复购。方舟的漏斗则应该按「大额商品曝光 → 支付方式选择 → 支付成功」拆开看,因为它的订单少、单笔大,一次失败的代价远高于消消。
同一个漏斗函数、同一套窗口语义,两个产品的步骤定义不同。这不是重复建设,而是承认业务链路不同。共用的部分越靠下越好:计算逻辑共用,步骤配置分开。
取舍:窗口越严,结论越准,样本越少
严格窗口能排除跨活动、跨版本的混杂转化,让结论更接近因果;代价是样本变少,长周期用户的转化被截断。
以 7 天窗口为例,一个用户在第八天完成支付,会被算作未转化。如果这个用户本来就会付费,严格窗口会低估转化率;如果他是被另一个活动拉回来的,宽松窗口又会高估当前活动的效果。没有一种窗口能同时满足两种解释。
案例里的选择是「主口径严格、辅助口径宽松」:
- 主报表用 7 天窗口,保证不同批次之间可比。
- 活动复盘额外提供 30 天窗口,用来看长尾转化,但不作为考核口径。
- 两个口径的差异单独列一张表,超过阈值时触发解释,而不是直接替换主口径。
代价是维护两套查询、两套解释。收益是当有人质疑「为什么这个数与渠道后台不一样」时,团队能拿出两个口径的差异,而不是互相指责。
小结
- 漏斗是查询契约,必须固定步骤、主体、顺序、窗口四要素。
- 用户漏斗与订单漏斗分开算,混用会产生超过 100% 的转化率。
- 付费率、ARPPU、ARPU 的分母不同,报表必须写明分母,不能靠默契。
- 留存看队列与观察窗,回流看沉默用户;DAU/MAU 是黏性指标,不是留存率。
- 窗口越严格结论越干净但样本越少,主口径与辅助口径要同时保留并解释差异。
阅读导航:上一节:10.1 商业化埋点体系与事件规范 · 下一节:10.3 数据质量:丢失、重复与口径漂移 。
常见错误与边界
- 上下半段换主体。上半段按用户、下半段按订单,转化率会超过 100%;主体必须全程一致。
- 不要求步骤顺序。先付款后曝光也被算作转化,活动效果被系统性高估;顺序必须写进查询。
- 窗口定义不一致。运营按 7 天、财务按 30 天,两个团队永远对不上;窗口要作为口径的一部分发布。
- 重复曝光重复计数。同一步被 UI 刷新放大,漏斗形状取决于刷新频率;每步每个主体只记一次。
- 用 DAU 做月度付费率分母。分子按月累计、分母按日,付费率会虚高数倍;分母必须与分子同周期。
- 把 DAU/MAU 当留存率。它衡量整体黏性,不追踪同一批用户;队列留存必须按首次活跃日分组。
- 留存不定义回访行为。只统计启动会高估留存,尤其是每日签到类产品;回访应绑定核心行为。
- 回流率分母用发送量。得到的是推送效率,不是召回能力;回流率应以触达的沉默用户为分母。
- 聚合数量不校验单调性。把不同口径的数字拼进一张表,会得到逐级放大的假漏斗;聚合输入必须校验。
- 只保留一个窗口口径。严格窗口低估长尾,宽松窗口高估活动效果;主口径与辅助口径应同时存在并解释差异。
- 订单漏斗用客户端埋点做事实源。埋点会丢会重,订单系统才是结算事实;埋点只用于解释损耗位置。
- 把不同品类的漏斗绝对值直接比较。消消与方舟的订单密度、客单价都不同,应比较同品类、同口径的斜率。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。