97.5% 与 98.6% 之间差的不是 1.1 个百分点
《潮汐消消》的下载成功率是 97.5%,弱网只有 92%;《潮汐方舟》是 98.6%。把这两个数字并排,很容易得出「方舟的下载链路更好」的结论。但这个结论经不起拆解。
消消的弱网会话占 22%,方舟占 14%。假设消消的非弱网会话成功率为 99% 左右,那么整体 97.5% 里,绝大部分失败来自那 22% 的弱网玩家:0.22 乘以 8% 已经是 1.76 个百分点,剩下 0.74 个百分点分摊到 78% 的非弱网会话上。方舟的 98.6% 建立在更低的弱网占比上,不代表它的重试策略更强。
下载失败从来不是单一原因,而是一个分布。 把失败当成一个笼统的「下载失败」去重试,就会出现两种浪费:对不该重试的错误反复重试,对应该换条路的错误死磕同一个节点。降级与重试的第一步,是把失败分类。
先分类,再决定重试
失败可以按「重试有没有意义」分成两类。第一类是瞬时的、与本次请求相关的错误:超时、5xx、签名过期。第二类是确定性的、与请求次数无关的错误:资源不存在、磁盘没空间、玩家主动取消。
| 失败类型 | 可重试 | 动作 | 理由 |
|---|---|---|---|
| HTTP 404 | 否 | 停止发布并告警 | 资源不在发布清单里,重试一万次也不会出现 |
| HTTP 403 | 是,一次 | 刷新签名后重试 | 签名过期是时间问题,刷新即可 |
| HTTP 5xx | 是 | 切到下一家 CDN | 当前边缘或源站有问题,换条路更快 |
| 超时 | 是 | 用更小的分片续传 | 已完成的分片仍然有效,只补缺口 |
| 校验失败 | 是,有限次 | 换 CDN 重新拉整个对象 | 续传修不好坏字节,只能整份重取 |
| 磁盘不足 | 否 | 引导清理空间 | 重试只会再次失败 |
| 玩家取消 | 否 | 尊重选择,下次再提醒 | 与玩家对抗是最差的产品决策 |
| 不支持 Range | 是 | 退化为单流下载 | 协议能力问题,换一种请求方式 |
这张表里最关键的一行是 404。它看起来像网络错误,实际是发布事故:清单里写了一个不存在的对象。客户端的正确反应不是重试,而是上报并停止,让服务端尽快发现发布残缺。把 404 当瞬时错误重试,只会把发布事故掩盖成「个别玩家下载失败」。
校验失败也不能用续传解决。分片校验只能证明「这一片传对了」,如果整文件校验失败,说明某个分片在传输中被污染,而续传会保留这些坏分片。正确做法是丢弃本地分片,换一个 CDN 重新拉整份对象。
重试预算:两层链路算出来的 99.9996% 只是上限
重试需要预算,否则弱网玩家会在同一个资源上无限消耗流量。预算可以用一个简单的概率模型来估:假设主链路成功率 97.5%、备用链路 92%,每条链路各试两次,那么整体成功率是
1 - (1 - 0.975)^2 × (1 - 0.92)^2 = 1 - 0.000625 × 0.0064 ≈ 99.9996%
assessFallback 就是按这个公式算的,同时给出期望尝试次数、期望传输字节和最坏情况字节。两条链路、各两次尝试,期望尝试次数约 1.026 次,最坏情况要传 4 份对象的字节。
但这个 99.9996% 只是上限,不是承诺。公式假设两次失败互相独立,而真实世界里它们高度相关:玩家的网络断了,换 CDN 也断;设备的磁盘满了,换哪家都写不进去;DNS 被污染,两条链路一起解析失败。相关性越强,实际成功率越接近单条链路的成功率,而不是公式算出的乘积。
所以重试预算要按「最坏情况」留,而不是按「公式结果」留。方舟的每周热更是 45MB,如果最坏要传 4 份,就是 180MB;消消的双周热更平均 12MB,最坏 48MB。这个数字要能和玩家的流量套餐对上,否则重试策略会在月底制造投诉。
降级:哪些资源可以晚点再来
不是所有资源都值得为它重试到成功。有些资源可以晚点再来,有些资源缺了就进不去玩法。降级策略的核心,是划出这条线。
| 资源组 | 消消(480MB) | 方舟(1,840MB) | 能否延后 |
|---|---|---|---|
| 首包 | 120MB | 350MB | 否,没有它游戏打不开 |
| 按需分包 | 4 个各 90MB | 战斗 4×260MB、角色 260MB、语音 190MB | 方舟的战斗与角色否;语音可降码率或延后 |
| 活动、皮肤、文本 | 随热更下发 | 随热更下发 | 是,错过一次活动不影响主循环 |
消消可以延后活动与皮肤资源,因为它的单局只有 90 秒,玩家进来是为了消一局,不是为了看活动页。方舟不能延后战斗场景与角色,因为一场战斗 8 到 12 分钟,缺一个场景包就直接进不去。同样是「按需下载」,两款产品的可延后边界完全不同。
降级的实现方式是给每个资源组标两个属性:是否关键、优先级。预算不够时,从优先级最低的可选组开始延后,直到剩余字节落进预算。关键组永远不参与延后;如果关键组本身就超预算,那不是降级能解决的问题,而是包体设计问题,要回到 8.x 重新分组。
延后不等于放弃。延后的资源应该记录在本地,下次启动或在 Wi-Fi 下自动续传,并在 UI 上给玩家一个明确的「还差多少」提示。悄悄延后、不告诉玩家,会让玩家在进入某个玩法时突然发现资源缺失,体验比直接说「还需下载 45MB」更差。
什么时候必须放弃
有三类情况必须立即放弃重试:玩家主动取消、磁盘空间不足、资源 404。前两个是边界条件,第三个是发布事故,重试都不会让结果变好。
放弃之后要给出可行动的出口,而不是一句「下载失败」。磁盘不足要告诉玩家还差多少空间;404 要上报并提示「资源更新中,请稍后重启」;玩家取消要记住进度,下次提示「继续下载」而不是从头开始。
还有一类是「预算耗尽」。分片尝试次数用尽、两层 CDN 都失败之后,下载器应该停止,把控制权交还给上层:是提示玩家切换网络,还是延后到下次启动,由产品决定。下载器不替产品做这个决定,但它必须如实报告「已经试过什么、浪费了多少字节」。
实现
下面的模块把失败分类、重试预算与降级选择写成三个纯函数。它不发起请求,只回答「这个错误该怎么处理」「这组链路值不值得试」「预算不够时先砍谁」。
// file: src/cdn/fallback.ts
// tier: L1
// verify: node --test test/cdn/fallback.test.ts
export type FailureKind =
| "http-404"
| "http-403"
| "http-5xx"
| "timeout"
| "checksum"
| "no-space"
| "user-cancel"
| "range-unsupported";
export type FallbackAction =
| "next-cdn"
| "resume-chunk"
| "refresh-signature"
| "free-space"
| "stop-publish"
| "ask-user"
| "single-stream";
export interface FailureAdvice {
readonly kind: FailureKind;
readonly retryable: boolean;
readonly action: FallbackAction;
readonly note: string;
}
export interface FallbackTier {
readonly id: string;
readonly successRate: number;
readonly maxAttempts: number;
readonly chunkScale: number;
}
export interface FallbackAssessment {
readonly successRate: number;
readonly expectedAttempts: number;
readonly expectedBytes: number;
readonly worstBytes: number;
readonly tiers: number;
}
export interface PackGroup {
readonly id: string;
readonly bytes: number;
readonly critical: boolean;
readonly priority: number;
}
export interface DegradeResult {
readonly feasible: boolean;
readonly retainedBytes: number;
readonly deferredBytes: number;
readonly deferred: readonly string[];
readonly overflowBytes: number;
}
const ADVICE: Record<FailureKind, Omit<FailureAdvice, "kind">> = {
"http-404": {
retryable: false,
action: "stop-publish",
note: "object missing from the release; retrying cannot create it",
},
"http-403": {
retryable: true,
action: "refresh-signature",
note: "signed url expired; refresh once before retrying",
},
"http-5xx": {
retryable: true,
action: "next-cdn",
note: "edge or origin error; move the request to the next vendor",
},
timeout: {
retryable: true,
action: "resume-chunk",
note: "keep completed ranges and resume with a smaller chunk",
},
checksum: {
retryable: true,
action: "next-cdn",
note: "resume cannot repair bad bytes; fetch the object elsewhere",
},
"no-space": {
retryable: false,
action: "free-space",
note: "the device cannot store the file until space is freed",
},
"user-cancel": {
retryable: false,
action: "ask-user",
note: "the player stopped the download; do not fight the user",
},
"range-unsupported": {
retryable: true,
action: "single-stream",
note: "the edge ignores Range; fall back to one stream",
},
};
export const DEFAULT_TIERS: readonly FallbackTier[] = [
{ id: "primary-cdn", successRate: 0.975, maxAttempts: 2, chunkScale: 1 },
{ id: "secondary-cdn", successRate: 0.92, maxAttempts: 2, chunkScale: 1 },
];
export function classifyFailure(kind: string): FailureAdvice {
const advice = ADVICE[kind as FailureKind];
if (advice === undefined) throw new Error(`unknown failure kind: ${kind}`);
return { kind: kind as FailureKind, ...advice };
}
export function assessFallback(tiers: readonly FallbackTier[], bytes: number): FallbackAssessment {
if (tiers.length === 0) throw new Error("at least one fallback tier is required");
if (!Number.isInteger(bytes) || bytes < 0) throw new Error(`invalid object size: ${bytes}`);
let reach = 1;
let expectedAttempts = 0;
let expectedBytes = 0;
let worstBytes = 0;
for (const tier of tiers) {
if (tier.id.length === 0) throw new Error("tier id must not be empty");
if (!Number.isFinite(tier.successRate) || tier.successRate <= 0 || tier.successRate > 1) {
throw new Error(`invalid success rate for ${tier.id}: ${tier.successRate}`);
}
if (!Number.isInteger(tier.maxAttempts) || tier.maxAttempts < 1) {
throw new Error(`invalid max attempts for ${tier.id}: ${tier.maxAttempts}`);
}
if (!Number.isFinite(tier.chunkScale) || tier.chunkScale <= 0) {
throw new Error(`invalid chunk scale for ${tier.id}: ${tier.chunkScale}`);
}
const fail = 1 - tier.successRate;
const attempts = (1 - fail ** tier.maxAttempts) / tier.successRate;
expectedAttempts += reach * attempts;
expectedBytes += reach * attempts * tier.chunkScale * bytes;
worstBytes += tier.maxAttempts * tier.chunkScale * bytes;
reach *= fail ** tier.maxAttempts;
}
return { successRate: 1 - reach, expectedAttempts, expectedBytes, worstBytes, tiers: tiers.length };
}
export function degradeToFit(groups: readonly PackGroup[], budgetBytes: number): DegradeResult {
if (!Number.isInteger(budgetBytes) || budgetBytes < 0) {
throw new Error(`invalid budget: ${budgetBytes}`);
}
const seen = new Set<string>();
let criticalBytes = 0;
for (const group of groups) {
if (group.id.length === 0) throw new Error("group id must not be empty");
if (seen.has(group.id)) throw new Error(`duplicate group: ${group.id}`);
seen.add(group.id);
if (!Number.isInteger(group.bytes) || group.bytes < 0) {
throw new Error(`invalid bytes for ${group.id}: ${group.bytes}`);
}
if (group.critical) criticalBytes += group.bytes;
}
if (criticalBytes > budgetBytes) {
return {
feasible: false,
retainedBytes: criticalBytes,
deferredBytes: 0,
deferred: [],
overflowBytes: criticalBytes - budgetBytes,
};
}
const optional = groups
.filter((group) => !group.critical)
.sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id));
let retainedBytes = criticalBytes;
let deferredBytes = 0;
const deferred: string[] = [];
for (const group of optional) {
if (retainedBytes + group.bytes <= budgetBytes) {
retainedBytes += group.bytes;
} else {
deferred.push(group.id);
deferredBytes += group.bytes;
}
}
return { feasible: true, retainedBytes, deferredBytes, deferred, overflowBytes: 0 };
}
export function formatFallbackReport(assessment: FallbackAssessment): string {
return [
`success=${(assessment.successRate * 100).toFixed(4)}%`,
`attempts=${assessment.expectedAttempts.toFixed(3)}`,
`bytes=${Math.round(assessment.expectedBytes)}B`,
`worst=${assessment.worstBytes}B`,
`tiers=${assessment.tiers}`,
].join(" ");
}
classifyFailure 用一张表把八种失败映射到动作,未知类型直接抛错,避免新错误被静默当成可重试。assessFallback 按链路顺序累计到达概率、期望尝试次数与期望字节,chunkScale 允许后续链路使用更小的分片,从而降低单次尝试的传输量。degradeToFit 先保住关键组,再按优先级从低到高延后可选组;如果关键组本身就超预算,返回不可行与缺口字节,而不是硬塞。
DEFAULT_TIERS 里的 97.5% 与 92% 取自消消的整体与弱网口径,只作为量级示例。真实的多厂商切换链路要单独压测,不能把这两个数字直接当成备用 CDN 的成功率。
运行结果与边界
用默认的两层链路、对象大小 1,000 字节运行,formatFallbackReport 的输出是:
success=99.9996% attempts=1.026 bytes=1026B worst=4000B tiers=2
这行输出给出四个决策依据:成功率上限、期望尝试次数、期望传输字节、最坏传输字节。最坏值和期望值差距越大,说明重试策略对弱网玩家越不友好,越应该考虑更小的分片或更早的降级。
降级的边界同样要明确。延后资源会改变玩家的可见内容,必须配合 UI 提示与后续补下;关键组超预算是包体问题,不是下载问题;degradeToFit 的优先级顺序是产品决策,不能由客户端随意调整。还有一条:降级只影响「这次能不能玩」,不影响「资源是否正确」,校验仍然要在下载完成后执行。
与相邻章节的关系
12.1 定义了分片与续传的原子单位,本节的重试与降级都建立在这个单位上:超时续传补的是分片,校验失败丢弃的也是分片。12.2 定义了 CDN 的缓存与回源,本节说的「换一家 CDN」能否奏效,取决于备用链路是否真的有容量、是否支持 Range。
向前看,11.3 的回滚与版本收敛决定了清单切换的时限:方舟 15 分钟内要能回滚战斗数值,意味着清单与热更文件不能被长缓存,否则降级策略再完整也拿不到正确版本。跨卷来看,降级后的「下次启动再补」要能跨进程存活,和客户端卷 4.3 的进程恢复一样,状态必须落盘并带版本。
小结
- 失败要先分类:瞬时错误可重试,确定性错误重试没有意义。
- 404 是发布事故,不是网络错误,客户端应停止并告警。
- 重试成功率的乘积公式只是上限,真实失败高度相关,预算要按最坏情况留。
- 可延后的资源边界由玩法决定:消消能延后活动,方舟不能延后战斗场景。
- 玩家取消、磁盘不足、资源缺失必须放弃,并给出可行动的出口。
阅读导航:上一节:12.2 CDN 选型与回源策略 · 下一节:13.1 网络与资源指标矩阵 。
常见错误与边界
- 把所有失败都当网络抖动。404 与磁盘不足被反复重试,真正的问题被掩盖。
- 对玩家取消自动重试。玩家刚关掉下载,客户端又偷偷开始,是最差的产品体验。
- 用乘积公式承诺成功率。失败相关时实际值远低于公式,承诺会变成事故。
- 重试不设上限。弱网玩家在同一个分片上无限消耗流量,月底收到账单投诉。
- 校验失败还用续传。坏分片被保留,重试多少次都拼不出正确文件。
- 降级不区分关键资源。方舟延后战斗场景,玩家直接进不去玩法。
- 降级不通知玩家。资源悄悄缺失,玩家进入玩法才发现,体验更差。
- 只按期望值做预算。最坏情况要传 4 份字节,流量套餐按期望值算一定不够。
- 边界:降级不改变校验标准。延后的是下载时机,不是资源完整性。
- 边界:关键组超预算是包体问题。下载器无法把 1,840MB 塞进 350MB 的首包。
- 边界:多厂商切换要压测。备用链路的真实成功率不能从主链路推算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。