1,840MB 是怎样变成 147TB 的
《潮汐方舟》的总资源是 1,840MB,资源文件数 86,000,首包 350MB,另外 6 个分包合计 1,490MB:战斗场景 4 个各 260MB、角色 260MB、语音 190MB。CDN 月下载量约 147TB,每月新增安装 8 万。
把这两个数字放在一起看:8 万乘以 1,840MB,正好约 147TB。也就是说,方舟的 CDN 月流量在量级上几乎等于「每个新增安装把全量资源拉一遍」。这不是巧合,而是中重度游戏的典型结构:新增玩家贡献了绝大部分下载字节,存量玩家的增量热更(每周平均 45MB)相比之下只是零头。
这个结构决定了 CDN 策略的重心。既然流量主要来自新安装的全量下载,那么缓存命中率、回源带宽和多厂商容量都必须按「新版本发布当天」来设计,而不是按日常均值。发布日的峰值可能远高于月均值,而平时的缓存命中再漂亮,也救不了发布日的回源拥塞。
四类对象,四种缓存模式
CDN 选型的第一步不是比较厂商,而是把自己的对象分类。方舟的 86,000 个文件可以归成四类,每一类的缓存语义完全不同。
| 对象 | 例子 | 缓存模式 | 建议 TTL | 原因 |
|---|---|---|---|---|
| 包体 | 首包、6 个分包 | immutable | 30 天 | 路径带内容哈希,内容永不改变 |
| 清单 | 热更清单、分包索引 | revalidate | 120 秒 | 必须反映回滚与版本切换 |
| 热更文件 | 补丁包、赛季配置 | revalidate | 120 秒 | 每周更新,回滚窗口只有 15 分钟 |
| 接口 | 公告、开关、支付回调 | no-store | 0 | 每次都必须到源站 |
包体是唯一可以放心长缓存的类别。前提是路径里带内容哈希:packs/battle-01.3f9c2a.pak 一旦发布就不再变化,新内容用新路径。这样缓存键天然唯一,既不会读到旧内容,也不需要主动刷新。
清单和热更文件不能长缓存。方舟的回滚时限是 15 分钟:战斗数值出问题时,运维要在 15 分钟内把清单指回旧版本。如果边缘节点还把旧清单缓存 30 分钟,回滚就会失效。清单 TTL 取 120 秒,是用「回滚时限」倒推出来的上限,而不是拍脑袋的「一分钟左右」。
接口走 no-store,原因更直接:公告和开关必须实时。把接口塞进 CDN 缓存,省下的那点回源流量远不值得用「玩家看到过期公告」来换。
命中率:用 TTL 与回访间隔算
「命中率 95%」这种说法没有信息量,除非同时说明对象、TTL 和玩家的回访间隔。命中率不是运营指标,而是这三个量的函数。
一个够用的估算模型是:假设请求在时间上均匀到达,对象在 TTL 内被再次请求的概率近似为
hit ≈ ttl / (ttl + revisit)
其中 revisit 是同一玩家再次请求该对象的平均间隔。TTL 远大于回访间隔时,命中率接近 1;TTL 远小于回访间隔时,命中率接近 0。
代入方舟的参数:包体 TTL 是 30 天,假设玩家平均每天回访一次,命中率约为 30 / 31,即 96.8%。清单 TTL 是 120 秒,同样按一天回访一次算,命中率只有 120 / 86,520,约 0.14%。这两个数字的差距不是实现好坏,而是缓存语义的必然结果。
| 对象 | TTL | 回访间隔 | 估算命中率 |
|---|---|---|---|
| 包体 | 30 天 | 1 天 | 96.8% |
| 清单 | 120 秒 | 1 天 | 0.14% |
| 热更文件 | 120 秒 | 1 天 | 0.14% |
| 接口 | 0 | 不适用 | 0% |
清单命中率低并不可怕,因为它很小。方舟的配置类文件是 6,000 个,清单本身通常只有几十 KB;真正占字节的是纹理 38,000 个、模型 21,000 个、动画 9,000 个和音频 12,000 个。按字节加权的整体命中率仍然由包体主导,这正是我们想要的结构。
回源:接住漏掉的 3.2%
把 147TB 粗算一遍:如果整体命中率接近包体的 96.8%,回源大约是 147TB 乘以 3.2%,也就是 4.7TB 左右;清单与接口的命中率接近零,会把实际回源再抬高一点。这个量级听起来不大,但它是月均;发布日的回源峰值可能是均值的数倍。
回源策略要解决三件事。第一是回源带宽要按峰值预留,而不是按均值采购。第二是回源路径要短:边缘节点回源时优先打到同区域的上层节点或对象存储,而不是跨地域回主源。第三是回源要能限流:源站被打挂时,宁可让部分请求失败并重试,也不能让源站雪崩。
还有一条容易被忽略:回源请求必须带对缓存键。如果缓存键包含玩家的鉴权 token,每个玩家的请求都会变成一次独立回源,命中率瞬间归零。正确做法是鉴权与缓存分离:URL 只保留内容标识,鉴权走单独的请求头或签名参数,并在 CDN 侧配置为不参与缓存键。
Range 支持也是回源约定的一部分。12.1 的分片下载依赖 Range 请求,如果某个节点或源站不支持 Range,下载器只能退化为单流。选型时要把「支持 Range」写成硬性准入条件,而不是可选项。
多厂商:权重、切换与成本
单一 CDN 的风险不是价格,而是同时失效。方舟采用主备两家:主 CDN 承担大部分流量,备 CDN 承担剩余部分,并在主 CDN 出现大面积 5xx 或超时时接管。
| 厂商 | 权重 | 角色 | 切换条件 |
|---|---|---|---|
| 主 CDN | 3 | 常规流量 | 5xx 比例或超时率超过阈值时降权 |
| 备 CDN | 1 | 兜底与容灾 | 主 CDN 降权后自动承接 |
权重不是拍脑袋的 3:1,而是容量与成本的函数。主 CDN 单价更低、容量更大,所以给它更高权重;备 CDN 单价更高,只在必要时承接。切换必须是渐进的:一次把 100% 流量切到备 CDN,备 CDN 也可能被打挂,正确做法是按比例降权,观察错误率后再继续。
成本要按回源字节算,而不是按 CDN 总流量算。边缘命中部分由 CDN 计费,回源部分由对象存储或源站计费,两者单价不同。把回源字节单独拉出来做预算,才能回答「提高命中率省下的钱,是否值得为长 TTL 承担的内容风险」。
实现
下面的模块把四类对象的缓存模式、TTL、命中率与多厂商分流量写成一次计算。它不访问网络,只根据对象清单和策略输出边缘字节、回源字节与厂商份额。
// file: src/cdn/cdn-plan.ts
// tier: L1
// verify: node --test test/cdn/cdn-plan.test.ts
export type ObjectKind = "bundle" | "manifest" | "hotfix" | "api";
export type CacheMode = "immutable" | "revalidate" | "no-store";
export interface CdnObject {
readonly path: string;
readonly kind: ObjectKind;
readonly bytes: number;
readonly requests: number;
}
export interface CachePolicy {
readonly immutableTtlSeconds: number;
readonly manifestTtlSeconds: number;
readonly respectRange: boolean;
}
export const DEFAULT_POLICY: CachePolicy = {
immutableTtlSeconds: 30 * 24 * 60 * 60,
manifestTtlSeconds: 120,
respectRange: true,
};
export interface KindSummary {
readonly kind: ObjectKind;
readonly objects: number;
readonly totalBytes: number;
readonly edgeBytes: number;
readonly originBytes: number;
readonly hitRatio: number;
}
export interface CachePlan {
readonly byKind: readonly KindSummary[];
readonly totalBytes: number;
readonly edgeBytes: number;
readonly originBytes: number;
readonly hitRatio: number;
readonly rangesSupported: boolean;
}
export interface Vendor {
readonly id: string;
readonly weight: number;
readonly unitCostPerGb: number;
}
export interface VendorShare {
readonly id: string;
readonly share: number;
readonly originBytes: number;
readonly cost: number;
}
const GIB = 1024 ** 3;
const KINDS: readonly ObjectKind[] = ["bundle", "manifest", "hotfix", "api"];
export function cacheModeFor(kind: ObjectKind): CacheMode {
switch (kind) {
case "bundle":
return "immutable";
case "manifest":
case "hotfix":
return "revalidate";
case "api":
return "no-store";
default:
throw new Error(`unknown object kind: ${String(kind)}`);
}
}
export function estimateHitRatio(ttlSeconds: number, revisitSeconds: number): number {
if (!Number.isFinite(ttlSeconds) || ttlSeconds < 0) throw new Error(`invalid ttl: ${ttlSeconds}`);
if (!Number.isFinite(revisitSeconds) || revisitSeconds <= 0) {
throw new Error(`invalid revisit interval: ${revisitSeconds}`);
}
if (ttlSeconds === 0) return 0;
return ttlSeconds / (ttlSeconds + revisitSeconds);
}
export function planCache(
objects: readonly CdnObject[],
policy: CachePolicy = DEFAULT_POLICY,
revisitSeconds = 24 * 60 * 60,
): CachePlan {
if (!Number.isInteger(policy.immutableTtlSeconds) || policy.immutableTtlSeconds < 1) {
throw new Error(`invalid immutable ttl: ${policy.immutableTtlSeconds}`);
}
if (!Number.isInteger(policy.manifestTtlSeconds) || policy.manifestTtlSeconds < 0) {
throw new Error(`invalid manifest ttl: ${policy.manifestTtlSeconds}`);
}
const totals = new Map<ObjectKind, { objects: number; totalBytes: number; edgeBytes: number }>();
let totalBytes = 0;
let edgeBytes = 0;
for (const object of objects) {
if (object.path.length === 0) throw new Error("object path must not be empty");
if (!Number.isInteger(object.bytes) || object.bytes < 0) {
throw new Error(`invalid bytes for ${object.path}: ${object.bytes}`);
}
if (!Number.isInteger(object.requests) || object.requests < 0) {
throw new Error(`invalid requests for ${object.path}: ${object.requests}`);
}
const mode = cacheModeFor(object.kind);
const ttl =
mode === "immutable"
? policy.immutableTtlSeconds
: mode === "revalidate"
? policy.manifestTtlSeconds
: 0;
const requested = object.bytes * object.requests;
const hit = Math.round(requested * estimateHitRatio(ttl, revisitSeconds));
const bucket = totals.get(object.kind) ?? { objects: 0, totalBytes: 0, edgeBytes: 0 };
bucket.objects += 1;
bucket.totalBytes += requested;
bucket.edgeBytes += hit;
totals.set(object.kind, bucket);
totalBytes += requested;
edgeBytes += hit;
}
const byKind: KindSummary[] = [];
for (const kind of KINDS) {
const bucket = totals.get(kind);
if (bucket === undefined) continue;
const originBytes = bucket.totalBytes - bucket.edgeBytes;
byKind.push({
kind,
objects: bucket.objects,
totalBytes: bucket.totalBytes,
edgeBytes: bucket.edgeBytes,
originBytes,
hitRatio: bucket.totalBytes === 0 ? 0 : bucket.edgeBytes / bucket.totalBytes,
});
}
return {
byKind,
totalBytes,
edgeBytes,
originBytes: totalBytes - edgeBytes,
hitRatio: totalBytes === 0 ? 0 : edgeBytes / totalBytes,
rangesSupported: policy.respectRange,
};
}
export function splitAcrossVendors(
vendors: readonly Vendor[],
originBytes: number,
): readonly VendorShare[] {
if (vendors.length === 0) throw new Error("at least one vendor is required");
if (!Number.isInteger(originBytes) || originBytes < 0) {
throw new Error(`invalid origin bytes: ${originBytes}`);
}
const seen = new Set<string>();
let weightSum = 0;
for (const vendor of vendors) {
if (vendor.id.length === 0) throw new Error("vendor id must not be empty");
if (seen.has(vendor.id)) throw new Error(`duplicate vendor: ${vendor.id}`);
seen.add(vendor.id);
if (!Number.isFinite(vendor.weight) || vendor.weight <= 0) {
throw new Error(`invalid weight for ${vendor.id}: ${vendor.weight}`);
}
if (!Number.isFinite(vendor.unitCostPerGb) || vendor.unitCostPerGb < 0) {
throw new Error(`invalid unit cost for ${vendor.id}: ${vendor.unitCostPerGb}`);
}
weightSum += vendor.weight;
}
let assigned = 0;
return vendors.map((vendor, index) => {
const isLast = index === vendors.length - 1;
const bytes = isLast ? originBytes - assigned : Math.round((originBytes * vendor.weight) / weightSum);
assigned += bytes;
return {
id: vendor.id,
share: vendor.weight / weightSum,
originBytes: bytes,
cost: Math.round(((bytes / GIB) * vendor.unitCostPerGb + Number.EPSILON) * 100) / 100,
};
});
}
export function formatCdnPlan(plan: CachePlan): string {
const lines = ["kind\tobjects\trequestedB\tedgeB\toriginB\thit"];
for (const row of plan.byKind) {
lines.push(
`${row.kind}\t${row.objects}\t${row.totalBytes}\t${row.edgeBytes}\t${row.originBytes}\t${(row.hitRatio * 100).toFixed(1)}%`,
);
}
lines.push(
`total=${plan.totalBytes}B edge=${plan.edgeBytes}B origin=${plan.originBytes}B hit=${(plan.hitRatio * 100).toFixed(1)}% range=${plan.rangesSupported}`,
);
return lines.join("\n");
}
cacheModeFor 是策略的唯一入口:包体 immutable,清单与热更 revalidate,接口 no-store。estimateHitRatio 实现上面的 TTL 模型,并对非法 TTL 与回访间隔抛错。planCache 按对象累加请求字节、计算边缘命中与回源字节,并按固定顺序汇总,保证同样的输入得到同样的报表。splitAcrossVendors 把回源字节按权重分配,最后一个厂商拿余数,避免四舍五入后总和对不上。formatCdnPlan 输出可直接贴进评审材料的三列表格。
有一个细节值得说明:planCache 对每个对象单独取整,再累加。这样报表里每一行的边缘字节与回源字节都能对上,不会出现「分项之和与总数差一个字节」的情况。代价是整体命中率会有极小误差,但对容量规划没有影响。
运行结果与边界
用四个小对象运行:一个 10,000 字节的包体、一个 1,000 字节的清单、一个 500 字节请求两次的热更文件、一个 100 字节请求十次的接口。formatCdnPlan 的输出是:
kind objects requestedB edgeB originB hit
bundle 1 10000 9677 323 96.8%
manifest 1 1000 1 999 0.1%
hotfix 1 1000 1 999 0.1%
api 1 1000 0 1000 0.0%
total=13000B edge=9679B origin=3321B hit=74.5% range=true
这张表说明两件事。第一,包体的字节占比决定了整体命中率,尽管清单和接口的命中率接近零,整体仍有 74.5%。第二,回源字节主要集中在清单和接口上,而它们的绝对体积很小,源站压力可控。
边界也要说清楚。命中率模型假设请求均匀到达,真实流量有早晚高峰和发布日尖峰,模型只能用于容量量级估算,不能替代压测。TTL 不是越长越好:包体可以长,是因为路径带内容哈希;如果路径不变而内容会变,长 TTL 会把旧内容锁在边缘。多厂商权重需要定期复核,厂商的容量、价格和故障率都会变化。
最后一个边界是「缓存键即契约」。路径、查询参数、请求头是否参与缓存键,必须在 CDN 配置里写死并纳入变更评审。缓存键改一次,等于把整个边缘缓存清零重来,发布日做这件事的代价极高。
与相邻章节的关系
12.1 的分片下载决定了请求形态:大量 Range 请求、并发连接、按分片重试。本节回答这些请求打到哪个边缘节点、节点是否缓存、缓存多久、回源到哪里。12.3 会接着讨论当 CDN 返回错误或超时时,客户端如何换厂商、降级或放弃。
向前看,8.1 的资源依赖图与 8.2 的资源分组决定了「哪些文件属于同一个包」,而包体正是本节里唯一可以长缓存的对象。分组边界稳定,缓存键才稳定;分组频繁变化,CDN 命中率会跟着一起抖。
小结
- 先给对象分类,再谈缓存;四类对象的缓存语义完全不同。
- 包体可以长缓存的前提是路径带内容哈希,而不是「相信不会变」。
- 清单 TTL 由回滚时限倒推,方舟的 15 分钟回滚决定了 TTL 只能是分钟级。
- 命中率是 TTL 与回访间隔的函数,要按字节加权看整体,不能被对象数量误导。
- 多厂商按权重渐进切换,回源字节单独做预算与容量规划。
阅读导航:上一节:12.1 下载器设计:分片、并发与断点续传 · 下一节:12.3 下载失败的降级与重试 。
常见错误与边界
- 把 CDN 当成一个开关。选型只比价格,不看 Range 支持、缓存键配置与回源能力。
- 所有对象一个 TTL。包体需要长缓存,清单需要短缓存,混在一起必然有一边出错。
- 清单缓存太久。回滚时限 15 分钟,边缘却缓存 30 分钟,回滚指令下不去。
- 缓存键带鉴权 token。每个玩家一个缓存键,命中率归零,回源被打爆。
- 只看命中率不看字节权重。清单命中率低但体积小,不该为它牺牲包体的长缓存。
- 忽略 Range。分片下载依赖 Range,节点不支持时下载器只能退化为单流。
- 一次性全量切换厂商。备 CDN 也可能被打挂,切换必须按比例渐进。
- 用月均值做容量规划。发布日峰值远高于均值,回源带宽要按峰值预留。
- 边界:命中率模型不是压测。均匀到达假设不成立时,只能用压测数据修正。
- 边界:长 TTL 依赖内容寻址。路径不变而内容会变的对象,不能套用 immutable。
- 边界:CDN 不负责内容正确性。缓存的是字节,校验仍由客户端完成。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。