《移动游戏产品的架构·商业化与发行工程》10.2 漏斗、留存与付费转化

漏斗图谁都会画,难的是让两个团队问出同一个数。本节把步骤、主体、顺序、窗口四要素写成查询契约,用《潮汐消消》42 万笔月订单演示从下单、渠道确认、发货到结算的完整漏斗,并区分付费率、ARPPU、留存与回流四种口径,附一个可运行的漏斗计算器。

漏斗不是一个图,是一份查询契约

同一款游戏,运营说付费率是 2.8%,财务说是 2.6%,渠道后台又给出第三个数字。差异通常不在数据本身,而在四个人对「付费用户」「活跃」「成功支付」的定义不同。漏斗的价值不是画出一张逐渐收窄的图,而是把定义固定下来,让不同团队查同一个数。

一份可执行的漏斗契约至少要说清四件事:

  • 步骤是什么,顺序是什么。
  • 主体是谁,是用户、订单还是设备。
  • 每一步允许多长时间,从哪一步开始计时。
  • 同一主体重复触发同一步时,算一次还是多次。

这四件事没有写清,漏斗就只是一张好看的图。写清之后,它才是一个可以被测试、被复现、被质疑的查询。

四要素缺一不可

要素必须回答常见错误
步骤哪几步,是否允许跳过把「浏览」和「加购」合成一步,之后无法定位流失
主体用户、订单、会话还是设备上半段按用户、下半段按订单,转化率超过 100%
顺序严格先后,还是只要求都发生不要求顺序,先付款后浏览也算转化
窗口从第一步起算,还是每步之间单独计时窗口定义不一致,两个团队的结果永远对不上

顺序与窗口是最容易被忽略的两项。严格顺序会过滤掉「先点广告再回首页再进入商店」的真实路径,导致转化率偏低;不要求顺序又会把「先付款、后曝光」的异常数据算成转化,导致转化率偏高。没有绝对正确的选择,只有写清楚的选择。

窗口同理。从第一步起算 7 天,和每一步之间 7 天,是两个完全不同的口径。前者适合衡量一次活动带来的完整转化,后者适合衡量每一步的产品体验。两者都能用,但不能混用。

付费漏斗:从下单到结算

以《潮汐消消》为例,月订单 42 万笔,掉单率 0.35%,退款率 1.2%。把订单作为主体,可以得到一条不依赖用户行为的交易漏斗:

步骤消消方舟说明
order_created420,000150,000客户端发起下单
channel_confirmed420,000150,000渠道回调确认
granted418,530149,700发货成功,扣除掉单
settled_7d413,508148,5027 日内未退款
下单到结算98.45%99.00%两端损耗合并

这条漏斗的每一步都对应一个可核对的系统状态:下单有订单号,确认有渠道流水号,发货有资产流水,结算有退款单。它不依赖客户端埋点是否完整,因此适合财务与客服使用。客户端埋点在这条链路里的作用,是解释「哪一步丢的」,而不是替代订单系统成为事实源。

把交易漏斗和用户漏斗混在一起,是付费分析最常见的错误。订单可以一个人下很多笔,用户不行;一旦上半段按用户、下半段按订单,就会出现转化率超过 100% 的荒谬结果。正确做法是分开算:用户漏斗回答「多少人愿意付费」,订单漏斗回答「付了之后链路是否健康」。

分母决定结论

付费率、ARPPU、ARPU 这三个指标经常被混用,因为它们的分母不同:

指标公式消消方舟
付费率付费用户 / 活跃用户2.8%6.5%
ARPPU内购流水 / 付费用户¥68¥420
ARPU总流水 / 活跃用户≈¥3.1≈¥28.2
单付费用户订单订单数 / 付费用户4.692.31

消消月活 320 万、付费用户 8.96 万,付费率 2.8%;方舟月活 100 万、付费用户 6.5 万,付费率 6.5%。方舟的付费率是消消的两倍多,但它的 ARPPU 是消消的六倍,两个数字共同解释了为什么方舟月流水 2,820 万元、消消 987 万元。

分母选错,结论会完全反过来。用 DAU 做分母,付费率会显得很高,因为分子是按月累计的付费用户;用新增用户做分母,付费率又会显得很低,因为新增用户还没有经历完整的付费决策周期。行业里说「付费率」时,默认分母通常是 MAU,但必须在报表里写出来,而不是靠默契。

单付费用户订单数也值得单独看。消消是 4.69,方舟是 2.31,说明消消靠高频小额,方舟靠低频大额。这个差异会直接影响后面所有分析:消消的漏斗要关注「第一次付费后如何复购」,方舟的漏斗要关注「第一次大额付费前的信任建立」。

留存:队列、观察窗与口径

留存不是「今天有多少人回来」,而是「某一批人在第 N 天还回来多少」。三个要素必须同时固定:

  1. 队列:按首次活跃日、首次付费日还是首次安装日分组。三者回答的问题不同,不能互换。
  2. 观察窗:D1、D7、D30 是自然日还是 24 小时滚动窗口。跨时区产品尤其容易在这里出错。
  3. 回访定义:启动算回访,还是必须完成一次核心行为。启动口径会显著高估留存。

案例里两个产品的 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.692.31消消靠复购,方舟靠单笔
掉单率0.35%0.20%方舟链路更稳,但单笔损失更大
退款率1.2%0.8%消消的小额退款更频繁

消消的漏斗应该按「首次付费 → 二次付费 → 三次付费」拆开看。它的付费率不高,但付费用户的订单密度高,任何一次首付体验的损耗都会影响后续复购。方舟的漏斗则应该按「大额商品曝光 → 支付方式选择 → 支付成功」拆开看,因为它的订单少、单笔大,一次失败的代价远高于消消。

同一个漏斗函数、同一套窗口语义,两个产品的步骤定义不同。这不是重复建设,而是承认业务链路不同。共用的部分越靠下越好:计算逻辑共用,步骤配置分开。

取舍:窗口越严,结论越准,样本越少

严格窗口能排除跨活动、跨版本的混杂转化,让结论更接近因果;代价是样本变少,长周期用户的转化被截断。

以 7 天窗口为例,一个用户在第八天完成支付,会被算作未转化。如果这个用户本来就会付费,严格窗口会低估转化率;如果他是被另一个活动拉回来的,宽松窗口又会高估当前活动的效果。没有一种窗口能同时满足两种解释。

案例里的选择是「主口径严格、辅助口径宽松」:

  • 主报表用 7 天窗口,保证不同批次之间可比。
  • 活动复盘额外提供 30 天窗口,用来看长尾转化,但不作为考核口径。
  • 两个口径的差异单独列一张表,超过阈值时触发解释,而不是直接替换主口径。

代价是维护两套查询、两套解释。收益是当有人质疑「为什么这个数与渠道后台不一样」时,团队能拿出两个口径的差异,而不是互相指责。

小结

  • 漏斗是查询契约,必须固定步骤、主体、顺序、窗口四要素。
  • 用户漏斗与订单漏斗分开算,混用会产生超过 100% 的转化率。
  • 付费率、ARPPU、ARPU 的分母不同,报表必须写明分母,不能靠默契。
  • 留存看队列与观察窗,回流看沉默用户;DAU/MAU 是黏性指标,不是留存率。
  • 窗口越严格结论越干净但样本越少,主口径与辅助口径要同时保留并解释差异。

阅读导航:上一节:10.1 商业化埋点体系与事件规范 · 下一节:10.3 数据质量:丢失、重复与口径漂移 。

常见错误与边界

  1. 上下半段换主体。上半段按用户、下半段按订单,转化率会超过 100%;主体必须全程一致。
  2. 不要求步骤顺序。先付款后曝光也被算作转化,活动效果被系统性高估;顺序必须写进查询。
  3. 窗口定义不一致。运营按 7 天、财务按 30 天,两个团队永远对不上;窗口要作为口径的一部分发布。
  4. 重复曝光重复计数。同一步被 UI 刷新放大,漏斗形状取决于刷新频率;每步每个主体只记一次。
  5. 用 DAU 做月度付费率分母。分子按月累计、分母按日,付费率会虚高数倍;分母必须与分子同周期。
  6. 把 DAU/MAU 当留存率。它衡量整体黏性,不追踪同一批用户;队列留存必须按首次活跃日分组。
  7. 留存不定义回访行为。只统计启动会高估留存,尤其是每日签到类产品;回访应绑定核心行为。
  8. 回流率分母用发送量。得到的是推送效率,不是召回能力;回流率应以触达的沉默用户为分母。
  9. 聚合数量不校验单调性。把不同口径的数字拼进一张表,会得到逐级放大的假漏斗;聚合输入必须校验。
  10. 只保留一个窗口口径。严格窗口低估长尾,宽松窗口高估活动效果;主口径与辅助口径应同时存在并解释差异。
  11. 订单漏斗用客户端埋点做事实源。埋点会丢会重,订单系统才是结算事实;埋点只用于解释损耗位置。
  12. 把不同品类的漏斗绝对值直接比较。消消与方舟的订单密度、客单价都不同,应比较同品类、同口径的斜率。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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