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