《移动游戏产品的架构·网络、资源与热更新》12.2 CDN 选型与回源策略

方舟总资源 1,840MB、86,000 个文件,CDN 月下载量约 147TB,几乎等于 8 万新增安装各拉一遍全量资源。本节把对象分成包体、清单、热更与接口四类,分别给出缓存模式与 TTL,用回访间隔估算命中率,再按权重把回源流量分给多家 CDN。

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 个分包immutable30 天路径带内容哈希,内容永不改变
清单热更清单、分包索引revalidate120 秒必须反映回滚与版本切换
热更文件补丁包、赛季配置revalidate120 秒每周更新,回滚窗口只有 15 分钟
接口公告、开关、支付回调no-store0每次都必须到源站

包体是唯一可以放心长缓存的类别。前提是路径里带内容哈希: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 或超时时接管。

厂商权重角色切换条件
主 CDN3常规流量5xx 比例或超时率超过阈值时降权
备 CDN1兜底与容灾主 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 下载失败的降级与重试 。

常见错误与边界

  1. 把 CDN 当成一个开关。选型只比价格,不看 Range 支持、缓存键配置与回源能力。
  2. 所有对象一个 TTL。包体需要长缓存,清单需要短缓存,混在一起必然有一边出错。
  3. 清单缓存太久。回滚时限 15 分钟,边缘却缓存 30 分钟,回滚指令下不去。
  4. 缓存键带鉴权 token。每个玩家一个缓存键,命中率归零,回源被打爆。
  5. 只看命中率不看字节权重。清单命中率低但体积小,不该为它牺牲包体的长缓存。
  6. 忽略 Range。分片下载依赖 Range,节点不支持时下载器只能退化为单流。
  7. 一次性全量切换厂商。备 CDN 也可能被打挂,切换必须按比例渐进。
  8. 用月均值做容量规划。发布日峰值远高于均值,回源带宽要按峰值预留。
  9. 边界:命中率模型不是压测。均匀到达假设不成立时,只能用压测数据修正。
  10. 边界:长 TTL 依赖内容寻址。路径不变而内容会变的对象,不能套用 immutable。
  11. 边界:CDN 不负责内容正确性。缓存的是字节,校验仍由客户端完成。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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