《独立开发者的商业指南·收入与财务工程》7.1 客户分层:按收入、按成本

LedgerLite 的 260 家客户不是一张平均脸:最大一家贡献 9%,前五家 31%,其余 255 家贡献 69%。本节把客户按收入分成三层,再按云、支付与支持成本重新摊分,比较收入份额与毛利份额的差异,并给出可运行的 tiers.ts 与分层动作。

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

260 家付费客户不是一个均匀的整体,而是一条从 21,622 美元到几百美元的长尾。ARPA 77 美元是这条分布的平均值,它把最大客户和最小客户抹平成同一个数。用平均值做决策,最常见的后果是:对头部客户按标准条款服务,对长尾客户按定制方式服务,两边的成本都被低估。

客户分层要回答两个不同的问题:谁贡献收入,谁消耗成本。按收入分层告诉你「失去谁会疼」,按成本分层告诉你「服务谁最贵」。只做第一层,会把高收入误当成高利润;只做第二层,会把低成本误当成高价值。这一节把两层都做出来,再放回同一张表。

第 1 章的 1.2 节已经做过一次收入线组合评分,第 4 章的 4.2 节把集中度作为收入质量的一个分项。它们都是一次性的结构快照。本章要把分层变成可以按月更新的模型:每一层有多少客户、贡献多少收入、摊到多少服务成本、毛利份额是多少。先看清分布,再谈集中度和留存。

为什么平均数会骗人

LedgerLite 的事实表只给了三个客户层数字:260 家客户、前 5 家占收入 31%、最大一家占 9%。这三个数足以把客户拆成三层,但拆法需要说明。下面把前 5 家拆成「最大一家」和「其余四家」,其余 255 家合并为长尾:

层客户数年收入收入占比单客年收入单客月收入
关键客户1$21,6229.0%$21,622$1,802
重要客户4$52,85222.0%$13,213$1,101
长尾客户255$165,76669.0%$650$54
合计260$240,240100.0%$924$77

拆法如下:最大一家 9% × 240,240 = 21,622 美元;前五家 31% × 240,240 = 74,474 美元,减去最大一家得到其余四家 52,852 美元;剩下的 255 家拿 165,766 美元。每一层的单客收入都是该层收入除以客户数。这些数全部由事实表的 260、9%、31%、240,240 推出,没有新增案例事实。

这张表最重要的一行是最后一行:整体单客月收入 77 美元,但关键客户是 1,802 美元,长尾是 54 美元,相差 33 倍。平均数 77 美元既不代表头部,也不代表尾部,它只代表「如果所有客户完全一样」时的假想客户。分层的第一件事,就是拒绝这个假想客户。

Tidecast 的分层对象不是客户账户,而是 9,600 次买断交易。它没有「客户数」这个稳定基数,只有渠道:Steam 80%、官网 20%。把买断玩家硬分成「大客户/小客户」没有财务意义,因为每个买家只付一次 19.99 美元。Nord Studio 则是另一种结构:咨询线按工时计费,产品线按 90 家订阅客户计费,两条线的分层单位不同,不能合并成一张客户表。

按收入分层:头部、中部、长尾

按收入分层的目的不是排名,而是决定「用哪种服务方式」。三层可以用三个问题来定义:

  • 关键客户:失去它,年收入立刻少 5% 以上,且短期找不到替代。
  • 重要客户:单家占比 1%–5%,有一定定制需求,但仍有标准化空间。
  • 长尾客户:单家占比低于 1%,数量多,服务必须自助化。

按这个定义,LedgerLite 的关键客户只有 1 家,重要客户 4 家,长尾 255 家。三层的收入占比是 9%、22%、69%。这个结构有两个容易被误读的地方。

第一,长尾不是「不重要」。255 家客户贡献 69% 的收入,接近七成。失去 10 家长尾客户,按单客 650 美元计算,一年少 6,501 美元;失去关键客户,一年少 21,622 美元。前者约等于后者的 30%,但前者只需要 10 个客户同时离开,后者只需要 1 个。两种损失的「触发难度」不同,不能用同一个百分比衡量。

第二,前五家 31% 不算极端集中,但也不是「很安全」。如果前五家同时不续约,收入立刻少 31%,而固定成本不会同步下降。LedgerLite 的运营费用是 72,000 美元/年,31% 的收入是 74,474 美元,刚好覆盖运营费用。换句话说,前五家集体流失会把 SDE 从 130,240 美元打到约 67,540 美元(毛利减少约 62,700 美元,运营费用不变;如果变动成本完全不随收入下降,最保守是 55,766 美元)。这个测算是教学推演,不是预测,但它说明「31%」这个数字在利润表上的分量。

分层之后,动作应该跟着层走,而不是跟着平均数走:

层收入特征主要风险优先动作
关键客户单家 9%,定制多单点流失、条款让步续约前 6 个月介入,准备备选方案
重要客户单家 5.5%,有扩张空间被当成大客户服务,成本失控标准化 onboarding,设定服务边界
长尾客户单家 0.27%,数量多支持成本吃掉毛利自助文档、模板回复、最低套餐

按成本分层:服务成本不随收入等比例

收入分层只看分子,成本分层要看分母。LedgerLite 的变动成本是 38,000 美元/年,拆成三块:云 18,000、支付 12,000、支持 8,000。这三块的成本驱动不同:

  • 云和支付随收入变化,适合按收入摊分。
  • 支持随账户数变化,适合按账户数摊分。

按这个口径,30,000 美元的云加支付按收入摊,8,000 美元的支持按账户数摊:

层云+支付摊分支持摊分总服务成本毛利毛利率
关键客户$2,700.04$30.76$2,730.80$18,891.2087.37%
重要客户$6,599.90$123.07$6,722.97$46,129.0387.28%
长尾客户$20,700.06$7,846.17$28,546.23$137,219.7782.78%
合计$30,000.00$8,000.00$38,000.00$202,240.0084.18%

这张表的关键不是绝对金额,而是毛利率的排序:关键客户 87.37%,重要客户 87.28%,长尾客户 82.78%,首尾差 4.59 个百分点。差距来自支持成本按账户摊:每个账户分到 30.77 美元支持成本,长尾客户的单客年收入只有 650 美元,支持成本占收入的 4.7%;关键客户的单客年收入 21,622 美元,同样的 30.77 美元只占 0.14%。

如果换一个摊法,结论会变。把 38,000 美元全部按收入摊,三层毛利率都等于 84.18%,分层就失去了成本信息;把支持成本按「大客户支持强度是长尾的 10 倍」加权摊,关键客户的毛利率会下降,长尾会上升。摊法不是中立的,它决定了你看到的风险。可复算的前提是:把驱动写出来,把假设标出来。

Nord Studio 的成本层更直接:咨询线 110 小时/月是时间上限,服务成本按工时走,不按客户数走。它的「关键客户」不是收入最大的客户,而是占用合伙人时间最多的客户。Tidecast 的平台分成按收入走,退款按交易走,也没有「每账户支持成本」这一层;它的成本分层只能按渠道做。

收入份额与毛利份额:两张表放一起

把收入份额和毛利份额并排,才能看出哪一层在「养」哪一层:

层收入占比毛利占比单客毛利单客服务成本
关键客户9.0%9.34%$18,891.20$2,730.80
重要客户22.0%22.81%$11,532.26$1,680.74
长尾客户69.0%67.85%$538.12$111.95
合计100.0%100.0%$777.85$146.15

毛利占比之和是 100.00%(934 + 2281 + 6785 = 10000 bps),没有尾差。关键客户的收入占比 9.0%,毛利占比 9.34%,略高于收入占比,因为它的毛利率最高;长尾客户的收入占比 69.0%,毛利占比 67.85%,略低于收入占比。三层之间没有出现「头部补贴尾部」的戏剧性反转,但单客毛利差距很大:关键客户 18,891 美元,长尾客户 538 美元,相差 35 倍。

这个结构给出三条动作线:

第一,关键客户要「保」。保的不是价格,而是续约确定性和服务边界。关键客户的毛利率最高,但它也是最容易被定制需求拖垮的一层;一旦支持强度从 1 倍变成 10 倍,87% 的毛利率会迅速下降。

第二,重要客户要「长」。四家客户贡献 22% 收入,单客毛利 11,532 美元,介于关键和长尾之间。这一层最值得做扩张:它们的需求已经形成,但还没有大到必须定制。扩张收入的目标应该放在这一层,而不是继续给关键客户加功能。

第三,长尾要「省」。255 家客户贡献 69% 收入,单客毛利 538 美元,服务成本 112 美元。长尾的毛利绝对值不小,但每一家都需要支持;如果支持成本从 8,000 美元涨到 16,000 美元,长尾的毛利率会从 82.78% 降到 78.0% 左右(教学推演)。长尾的动作不是涨价,而是把服务成本压到可控:文档、模板、自动回复、最低套餐;具体做法见 自助支持的验收 。

分层不是一次性的分类。客户会升级、降级、流失,层的边界也会随价格和成本变化。一个季度重算一次,比年初分一次更可靠。

把分层写成代码

下面的 tiers.ts 把三个动作固化成函数:allocateServiceCost 按收入、账户数或平均三种驱动摊分共享成本;tierContribution 计算每层的收入份额、毛利份额和单客经济;tierReport 把摊分和贡献串起来。所有金额用整数美分,比率用基点,非法输入返回 Result 错误。

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

import { type Cents, sum } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";

export type CustomerTier = {
  name: string;
  customers: number;
  annualRevenueCents: Cents;
  serviceCostCents: Cents;
};

export type CostDriver = "revenue" | "accounts" | "equal";

export type TierContribution = {
  name: string;
  customers: number;
  annualRevenueCents: Cents;
  serviceCostCents: Cents;
  grossProfitCents: Cents;
  grossMarginBps: Bps;
  revenueShareBps: Bps;
  profitShareBps: Bps;
  revenuePerCustomerCents: Cents;
  costPerCustomerCents: Cents;
};

type Totals = {
  revenue: Cents;
  cost: Cents;
  profit: Cents;
};

function validate(tiers: CustomerTier[]): Result<Totals> {
  if (tiers.length === 0) {
    return fail("at least one tier is required");
  }
  for (const tier of tiers) {
    if (tier.name.trim() === "") {
      return fail("tier name must not be empty");
    }
    if (!Number.isInteger(tier.customers) || tier.customers <= 0) {
      return fail(`invalid customer count for ${tier.name}`);
    }
    if (
      !Number.isInteger(tier.annualRevenueCents) ||
      tier.annualRevenueCents < 0
    ) {
      return fail(`invalid annual revenue for ${tier.name}`);
    }
    if (
      !Number.isInteger(tier.serviceCostCents) ||
      tier.serviceCostCents < 0
    ) {
      return fail(`invalid service cost for ${tier.name}`);
    }
  }
  const revenue = sum(tiers.map((tier) => tier.annualRevenueCents));
  if (revenue <= 0) {
    return fail("total tier revenue must be positive");
  }
  const cost = sum(tiers.map((tier) => tier.serviceCostCents));
  return ok({ revenue, cost, profit: revenue - cost });
}

function weightsFor(tiers: CustomerTier[], driver: CostDriver): number[] {
  if (driver === "revenue") {
    return tiers.map((tier) => tier.annualRevenueCents);
  }
  if (driver === "accounts") {
    return tiers.map((tier) => tier.customers);
  }
  return tiers.map(() => 1);
}

/**
 * Allocate a shared service cost across tiers with integer cents.
 * The last tier receives the rounding remainder so the parts always
 * sum exactly to the requested total.
 */
export function allocateServiceCost(
  tiers: CustomerTier[],
  totalCostCents: Cents,
  driver: CostDriver,
): Result<Cents[]> {
  const checked = validate(tiers);
  if (!checked.ok) {
    return checked;
  }
  if (!Number.isInteger(totalCostCents) || totalCostCents < 0) {
    return fail("total service cost must be a non-negative integer number of cents");
  }
  const weights = weightsFor(tiers, driver);
  const weightTotal = weights.reduce((acc, weight) => acc + weight, 0);
  if (weightTotal <= 0) {
    return fail("allocation weights must sum to a positive value");
  }
  let allocated = 0;
  return ok(
    weights.map((weight, index) => {
      if (index === weights.length - 1) {
        return totalCostCents - allocated;
      }
      const part = Math.floor((totalCostCents * weight) / weightTotal);
      allocated += part;
      return part;
    }),
  );
}

/**
 * Revenue share, gross profit share and per-customer economics for
 * each tier. Service costs are whatever `serviceCostCents` holds,
 * so the caller decides whether they came from revenue, accounts or
 * a mixed allocation.
 */
export function tierContribution(
  tiers: CustomerTier[],
): Result<TierContribution[]> {
  const checked = validate(tiers);
  if (!checked.ok) {
    return checked;
  }
  return ok(
    tiers.map((tier) => {
      const grossProfit = tier.annualRevenueCents - tier.serviceCostCents;
      return {
        name: tier.name,
        customers: tier.customers,
        annualRevenueCents: tier.annualRevenueCents,
        serviceCostCents: tier.serviceCostCents,
        grossProfitCents: grossProfit,
        grossMarginBps: divide(grossProfit, tier.annualRevenueCents),
        revenueShareBps: divide(tier.annualRevenueCents, checked.value.revenue),
        profitShareBps:
          checked.value.profit > 0
            ? divide(grossProfit, checked.value.profit)
            : 0,
        revenuePerCustomerCents: Math.round(
          tier.annualRevenueCents / tier.customers,
        ),
        costPerCustomerCents: Math.round(
          tier.serviceCostCents / tier.customers,
        ),
      };
    }),
  );
}

/**
 * Allocate one shared cost with a single driver and return the tier
 * contributions. Callers that split cloud, payment and support across
 * different drivers can call `allocateServiceCost` twice and add the
 * parts before calling `tierContribution`.
 */
export function tierReport(
  tiers: CustomerTier[],
  totalCostCents: Cents,
  driver: CostDriver,
): Result<TierContribution[]> {
  const parts = allocateServiceCost(tiers, totalCostCents, driver);
  if (!parts.ok) {
    return parts;
  }
  return tierContribution(
    tiers.map((tier, index) => ({
      ...tier,
      serviceCostCents: tier.serviceCostCents + parts.value[index],
    })),
  );
}

代码里有四个刻意的边界。

第一,allocateServiceCost 的最后一层拿余数,保证各层摊分之和精确等于总成本。前两层用 Math.floor,余数可能是几分钱;如果不处理,报表会出现「摊分合计 37,999.99」这种无法对账的尾差。

第二,驱动是显式参数,不是函数内部猜的。云和支付按收入、支持按账户数,调用方要自己组合;代码不会替你决定哪种驱动更合理。这个选择本身是财务判断,不是技术细节。

第三,tierContribution 只在总毛利为正时计算毛利占比。总毛利为负时,占比没有意义,函数返回 0,而不是返回一个负的百分比让人误读。

第四,单客收入和服务成本都做整数美分四舍五入。长尾单客年收入 650.06 美元,显示成 650 美元;单客服务成本 111.95 美元,显示成 112 美元。展示层可以有小数,计算层必须保持整数美分。

小结

  • 260 家客户的平均 ARPA 77 美元会掩盖分布:关键客户月收入 1,802 美元,长尾客户 54 美元,相差 33 倍。
  • 按收入分层:最大一家 9%、其余四家 22%、长尾 255 家 69%;前五家合计 31%。
  • 按成本分层:云和支付按收入摊,支持按账户摊;关键客户毛利率 87.37%,长尾 82.78%,首尾差 4.59 个百分点。
  • 收入份额与毛利份额接近,但单客毛利相差 35 倍;分层动作应跟随层,而不是跟随平均数。
  • Tidecast 的 9,600 次买断交易没有稳定客户基数,分层对象是渠道;Nord 的咨询按工时、产品按账户,两条线不能合成一张客户表。
  • 关键客户保续约和服务边界,重要客户做扩张,长尾客户压服务成本。
  • 分层模型要按季度重算,因为客户会升级、降级和流失,成本驱动也会变。
  • 本节只做了静态分层;下一节把「前五大占比」和 HHI 变成集中度阈值与应对动作。

阅读导航:上一节:6.3 套餐组合的收入结构 · 下一节:7.2 集中度风险:前五大客户占比 。

常见错误与边界

  1. 用平均 ARPA 代表所有客户。 77 美元是 260 家客户的加权平均;关键客户 1,802 美元、长尾 54 美元,用平均数定价会同时得罪两头。
  2. 把前五家 31% 当成「很安全」。 31% 的收入是 74,474 美元,刚好覆盖 72,000 美元的运营费用;前五家集体流失会把 SDE 打到接近 55,766 美元。
  3. 只按收入分层,不看服务成本。 长尾收入占 69%,但支持成本按账户摊,毛利率比关键客户低 4.59 个百分点;只看收入会高估长尾的利润贡献。
  4. 把支持成本按收入摊。 支持不是随收入等比例变化的;按收入摊会把成本推给头部,掩盖长尾的服务负担。
  5. 摊分不处理尾差。 整数美分摊分后各层之和可能差几分钱;不把余数给最后一层,报表无法对账。
  6. 把 Tidecast 的买家当客户账户。 9,600 次买断交易没有稳定的客户基数,分层对象是渠道,不是买家。
  7. 把 Nord 的咨询客户和产品客户合成一张表。 咨询按工时、产品按账户,两条线的分层单位和成本驱动不同。
  8. 把关键客户的高毛利当成「可以无限定制」。 关键客户毛利率最高,但支持强度一旦上升,87% 会迅速下降;保的是续约,不是无限让步。
  9. 分层一次就固定。 客户会升级、降级、流失,价格和成本也会变;至少每季度重算一次。
  10. 把分层结果当估值依据。 分层只描述收入与成本的分布,不直接决定倍数;估值还要看集中度、留存和增长,见第 11 章。

分层是把「260 家客户」翻译成「三类经济关系」的过程。下一节会把注意力从层内移到层间:前五大客户占比、HHI 和平台依赖,回答「如果一层突然消失,收入会少多少」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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