深蓝黑背景封面:右侧大标题「一条长连接,四层保险」,副标题「心跳、重连、超时、背压」;左侧原创示意图——一条横贯的长连接管线,管线上依次挂着四个阀门形节点:心跳(脉冲波纹)、重连(回旋箭头)、超时(沙漏)、背压(蓄水池水位线),管线两端是客户端与服务端;左上标注「知识专栏 · 可靠性工程」,右上标注「Day 05 · 11-02」 图 1:本文封面,四个阀门各管一种失败。

「长连接和短连接怎么选、Keep-Alive 和心跳什么区别」是一道经典面试题。那道题的正解止步于成本对比;本文换个角度:假设你已经决定用长连接(IM、推送、流式推理都绕不开它),接下来真正的问题是——这条连接怎么才能靠得住。答案是一套四件套:心跳保活、退避重连、超时预算、背压处理。它们各自独立又互相咬合。

第一件:心跳保活,回答「这条连接还活着吗」。

TCP 自带的 Keep-Alive 帮不上忙:Linux 的 tcp(7) 手册写明,默认要空闲 7200 秒(两小时)才开始发探测,再发 9 个间隔 75 秒的探测才宣判死亡——等业务感知到连接死了,两个多小时过去了。而且 Keep-Alive 只在显式开启 SO_KEEPALIVE 的 socket 上生效。所以长连接服务几乎都自己实现应用层心跳:客户端每 30 秒发一个 ping,服务端回 pong,连续 3 次没回就判连接死亡。

心跳间隔还有一个隐藏约束:中间的 NAT 设备和运营商会回收长时间静默的连接,阈值常见在几分钟量级。心跳必须比这阈值短,否则连接会被中间设备悄悄掐掉,而两端都以为对方还在。

第二件:指数退避重连,回答「断了怎么回来」。

心跳判死之后立刻重连是最坏的选择。如果故障在服务端,一万个客户端同时秒级重连,恢复的瞬间会被重连风暴再打垮一次。标准做法是指数退避:第 1 次等 1 秒、第 2 次等 2 秒、4 秒、8 秒……封顶在 30 秒上下,再叠加随机抖动(比如实际等待取基准值的 50%~100%)。抖动的作用是打散群体——大家都按同样的 1、2、4、8 退避,等于在离散时间点上对齐了风暴。

第三件:超时预算,回答「卡住多久算失败」。

没有超时的调用等于把命运交给对端。一次调用拆成连接、写入、读响应三段,每段独立限时;各段之和必须小于上游给的总预算,形成逐级收敛的预算树。这样任何一环卡死,最坏耗时都是可预期的数字,而不是无限挂起后把线程池拖垮。

第四件:背压处理,回答「对面发得太快怎么办」。

服务端推得猛、客户端消费慢,内存里的缓冲队列就会涨。正确姿势是设高水位线:水位越线就通知上游降速(应用层背压),逼近容量上限时按策略丢弃最旧或最不重要的消息——牺牲消息保内存,比 OOM 整体宕机划算得多。

四件套咬合运行一遍。

下面这段模拟把四个机制串起来跑,用 Node.js 22.15.0 直接运行(node connection.js):

'use strict';

// 1) 心跳:周期 ping,连续 N 次未回 pong 判连接死亡
function heartbeatDemo() {
  const INTERVAL = 30, MISS_LIMIT = 3; // 30s 一次心跳,连续丢 3 次判死
  const natTimeout = 120;              // 假设 NAT 静默回收阈值 120s
  let missed = 0, dead = false;
  const log = [];
  for (let t = INTERVAL; t <= 150 && !dead; t += INTERVAL) {
    const pong = t < 90; // 模拟第 90s 起服务端不再回包
    missed = pong ? 0 : missed + 1;
    log.push(`t=${t}s ${pong ? 'pong 正常' : `未回 pong(连续第 ${missed} 次)`}`);
    if (missed >= MISS_LIMIT) { dead = true; log.push(`t=${t}s 判定连接死亡,触发重连`); }
  }
  log.push(`心跳间隔 ${INTERVAL}s < NAT 回收阈值 ${natTimeout}s:静默连接不会被中间设备悄悄回收`);
  return log;
}

// 2) 指数退避重连:间隔翻倍 + 随机抖动 + 封顶
function backoffDemo() {
  const BASE = 1000, MAX = 30000; // ms
  const log = [];
  for (let attempt = 1; attempt <= 5; attempt++) {
    const raw = Math.min(MAX, BASE * 2 ** (attempt - 1));
    const jitter = raw * (0.5 + Math.random() * 0.5); // 50%~100% 抖动
    log.push(`第 ${attempt} 次重连:基准 ${raw}ms,加抖动后实际等待 ${Math.round(jitter)}ms`);
  }
  return log;
}

// 3) 超时预算:一次调用拆三段,各段独立限时,逐级收敛
function timeoutBudgetDemo() {
  const budget = { connect: 1000, write: 500, read: 3000 };
  const total = budget.connect + budget.write + budget.read;
  return [
    `分段超时:连接 ${budget.connect}ms / 写入 ${budget.write}ms / 读响应 ${budget.read}ms`,
    `合计上限 ${total}ms <= 上游总预算 5000ms:最坏情况也可预期,不会无限挂起`,
  ];
}

// 4) 背压:消费跟不上时水位上涨,高水位触发降速/丢弃
function backpressureDemo() {
  const HIGH = 100, CAP = 200;
  let level = 0;
  const log = [];
  for (let tick = 1; tick <= 10; tick++) {
    const produced = 40, consumed = tick >= 6 ? 50 : 20; // 第 6 拍起消费提速
    level = Math.max(0, Math.min(CAP, level + produced - consumed));
    let action = '正常';
    if (level >= CAP) action = '缓冲已满:丢弃最旧消息(保内存)';
    else if (level >= HIGH) action = '超过高水位:通知上游降速';
    log.push(`第 ${tick} 拍:水位 ${level}/${CAP} -> ${action}`);
  }
  return log;
}

console.log('== 心跳保活 ==');     heartbeatDemo().forEach((l) => console.log(l));
console.log('== 指数退避重连 =='); backoffDemo().forEach((l) => console.log(l));
console.log('== 超时预算 ==');     timeoutBudgetDemo().forEach((l) => console.log(l));
console.log('== 背压处理 ==');     backpressureDemo().forEach((l) => console.log(l));

实测输出(抖动数值每次运行不同):

== 心跳保活 ==
t=30s pong 正常
t=60s pong 正常
t=90s 未回 pong(连续第 1 次)
t=120s 未回 pong(连续第 2 次)
t=150s 未回 pong(连续第 3 次)
t=150s 判定连接死亡,触发重连
心跳间隔 30s < NAT 回收阈值 120s:静默连接不会被中间设备悄悄回收
== 指数退避重连 ==
第 1 次重连:基准 1000ms,加抖动后实际等待 655ms
第 2 次重连:基准 2000ms,加抖动后实际等待 1497ms
第 3 次重连:基准 4000ms,加抖动后实际等待 3941ms
== 超时预算 ==
分段超时:连接 1000ms / 写入 500ms / 读响应 3000ms
合计上限 4500ms <= 上游总预算 5000ms:最坏情况也可预期,不会无限挂起
== 背压处理 ==
第 5 拍:水位 100/200 -> 超过高水位:通知上游降速
第 6 拍:水位 90/200 -> 正常

四象限机制图:左上「心跳保活」——时间轴上客户端周期发出 ping、服务端回 pong,连续 N 次未回判连接死亡;注意 NAT 与运营商会回收静默连接,心跳间隔要短于回收阈值。右上「指数退避重连」——重连间隔 1s、2s、4s、8s 阶梯拉长并加随机抖动,防止故障恢复瞬间被重连风暴再打垮。左下「超时预算」——一次调用拆成连接、写入、读响应三段,各段独立超时且总预算逐级收敛,避免无限等待。右下「背压处理」——消费速度跟不上时缓冲队列水位上涨,高水位触发降速/丢弃策略,保护内存不被打爆。底部总结:四件套各自独立又互相咬合——心跳触发重连,超时定义「卡住」,背压决定「装不下时怎么办」 图 2:四件套各自独立,又按「检测→恢复→限时→兜底」咬合。

最后看一眼咬合关系:心跳负责检测(发现连接死了),触发重连负责恢复(但要用退避保护服务端),超时预算定义什么叫卡住(等不到 pong 本身就是一种超时),背压决定装不下时怎么办(降速还是丢弃)。少了任何一件,系统都有一种对应的死法:没心跳,断线两小时无人知;没退避,故障恢复变二次故障;没超时,线程被无限挂起拖垮;没背压,内存被打爆。设计长连接服务时,你最容易漏掉哪一件?欢迎对照自查后留言。