《移动游戏产品的架构·网络、资源与热更新》1.1 弱网、切换与丢包:客户端要面对的三种网络

移动网络不是通或断的开关。本节以《潮汐消消》为样本,把 RTT P50 78ms、P95 310ms、丢包 0.4%、弱网会话 22% 这些事实拆成弱网、切换、丢包三种形态,给出会话画像的计算口径、阈值来源与排查顺序,并划清客户端在三种形态里能做与不能做的事。

一局 90 秒里的三次卡顿

《潮汐消消》一局 90 秒。玩家在晚高峰地铁上开一局,客户端可能在同一局里遇到三种完全不同的网络事件:

  1. 进站时 Wi-Fi 断开,系统切到 4G,连接路径变了,但网络并没有真正消失。
  2. 过隧道时信号短暂中断,几秒内所有上行消息都发不出去。
  3. 结算时提交本局数据,每次提交 600B–1.2KB,单局 14–22 次提交,其中一次没有收到确认。

对玩家来说,这三种情况都表现为「卡了一下」。对客户端来说,它们的处理方式完全不同:第一种要保住会话,第二种要保住队列,第三种要保住幂等。把三者混成一句「网络不好」,最后往往是用同一种重试策略去处理三种问题,越处理越糟。

本节先建立一套可观测的分类,再把分类写成可运行的会话画像。后面的 1.2 讨论责任边界,1.3 把画像接进实验骨架。

把「网络差」拆成三种形态

案例事实表给消消的会话数据是:RTT P50 78ms、P95 310ms,丢包率中位会话 0.4%,弱网会话占比 22%。这三个数放在一起,已经说明「平均网络」和「玩家体验」不是一回事。

形态观测信号典型时间尺度客户端该做客户端不该做
弱网会话内 RTT P95 超过 300ms,或丢包超过 2%整局持续降频、合并请求、预取关键资源把超时直接当成玩家放弃操作
切换网络接口变化、短时间不可达秒级暂停发送、保留待发队列、等待可用路径立刻重建长连接并清空本地状态
丢包单条消息未确认毫秒到秒幂等重传、按序号去重无上限重试,把拥塞放大

这张表的第一行直接引用了事实表里的弱网定义。定义写成「会话内 P95 超过 300ms 或丢包超过 2%」,而不是「平均 RTT 超过多少」,原因很实际:平均值会被大量正常样本拉低,而玩家记住的是最差的那几秒。

第二行和第三行经常被合并,但它们的代价不同。切换是路径问题,重连本身不贵,贵在重连后要不要补状态;丢包是消息问题,连接还在,贵在重复提交会不会造成二次结算。把切换当成丢包处理,会多做一次无谓的重连;把丢包当成切换处理,会丢掉一条本可以重传的消息。

用会话画像把感觉变成数字

把一局拆成若干采样点,每个点记录往返时延、发送包数、送达包数和是否发生切换,就可以算出一份会话画像。下面的实现只依赖事实表已经给出的两个阈值:300ms 与 2%。

// file: src/net/network-profile.ts
// tier: L1
// verify: node --test test/net/network-profile.test.ts

export interface NetSample {
  readonly rttMs: number;
  readonly sent: number;
  readonly delivered: number;
  readonly switched: boolean;
}

export type QualityVerdict = "normal" | "weak";

export interface SessionProfile {
  readonly samples: number;
  readonly rttP50: number;
  readonly rttP95: number;
  readonly lossRatio: number;
  readonly switches: number;
  readonly weak: boolean;
  readonly reasons: readonly string[];
}

export const WEAK_NETWORK_THRESHOLDS = {
  rttP95Ms: 300,
  lossRatio: 0.02,
} as const;

export function percentile(values: readonly number[], p: number): number {
  if (!Number.isFinite(p) || p <= 0 || p > 100) {
    throw new RangeError(`percentile must be in (0, 100], got ${p}`);
  }
  if (values.length === 0) {
    throw new Error("percentile needs at least one value");
  }
  for (const value of values) {
    if (!Number.isFinite(value) || value < 0) {
      throw new RangeError(`invalid sample value: ${value}`);
    }
  }
  const sorted = [...values].sort((a, b) => a - b);
  const rank = Math.ceil((p / 100) * sorted.length);
  return sorted[Math.min(rank, sorted.length) - 1];
}

export function profileSession(samples: readonly NetSample[]): SessionProfile {
  if (samples.length === 0) {
    throw new Error("profileSession needs at least one sample");
  }
  const rtts: number[] = [];
  let sent = 0;
  let delivered = 0;
  let switches = 0;
  for (const sample of samples) {
    if (!Number.isFinite(sample.rttMs) || sample.rttMs < 0) {
      throw new RangeError(`invalid rtt: ${sample.rttMs}`);
    }
    if (!Number.isInteger(sample.sent) || sample.sent <= 0) {
      throw new RangeError(`sent must be a positive integer, got ${sample.sent}`);
    }
    if (!Number.isInteger(sample.delivered) || sample.delivered < 0 || sample.delivered > sample.sent) {
      throw new RangeError(`delivered out of range: ${sample.delivered}/${sample.sent}`);
    }
    rtts.push(sample.rttMs);
    sent += sample.sent;
    delivered += sample.delivered;
    if (sample.switched) {
      switches += 1;
    }
  }
  const lossRatio = sent === 0 ? 0 : 1 - delivered / sent;
  const rttP50 = percentile(rtts, 50);
  const rttP95 = percentile(rtts, 95);
  const reasons: string[] = [];
  if (rttP95 > WEAK_NETWORK_THRESHOLDS.rttP95Ms) {
    reasons.push("rtt_p95>300ms");
  }
  if (lossRatio > WEAK_NETWORK_THRESHOLDS.lossRatio) {
    reasons.push("loss>2%");
  }
  return {
    samples: samples.length,
    rttP50,
    rttP95,
    lossRatio,
    switches,
    weak: reasons.length > 0,
    reasons,
  };
}

export function classifyQuality(profile: SessionProfile): QualityVerdict {
  return profile.weak ? "weak" : "normal";
}

export function describeProfile(profile: SessionProfile): string {
  const lossPct = (profile.lossRatio * 100).toFixed(2);
  const verdict = classifyQuality(profile);
  const reason = profile.reasons.length === 0 ? "within-threshold" : profile.reasons.join("+");
  return `rtt p50=${profile.rttP50}ms p95=${profile.rttP95}ms loss=${lossPct}% switches=${profile.switches} verdict=${verdict} reason=${reason}`;
}

export function weakSessionShare(profiles: readonly SessionProfile[]): number {
  if (profiles.length === 0) {
    throw new Error("weakSessionShare needs at least one profile");
  }
  const weak = profiles.filter((profile) => profile.weak).length;
  return weak / profiles.length;
}

这段代码有三个刻意的选择。

第一,分位数用最近秩,不做线性插值。运维判断只需要一个稳定、可复现的边界值;插值会让同一批样本在不同实现里得到略有差异的 P95,排查时反而增加争议。

第二,丢包率按样本汇总后计算,而不是先算每个样本的丢包率再取平均。后者会让包数少的样本获得与包数多的样本相同的权重,长会话的真实丢失被稀释。

第三,reasons 保留触发原因,而不是只返回一个布尔值。只告诉调用方「这是弱网」,排查时仍然要从头再算一遍;把 rtt_p95>300ms 与 loss>2% 分开记录,才能直接决定是优化链路还是优化重传。

把一条会话喂进去,输出形如:

rtt p50=78ms p95=310ms loss=0.40% switches=1 verdict=weak reason=rtt_p95>300ms

这行文本可以直接进日志,也可以作为 1.3 实验骨架里的探针输出。它的价值不在于好看,而在于同一个会话在不同版本、不同机型上可以逐字段对比。

22% 弱网会话意味着什么

22% 是会话占比,不是玩家占比,也不是请求占比。一个玩家一天开十局,可能只有两局落在弱网里;但这两局决定了他当天对「卡不卡」的印象。

这里有两个容易混淆的口径:

口径含义用途
会话内 P95单局内部时延分布的尾部判断这一局是否算弱网
会话占比 22%所有会话中满足弱网定义的比重判断弱网是不是普遍问题
人均日在线 26 分钟玩家每天在游戏里的总时长估算弱网暴露时长,不直接等于弱网时长

把 22% 直接乘上 DAU 80 万,得到的是一个「弱网会话次数」的量级,不是「弱网玩家数」。这两个数在汇报里经常被混用,最后导致优化目标定错:该优化首局加载,却去优化长尾重连。

作为对照,方舟的弱网会话占比是 14%,低于消消的 22%;但方舟是强联网,重连后 31% 的会话需要服务端补状态,恢复成本反而更高。这个差异会在 1.2 展开。

切换:被低估的 2.1%

消消的断线率是 2.1%,口径是「一次前台会话中至少发生一次非主动断线」,包含切后台。重连成功率 97.2%,统计窗口是 30 秒。

这两个数要一起看。2.1% 的断线率不算高,但一次断线发生在 90 秒一局里,玩家感知到的就是整局节奏被打断;97.2% 的 30 秒内恢复,意味着仍有少数会话在 30 秒后还没回来。会话时长中位是 6.5 分钟,人均日在线 26 分钟,30 秒的恢复窗口已经占到单局的三分之一。

切换还有两个结构性因素:

  • 平台分布是 Android 60%、iOS 40%,两个系统的后台策略与网络切换行为不同,同一套重连参数不能直接照搬。
  • 低端机占比 35%,这些设备在 Wi-Fi 与蜂窝之间切换时,重建连接的成功率与耗时都更不稳定。

客户端在切换时最该做的不是「更快重连」,而是「不丢待发消息」。心跳间隔 25 秒覆盖全场景,意味着服务端本来就有 25 秒的容忍窗口;在这个窗口内,客户端完全可以把待发消息留在队列里,等路径恢复后按序号补发,而不是每检测到一次接口变化就清空状态重来。

丢包:0.4% 不是小数

0.4% 是丢包率的中位会话。单看比例很小,但消消的提交是「一局 14–22 次、每次 600B–1.2KB」的结算型消息,丢一次就可能让这一局的奖励延迟到下一局才确认。

这类消息的处理原则与实时同步不同:

消息类型例子失败后的正确动作失败后的错误动作
幂等提交回合结算、领奖用同一业务序号重传生成新序号重复提交
状态查询拉取活动配置退避后重试,可合并并发发起多次相同查询
表现同步动画、音效触发允许丢弃,等待下一次快照阻塞主流程等待确认

消消每会话流量 3.2MB、每 DAU 日流量 3.6MB,不含资源下载。这个量级说明重传预算并不紧张,真正的约束是「不能重复结算」。因此幂等键应该由客户端生成、服务端持久化,而不是靠「大概没收到」来猜。

一个可执行的排查顺序

当真机反馈「这局很卡」时,按下面的顺序查,比直接看平均 RTT 有效:

  1. 先看会话级 P95 与丢包率。 平均值正常、P95 超标,说明问题集中在少数时间段,不要用均值结案。
  2. 对齐切换时间点。 把网络接口变化事件和卡顿时间轴放在一起,确认是路径切换还是链路质量下降。
  3. 检查提交是否幂等。 找到卡顿前后的业务序号,确认没有重复结算,也没有因为重试生成新序号。
  4. 检查重连后的补状态。 消消有 8% 的重连需要服务端下发增量;如果日志里这个比例异常升高,优先查增量协议而不是重连本身。
  5. 最后才看服务端与 CDN。 客户端画像只能证明「这一段网络发生了什么」,不能证明「服务端处理得对不对」。

这个顺序的核心是:先用客户端能观测到的证据缩小范围,再把服务端指标接进来。反过来做,很容易在服务端看到一片正常,却解释不了玩家那一局的卡顿。

与相邻章节的关系

本节建立了「弱网、切换、丢包」三类观测,以及把观测压成会话画像的最小实现。1.2 会在这个分类之上讨论弱联网与强联网的责任边界:同样是 300ms 的 P95,消消可以把实时性换成可重放,方舟必须把实时性换成可补偿。

1.3 会把本节的分位数、丢包汇总与弱网判定接进 mga-lab-net 的实验骨架,用统一夹具跑消消与方舟两组基线。再往后,第 2 章开始处理消息信封与版本兼容,那些设计的前提正是本节所说的「消息可能丢、连接可能换、状态可能补」。

小结

  • 弱网、切换、丢包是三种不同故障,不能用同一套重试策略处理。
  • 弱网判定用会话内 P95 超过 300ms 或丢包超过 2%,不用平均值。
  • 分位数用最近秩,丢包率按样本汇总,保证结果可复现、可对比。
  • 22% 是会话占比,不是玩家占比;汇报时不要把两个口径混用。
  • 断线率 2.1% 与 30 秒内 97.2% 的重连成功率要一起看,恢复窗口相对 90 秒一局并不短。
  • 切换时优先保住待发队列,丢包时优先保证幂等,二者不要互相替代。
  • 排查顺序从客户端画像开始,再用服务端指标验证,不要反过来。

阅读导航:上一节:目录与学习路径 · 下一节:1.2 弱联网与强联网:两种联网形态的责任边界 。

常见错误与边界

  1. 用平均 RTT 判断弱网。 均值会被大量正常样本拉低,消消的 P50 只有 78ms,但 P95 已经到 310ms;看均值会漏掉真正影响体验的尾部。
  2. 把弱网会话占比当成玩家占比。 22% 是会话口径,一个玩家可能贡献多个会话,也可能一个都不贡献。
  3. 切换后立刻重建连接并清空状态。 心跳间隔 25 秒给了客户端缓冲窗口,清空状态会把一次可恢复的切换变成一次真正的断线。
  4. 丢包后无上限重试。 重试会放大拥塞,尤其是结算型消息;正确做法是幂等重传加退避,而不是堆并发。
  5. 把重连成功当成恢复完成。 消消有 8% 的重连需要服务端补状态,连接恢复不等于业务状态恢复。
  6. 用同一套参数覆盖 Android 与 iOS。 两个平台的后台策略与切换行为不同,平台占比 60% 与 40% 也意味着问题分布不同。
  7. 忽略低端机的切换成本。 低端机占比 35%,这些设备重建连接更慢,重连超时应该按机型分档观察。
  8. 只看单局,不看日累计。 人均日在线 26 分钟、每 DAU 日流量 3.6MB,一天内的多次小断线会累积成明显的体验问题。
  9. 把会话画像当成服务端监控的替代品。 画像只能说明客户端侧看到了什么,服务端处理是否正确仍需服务端指标与日志。
  10. 在弱网下阻塞主流程等待确认。 消消是回合制提交,等待确认应该异步化;阻塞主流程会把网络问题放大成玩法问题。
  11. 把本节阈值当成所有产品的通用标准。 300ms 与 2% 是案例事实表里消消的弱网定义;换产品要重新对齐口径,不能直接照搬。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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