《移动游戏产品的架构·网络、资源与热更新》7.1 一次会话的流量构成

一次会话的 3.2MB 并不是一条消息,而是业务、心跳、埋点与补状态四类流量的叠加。本节用可运行的账本模型拆开载荷、帧头、确认与重试成本,解释 180B 上行与 620B 下行如何累积成日流量,并给出能复核的口径与边界,避免把不同分母的数字直接相乘。

场景:3.2MB 的会话,账单上看不出花在哪

《潮汐消消》的运营在月度复盘时问了一个很具体的问题:每 DAU 日流量 3.6MB,一次会话 3.2MB,为什么把在线时长乘上单条消息大小,算出来只有几百 KB?玩家到底把流量花在了哪里?

客户端日志里只有「发了多少条消息」,没有「每条消息在链路上占多少字节」。业务消息、25 秒一次的心跳、埋点上报、断线后的补状态混在同一个连接里,谁都认为自己只占一小块。要回答这个问题,不能靠抽样抓包,而要先把会话拆成一条可对账的流水:谁产生的、走哪个通道、载荷多少、帧头多少、有没有重发。

本节以消消为案例,建立一份「会话流量账本」。它不追求抓到每一个字节,而是让每一类流量都有明确的归属和可复算的口径。账本跑通之后,7.3 的预算和 7.2 的心跳参数才有讨论的基础。

会话不是一个数,而是四个通道

把一次前台会话里的所有帧按产生原因分类,最少要分成四个通道。分类的意义不在于名字,而在于它们的触发频率和结算方式完全不同:业务消息随玩家操作发生,心跳随时间发生,埋点随事件发生,补状态只在断线后发生。

通道触发来源频率特征账本里的结算方式
业务(biz)开局、提交、领奖、购买随操作突发,局内集中按请求/响应逐条记录载荷与帧数
心跳(heartbeat)保活定时器固定间隔,空闲时也发生按间隔与在线时长估算次数
埋点(telemetry)客户端事件与性能采样可批量、可降频按批记录,允许丢弃与合并
补状态(resync)重连、切后台回来低频但单次体积大按次记录,单独看 P95 而不是均值

消消一次会话 3.2MB 的口径,正是这四类流量的总和,不含资源下载。资源下载属于另一本账,由热更与分包系统结算;把它混进会话流量,会让「网络成本」和「内容成本」互相污染,最后两边都说不清。

四个通道里,业务和心跳是连续发生的,埋点是可以被策略调整的,补状态是长尾的。账本要能分别回答「谁在稳定消耗」和「谁在偶发地制造尖峰」,而不是只给一个总数。

单条消息的成本:载荷只是分子的一部分

玩家看到的「一条消息 180B」通常指业务载荷。链路上真正消耗的字节还包括协议帧头、确认包、重试副本,以及为维持连接付出的心跳。把这些混为一谈,就会得出「消息很小,所以流量不是问题」的错误结论。

成本项是否随消息数增长账本里的处理
业务载荷是由调用方传入,必须是非负整数
协议帧头是作为「每帧固定开销」参数统一计入
确认包是,但可能合并由协议决定;账本按帧数近似
重试副本是,弱网下更明显同一条业务消息按实际发送次数记账
心跳与保活随时间,不随业务消息单独通道,避免摊进业务均值
补状态随断线次数单独通道,观察长尾

把帧头做成显式参数,而不是埋在某个常量里,是为了让不同协议版本可以放在同一张表上比较。帧头从 28B 降到 20B,对单条消息只是几个字节,对每会话几万帧就是可观的差异;反过来,如果账本把帧头算成零,任何协议优化都无法在报表上体现。

这里有一个容易忽略的边界:重试副本到底算不算「一次会话的流量」?答案是算。链路不会因为客户端认为这是重发就免单。账本按实际发送记账,重试次数由连接层上报,而不是由业务层猜测。

上行 180B 与下行 620B:不对称是常态

事实表给出的单条消息均值是上行 180B、下行 620B。这个不对称不是异常,而是请求-响应模型的自然结果:上行通常是一条紧凑的指令,下行要带结果、状态和展示所需的数据。

方向均值主要构成优化空间
上行180B操作意图、参数、少量上下文压缩字段、合并提交、去掉冗余标识
下行620B结果、状态增量、展示数据增量下发、字段裁剪、避免整包回传

下行更大,意味着「少发一次请求」往往比「把请求压小一点」更有效:省下的是 620B 量级的响应,而不只是 180B 量级的请求。这也是为什么批量提交、合并领奖、延迟拉取在移动游戏里收益明显。

但下行均值不能直接乘以消息数当作会话流量。均值会被少量大响应拉高,尤其是补状态和榜单类请求;账本要同时保留总量和分布,至少能看出「哪一类消息贡献了长尾」。

重连与补状态如何改写会话账本

消消的断线率是 2.1%,重连成功率 97.2%,重连后需要服务端补状态的比例是 8%。这三个数字放在一起,说明绝大多数会话不会断,但一旦断了,补状态可能一次带来远高于普通消息的流量。

事件发生比例对账本的影响
正常会话多数业务、心跳、埋点按常规累积
发生断线2.1%重连握手与重发进入账本
30 秒内重连成功断线中的 97.2%会话继续,补状态可能触发
重连后需补状态全部会话的 8%单次大流量,进入 resync 通道

补状态的比例不是「断线后的 8%」,而是全部会话的 8%,这是事实表的口径。它意味着即使断线率只有 2.1%,补状态的影响面仍然更大,因为切后台、网络切换、进程被回收后回来都可能触发补状态,而不一定被记成一次「断线」。

账本因此不能只用均值。业务流量看均值是合理的,补状态必须看单次体积和发生频率;否则一个偶尔出现的整包状态会把「平均每会话流量」抬高,却无法解释是哪一天、哪一批用户贡献的。

可运行模型:traffic-ledger.ts

下面的 traffic-ledger.ts 把通道记账、帧开销、会话估算、月度量级与摘要放在同一个文件里。它不访问网络,也不持久化;所有字节数由调用方传入,帧开销是显式参数,便于把不同协议放在同一口径下比较。

// file: src/net/traffic-ledger.ts
// tier: L1
// verify: node --test test/net/traffic-ledger.test.ts

export const CHANNELS = ["biz", "heartbeat", "telemetry", "resync"] as const;

export type Channel = (typeof CHANNELS)[number];

export interface LedgerEntry {
  readonly channel: Channel;
  readonly payloadBytes: number;
  readonly frames: number;
}

export interface Ledger {
  readonly entries: readonly LedgerEntry[];
  readonly frameOverheadBytes: number;
}

export interface ChannelSlot {
  readonly channel: Channel;
  readonly payloadBytes: number;
  readonly wireBytes: number;
  readonly frames: number;
  readonly share: number;
}

export interface LedgerSummary {
  readonly slots: readonly ChannelSlot[];
  readonly totalPayloadBytes: number;
  readonly totalWireBytes: number;
  readonly totalFrames: number;
}

export function createLedger(frameOverheadBytes = 0): Ledger {
  if (!Number.isInteger(frameOverheadBytes) || frameOverheadBytes < 0) {
    throw new RangeError(`frame overhead must be a non-negative integer: ${frameOverheadBytes}`);
  }
  return { entries: [], frameOverheadBytes };
}

export function record(
  ledger: Ledger,
  channel: Channel,
  payloadBytes: number,
  frames = 1,
): Ledger {
  if (!Number.isInteger(payloadBytes) || payloadBytes < 0) {
    throw new RangeError(`payload bytes must be a non-negative integer: ${payloadBytes}`);
  }
  if (!Number.isInteger(frames) || frames <= 0) {
    throw new RangeError(`frames must be a positive integer: ${frames}`);
  }
  return {
    entries: [...ledger.entries, { channel, payloadBytes, frames }],
    frameOverheadBytes: ledger.frameOverheadBytes,
  };
}

export function summarize(ledger: Ledger): LedgerSummary {
  let totalPayloadBytes = 0;
  let totalWireBytes = 0;
  let totalFrames = 0;
  const slots: ChannelSlot[] = [];

  for (const channel of CHANNELS) {
    const entries = ledger.entries.filter((entry) => entry.channel === channel);
    const payloadBytes = entries.reduce((sum, entry) => sum + entry.payloadBytes, 0);
    const frames = entries.reduce((sum, entry) => sum + entry.frames, 0);
    const wireBytes = payloadBytes + frames * ledger.frameOverheadBytes;
    totalPayloadBytes += payloadBytes;
    totalWireBytes += wireBytes;
    totalFrames += frames;
    slots.push({ channel, payloadBytes, wireBytes, frames, share: 0 });
  }

  return {
    slots: slots.map((slot) => ({
      ...slot,
      share: totalWireBytes === 0 ? 0 : slot.wireBytes / totalWireBytes,
    })),
    totalPayloadBytes,
    totalWireBytes,
    totalFrames,
  };
}

export function estimateSessionBytes(
  perMessageBytes: number,
  messageCount: number,
  frameOverheadBytes = 0,
): number {
  if (!Number.isInteger(perMessageBytes) || perMessageBytes < 0) {
    throw new RangeError(`per-message bytes must be a non-negative integer: ${perMessageBytes}`);
  }
  if (!Number.isInteger(messageCount) || messageCount < 0) {
    throw new RangeError(`message count must be a non-negative integer: ${messageCount}`);
  }
  if (!Number.isInteger(frameOverheadBytes) || frameOverheadBytes < 0) {
    throw new RangeError(`frame overhead must be a non-negative integer: ${frameOverheadBytes}`);
  }
  return messageCount * (perMessageBytes + frameOverheadBytes);
}

export function projectMonthlyBytes(perDauDailyBytes: number, dau: number, days = 30): number {
  if (!Number.isFinite(perDauDailyBytes) || perDauDailyBytes < 0) {
    throw new RangeError(`per-DAU daily bytes must be non-negative: ${perDauDailyBytes}`);
  }
  if (!Number.isInteger(dau) || dau < 0) {
    throw new RangeError(`DAU must be a non-negative integer: ${dau}`);
  }
  if (!Number.isInteger(days) || days <= 0) {
    throw new RangeError(`days must be a positive integer: ${days}`);
  }
  return perDauDailyBytes * dau * days;
}

export function formatBytes(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes < 0) {
    throw new RangeError(`bytes must be a non-negative finite number: ${bytes}`);
  }
  if (bytes < 1024) {
    return `${bytes} B`;
  }
  const units = ["KB", "MB", "GB", "TB"];
  let value = bytes / 1024;
  let unitIndex = 0;
  while (value >= 1024 && unitIndex < units.length - 1) {
    value /= 1024;
    unitIndex += 1;
  }
  return `${value.toFixed(1)} ${units[unitIndex]}`;
}

export function describeLedger(summary: LedgerSummary): string {
  const parts = summary.slots
    .filter((slot) => slot.frames > 0)
    .map((slot) => `${slot.channel} ${formatBytes(slot.wireBytes)}(${(slot.share * 100).toFixed(1)}%)`);
  const body = parts.length === 0 ? "无记录" : parts.join(",");
  return `会话流量 ${formatBytes(summary.totalWireBytes)},共 ${summary.totalFrames} 帧:${body}`;
}

模型导出六个入口:建账本、记一笔、汇总、按消息估算会话、按月投影、格式化与摘要。记账时把「载荷字节」和「帧数」分开,汇总时再把帧开销乘回去,这样协议帧头变化可以被单独观察。校验只接受非负整数,避免小数或负数悄悄污染账本。

配套测试覆盖九条路径:空账本、不可变追加、帧开销与份额、按消息估算、按月投影、单位边界、摘要输出,以及多组非法输入。单位格式化的边界(1023B 与 1KB、1.5MB、2GB、2TB)单独断言,是因为报表上最容易出错的就是单位切换。

模型不判断「多少流量算多」。那是 7.3 的预算问题;本节只负责把账记准,让预算有一个可信的输入。

从会话账本到日账本:不要直接相乘

有了会话账本,最常见的下一步是「会话数 × 每会话流量」。这个乘法在消消身上会立刻自相矛盾:人均日在线 26 分钟、会话中位 6.5 分钟,看起来像四次会话;但每 DAU 日流量只有 3.6MB,而单次会话口径是 3.2MB。

矛盾来自口径不同,不是数据错了。3.2MB 是「一次完整会话」的样本口径,通常包含连接建立、补状态和较完整的业务往返;3.6MB 是「每 DAU 平均」的口径,分母包含大量只打开几分钟、只完成一两个动作的用户。把两个口径直接相乘,等于假设每个 DAU 都完成了多次完整会话。

口径数值分母适用问题
每会话流量3.2MB一次前台会话连接与协议优化的效果
每 DAU 日流量3.6MB一个活跃用户一天月度成本与运营商合作
人均日在线26 分钟一个活跃用户一天容量与服务器成本
会话时长中位6.5 分钟一次前台会话心跳与超时参数

正确做法是让两个账本各自闭环:会话账本回答「一次连接花多少」,日账本回答「一个用户一天花多少」。两者之间的桥是真实的会话分布,而不是中位数乘均值。如果一定要估算,应该用会话时长的分布和每类会话的流量分布做加权,并明确标注这是估算而不是实测。

对 80 万 DAU 的消消来说,日账本的一点点偏差都会被放大。账本的价值不在于算出一个漂亮的数字,而在于当数字变化时,能指出是哪个通道、哪类用户、哪种网络条件发生了变化。

与相邻章节的关系

6.3 讨论赛季结算与客户端展示口径,本节讨论这些结算消息在链路上占多少。7.2 会把心跳通道单独拿出来,讨论 25 秒的间隔如何在保活、耗电与断线判定之间取舍;7.3 则把会话账本升级成预算,定义超支时的降级顺序。重连后的整体状态校准属于 3.3 的范围,本节只记录补状态的流量,不设计补状态的内容。

小结

  • 会话流量至少分四个通道:业务、心跳、埋点、补状态;混在一起就无法定位成本来源。
  • 载荷只是单条消息成本的一部分,帧头、确认与重试都要进账本,帧头应作为显式参数。
  • 上行 180B、下行 620B 的不对称意味着「少发一次请求」通常比「压小一次请求」更有效。
  • 重连率 2.1%、补状态比例 8% 说明长尾流量必须单独统计,不能只用均值。
  • 会话账本与日账本是两个口径,不要用会话中位数直接乘会话流量。

阅读导航:上一节:6.3 赛季结算与客户端展示口径 · 下一节:7.2 心跳、长连接与推送的取舍 。

常见错误与边界

  1. 把业务载荷当成链路流量。帧头、确认和重试都不在载荷里;只统计载荷会系统性低估。
  2. 把心跳摊进业务均值。心跳随时间发生,不随消息数发生;混在一起会让业务优化的效果无法度量。
  3. 用会话中位数乘每会话流量估算日流量。两个口径的分母不同,直接相乘会得到与事实表冲突的结果。
  4. 补状态按均值统计。补状态是低频大体积事件,均值会掩盖尖峰;应单独看发生频率与单次体积。
  5. 把资源下载算进会话流量。资源属于内容成本,混进来会让网络成本和热更成本互相污染。
  6. 帧头写成隐藏常量。协议升级后无法对比;应作为参数进入账本。
  7. 重试不计费。链路不会因为客户端认为是重发就免单;账本按实际发送次数记账。
  8. 只记总量,不记通道。总量只能告诉你「变了」,通道才能告诉你「为什么变」。
  9. 用浮点数记字节。小数会在累加中产生误差;账本只接受整数。
  10. 边界:账本是估算模型,不是抓包替代品。它能统一口径、暴露结构,但单条消息的真实字节仍应以协议实现为准。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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