《独立开发者的商业指南·收入与财务工程》2.3 单位经济失效的五个信号

四个指标都好看,单位经济仍可能失效。本节把毛利率、LTV/CAC、回本期、月流失与 GRR 收成五个可判定信号,用 LedgerLite 的 84.2% 毛利、30.9 倍 LTV/CAC、2.2 个月回本和 83% GRR 做完整评分,并给出结构化判定代码 signals.ts。

以下情境为教学假设,人物与数字不代表经核实的真实案例。

上一节把 LTV 拆成三种算法,说明同一个客户可以算出不同的生命周期价值。这一节换一个方向:不再追求「算得更准」,而是问「什么时候该停下来」。单位经济失效很少以「亏损」的形式突然出现,它通常先表现为几个指标同时变差,而报表上的收入还在增长。

五个信号是:毛利率、LTV/CAC、CAC 回本期、月流失率、GRR。前四个来自 2.1 节的四个数,第五个来自 2.2 节的队列留存。它们不是五个孤立的 KPI,而是一条链上的五个检查点:毛利决定每个客户能贡献多少,LTV/CAC 决定获客值不值,回本期决定现金多久能回来,流失率决定 LTV 能维持多久,GRR 决定存量收入漏多少。

LedgerLite 是主案例。它在四个信号上是绿灯,却在 GRR 上亮黄灯。这个组合比「五项全红」更有教学价值:它说明单位经济失效不一定是「数字很差」,也可能是「数字很好,但结构在漏」。

从指标到信号:阈值与四种状态

指标和信号的区别在于「可判定」。ARPA 77 美元是一个指标,但它不告诉你该不该继续投入;LTV/CAC 30.9 是一个指标,配上「低于 3.0 就观察」才变成信号。阈值不是宇宙常数,而是把连续数字切成可行动区间的约定。

本节采用一组教学阈值。它们来自常见的订阅业务经验,不是案例事实,也不是行业定律;换一个业务阶段,阈值可以调整,但必须提前写下来,不能等结果出来再改。

信号计算绿灯黄灯红灯
毛利率毛利 ÷ 收入≥ 75.0%60.0%–74.9%< 60.0%
LTV/CACLTV ÷ CAC≥ 3.0x1.0x–2.9x< 1.0x
CAC 回本期CAC ÷ 月毛利≤ 12 个月12.1–18 个月> 18 个月
月流失率当月流失收入 ÷ 期初 MRR≤ 3.0%3.1%–5.0%> 5.0%
GRR存量客户续费与收缩后的收入 ÷ 期初收入≥ 85.0%80.0%–84.9%< 80.0%

每个信号有四种状态,而不是三种:绿灯、黄灯、红灯,以及「未知」。未知不是绿灯。缺少 CAC 时,LTV/CAC 和回本期都无法判定;买断制没有月流失时,流失信号不适用。把「不适用」写成「通过」,是单位经济评分里最常见的自欺。

判定顺序也重要:只要有一个红灯,整体就是红灯;没有红灯但有黄灯,整体是黄灯;没有红黄灯但有未知,整体是「数据不足」;五项全绿才是健康。这个顺序承认了一件事:一个红灯足以否定一组单位经济,而一个未知不足以肯定它。

五个信号分别在防什么

五个信号对应五种不同的失效方式。把它们分开看,才能知道该修哪里。

毛利率防的是「卖得越多,变动成本越高」。 LedgerLite 的毛利率 84.2%,变动成本 38,000 美元,其中云 18,000、支付 12,000、支持 8,000。毛利率低于 75% 时,每 100 美元收入留下的毛利不足以覆盖获客和运营;低于 60% 时,规模扩张会先耗尽现金。对一人公司,毛利率还有一个隐含作用:它决定你能不能靠自己的时间同时做产品、支持和销售。

LTV/CAC 防的是「获客比客户终身价值还贵」。 LedgerLite 的 LTV 4,322 美元、CAC 140 美元,比值 30.9,远高于 3.0。低于 3.0 时,获客投入的回收空间变薄,任何流失率上升或 CAC 上涨都可能把比值压到 1 附近;低于 1.0 时,每获取一个客户都在毁灭价值。这个信号依赖 LTV 的口径,所以必须和 2.2 节的算法说明一起看。

回本期防的是「账上先没钱」。 LedgerLite 的回本期 2.2 个月,意味着获客成本在第三个月初就由毛利收回。超过 12 个月,增长会先消耗现金;超过 18 个月,即使 LTV/CAC 看起来不错,现金流也可能撑不到回收那天。回本期是 LTV/CAC 的「时间版本」:一个说总量,一个说节奏。

月流失率防的是「LTV 的地基在塌」。 LedgerLite 的月流失 1.5%,年化毛留存 (1 − 1.5%)¹² = 83.4%,与案例的 GRR 83% 基本一致。月流失超过 3.0% 时,年化毛留存降到 69.4%,LTV 会缩短三分之一以上;超过 5.0% 时,年化毛留存只剩 54.0%,获客投入很难回收。流失率的可怕之处在于它同时影响 LTV 和收入增长,而报表上往往先看到「新签在涨」。

GRR 防的是「扩张掩盖了漏损」。 LedgerLite 的 GRR 83%,NRR 102%。这两个数放在一起,说明存量客户每年漏掉 17 个百分点,扩张又补回 19 个百分点,净留存 +2%。GRR 低于 85% 时,存量收入的漏损已经偏高;低于 80% 时,即使扩张不错,NRR 也可能被拖到 100% 以下。NRR 是结果,GRR 是地基;只看 NRR,会把「扩张很猛」误读成「留存很好」。

五个信号之间有重合,但重合是故意的。LTV/CAC 和回本期都依赖毛利与 CAC;月流失和 GRR 都描述留存。重合意味着同一个问题会在多个信号上显形,也意味着不能只挑一个好看的信号来汇报。

LedgerLite:四个绿灯与一个黄灯

把 LedgerLite 的数字代入五个信号:

信号数值状态判据
毛利率84.2%绿灯≥ 75.0%
LTV/CAC30.9x绿灯≥ 3.0x
CAC 回本期2.2 个月绿灯≤ 12 个月
月流失率1.5%绿灯≤ 3.0%
GRR83.0%黄灯80.0%–84.9%

四项绿灯说明 LedgerLite 的单位经济在「获客—回本—留存」这条主链上是健康的:每个客户贡献 64.83 美元月毛利,140 美元 CAC 在 2.2 个月收回,LTV 是 CAC 的 30.9 倍,月流失只有 1.5%。如果只看这四个数,结论会是「继续投入」。

GRR 83% 把这个结论从「健康」改成「观察」。83% 的毛留存意味着每年有 17% 的存量收入漏掉;NRR 102% 之所以还是正的,是因为扩张补回了 19 个百分点。扩张不可能永远补得上:如果 GRR 从 83% 降到 80%,同样 19 个百分点的扩张只能得到 99% 的 NRR;如果 GRR 降到 79%,NRR 变成 98%,存量收入开始净萎缩。

这就是「四个绿灯一个黄灯」的含义:单位经济没有失效,但它的健康依赖扩张持续发生。扩张来自涨价、加购、升级,这些动作一旦放缓,GRR 的 17% 漏损就会直接暴露在 NRR 上。收入的「质量」问题会在第 4 章展开,见 收入质量 ;本节只负责把它标成黄灯。

黄灯不等于停下。它要求的动作是「先查清楚 17% 的漏损从哪里来」:是客户倒闭、是降级、还是某一类客户天然短命。查清楚之前,继续按 30.9 的 LTV/CAC 扩张,等于在用一个可能被高估的 LTV 做决策。

Tidecast 与 Nord Studio:缺失的信号不是绿灯

Tidecast 的评分是「两绿一黄两未知」。毛利率用净得率 72.0%,落在 60.0%–74.9% 的黄灯区;LTV/CAC 是 14.39 ÷ 1.25 = 11.5,绿灯;回本期在成交时完成,绿灯;月流失和 GRR 不适用,标为未知。它的 72.0% 已经扣掉退款 4%、Steam 分成 30% 和官网支付费 5%,但没有扣营销、服务器和工具;28 个百分点的「漏损」主要来自平台分成,这是买断制的结构成本,不是管理失误。买断制没有订阅存量,用月流失和 GRR 去判它没有意义;硬填一个「0% 流失」会让评分虚高,正确做法是保留未知。

Nord Studio 的评分是「两绿三未知」,整体为数据不足。产品线毛利率用混合毛利率 84.4%,绿灯;产品月流失 3.0%,恰好等于阈值,绿灯;但案例没有单列营销费用,CAC 不可复算,LTV/CAC 和回本期都是未知;GRR 也没有单列,同样是未知。它的服务线年续约率 70%,折算成月流失是 1 − 70%^(1÷12) = 2.93%,也在绿灯区;但服务收入占 72.7%,产品只占 27.3%,两条线的客户和成本结构不同,不能用一个混合评分代替两条线分别评分。CAC 缺失不是小事,它让「获客值不值」这个核心问题无法回答;获客如何归因见 获客归因与复盘 。

两个案例的共同教训是:未知必须保留为未知。Tidecast 如果把流失填成 0%,会得到「五项全绿」的假象;Nord Studio 如果拍一个 CAC,会得到一个看起来精确、其实没有依据的 LTV/CAC。评分的价值不在于给出一个漂亮的总分,而在于告诉你「哪一项还没法判断」。

把五个信号写成代码

下面的 signals.ts 把五个阈值固化成函数。每个 check 返回一个 Signal,包含 id、标签、状态、数值、单位、阈值和说明;evaluateSignals 汇总成 SignalReport,给出四种状态的数量和最终判定。数值缺失时返回 unknown,不参与绿灯计数。

// file: src/unit/signals.ts
// tier: L1
// verify: node --test test/unit/signals.test.ts

import { type Cents } from "../core/money.ts";
import { type Bps } from "../core/rate.ts";

export type SignalId =
  | "gross-margin"
  | "ltv-cac"
  | "payback"
  | "churn"
  | "grr";

export type SignalStatus = "ok" | "watch" | "fail" | "unknown";

export type Signal = {
  id: SignalId;
  label: string;
  status: SignalStatus;
  value: number | null;
  unit: "bps" | "months" | "ratio";
  threshold: string;
  note: string;
};

export type SignalInput = {
  grossMarginBps?: Bps;
  ltvCents?: Cents;
  cacCents?: Cents;
  paybackMonths?: number;
  monthlyChurnBps?: Bps;
  grrBps?: Bps;
};

export type SignalVerdict = "healthy" | "watch" | "fail" | "incomplete";

export type SignalReport = {
  signals: Signal[];
  counts: { ok: number; watch: number; fail: number; unknown: number };
  verdict: SignalVerdict;
};

function missing(
  id: SignalId,
  label: string,
  unit: Signal["unit"],
  threshold: string,
): Signal {
  return {
    id,
    label,
    status: "unknown",
    value: null,
    unit,
    threshold,
    note: "输入不足,无法判定",
  };
}

export function checkGrossMargin(grossMarginBps?: Bps): Signal {
  const threshold = "ok >= 75.0%;watch 60.0%–74.9%;fail < 60.0%";
  if (
    grossMarginBps === undefined ||
    !Number.isInteger(grossMarginBps) ||
    grossMarginBps < 0 ||
    grossMarginBps > 10000
  ) {
    return missing("gross-margin", "毛利率", "bps", threshold);
  }
  let status: SignalStatus = "ok";
  if (grossMarginBps < 6000) {
    status = "fail";
  } else if (grossMarginBps < 7500) {
    status = "watch";
  }
  return {
    id: "gross-margin",
    label: "毛利率",
    status,
    value: grossMarginBps,
    unit: "bps",
    threshold,
    note:
      status === "ok"
        ? "毛利足以覆盖获客与运营"
        : "毛利偏薄,先检查变动成本口径",
  };
}

export function checkLtvCac(
  ltvCents?: Cents,
  cacCents?: Cents,
): Signal {
  const threshold = "ok >= 3.0x;watch 1.0x–2.9x;fail < 1.0x";
  if (
    ltvCents === undefined ||
    cacCents === undefined ||
    !Number.isInteger(ltvCents) ||
    ltvCents < 0 ||
    !Number.isInteger(cacCents) ||
    cacCents <= 0
  ) {
    return missing("ltv-cac", "LTV / CAC", "ratio", threshold);
  }
  const value = Math.round((ltvCents / cacCents) * 100) / 100;
  let status: SignalStatus = "ok";
  if (value < 1) {
    status = "fail";
  } else if (value < 3) {
    status = "watch";
  }
  return {
    id: "ltv-cac",
    label: "LTV / CAC",
    status,
    value,
    unit: "ratio",
    threshold,
    note:
      status === "ok"
        ? "获客投入有足够的毛利回收空间"
        : "获客回报偏薄,需检查 LTV 口径",
  };
}

export function checkPayback(payback?: number): Signal {
  const threshold = "ok <= 12 个月;watch 12.1–18 个月;fail > 18 个月";
  if (
    payback === undefined ||
    !Number.isFinite(payback) ||
    payback < 0
  ) {
    return missing("payback", "CAC 回本期", "months", threshold);
  }
  let status: SignalStatus = "ok";
  if (payback > 18) {
    status = "fail";
  } else if (payback > 12) {
    status = "watch";
  }
  return {
    id: "payback",
    label: "CAC 回本期",
    status,
    value: payback,
    unit: "months",
    threshold,
    note:
      status === "ok"
        ? "获客成本可在一年内由毛利收回"
        : "回本慢于一年,增长会先消耗现金",
  };
}

export function checkChurn(monthlyChurnBps?: Bps): Signal {
  const threshold = "ok <= 3.0%/月;watch 3.1%–5.0%/月;fail > 5.0%/月";
  if (
    monthlyChurnBps === undefined ||
    !Number.isInteger(monthlyChurnBps) ||
    monthlyChurnBps < 0 ||
    monthlyChurnBps > 10000
  ) {
    return missing("churn", "月流失率", "bps", threshold);
  }
  let status: SignalStatus = "ok";
  if (monthlyChurnBps > 500) {
    status = "fail";
  } else if (monthlyChurnBps > 300) {
    status = "watch";
  }
  return {
    id: "churn",
    label: "月流失率",
    status,
    value: monthlyChurnBps,
    unit: "bps",
    threshold,
    note:
      status === "ok"
        ? "流失速度在可管理范围"
        : "流失偏快,LTV 会被显著压缩",
  };
}

export function checkGrr(grrBps?: Bps): Signal {
  const threshold = "ok >= 85.0%;watch 80.0%–84.9%;fail < 80.0%";
  if (
    grrBps === undefined ||
    !Number.isInteger(grrBps) ||
    grrBps < 0 ||
    grrBps > 20000
  ) {
    return missing("grr", "毛利留存率 GRR", "bps", threshold);
  }
  let status: SignalStatus = "ok";
  if (grrBps < 8000) {
    status = "fail";
  } else if (grrBps < 8500) {
    status = "watch";
  }
  return {
    id: "grr",
    label: "毛利留存率 GRR",
    status,
    value: grrBps,
    unit: "bps",
    threshold,
    note:
      status === "ok"
        ? "存量收入漏损可控"
        : "存量收入漏损偏高,需看扩张是否掩盖了流失",
  };
}

/** Run all five checks and return a structured verdict. */
export function evaluateSignals(input: SignalInput): SignalReport {
  const signals = [
    checkGrossMargin(input.grossMarginBps),
    checkLtvCac(input.ltvCents, input.cacCents),
    checkPayback(input.paybackMonths),
    checkChurn(input.monthlyChurnBps),
    checkGrr(input.grrBps),
  ];
  const counts = { ok: 0, watch: 0, fail: 0, unknown: 0 };
  for (const signal of signals) {
    counts[signal.status] += 1;
  }
  let verdict: SignalVerdict = "healthy";
  if (counts.fail > 0) {
    verdict = "fail";
  } else if (counts.watch > 0) {
    verdict = "watch";
  } else if (counts.unknown > 0) {
    verdict = "incomplete";
  }
  return { signals, counts, verdict };
}

代码有三个刻意的设计。

第一,Signal.value 允许 null,status 允许 unknown。这不是类型系统的装饰,而是评分逻辑的一部分:缺 CAC 时,LTV/CAC 不是 0,也不是「通过」,而是「还不知道」。报表里保留 null,比填一个默认值更诚实。

第二,阈值写在每个 check 函数的 threshold 字段里,和判定逻辑放在一起。改阈值时只需要改一处,报告里的说明会自动跟着变;不会出现「代码按 3.0 判、文档写 2.5」的漂移。

第三,整体判定用「红灯优先、黄灯其次、未知再次」的顺序。counts.unknown 只在前两者都为零时才影响结论;这意味着「一绿四未知」不会被误判成健康,而「四绿一未知」也不会被误判成失败。测试覆盖了 LedgerLite 的 4 绿 1 黄、Tidecast 的 2 绿 1 黄 2 未知、Nord Studio 的 2 绿 3 未知,以及五项全红和全部未知的边界。

小结

  • 指标是数字,信号是带阈值的判定;五个信号分别防毛利过薄、获客过贵、回本过慢、流失过快、存量漏损。
  • 本节阈值:毛利率 75%/60%,LTV/CAC 3.0x/1.0x,回本期 12/18 个月,月流失 3.0%/5.0%,GRR 85%/80%;阈值是教学约定,不是行业定律。
  • 状态有四种:绿灯、黄灯、红灯、未知;未知不是绿灯,缺失数据不能填成通过。
  • LedgerLite 是四绿一黄:84.2% 毛利、30.9x LTV/CAC、2.2 个月回本、1.5% 月流失都是绿灯;GRR 83% 是黄灯,因为 17% 的存量漏损靠 19 个百分点的扩张补回。
  • Tidecast 是两绿一黄两未知:72.0% 净得率黄灯,11.5x LTV/CAC 和成交即回本绿灯,月流失与 GRR 不适用。
  • Nord Studio 是两绿三未知:84.4% 毛利和 3.0% 产品月流失绿灯,CAC、回本期、GRR 因缺少营销与留存口径无法判定。
  • 整体判定顺序是红灯 > 黄灯 > 未知 > 健康;一个红灯足以否定一组单位经济,一个未知不足以肯定它。
  • 黄灯的动作是查清漏损来源,不是立刻停下;红灯的动作才是重新设计单位经济或停止扩张。

阅读导航:上一节:2.2 LTV 的三种算法与适用边界 · 下一节:3.1 利润表:收入、成本、费用的口径 。

常见错误与边界

  1. 把未知填成绿灯。 Tidecast 没有月流失,填 0% 会得到五项全绿的假象;正确状态是 unknown。
  2. 只报总分,不报分项。 「三项绿灯」可能掩盖一个红灯;五个信号的用途是定位问题,不是打一个总分。
  3. 阈值事后调整。 看到 GRR 83% 就把黄灯线改成 80%,等于取消信号;阈值必须在看结果之前写死。
  4. 把 GRR 和 NRR 混为一谈。 LedgerLite 的 GRR 83%、NRR 102%;只看 NRR 会以为留存很好,实际存量每年漏 17%。
  5. 用收入毛利率替代毛利。 毛利率的分母是收入、分子是毛利;用收入算回本期会低估回本时间。
  6. 在买断制里套订阅信号。 Tidecast 没有月流失和 GRR,硬套会得到无意义的判定;它的核心信号是净得率和单位获客成本。
  7. 在混合业务里用一个混合评分。 Nord Studio 的服务线年续约 70%、产品线月流失 3.0%,两条线要分开评分,不能平均。
  8. 把回本期和 LTV/CAC 当成同一个信号。 一个说总量,一个说节奏;LTV/CAC 高但回本期长的业务,仍可能被现金流拖死。
  9. 忽略流失率的年化效应。 月流失 3.0% 对应年化毛留存 69.4%,不是 64%;月流失 5.0% 对应 54.0%,不是 60%。
  10. 把黄灯当成「没问题」。 黄灯是「继续观察并查原因」,不是「可以忽略」;LedgerLite 的 83% GRR 就是靠扩张撑住的黄灯。

五个信号把单位经济从「四个数」变成「一组判断」。它们不能替你做战略决定,但能告诉你什么时候该停下来查一查。下一章会把单位经济接进利润表,看 84.2% 的毛利率如何经过运营费用、创始人薪酬和所得税,变成 130,240 美元的 SDE 和 58,299 美元的净利润;届时这五个信号会变成年度报表上的具体科目。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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