两张商店页,两种体积曲线
把《潮汐消消》和《潮汐方舟》的商店页放在一起,会看到两种完全不同的体积曲线。消消的首包是 120MB,总资源 480MB;方舟的首包是 350MB,总资源 1,840MB。前者靠 4 个 90MB 的分包把 360MB 移出安装路径,后者靠 6 个分包把 1,490MB 移出安装路径。
单看首包,方舟是消消的 2.9 倍。单看总资源,方舟是消消的 3.8 倍。两个倍数不一致,说明它们的分包策略并不相同:方舟把更大比例的资源放进了分包,首包只保留强联网战斗启动必须的部分。
这就引出一个常见问题:谁的首包更「克制」?用占比回答,消消是 25%,方舟是 19.02%,方舟更低;用绝对值回答,消消 120MB,方舟 350MB,消消更低。两个答案都对,但都没有回答真正的问题——这份体积对应的玩家成本是多少。
体积账必须把分母带上。消消的月新增是 42 万,方舟是 8 万,相差 5.25 倍;DAU 分别是 80 万和 25 万,相差 3.2 倍。同样 10MB 的首包增量,落在消消上是 4.2 万新增用户的等待,落在方舟上只有 0.8 万。
首包占比:25% 与 19.02% 说明不了谁更克制
占比是一个相对指标,它把「首包」和「总资源」绑在一起。总资源越大,占比越容易被稀释;一个游戏可以首包 350MB,但因为总资源 1,840MB,占比看起来只有 19.02%,比消消的 25% 更「好看」。
但玩家不下载「占比」,玩家下载「体积」。方舟的 350MB 首包对应 8 万月新增,消消的 120MB 对应 42 万月新增;前者的单个用户成本更高,后者的总用户成本更高。占比在这里只说明结构,不说明代价。
| 指标 | 消消 | 方舟 | 说明 |
|---|---|---|---|
| 首包 | 120MB | 350MB | 商店首次安装体积 |
| 分包合计 | 360MB(4×90MB) | 1,490MB(4×260+260+190) | 安装后按需下载 |
| 总资源 | 480MB | 1,840MB | 首包 + 分包 |
| 首包占比 | 25% | 19.02% | 首包 ÷ 总资源 |
| 月新增 | 42 万 | 8 万 | 新增安装 |
| 首包月流量 | 约 48.07TiB | 约 26.70TiB | 新增 × 首包 |
| CDN 占比 | 约 23.9% | 约 18.2% | 首包流量 ÷ CDN 月下载 |
这张表里最有信息量的一行不是占比,而是首包月流量:消消的 48.07TiB 比方舟的 26.70TiB 高出约 80%,尽管它的首包只有对方的三分之一。原因就是新增安装数的差距。
占比还有一层误导:它假设总资源是固定的。实际上总资源会随版本增长,如果首包按固定预算锁死,占比会自然下降,看起来越来越「克制」,但玩家下载的绝对体积一点没变。占比适合描述结构,不适合作为预算指标。
单位资源体积:38.4KB 与 21.9KB
消消有 12,800 个资源文件,方舟有 86,000 个。用总资源除以文件数,消消的平均资源约 38.4KB,方舟约 21.9KB。方舟的文件数是消消的 6.7 倍,总资源是 3.8 倍,文件更碎、单文件更小。
这个差异会传导到下载调度。文件越碎,依赖图越密:消消的平均出度 3.2、最大深度 6,方舟是 4.1 和 9。深度 9 意味着一个资源可能牵着九层下游,任何一个中间节点缺失,都会让整条链无法完成。
文件更碎也意味着校验开销更高。86,000 个文件的哈希校验、索引建立和失败重试,都比 12,800 个文件更重;这就是方舟冷启动 P50 3.5 秒、P95 6.8 秒,而消消是 1.8 秒和 3.2 秒的原因之一。首包体积只是冷启动的一部分,文件数量和索引结构同样计入。
| 指标 | 消消 | 方舟 |
|---|---|---|
| 资源文件数 | 12,800 | 86,000 |
| 平均单文件 | 约 38.4KB | 约 21.9KB |
| 依赖图平均出度 | 3.2 | 4.1 |
| 依赖图最大深度 | 6 | 9 |
| 冷启动 P50 / P95 | 1.8s / 3.2s | 3.5s / 6.8s |
这组数字说明,首包预算不能只按字节定,还要按文件数定。两个同样 350MB 的包,一个装 1 万个文件、一个装 8 万个文件,后者的校验与索引成本明显更高。首包准入如果把「文件数」作为第二预算,就能避免「字节没超、启动变慢」的情况。
更新节奏:312MB 与 2,340MB 的年度账
消消的增量热更是双周一次、平均 12MB,商店整包每 8 周一次,整包差量 55 到 70MB。方舟是每周一次、平均 45MB,商店整包每 6 周一次,整包差量 60 到 90MB。
把节奏折成年:消消一年约 26 次热更,合计 312MB;方舟一年约 52 次,合计 2,340MB。方舟的年度热更量是消消的 7.5 倍,也超过了它自己 1,840MB 的总资源——一个留存一年的玩家,光热更下载量就超过一份完整资源。
| 指标 | 消消 | 方舟 |
|---|---|---|
| 热更周期 | 双周 | 每周 |
| 单次热更 | 平均 12MB | 平均 45MB |
| 年热更次数 | 约 26 | 约 52 |
| 年热更量 | 约 312MB | 约 2,340MB |
| 商店整包周期 | 每 8 周 | 每 6 周 |
| 年商店版本 | 约 6.5 | 约 8.7 |
| 整包差量 | 55–70MB | 60–90MB |
商店整包的年度量可以按区间折算:消消约 6.5 次乘以 55 到 70MB,约 358 到 455MB;方舟约 8.7 次乘以 60 到 90MB,约 522 到 783MB。加上热更,消消一年约 670 到 767MB,方舟约 2,862 到 3,123MB。
这组数字决定了首包边界的重要性。包边界越不稳定,整包差量越接近整包体积;方舟的 60 到 90MB 差量对应 1,840MB 总资源,说明它的边界控制得不错,但年度累计仍然是 GB 级。
流量结构:资源下载与业务流量谁更大
消消的 CDN 月下载量约 201TB,方舟约 147TB。用每 DAU 日流量折算业务流量:消消每 DAU 3.6MB,按 80 万 DAU、30 天算,约 82.4TB/月;方舟每 DAU 21MB,按 25 万 DAU、30 天算,约 150.2TB/月。
对比很清楚:消消的资源下载约是业务流量的 2.4 倍,方舟则基本持平。这不是因为消消的资源更多,而是因为它的新增安装多、单局时长短——6.5 分钟的会话里产生不了多少业务流量,但每个新玩家都要下载 120MB。
| 流量项 | 消消 | 方舟 |
|---|---|---|
| CDN 月下载 | 约 201TB | 约 147TB |
| 业务流量(折算) | 约 82.4TB | 约 150.2TB |
| 资源 / 业务 | 约 2.4 倍 | 约 1.0 倍 |
| 每会话流量 | 3.2MB | 18MB |
| 每 DAU 日流量 | 3.6MB | 21MB |
这个结构差异会改变优化重点。消消的流量大头在资源分发,首包和分包的体积、CDN 命中率和增量命中率更值得投入;方舟的流量大头在长连接与战斗同步,每会话 18MB、每 DAU 21MB,优化协议头和心跳频率的收益更直接。
失败安装:绝对值比比率更诚实
消消的下载成功率是 97.5%,弱网环境 92%;方舟是 98.6%。单看比率,方舟更好;换算成绝对值,消消每月约 1.05 万次失败安装,方舟约 1,120 次,前者是后者的 9.4 倍。
失败安装的绝对值才是客服工单和差评的来源。一个 2.5% 的失败率放在 42 万新增上,就是每月一万多个玩家在下完首包后卡住;放在 8 万新增上,只有一千多。比率适合做质量指标,绝对值适合做运营指标。
| 指标 | 消消 | 方舟 |
|---|---|---|
| 下载成功率 | 97.5% | 98.6% |
| 弱网成功率 | 92% | — |
| 月新增 | 42 万 | 8 万 |
| 月失败安装 | 约 1.05 万 | 约 1,120 |
| 首包体积 | 120MB | 350MB |
弱网数据的缺口也值得说明。消消有明确的弱网成功率 92%,方舟只有整体成功率 98.6%,没有单独的弱网口径。做对照时不能用一个游戏的弱网数字去推另一个游戏,只能承认这一格缺数据。
实现
下面的文件把两款游戏的包体、流量、更新与失败安装放进同一张账。输入是每个游戏的画像,输出是逐项派生指标、合并总量和一张可直接对照的表。
// file: src/lab/package-ledger.ts
// tier: L1
// verify: node --test test/lab/package-ledger.test.ts
export interface GameProfile {
readonly id: string;
readonly firstPackageBytes: number;
readonly subpackageBytes: readonly number[];
readonly downloadSuccess: number;
readonly weakNetworkSuccess: number | null;
readonly monthlyInstalls: number;
readonly dau: number;
readonly dailyTrafficMb: number;
readonly incrementalUpdateBytes: number;
readonly updatePeriodDays: number;
readonly storeReleaseDays: number;
readonly deltaMinBytes: number;
readonly deltaMaxBytes: number;
readonly assetFiles: number;
readonly cdnMonthlyTb: number;
}
export interface GameLedger {
readonly id: string;
readonly firstPackageBytes: number;
readonly subpackageBytes: number;
readonly totalBytes: number;
readonly firstSharePct: number;
readonly averageAssetBytes: number;
readonly monthlyInstalls: number;
readonly failedInstallsPerMonth: number;
readonly installFirstTb: number;
readonly cdnFirstSharePct: number;
readonly monthlyBusinessTb: number;
readonly updatesPerYear: number;
readonly updateBytesPerYear: number;
readonly storeReleasesPerYear: number;
readonly weakNetworkSuccess: number | null;
}
export interface LedgerReport {
readonly entries: readonly GameLedger[];
readonly combinedInstalls: number;
readonly combinedCdnTb: number;
readonly combinedInstallFirstTb: number;
}
const TB_BYTES = 1024 ** 4;
const MB_BYTES = 1024 * 1024;
const MB_PER_TB = 1024 * 1024;
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function validate(profile: GameProfile): void {
if (!Number.isInteger(profile.firstPackageBytes) || profile.firstPackageBytes <= 0) {
throw new Error(`invalid first package for ${profile.id}: ${profile.firstPackageBytes}`);
}
for (const bytes of profile.subpackageBytes) {
if (!Number.isInteger(bytes) || bytes < 0) {
throw new Error(`invalid subpackage for ${profile.id}: ${bytes}`);
}
}
if (!(profile.downloadSuccess > 0 && profile.downloadSuccess <= 1)) {
throw new Error(`invalid download success for ${profile.id}: ${profile.downloadSuccess}`);
}
if (
profile.weakNetworkSuccess !== null &&
!(profile.weakNetworkSuccess > 0 && profile.weakNetworkSuccess <= 1)
) {
throw new Error(
`invalid weak network success for ${profile.id}: ${profile.weakNetworkSuccess}`,
);
}
if (!Number.isInteger(profile.monthlyInstalls) || profile.monthlyInstalls < 0) {
throw new Error(`invalid installs for ${profile.id}: ${profile.monthlyInstalls}`);
}
if (!Number.isInteger(profile.dau) || profile.dau < 0) {
throw new Error(`invalid dau for ${profile.id}: ${profile.dau}`);
}
if (profile.dailyTrafficMb < 0) {
throw new Error(`invalid daily traffic for ${profile.id}: ${profile.dailyTrafficMb}`);
}
if (!Number.isInteger(profile.incrementalUpdateBytes) || profile.incrementalUpdateBytes < 0) {
throw new Error(`invalid update bytes for ${profile.id}: ${profile.incrementalUpdateBytes}`);
}
if (!Number.isInteger(profile.updatePeriodDays) || profile.updatePeriodDays <= 0) {
throw new Error(`invalid update period for ${profile.id}: ${profile.updatePeriodDays}`);
}
if (!Number.isInteger(profile.storeReleaseDays) || profile.storeReleaseDays <= 0) {
throw new Error(`invalid store release period for ${profile.id}: ${profile.storeReleaseDays}`);
}
if (
!Number.isInteger(profile.deltaMinBytes) ||
!Number.isInteger(profile.deltaMaxBytes) ||
profile.deltaMinBytes < 0 ||
profile.deltaMaxBytes < profile.deltaMinBytes
) {
throw new Error(
`invalid delta range for ${profile.id}: ${profile.deltaMinBytes}..${profile.deltaMaxBytes}`,
);
}
if (!Number.isInteger(profile.assetFiles) || profile.assetFiles <= 0) {
throw new Error(`invalid asset files for ${profile.id}: ${profile.assetFiles}`);
}
if (!Number.isFinite(profile.cdnMonthlyTb) || profile.cdnMonthlyTb <= 0) {
throw new Error(`invalid cdn volume for ${profile.id}: ${profile.cdnMonthlyTb}`);
}
}
function installFirstTb(profile: GameProfile): number {
return (profile.monthlyInstalls * profile.firstPackageBytes) / TB_BYTES;
}
export function summarizeGame(profile: GameProfile): GameLedger {
validate(profile);
const subpackageBytes = profile.subpackageBytes.reduce((sum, bytes) => sum + bytes, 0);
const totalBytes = profile.firstPackageBytes + subpackageBytes;
const updatesPerYear = Math.round(365 / profile.updatePeriodDays);
const firstTb = installFirstTb(profile);
return {
id: profile.id,
firstPackageBytes: profile.firstPackageBytes,
subpackageBytes,
totalBytes,
firstSharePct: round2((profile.firstPackageBytes / totalBytes) * 100),
averageAssetBytes: Math.round(totalBytes / profile.assetFiles),
monthlyInstalls: profile.monthlyInstalls,
failedInstallsPerMonth: Math.round(
profile.monthlyInstalls * (1 - profile.downloadSuccess),
),
installFirstTb: round2(firstTb),
cdnFirstSharePct: round2((firstTb / profile.cdnMonthlyTb) * 100),
monthlyBusinessTb: round2(
(profile.dau * 30 * profile.dailyTrafficMb) / MB_PER_TB,
),
updatesPerYear,
updateBytesPerYear: updatesPerYear * profile.incrementalUpdateBytes,
storeReleasesPerYear: round1(365 / profile.storeReleaseDays),
weakNetworkSuccess: profile.weakNetworkSuccess,
};
}
export function compareLedger(profiles: readonly GameProfile[]): LedgerReport {
if (profiles.length === 0) {
throw new Error("ledger needs at least one game");
}
const seen = new Set<string>();
const entries: GameLedger[] = [];
let combinedInstalls = 0;
let combinedCdnTb = 0;
let combinedInstallFirstTb = 0;
for (const profile of profiles) {
if (seen.has(profile.id)) throw new Error(`duplicate game: ${profile.id}`);
seen.add(profile.id);
validate(profile);
entries.push(summarizeGame(profile));
combinedInstalls += profile.monthlyInstalls;
combinedCdnTb += profile.cdnMonthlyTb;
combinedInstallFirstTb += installFirstTb(profile);
}
entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
return {
entries,
combinedInstalls,
combinedCdnTb: round2(combinedCdnTb),
combinedInstallFirstTb: round2(combinedInstallFirstTb),
};
}
function mb(bytes: number): string {
return (bytes / MB_BYTES).toFixed(2);
}
export function formatLedger(report: LedgerReport): string {
const lines = [
"game\tfirstMB\tsubMB\ttotalMB\tfirst%\tinstalls/mo\tfail/mo\tinstallFirstTiB\tcdnShare%\tupdateMB/yr",
];
for (const entry of report.entries) {
lines.push(
[
entry.id,
mb(entry.firstPackageBytes),
mb(entry.subpackageBytes),
mb(entry.totalBytes),
entry.firstSharePct.toFixed(2),
entry.monthlyInstalls,
entry.failedInstallsPerMonth,
entry.installFirstTb.toFixed(2),
entry.cdnFirstSharePct.toFixed(1),
entry.updateBytesPerYear / MB_BYTES,
].join("\t"),
);
}
lines.push(
`combined\t-\t-\t-\t-\t${report.combinedInstalls}\t-\t${report.combinedInstallFirstTb.toFixed(2)}\t-\t-`,
);
lines.push(
`cdn\t${report.combinedCdnTb.toFixed(2)}TB/mo\t${report.entries.length} games\t-\t-\t-\t-\t-\t-\t-`,
);
return lines.join("\n");
}
summarizeGame 先校验输入:首包为正、分包非负、成功率在 (0,1]、更新周期为正、差量区间不倒置。任何一项不合法都直接抛错,避免一张看似正常但口径错误的账进入评审。
派生指标分四组。体积组算总资源、首包占比和平均单文件;流量组把月新增乘首包换算成 TiB,再与 CDN 月下载量比较;更新组把周期折成年,算出年热更量与年商店版本数;失败组用新增乘失败率得到绝对值。
compareLedger 负责合并:新增安装相加、CDN 下载相加、首包流量相加,并按 id 排序,保证输出稳定。formatLedger 输出一行一个游戏,最后两行分别是合并新增与合并 CDN。
运行结果与边界
用两款游戏的画像运行,输出如下。体积单位是 MB,流量单位是 TiB:
game firstMB subMB totalMB first% installs/mo fail/mo installFirstTiB cdnShare% updateMB/yr
casual 120.00 360.00 480.00 25.00 420000 10500 48.07 23.9 312
midcore 350.00 1490.00 1840.00 19.02 80000 1120 26.70 18.2 2340
combined - - - - 500000 - 74.77 - -
cdn 348.00TB/mo 2 games - - - - - - -
这张表能直接回答开头的问题:方舟的首包占比更低,但消消的首包月流量更高;方舟的失败率更低,但消消的失败绝对值更高;方舟的年度更新量是消消的 7.5 倍。三个结论分别对应结构、运营和质量三个视角。
第一条边界是口径。CDN 月下载包含热更、重复下载和回源,首包月流量只算新增用户的首次安装,两者相除得到的是一个量级参考,不是成本核算。表里的 23.9% 与 18.2% 只能用来比较量级,不能用来报销。
第二条边界是时间。年热更量按当前周期线性外推,实际节奏会随版本计划变化;方舟每周 45MB 是平均值,赛季版本可能明显高于它。线性外推适合做预算,不适合做承诺。
第三条边界是缺失数据。方舟没有单独的弱网成功率,表里用「—」而不是补一个估计值。对照表最危险的错误不是算错,而是把缺失的数据用另一款游戏的数字填上。
与相邻章节的关系
9.1 回答了首包里该放什么,9.2 回答了首包之外什么时候下,本节把两款游戏的账放在一起,回答「这些决定合起来贵不贵」。8.3 的包分层与 8.1 的依赖图是这张账的结构基础。
本节不讨论 CDN 调度与回源策略,那属于 10.x 和 11.x;也不讨论商店政策对体积的限制,那属于 10.1。账本只回答「体积、流量、更新和失败各是多少」,不回答「应该改成多少」。
小结
- 首包占比只描述结构,不描述代价;同样的占比,42 万新增和 8 万新增的成本完全不同。
- 消消首包 120MB、月流量约 48.07TiB,方舟 350MB、约 26.70TiB;体积小不代表总流量小。
- 方舟的资源更碎:86,000 个文件、平均约 21.9KB,依赖深度 9,校验与索引成本更高。
- 年度热更量消消约 312MB,方舟约 2,340MB;方舟一年的热更量超过自身总资源。
- 失败率要换算成绝对值:消消约 1.05 万次/月,方舟约 1,120 次/月,前者是后者的 9.4 倍。
- 对照表必须标注缺失数据,不能用一个游戏的弱网数字去填另一个游戏的空。
阅读导航:上一节:9.2 分包与按需下载的调度 · 下一节:10.1 商店允许更新什么 。
常见错误与边界
- 只用首包占比比较克制程度。占比会被总资源稀释,玩家下载的是绝对体积。
- 忽略新增安装的分母。同样 10MB 增量,42 万新增和 8 万新增的成本差 5.25 倍。
- 把 CDN 下载等同于首包流量。CDN 还包含热更、重复下载和回源,相除只能看量级。
- 按字节定预算,不看文件数。86,000 个文件的校验成本明显高于 12,800 个。
- 用线性外推承诺更新量。赛季版本的 45MB 平均值会被高峰拉高,预算可以外推,承诺不行。
- 拿一个游戏的弱网数字填另一个。方舟没有单独的弱网口径,缺失就是缺失。
- 只看失败率不看绝对值。2.5% 放在 42 万新增上是一万多玩家,放在 8 万上只有两千。
- 把年度热更量和总资源分开看。方舟一年热更 2,340MB,已经超过 1,840MB 总资源。
- 忽略业务流量的结构差异。消消资源流量约是业务的 2.4 倍,方舟基本持平,优化重点不同。
- 边界:账本不做成本核算。它给的是体积与流量的量级,不是 CDN 账单。
- 边界:账本不提供改造建议。它只回答现状是多少,改多少属于 9.1 与 9.2 的决策。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。