《移动游戏产品的架构·网络、资源与热更新》7.2 心跳、长连接与推送的取舍

心跳间隔不是越短越可靠:它同时受保活、NAT 映射、服务端会话与耗电约束。本节以消消的 25 秒心跳为例,拆开长连接、轮询与推送三种形态,给出用连续未响应次数判定断线的可运行模型,并对照方舟的场景化间隔,说明缩短间隔并不能降低断线率,先把代价算清楚。

场景:把 25 秒改成 5 秒,断线率没有降

《潮汐消消》的一次网络优化里,有人提出把心跳从 25 秒缩短到 5 秒,理由很直接:心跳越密,越早发现断线,玩家越少卡在「点了没反应」的状态。改动上线后,断线率仍然是 2.1%,重连成功率仍然是 97.2%,但耗电和流量投诉先上来了。

问题在于把「发现断线」和「保持连接」混成了一件事。心跳确实能探测链路,但缩短间隔并不能阻止断线发生,只能让客户端更早知道。更早知道的收益,取决于玩家在这段时间里是否会发出操作;而在消消这种弱联网、回合制的产品里,玩家大部分时间在看动画和结算,5 秒与 25 秒的体感差异远小于耗电差异。

这个案例的结论不是「心跳越慢越好」,而是「心跳间隔是一组约束的交集」。本节把约束拆开,再给出一个能计算次数、流量与判定延迟的模型。

心跳同时在回答三个问题

很多团队把心跳当成一个定时器,实际上它在同时回答三个不同的问题。三个问题的答案不同,间隔的取值也会不同;只盯着其中一个,就会得到片面的结论。

问题心跳的作用间隔太长的后果间隔太短的代价
连接还活着吗探测链路与服务端断线发现慢,操作卡住空包变多,流量与耗电上升
NAT 映射还在吗维持中间设备的映射映射过期,重连变频繁收益递减,多数映射不会那么快过期
服务端会话还有效吗续期会话与在线状态会话被回收,重连要补状态服务端定时器压力上升

这三个问题里,NAT 映射和服务端会话都有自己的超时策略,心跳必须比它们更短,但不需要短到「每秒一次」。真正决定下限的是最严格的那个超时,而不是产品经理对「实时」的直觉。

消消采用 25 秒全场景心跳,就是在这三个问题之间取了一个保守值:比常见的中间设备映射超时短,又不至于让空闲玩家持续产生可观的空包。它不追求最快发现断线,而是接受「发现得稍晚,但重连能恢复」的取舍。

间隔是四组约束的交集

把影响心跳间隔的因素列全,会发现它不是一个可以拍脑袋定的数。下面四组约束分别给出上限或下限,最终取值必须同时满足。

约束方向说明
中间设备映射超时上限心跳必须短于映射过期时间,否则连接被静默丢弃
服务端会话超时上限心跳要能在会话被回收前续期
断线发现时延上限间隔乘以允许的未响应次数,决定最晚多久发现
耗电与流量下限间隔越短,空闲时的空包越多;移动网络下尤其明显
服务端定时器容量下限在线用户越多,固定间隔的心跳越像一次小型定时洪峰

这四组约束里,前两组是「不能超过」,后两组是「不能低于」。工程上通常先取一个满足上限的保守间隔,再用场景化策略降低空闲时的成本,而不是全局缩短。

场景化是这里的关键:战斗内需要更快发现断线,大厅和后台可以慢一些。消消是弱联网、回合制,单局 90 秒,全场景 25 秒已经够用;方舟是强联网、单场 8–12 分钟,战斗内 5 秒、大厅 20 秒的分档更合适。间隔不是产品调性,而是场景约束的映射。

长连接、轮询与推送的取舍

心跳只是长连接的一部分。真正决定连接成本的是形态:长连接、短轮询,还是服务端推送。三者不是互斥的,很多产品会组合使用,但主形态只能有一个。

形态实时性空闲成本实现复杂度适用场景
长连接高心跳维持,空闲也有空包连接管理、重连、补状态战斗、聊天、需要服务端主动下发
短轮询低,取决于间隔每次请求都有完整往返简单,易缓存低频状态、配置、公告
服务端推送高依赖系统通道,应用不在前台也能到厂商通道、去重、合规召回、活动提醒、离线消息

长连接的优势是双向、低延迟,代价是必须维护连接状态:断线要重连,重连要补状态,切后台要处理挂起。消消的断线率 2.1%、重连成功率 97.2%,说明这套维护逻辑是可行的,但它不是免费的。

短轮询的优势是实现简单,代价是每次都要重新建立请求上下文,空闲时也可能产生完整往返。它适合「不需要服务端主动说话」的场景,不适合战斗同步。

推送的优势是能触达不在前台的用户,但它通常不保证实时,也不适合承载战斗内消息。把推送当成战斗通道,是移动游戏架构里最常见的误用之一。

断线判定:用连续未响应次数,而不是固定超时

「多久没收到包算断线」这个问题,答案不应该是一个固定毫秒数,而应该是「连续多少个心跳周期没有响应」。原因很简单:间隔会随场景变化,固定超时在 5 秒场景下太松,在 25 秒场景下又太紧。

判定方式5 秒间隔下的表现25 秒间隔下的表现问题
固定 30 秒超时要等 6 个周期只等 1 个多周期无法随场景伸缩
固定 2 个周期10 秒50 秒随场景变化,语义一致
连续 2 次未响应10 秒50 秒需要能区分「没发」与「发了没回」

用「连续未响应次数」判定,还要处理一种边界:客户端自己因为切后台而没有发送心跳。这时「未响应」是客户端造成的,不是链路问题。模型里把「活动时长」作为输入,活动时长为零时不产生心跳,也不应触发断线判定;只有真正发出心跳却没有收到响应,才计入未响应。

消消的重连成功率 97.2%,说明大多数断线能在 30 秒内恢复。这个数字反过来约束了判定策略:判定太激进会制造不必要的重连,判定太保守会让玩家多等。25 秒间隔配两次未响应,最晚 50 秒发现,看似偏慢,但配合「操作时立即探测」的补充策略,体感可以接受。

可运行模型:heartbeat.ts

下面的 heartbeat.ts 把场景配置、会话计划、静默判定、方案比较与摘要放在同一个文件里。模型不启动定时器,也不访问网络;时间由调用方传入,判定结果只依赖间隔与未响应次数。

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

export const SCENES = ["battle", "lobby", "background"] as const;

export type Scene = (typeof SCENES)[number];

export interface KeepaliveProfile {
  readonly scene: Scene;
  readonly intervalMs: number;
  readonly upstreamBytes: number;
  readonly downstreamBytes: number;
  readonly missedToDetect: number;
}

export interface HeartbeatPlan {
  readonly profile: KeepaliveProfile;
  readonly beats: number;
  readonly wireBytes: number;
  readonly detectionMs: number;
}

export type LinkState = "ok" | "suspect" | "dead";

export interface SilenceReport {
  readonly state: LinkState;
  readonly missed: number;
  readonly silentMs: number;
}

export interface ProfileInput {
  readonly scene: Scene;
  readonly intervalMs: number;
  readonly upstreamBytes: number;
  readonly downstreamBytes: number;
  readonly missedToDetect?: number;
}

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 defineProfile(input: ProfileInput): KeepaliveProfile {
  if (!Number.isInteger(input.intervalMs) || input.intervalMs <= 0) {
    throw new RangeError(`heartbeat interval must be a positive integer: ${input.intervalMs}`);
  }
  if (!Number.isInteger(input.upstreamBytes) || input.upstreamBytes < 0) {
    throw new RangeError(`upstream bytes must be a non-negative integer: ${input.upstreamBytes}`);
  }
  if (!Number.isInteger(input.downstreamBytes) || input.downstreamBytes < 0) {
    throw new RangeError(`downstream bytes must be a non-negative integer: ${input.downstreamBytes}`);
  }
  const missedToDetect = input.missedToDetect ?? 2;
  if (!Number.isInteger(missedToDetect) || missedToDetect <= 0) {
    throw new RangeError(`missed-to-detect must be a positive integer: ${missedToDetect}`);
  }
  return {
    scene: input.scene,
    intervalMs: input.intervalMs,
    upstreamBytes: input.upstreamBytes,
    downstreamBytes: input.downstreamBytes,
    missedToDetect,
  };
}

export function planSession(profile: KeepaliveProfile, activeMs: number): HeartbeatPlan {
  if (!Number.isInteger(activeMs) || activeMs < 0) {
    throw new RangeError(`active milliseconds must be a non-negative integer: ${activeMs}`);
  }
  const beats = activeMs === 0 ? 0 : Math.floor(activeMs / profile.intervalMs) + 1;
  const wireBytes = beats * (profile.upstreamBytes + profile.downstreamBytes);
  return {
    profile,
    beats,
    wireBytes,
    detectionMs: profile.intervalMs * profile.missedToDetect,
  };
}

export function classifySilence(profile: KeepaliveProfile, silentMs: number): SilenceReport {
  if (!Number.isInteger(silentMs) || silentMs < 0) {
    throw new RangeError(`silent milliseconds must be a non-negative integer: ${silentMs}`);
  }
  const missed = Math.floor(silentMs / profile.intervalMs);
  let state: LinkState = "ok";
  if (missed >= profile.missedToDetect * 2) {
    state = "dead";
  } else if (missed >= profile.missedToDetect) {
    state = "suspect";
  }
  return { state, missed, silentMs };
}

export interface PlanComparison {
  readonly cheapest: HeartbeatPlan;
  readonly fastest: HeartbeatPlan;
  readonly overheadBytes: number;
}

export function comparePlans(plans: readonly HeartbeatPlan[]): PlanComparison {
  if (plans.length === 0) {
    throw new RangeError("at least one heartbeat plan is required");
  }
  let cheapest = plans[0];
  let fastest = plans[0];
  for (const plan of plans) {
    if (plan.wireBytes < cheapest.wireBytes) {
      cheapest = plan;
    }
    if (plan.detectionMs < fastest.detectionMs) {
      fastest = plan;
    }
  }
  return {
    cheapest,
    fastest,
    overheadBytes: fastest.wireBytes - cheapest.wireBytes,
  };
}

export function describePlan(plan: HeartbeatPlan): string {
  return `${plan.profile.scene}:间隔 ${plan.profile.intervalMs}ms,${plan.beats} 次心跳,流量 ${formatBytes(plan.wireBytes)},断线判定 ${plan.detectionMs}ms`;
}

模型导出六个入口:场景常量、配置校验、会话计划、静默判定、方案比较与摘要。planSession 在活动时长为零时返回零次心跳,避免把「切后台」误算成流量;classifySilence 用连续未响应次数区分正常、可疑与断开,阈值由配置决定,而不是写死毫秒数。

配套测试覆盖九条路径:默认阈值、非法配置、正常会话、零活动、判定边界、方案比较、空方案、摘要输出与负数时长。边界用例特意覆盖「刚好等于阈值」与「差一毫秒」两种情况,因为判定逻辑最常见的错误就是多算或少算一个周期。

模型里的字节数是参数,不是事实。不同产品的单条心跳大小不同,把它做成输入,才能在同一套判定逻辑下比较不同场景的成本。

对照:消消的全场景与方舟的分档

消消采用 25 秒全场景心跳,配合 6.5 分钟的中位会话和 26 分钟的人均日在线,空闲成本可控;它的弱联网属性决定了「早发现」的收益有限,稳定重连比极致实时更重要。

方舟则在战斗内用 5 秒、大厅用 20 秒:战斗内需要更快发现断线以触发补状态,大厅可以放宽以降低空闲成本。它的会话中位 34 分钟、每会话 18MB,说明高频心跳的代价被更长的会话和更强的实时需求分摊了。

与相邻章节的关系

7.1 把心跳作为一个流量通道记账,本节决定这个通道的频率与判定逻辑。7.3 会把心跳成本放进日预算,讨论弱网下如何降级。重连后的补状态内容属于 3.3,本节只关心「何时判定断线」和「判定后是否重连」,不设计补什么。

小结

  • 心跳同时回答连接存活、NAT 映射与服务端会话三个问题,间隔由最严格的超时决定。
  • 间隔是四组约束的交集:映射与会话超时给上限,耗电流量与服务端容量给下限。
  • 长连接、短轮询与推送各有适用面,主形态只能有一个,其余作为补充。
  • 断线判定应使用连续未响应次数,而不是固定毫秒数;切后台造成的不发送不能计入。
  • 消消用 25 秒全场景换取稳定与省电,方舟用 5 秒/20 秒分档换取战斗内的快速发现。

阅读导航:上一节:7.1 一次会话的流量构成 · 下一节:7.3 流量预算与弱网适配 。

常见错误与边界

  1. 把心跳间隔当成实时性开关。缩短间隔只能更早发现断线,不能阻止断线;收益取决于玩家是否正在操作。
  2. 用固定毫秒数判定断线。间隔随场景变化时,固定超时要么太松要么太紧;应使用连续未响应次数。
  3. 把切后台未发送算成断线。客户端主动不发送与链路无响应是两件事,混在一起会制造假重连。
  4. 心跳间隔短于所有约束却不计成本。空闲空包会累积成可观的流量与耗电,尤其在移动网络下。
  5. 只做长连接,不做重连与补状态。长连接的可用性取决于恢复路径,而不是连接本身。
  6. 把推送当作战斗通道。系统推送不保证实时,也无法承载高频双向消息。
  7. 心跳与业务消息共用一个超时。业务请求的超时针对单次往返,心跳超时针对连接存活,语义不同。
  8. 服务端按全量在线用户同一时刻发心跳。固定间隔会形成定时洪峰,应加抖动或错峰。
  9. 判定阈值写死在客户端。服务端调整会话超时后,客户端无法跟随;阈值应可下发。
  10. 边界:心跳不是越密越安全。过度心跳会增加耗电、流量和服务端定时器压力,收益却很快递减。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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