场景:月底才发现流量超了
《潮汐消消》曾经在月底收到一条告警:某个渠道的会话流量比上月高出一截。排查发现不是玩法变了,而是新版本把埋点从批量上报改成了逐条上报,又在每次进入大厅时多发了一次榜单请求。功能都没错,但没有人知道这些改动叠起来会把每 DAU 日流量推高。
这就是「没有预算」的典型表现:每个需求都只证明自己「只多发一条消息」,没有人负责总量。等到月底看报表,流量已经发生,能做的只有下个月改回来。
预算的作用不是限制功能,而是让每一次新增都有价格,并且让超支在当天就能被发现。本节以消消的 3.6MB 每 DAU 日流量为起点,建立一套分层预算与弱网降级模型。
预算的三层口径
流量预算至少要分三层,否则数字之间会互相打架。会话层看一次连接的效率,用户层看一天的平均成本,月度层看商务与容量;三层的分母不同,不能直接换算。
| 层级 | 口径 | 消消的基准 | 主要用途 |
|---|---|---|---|
| 会话层 | 一次前台会话的总字节 | 3.2MB | 协议优化、连接策略、补状态成本 |
| 用户层 | 每 DAU 每日总字节 | 3.6MB | 产品迭代的日常门禁 |
| 月度层 | 用户层按 DAU 与天数汇总 | 由 DAU 决定 | 商务结算、运营商合作、容量规划 |
用户层是最适合做日常门禁的一层:它直接对应「每个活跃玩家一天花多少」,又能被产品与运营理解。会话层更适合工程优化,因为一次会话的变量更少;月度层波动大,不适合用来判断某次改动的好坏。
需要强调的是,3.2MB 与 3.6MB 不是同一个分母下的两个估计,而是两个不同口径的基准。7.1 已经解释过,把会话中位数直接乘成日流量会得到自相矛盾的结果;预算模型必须选定一层作为主口径,其余层只做参照。
分级预算:核心、可选与诊断
把预算平均分给所有功能,会导致核心功能被边缘功能挤占。更合理的做法是按「缺了会不会影响可玩性」分三层,每层有独立的额度与优先级。
| 层级 | 包含什么 | 超支时的处理 | 默认份额 |
|---|---|---|---|
| 核心(core) | 登录、开局、提交、结算、支付确认 | 不允许静默丢弃,只能优化或告警 | 70% |
| 可选(optional) | 榜单、社交、外观预览、非关键刷新 | 可以降频、延迟、合并 | 25% |
| 诊断(diagnostic) | 埋点、性能采样、日志上报 | 可以采样、批量、弱网下暂停 | 5% |
份额是策略参数,不是事实。不同产品可以根据玩法调整,但三条原则不变:核心层必须有保底额度;可选层必须可以被牺牲;诊断层必须默认低优先级。把埋点放进核心层,或者把支付确认放进诊断层,都会在超支时做出错误决定。
分层之后,「扣减」就有了明确语义:一笔流量先记到它所属的层,层内额度用完就拒绝或降级,而不是从总池里无差别扣。这样即使总预算还有剩余,也不会出现「诊断流量吃掉了核心额度」的情况。
弱网判定:用 P95 与丢包率,不用均值
弱网适配的第一步是判定「现在算不算弱网」。用平均 RTT 判定是常见的错误:均值会被大量正常样本拉低,少数高延迟会话被平均掉,而降级恰恰是为了服务这些少数会话。
| 指标 | 消消的取值 | 在判定中的角色 |
|---|---|---|
| RTT P50 | 78ms | 参考,说明多数会话正常 |
| RTT P95 | 310ms | 主判据,超过 300ms 视为弱网 |
| 丢包率(中位会话) | 0.4% | 参考,中位会话并不差 |
| 弱网会话占比 | 22% | 结果,说明降级策略的影响面 |
| 弱网定义 | RTT P95 > 300ms 或丢包 > 2% | 口径,必须写进文档 |
事实表把弱网定义为「RTT P95 大于 300ms 或丢包大于 2%」。消消的 P95 是 310ms,已经越过阈值,所以有 22% 的会话被归为弱网;这不是说 22% 的用户网络都很差,而是说这部分会话的尾部延迟或丢包达到了需要降级的程度。
判定用「或」而不是「与」,是因为延迟和丢包都会独立伤害体验:高延迟让操作反馈变慢,丢包让消息反复重传。只处理其中一种,另一种仍会把体验拉垮。
阈值必须可配置。运营商、地区、版本都会改变分布,写死的阈值会在某个版本突然把所有用户判成弱网,或者在网络变差时毫无反应。
降级不是关功能,而是换传输策略
一旦判定为弱网,最直接的反应是「关掉一些功能」。这通常是最差的做法:玩家会觉得产品坏了,而不是网络不好。更好的降级是换传输策略,在不改变功能语义的前提下降低链路压力。
| 等级 | 触发条件 | 允许的层 | 典型动作 |
|---|---|---|---|
| 0 正常 | 未越过弱网阈值 | 全部 | 正常频率、正常批量 |
| 1 弱网 | RTT P95 > 300ms 或丢包 > 2% | 核心 + 可选 | 可选层降频、合并请求、缩小响应字段 |
| 2 严重 | 延迟或丢包继续恶化 | 仅核心 | 暂停诊断上报、延迟非关键刷新、只保留必要往返 |
「仅核心」不等于「只发支付」。核心层还包括登录、开局、提交与结算;这些是玩下去的必要条件。降级到 2 级时,产品仍然可玩,只是榜单、社交和诊断被推迟。
分级的好处是决策可以事先写死,而不是在告警时临场讨论。弱网是常态,22% 的会话都会遇到;如果没有预定义的降级顺序,每次都要靠人判断,最终一定会在某个高峰夜里做出错误取舍。
可运行模型:traffic-budget.ts
下面的 traffic-budget.ts 把分层预算、扣减、剩余额度、网络判定与摘要放在同一个文件里。它不访问网络,也不持久化;预算与份额由调用方传入,扣减返回新的不可变状态。
// file: src/net/traffic-budget.ts
// tier: L1
// verify: node --test test/net/traffic-budget.test.ts
export const TIERS = ["core", "optional", "diagnostic"] as const;
export type Tier = (typeof TIERS)[number];
export type TierBytes = Readonly<Record<Tier, number>>;
export interface Budget {
readonly dailyBytes: number;
readonly caps: TierBytes;
readonly spent: TierBytes;
}
export interface ChargeResult {
readonly budget: Budget;
readonly accepted: number;
readonly rejected: number;
readonly reason: "accepted" | "partial" | "capped";
}
export interface NetworkSample {
readonly rttP95Ms: number;
readonly lossRate: number;
}
export interface NetworkPolicy {
readonly weakRttMs: number;
readonly weakLossRate: number;
readonly severeRttMs: number;
readonly severeLossRate: number;
}
export type DegradeLevel = 0 | 1 | 2;
const DEFAULT_NETWORK_POLICY: NetworkPolicy = {
weakRttMs: 300,
weakLossRate: 0.02,
severeRttMs: 600,
severeLossRate: 0.04,
};
const DEFAULT_SHARES: TierBytes = { core: 0.7, optional: 0.25, diagnostic: 0.05 };
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function createBudget(dailyBytes: number, shares: TierBytes = DEFAULT_SHARES): Budget {
if (!Number.isInteger(dailyBytes) || dailyBytes <= 0) {
throw new RangeError(`daily budget must be a positive integer: ${dailyBytes}`);
}
const total = shares.core + shares.optional + shares.diagnostic;
if (Math.abs(total - 1) > 1e-9) {
throw new RangeError(`tier shares must sum to 1: ${total}`);
}
if (shares.core < 0 || shares.optional < 0 || shares.diagnostic < 0) {
throw new RangeError("tier shares must be non-negative");
}
const core = Math.floor(dailyBytes * shares.core);
const optional = Math.floor(dailyBytes * shares.optional);
const diagnostic = dailyBytes - core - optional;
return {
dailyBytes,
caps: { core, optional, diagnostic },
spent: { core: 0, optional: 0, diagnostic: 0 },
};
}
export function charge(budget: Budget, tier: Tier, bytes: number): ChargeResult {
if (!Number.isInteger(bytes) || bytes < 0) {
throw new RangeError(`charge bytes must be a non-negative integer: ${bytes}`);
}
const cap = budget.caps[tier];
const spent = budget.spent[tier];
const available = Math.max(0, cap - spent);
const accepted = Math.min(bytes, available);
const rejected = bytes - accepted;
const reason: ChargeResult["reason"] =
rejected === 0 ? "accepted" : accepted === 0 ? "capped" : "partial";
return {
budget: {
dailyBytes: budget.dailyBytes,
caps: budget.caps,
spent: { ...budget.spent, [tier]: spent + accepted },
},
accepted,
rejected,
reason,
};
}
export interface RemainingReport {
readonly total: number;
readonly byTier: TierBytes;
}
export function remainingBytes(budget: Budget): RemainingReport {
const byTier: Record<Tier, number> = {
core: budget.caps.core - budget.spent.core,
optional: budget.caps.optional - budget.spent.optional,
diagnostic: budget.caps.diagnostic - budget.spent.diagnostic,
};
return {
total: byTier.core + byTier.optional + byTier.diagnostic,
byTier,
};
}
export interface NetworkPlan {
readonly level: DegradeLevel;
readonly allowed: readonly Tier[];
}
export function planForNetwork(
sample: NetworkSample,
policy: NetworkPolicy = DEFAULT_NETWORK_POLICY,
): NetworkPlan {
if (!Number.isFinite(sample.rttP95Ms) || sample.rttP95Ms < 0) {
throw new RangeError(`RTT P95 must be non-negative: ${sample.rttP95Ms}`);
}
if (!Number.isFinite(sample.lossRate) || sample.lossRate < 0 || sample.lossRate > 1) {
throw new RangeError(`loss rate must be within 0..1: ${sample.lossRate}`);
}
let level: DegradeLevel = 0;
if (sample.rttP95Ms > policy.weakRttMs || sample.lossRate > policy.weakLossRate) {
level = 1;
}
if (sample.rttP95Ms > policy.severeRttMs || sample.lossRate > policy.severeLossRate) {
level = 2;
}
const allowed: readonly Tier[] = level === 0 ? TIERS : level === 1 ? ["core", "optional"] : ["core"];
return { level, allowed };
}
export function describeBudget(budget: Budget): string {
const remaining = remainingBytes(budget);
return `日预算 ${formatBytes(budget.dailyBytes)},已用 ${formatBytes(budget.dailyBytes - remaining.total)},剩余 ${formatBytes(remaining.total)}(core ${formatBytes(remaining.byTier.core)},optional ${formatBytes(remaining.byTier.optional)},diagnostic ${formatBytes(remaining.byTier.diagnostic)})`;
}
模型导出六个入口:层级常量、建预算、扣减、剩余额度、网络计划与摘要。扣减不是「超了就整笔拒绝」,而是先接受剩余额度、再报告被拒绝的部分;这样一笔大流量不会因为差几百字节就完全失败,调用方也能拿到明确的 partial 或 capped。
网络判定严格使用「大于」而不是「大于等于」:事实表的弱网定义是 P95 大于 300ms 或丢包大于 2%,刚好等于阈值不算越界。这个边界在测试里单独覆盖,因为把 > 写成 >= 会让临界样本被误判。
配套测试覆盖九条路径:预算切分、非法份额、全额扣减、部分扣减与额度耗尽、剩余额度、弱网边界、非法样本、摘要输出与非法字节数。分层预算最容易出错的地方是份额之和与取整,测试同时断言三层额度之和等于总预算。
超预算后的处理顺序
发现超支时,处理顺序应该是确定的。先降诊断,再降可选,最后才动核心;先降频率,再降批量,最后才砍功能。把顺序写下来,比事后争论「哪个功能不重要」有效得多。
- 暂停或采样诊断层上报,保留崩溃与支付相关事件。
- 合并可选层的请求,降低刷新频率,延迟非关键数据。
- 缩小下行响应字段,优先下发展示必需的数据。
- 检查是否有重复请求、重复埋点或未合并的批量。
- 核心层只做协议优化,不做功能删减;必要时提升告警级别。
这个顺序也适用于弱网降级。区别只在于触发条件:超预算看额度,弱网看 P95 与丢包。两者可以同时生效,取更严格的等级。
对照:方舟的预算形状
方舟的每 DAU 日流量是 21MB、每会话 18MB,远高于消消,但它的弱网会话占比只有 14%,RTT P95 是 240ms、丢包 0.6%。它的预算更多花在战斗同步与状态补发上,核心层占比应当更高。
因此方舟的降级顺序会更保守:可选层可以先降,但战斗相关的核心同步不能停;诊断层在战斗内本就应当低采样。预算形状不同,分层份额与降级阈值也应不同,不能直接套用消消的参数。
与相邻章节的关系
7.1 提供会话账本,7.2 决定心跳频率,本节把两者放进预算并按网络质量降级。8.x 会转向资源依赖与下载,那是另一本账;本节只处理会话流量,不把资源下载混进预算。跨端的连接恢复策略可参考卷一的相关章节,但预算口径仍以本卷事实表为准。
小结
- 预算分三层:会话层看效率,用户层做门禁,月度层做结算;三层分母不同,不能直接换算。
- 分层预算按核心、可选、诊断切分,核心有保底,可选可牺牲,诊断默认低优先级。
- 弱网判定用 RTT P95 与丢包率,阈值是「大于 300ms 或大于 2%」,且必须可配置。
- 降级优先换传输策略,而不是关功能;顺序是诊断、可选、核心,频率、批量、字段。
- 消消与方舟的预算形状不同,份额与阈值不能直接套用。
阅读导航:上一节:7.2 心跳、长连接与推送的取舍 · 下一节:8.1 资源依赖图与循环检测 。
常见错误与边界
- 没有预算,只看月底报表。流量已经发生,只能事后补救;预算要能在当天发现超支。
- 把会话层与用户层直接换算。两个口径的分母不同,相乘会得到自相矛盾的结果。
- 把预算平均分给所有功能。边缘功能会挤占核心额度;必须分层并保留核心保底。
- 用平均 RTT 判定弱网。均值会掩盖尾部延迟,而降级正是为尾部会话服务。
- 弱网阈值写死在客户端。地区与版本会改变分布,阈值应可下发、可观测。
- 一超预算就关功能。玩家会认为产品坏了;应优先降频、合并、裁剪字段。
- 降级顺序临场决定。弱网是常态,没有预定义顺序就会在高峰夜做出错误取舍。
- 诊断层优先级过高。埋点不能挤占核心额度;默认低采样、可暂停。
- 扣减只返回成功或失败。大流量差几百字节就整笔失败不合理;应支持部分接受并报告拒绝量。
- 边界:预算是产品约束,不是网络实测。它约束的是客户端行为,不能替代对真实链路的监控与告警。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。