以下情境为教学假设,人物与数字不代表经核实的真实案例。
NRR 和 GRR 是同一批客户在两个口径下的留存:GRR 只看续约、收缩和流失,NRR 再加上扩张。它们都不是「公司收入增长率」:新签客户被排除在外,所以 NRR 102% 的公司完全可能因为新签不足而收入下降;NRR 99% 的公司也可能因为新签强劲而增长 20%。
LedgerLite 同时给了两组数:月度 MRR 桥和年度 NRR 102%、GRR 83%。这两组数不是同一个东西。月度桥是 19,280 到 20,020 的当月快照,算出来 NRR 99.79%、GRR 98.18%;年度数是 12 个月的队列结果,NRR 102%、GRR 83%。把月度桥乘以 12 或直接年化,会得到 97.51% 和 80.22%,与年度数都不相等。
第 4 章的 4.2 节把留存作为收入质量的 30% 分项,用的是年化留存(接近 GRR),而不是 NRR。本节把留存拆成四个动作、两个比率和一条时间轴;下一章 8.1 会把同样的桥接到 13 周现金流上。
三个案例的适用性差别很大:
| 案例 | NRR 是否适用 | 可用的留存口径 | 不能算的数 |
|---|---|---|---|
| LedgerLite | 是 | NRR 102%、GRR 83% | 客户级扩张明细 |
| Tidecast | 否 | 退款率 4%、渠道集中 | NRR、GRR、复购率 |
| Nord Studio | 部分 | 咨询续约 70%、产品月流失 3.0% | 两条线的扩张收入 |
Tidecast 的「不适用」不是「留存差」,而是「没有可续约的经常性收入基数」。这个区别决定了你不能用 NRR 给它排名。
四个动作:新签、扩张、收缩、流失
留存桥把期初收入到期末收入的变化拆成四个动作:
- 新签:本期新客户带来的收入。它改变期末余额,但不进入 NRR/GRR。
- 扩张:现有客户加购、升级、增座带来的收入增加。它进入 NRR,不进入 GRR。
- 收缩:现有客户降级、减座带来的收入减少。它同时进入 NRR 和 GRR。
- 流失:现有客户完全离开带来的收入减少。它同时进入 NRR 和 GRR。
LedgerLite 的当月 MRR 桥如下:
| 项 | 金额 | 是否计入 NRR/GRR |
|---|---|---|
| 期初 MRR | $19,280 | 基数 |
| 新签 | +$780(10 家 × $78) | 否 |
| 扩张 | +$310 | 是 |
| 收缩 | −$60 | 是 |
| 流失 | −$290(1.5%) | 是 |
| 期末 MRR | $20,020 | 结果 |
先检查桥是否闭合:19,280 + 780 + 310 − 60 − 290 = 20,020。再算两个比率:
NRR = (19,280 + 310 − 60 − 290) ÷ 19,280 = 99.79%
GRR = (19,280 − 60 − 290) ÷ 19,280 = 98.18%
存量客户的净变化是 310 − 60 − 290 = −40 美元;期末的 20,020 美元里,有 780 美元来自新签,−40 美元来自存量。整张桥的增长是 20,020 ÷ 19,280 − 1 = 3.84%,但这个 3.84% 是「总收入增长」,不是 NRR。NRR 只回答「同一批客户留下了多少」,所以它是 99.79%,不是 103.84%。
新签是否计入,是 NRR 和「收入增长率」的分界线。很多报表把两者混在一起,结果是一个靠新签撑起来的公司看起来「留存很好」,实际上存量在漏水。LedgerLite 的月度桥里,新签 780 美元把存量的 −40 美元盖住了;如果新签降到 0,期末会是 19,240 美元,总收入下降 0.21%,但 NRR 仍然是 99.79%。
NRR 与 GRR:一个看净,一个看漏
两个比率的公式只差一项:
GRR = (期初收入 − 收缩 − 流失) ÷ 期初收入
NRR = (期初收入 + 扩张 − 收缩 − 流失) ÷ 期初收入
为了看清年度口径,把 LedgerLite 的年度数归一化到 100,000 美元期初收入。案例只给了年度 GRR 83% 和 NRR 102%,没有给收缩与流失的拆分;下表的 −7,000 与 −10,000 是教学拆分,合计 −17,000,与 83% 的 GRR 一致:
| 项 | 金额 | 占期初 |
|---|---|---|
| 期初存量收入 | $100,000 | 100.0% |
| 扩张 | +$19,000 | +19.0% |
| 收缩 | −$7,000 | −7.0% |
| 流失 | −$10,000 | −10.0% |
| 期末存量收入 | $102,000 | 102.0% |
| GRR 口径(不含扩张) | $83,000 | 83.0% |
这张表说明 NRR 102% 和 GRR 83% 可以同时成立:扩张补上了 17 个百分点的漏损,还多出 2 个百分点。NRR 回答「净留下多少」,GRR 回答「不靠扩张能留下多少」。前者看增长,后者看底盘。
两个指标要一起看,因为它们的组合对应不同的动作:
| NRR | GRR | 结构 | 优先动作 |
|---|---|---|---|
| ≥ 110% | ≥ 90% | 扩张强、底盘稳 | 继续加扩张,注意不要过度承诺 |
| 100%–110% | 80%–90% | 扩张补漏 | 修流失与收缩,别让扩张掩盖问题 |
| 90%–100% | ≥ 90% | 底盘稳、扩张弱 | 做升级、增购、年付转化 |
| < 90% | < 80% | 双弱 | 先止损,再谈增长 |
LedgerLite 落在「扩张补漏」:NRR 102% 是稳定档,GRR 83% 是中等档。它的扩张 19% 刚好覆盖 17% 的漏损,净留存只多 2 个百分点。如果 GRR 从 83% 掉到 75%,同样的 19% 扩张只能把 NRR 拉到 94%,从「稳定」掉到「漏水」。这就是为什么 NRR 好看时仍要看 GRR:扩张不是无限的,流失是复利的。
反过来,如果 GRR 是 92%、NRR 只有 96%,问题不在流失,而在扩张和收缩。收缩(降级、减座)和流失要分开看:收缩客户的收入还在,只是变小了;流失客户的收入完全消失。两者的补救动作不同,前者靠升级路径,后者靠 流失分诊 。
期间对齐:月度桥不能直接年化
LedgerLite 的月度桥算出来 NRR 99.79%、GRR 98.18%,年度数是 NRR 102%、GRR 83%。把月度数几何年化:
月度 NRR 99.79% → 年化 99.79%^12 = 97.51%
月度 GRR 98.18% → 年化 98.18%^12 = 80.22%
三个口径放在一起:
| 口径 | NRR | GRR | 说明 |
|---|---|---|---|
| 单月桥 | 99.79% | 98.18% | 当月快照,受单月扩张与流失影响 |
| 单月几何年化 | 97.51% | 80.22% | 假设每个月都按同一速度变化 |
| 年度队列 | 102.00% | 83.00% | 事实表的年度结果 |
差异不是算错了,而是「月度桥不是恒定速率」。年度 NRR 102% 包含全年分散发生的扩张事件;单月的 310 美元扩张只是一个月的样本,不能代表 12 个月。年度 GRR 83% 是全年流失的实际结果;单月流失 1.5% 年化后是 80.22%,说明这一年的平均流失比单月略低,或者流失在月份之间不均匀。
最危险的情况是符号翻转:单月 NRR 99.79% 小于 100%,年度 NRR 102% 大于 100%。如果只看单月,会得出「存量在缩」;如果只看年度,会得出「存量在长」。两个结论都基于真实数据,但回答的是不同期间。期间不对齐,讨论就没有意义。
三条规则可以把期间对齐做对:
第一,比较 NRR/GRR 时,必须用同一期间。月度对月度,年度对年度,不能拿单月 NRR 去和年度 GRR 比。
第二,年化用几何平均,不是乘以 12。99.79% × 12 会得到 1197%,明显错误;正确做法是 99.79%^12。
第三,如果扩张是脉冲式的(大单集中在某个月),单月桥和年度队列会差很多;这时要报「期间 + 年度」两个数,并注明扩张的时间分布。
为什么游戏不能套用 SaaS 的 NRR
Tidecast 是买断制,9,600 份销量、标价 19.99 美元、退款率 4%、Steam 占 80%。它没有「期初经常性收入」这个基数:每一份拷贝在售出时确认收入,玩家下个月不会自动再付一次。没有基数,NRR 的分母就是 0,比率没有定义。
把 SaaS 的四个动作硬套到买断制,会得到四个错误类比:
| SaaS 动作 | 买断制的对应现象 | 为什么不能类比 |
|---|---|---|
| 续约 | 玩家继续玩、买 DLC | 不续约不代表收入消失,收入已经确认 |
| 扩张 | 买续作、买 DLC | 这是新交易,不是同一客户加购 |
| 收缩 | 等折扣、买低价区 | 这是价格与渠道差异,不是降级 |
| 流失 | 退款、差评、弃玩 | 退款是收入冲减,不是客户流失 |
如果强行算一个「NRR」,只有两种结果:把基数当成 0,比率无定义;或者把续作购买算成扩张,得到一个没有分母的百分比。两种都没有财务意义。Tidecast 应该看的指标是退款率 4%、渠道集中(Steam 80%)、长尾销量曲线、续作转化率和 DLC 附着率;案例事实表没有复购数据,所以「复购 NRR」不能计算,只能作为待验证假设。
Nord Studio 处在中间:它有经常性收入,但两条线的口径不同。咨询线的续约率是 70%,这是「合同到期是否续签」,接近 GRR,但不包含合同期内的收缩;产品线的月流失是 3.0%,年化 GRR 是 (1 − 3.0%)^12 = 69.38%。按收入占比 72.7% 和 27.3% 混合:
混合 GRR = 72.7% × 70.0% + 27.3% × 69.38%
= 50.89% + 18.94%
= 69.83%
NRR 算不出来,因为案例没有给两条线的扩张收入。如果假设扩张为 0,NRR 就等于 GRR 69.83%;但这是假设,不是事实。咨询线的 70% 是续约率,产品线的 69.38% 是年化留存率,两者含义不同,混合值只能当近似。
| 案例 | NRR | GRR 代理 | 替代指标 |
|---|---|---|---|
| LedgerLite | 102% | 83% | 月度 MRR 桥、扩张与收缩拆分 |
| Tidecast | 不适用 | 不适用 | 退款率、渠道集中、续作转化 |
| Nord Studio | 不可算(缺扩张) | 69.83% 混合 | 咨询续约率、产品月流失 |
把 NRR 写成代码
下面的 nrr.ts 把桥、比率和期间对齐固化成函数:buildRetention 从五段桥计算 NRR/GRR 并校验期末余额;annualizeRetention 和 periodRetention 做几何换算;nrrBand 和 grrBand 给出档位。所有金额用整数美分,比率用基点,非法输入返回 Result 错误。
// file: src/unit/nrr.ts
// tier: L1
// verify: node --test test/unit/nrr.test.ts
import { type Cents, sub } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type RevenueBridge = {
startingCents: Cents;
newCents: Cents;
expansionCents: Cents;
contractionCents: Cents;
churnCents: Cents;
};
export type RetentionMetrics = {
startingCents: Cents;
endingCents: Cents;
newCents: Cents;
expansionCents: Cents;
contractionCents: Cents;
churnCents: Cents;
nrrBps: Bps;
grrBps: Bps;
expansionBps: Bps;
contractionBps: Bps;
churnBps: Bps;
netChangeCents: Cents;
};
export type RetentionBand = "expanding" | "stable" | "leaking" | "critical";
export type GrrBand = "strong" | "moderate" | "weak";
function isCents(value: number): boolean {
return Number.isInteger(value) && value >= 0;
}
/**
* NRR and GRR exclude new customers. The starting cohort can expand,
* contract or churn; new logos are added to the ending balance but
* never enter the retention ratios.
*/
export function buildRetention(
bridge: RevenueBridge,
): Result<RetentionMetrics> {
const amounts: [string, Cents][] = [
["starting", bridge.startingCents],
["new", bridge.newCents],
["expansion", bridge.expansionCents],
["contraction", bridge.contractionCents],
["churn", bridge.churnCents],
];
for (const [label, amount] of amounts) {
if (!isCents(amount)) {
return fail(`${label} must be a non-negative integer number of cents`);
}
}
if (bridge.startingCents <= 0) {
return fail("starting revenue must be positive");
}
if (bridge.contractionCents + bridge.churnCents > bridge.startingCents) {
return fail("contraction plus churn cannot exceed starting revenue");
}
const existingChange = sub(
bridge.expansionCents,
bridge.contractionCents + bridge.churnCents,
);
const ending =
bridge.startingCents + bridge.newCents + existingChange;
if (ending < 0) {
return fail("bridge produces a negative ending balance");
}
const retained = bridge.startingCents - bridge.contractionCents - bridge.churnCents;
return ok({
startingCents: bridge.startingCents,
endingCents: ending,
newCents: bridge.newCents,
expansionCents: bridge.expansionCents,
contractionCents: bridge.contractionCents,
churnCents: bridge.churnCents,
nrrBps: divide(retained + bridge.expansionCents, bridge.startingCents),
grrBps: divide(retained, bridge.startingCents),
expansionBps: divide(bridge.expansionCents, bridge.startingCents),
contractionBps: divide(bridge.contractionCents, bridge.startingCents),
churnBps: divide(bridge.churnCents, bridge.startingCents),
netChangeCents: existingChange,
});
}
/** Geometric annualization: a 99.79% month is 97.51% for the year, not 12 x 99.79%. */
export function annualizeRetention(
periodBps: Bps,
periodMonths: number,
): Result<Bps> {
if (
!Number.isInteger(periodBps) ||
periodBps < 0 ||
periodBps > 100000
) {
return fail("period retention must be an integer between 0 and 100000 bps");
}
if (
!Number.isInteger(periodMonths) ||
periodMonths <= 0 ||
periodMonths > 120
) {
return fail("period months must be an integer between 1 and 120");
}
const annual = Math.pow(periodBps / 10000, 12 / periodMonths) * 10000;
return ok(Math.round(annual));
}
/** Convert an annual retention rate to a shorter period with the same geometric path. */
export function periodRetention(
annualBps: Bps,
months: number,
): Result<Bps> {
if (
!Number.isInteger(annualBps) ||
annualBps < 0 ||
annualBps > 100000
) {
return fail("annual retention must be an integer between 0 and 100000 bps");
}
if (!Number.isInteger(months) || months <= 0 || months > 120) {
return fail("months must be an integer between 1 and 120");
}
const period = Math.pow(annualBps / 10000, months / 12) * 10000;
return ok(Math.round(period));
}
export function nrrBand(nrrBps: Bps): RetentionBand {
if (nrrBps >= 11000) return "expanding";
if (nrrBps >= 10000) return "stable";
if (nrrBps >= 9000) return "leaking";
return "critical";
}
export function grrBand(grrBps: Bps): GrrBand {
if (grrBps >= 9000) return "strong";
if (grrBps >= 8000) return "moderate";
return "weak";
}
代码里有四个刻意的边界。
第一,buildRetention 不把新签放进 NRR/GRR 的分子,只把它加进期末余额。这样调用方无法通过「多签新客户」美化留存;新签只影响 ending,不影响两个比率。
第二,桥必须闭合。期末余额不是外部输入,而是由期初、新签、扩张、收缩、流失算出来的;如果四个动作加起来对不上,说明桥本身有问题,函数不会替你调平。
第三,收缩加流失不能超过期初收入。超过 100% 的流失在会计上意味着负的存量收入,函数直接报错,而不是返回一个负数 NRR。
第四,年化用 Math.pow(periodBps / 10000, 12 / periodMonths),不是线性乘法。99.79% 的单月 NRR 年化是 97.51%,不是 1197%;这个差异在低流失时看起来小,在高流失时会变成数量级错误。
小结
- NRR 和 GRR 是同一批客户的两个口径:GRR 只看收缩与流失,NRR 加上扩张;新签不进入任何一个。
- LedgerLite 月度桥:期初 19,280、新签 780、扩张 310、收缩 60、流失 290、期末 20,020;NRR 99.79%、GRR 98.18%。
- 年度归一化:期初 100,000、扩张 19,000、收缩加流失 17,000,NRR 102%、GRR 83%;扩张补上 17 个点的漏损。
- 期间不能混用:单月 NRR 99.79% 年化是 97.51%,单月 GRR 98.18% 年化是 80.22%,都不是年度事实表的 102% 和 83%。
- NRR 不是收入增长率:新签 780 美元把存量的 −40 美元盖住,总收入增长 3.84%,但 NRR 仍是 99.79%。
- Tidecast 没有经常性基数,NRR 不适用;它该看退款率 4%、渠道集中和续作转化,案例没有复购数据。
- Nord Studio 只能算 GRR 代理:咨询续约 70%、产品年化 69.38%,混合 69.83%;NRR 因缺扩张数据不可算。
阅读导航:上一节:7.2 集中度风险:前五大客户占比 · 下一节:8.1 13 周现金流表 。
常见错误与边界
- 把 NRR 当收入增长率。 NRR 排除新签;LedgerLite 总收入增长 3.84%,NRR 只有 99.79%,两者回答不同问题。
- 把新签算进 NRR。 新签只改变期末余额,不进入留存分子;算进去会把「多卖新客户」误当成「老客户留存好」。
- 用月度 NRR 直接乘 12。 99.79% × 12 是 1197%,正确年化是 99.79%^12 = 97.51%。
- 拿单月 NRR 和年度 GRR 比较。 期间不同,符号都可能翻转:单月 NRR 99.79% < 100%,年度 NRR 102% > 100%。
- 只看 NRR 不看 GRR。 NRR 102% 可能由 19% 扩张盖住 17% 漏损;GRR 83% 才是底盘。扩张一旦放缓,NRR 会迅速掉档。
- 把收缩和流失混在一起。 收缩客户的收入还在,只是变小;流失客户的收入完全消失。两者的补救动作不同。
- 给买断制硬套 NRR。 Tidecast 没有经常性基数,NRR 分母为 0;退款是收入冲减,续作是新交易,都不是续约或扩张。
- 把咨询续约率当 GRR。 70% 是合同到期是否续签,不包含合同期内收缩;它只能当 GRR 的近似。
- 给 Nord 编一个 NRR。 案例没有扩张数据;假设扩张为 0 得到的 69.83% 是假设,不是事实。
- 忽略扩张的脉冲性。 大单集中在某个月时,单月桥和年度队列会差很多;必须同时报期间和年度,并注明扩张的时间分布。
留存桥把「收入从谁身上来、留下多少」接到时间轴上。下一章把这根轴拉长到 13 周:应收、应付、税费和工资的现金时点,会和这里的 MRR 桥发生冲突,现金流表就是解决冲突的地方。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。