Node.js WebSocket 优雅关闭:一次滚动发布如何避免 1 万次重连风暴

先看一段并不罕见的客户端逻辑:

ts 复制代码
let socket: WebSocket

function connect() {
  socket = new WebSocket(WS_URL)

  socket.onclose = () => {
    setTimeout(connect, 1000)
  }
}

单看一台手机,它很合理:断线后等 1 秒,再连。

把它放到滚动发布里,问题就出现了。假设 3 个 Node.js WebSocket 实例分别持有约 1 万条连接,发布时只下线其中一个。如果该实例直接退出,那 1 万个客户端会从几乎同一时刻开始计时,又在 1 秒后一起发起新连接。

这些数字是本文的实验模型,不是生产跑分。我们要验证的也不是某个框架"能不能承载 3 万连接",而是发布期间连接怎样离开、重连怎样错峰、客户端怎样恢复到正确的消息位置。

这也是一道很好的 Node.js / 系统设计面试题。因为"会写自动重连"与"能交付可发布的实时系统"之间,还隔着一整条连接生命周期。

先把问题变成可观测事件

如果日志只有"connected"和"disconnected",很难看出重连是均匀发生,还是挤在同一秒。

我会给发布和连接事件加一组有界字段:

ts 复制代码
interface ReconnectEvent {
  rolloutId: string
  revision: string
  podId: string
  connectionIdHash: string
  phase:
    | 'close_sent'
    | 'closed'
    | 'reconnect_scheduled'
    | 'handshake_started'
    | 'socket_open'
    | 'session_resumed'
    | 'stable'
  closeCode?: number
  attempt?: number
  delayMs?: number
  at: number
}

connectionIdHash 只用于局部追踪,不要把 userIdsessionId 或设备 ID 直接放进 Prometheus label。无界标签会制造另一类高基数问题,明细身份应进日志或 trace。

将事件按 100ms 时间桶聚合,坏版本的形状会很直观。下表仍是建模预期,真实数值需要用压测得到:

时间 客户端事件 服务端现象
T0 1 万条连接几乎同时异常关闭 旧实例退出
T+1s 1 万次 handshake_started TLS、Upgrade、鉴权和会话存储同时出现尖峰
T+1.xs 部分建连失败 新实例还未稳定,超时/拒绝增加
T+2.xs 失败客户端再次整齐重试 第二个尖峰出现

所谓"重连风暴",可以用这种事件分布定义,而不是靠日志里一句"重连很多"。

服务端要有自己的 admission gate

Kubernetes 删除 Pod 时,terminationGracePeriodSeconds 已经开始倒计时。配置了 preStop 且 grace 不为 0 时,kubelet 会先等待 hook 完成,再通常向容器主进程发送 TERM;控制面会并行更新 EndpointSlice,端点不会立刻消失,而会呈现 terminating=trueready=falseserving 仍可能为 true。preStop 与应用排空共享同一份 grace budget,截止后剩余进程会被强制终止。

所以应用内部仍然需要一个原子的 draining 状态。从收到终止事件的最早时刻起,它就拒绝新的 WebSocket Upgrade,不再等网关路由完全收敛。

ts 复制代码
import http from 'node:http'
import { WebSocketServer } from 'ws'

let draining = false

const server = http.createServer((req, res) => {
  if (req.url === '/readyz') {
    res.writeHead(draining ? 503 : 200)
    res.end(draining ? 'draining' : 'ready')
    return
  }

  res.writeHead(404)
  res.end()
})

const wss = new WebSocketServer({ noServer: true })

server.on('upgrade', (req, socket, head) => {
  if (draining) {
    socket.write(
      'HTTP/1.1 503 Service Unavailable\r\n' +
      'Connection: close\r\n\r\n',
    )
    socket.destroy()
    return
  }

  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit('connection', ws, req)
  })
})

这里有两个边界需要说清楚:

  • readiness 变红会影响后续选路,不会把已经建立的 TCP/WebSocket 迁移到另一个 Pod。
  • Node.js server.close() 不会替你关闭所有已升级的 WebSocket;应用需要显式跟踪和排空现有客端。
  • 浏览器原生 WebSocket API 不暴露握手响应头,重连节奏不能依赖客户端读取 Retry-After;这里由客户端自己的退避与抖动策略负责错峰。

排空需要一个有界窗口

如果一条连接可以持续数小时,那么"等待连接数变成 0"可能会让发布永远不结束。排空需要一个有界窗口:

  1. 先停止接收新 Upgrade;
  2. 完成或持久化正在处理的业务操作;
  3. 在排空窗口内分批发送 Close frame;
  4. 等待关闭握手和必要的应用层 ACK;
  5. 硬截止时间之前尚未退出的连接被显式 terminate()

下面是简化伪代码。hardDeadlineMs 必须小于进程实际收到 TERM 时剩余的 grace budget,并预留关闭握手和进程退出余量;配置 preStop 时还要先扣除 hook 的耗时。20/25 秒只用于展示代码关系,不是 Kubernetes 默认答案。

ts 复制代码
async function beginDrain() {
  if (draining) return
  draining = true

  const clients = [...wss.clients]
  const drainWindowMs = 20_000
  const hardDeadlineMs = 25_000

  for (const ws of clients) {
    const delay = Math.random() * drainWindowMs
    setTimeout(() => {
      if (ws.readyState === ws.OPEN) {
        ws.close(1012, 'service restart')
      }
    }, delay)
  }

  await waitUntil(
    () => wss.clients.size === 0,
    hardDeadlineMs,
  )

  for (const ws of wss.clients) {
    ws.terminate()
  }

  server.close()
}

process.on('SIGTERM', () => {
  void beginDrain()
})

1012 在 CloseEvent 中表示 Service Restart,适合服务端向客户端表达受控重启。1006 则是本地观察到的异常关闭保留值,不能由服务端放入 Close frame。

即使服务端选了正确关闭码,关闭帧也可能因崩溃、SIGKILL 或网络中断没有到达客户端。客户端的恢复路径仍要能处理未知断线。

客户端的退避需要打散"相位"

下一步是替换固定 1 秒重连。

只做指数退避仍然可能形成整齐波峰:同一批客户端一起在 1、2、4、8 秒重试。Full Jitter 会在当前退避上限内随机取一个值,把这批客户端分散到一个时间窗口。

ts 复制代码
const BASE_MS = 500
const MAX_MS = 30_000
const STABLE_MS = 30_000

let attempt = 0
let stableTimer: ReturnType<typeof setTimeout> | undefined

function nextDelayMs(currentAttempt: number): number {
  const cap = Math.min(MAX_MS, BASE_MS * 2 ** currentAttempt)
  return Math.random() * cap
}

function connect() {
  const ws = new WebSocket(WS_URL)

  ws.onopen = () => {
    clearTimeout(stableTimer)
    stableTimer = setTimeout(() => {
      attempt = 0
    }, STABLE_MS)

    resumeSession(ws)
  }

  ws.onclose = (event) => {
    clearTimeout(stableTimer)

    if (!isRecoverable(event)) return

    const delay = nextDelayMs(attempt++)
    setTimeout(connect, delay)
  }
}

有一个很容易忽略的细节:连接刚触发 onopen 时,不要立即把 attempt 清零。如果新实例在过载中不断接受又断开连接,"open 即清零"会让客户端每次都回到最激进的首次重试。连接稳定运行一段时间后再清零,才能防止"短暂打开 -> 立即断开"的振荡。

BASE_MSMAX_MSSTABLE_MS 没有通用魔法值。它们取决于用户容忍的恢复时间、服务端可承受的握手峰值、移动网络状态和会话恢复成本。

onopen 之后还差一次会话恢复

连接打开后,客户端需要告诉服务端"我上次确认到哪里"。

ts 复制代码
interface ResumeRequest {
  type: 'resume'
  resumeToken: string
  clientInstanceId: string
  lastAckedServerSeq: number
  lastAckedClientSeq: number
}

上下行游标应该分开。服务端的 send() 成功只表示数据已交给本地 socket 缓冲,不足以证明客户端已经应用该事件。同样,客户端把消息写入 WebSocket 也不等于业务副作用已经持久化。

服务端恢复可以分两条路径:

text 复制代码
cursor 仍在 replay window 内
  -> 返回 (lastAckedServerSeq, durableHead] 的事件
  -> 客户端按稳定 eventId 去重
  -> 进入 LIVE

cursor 已过期
  -> 返回 snapshotRequired
  -> 客户端加载 snapshot@N
  -> 再应用 seq > N 的增量
  -> 进入 LIVE

恢复协议应明确允许事件被再次补发,并依靠稳定事件 ID 识别重复内容。本文只验证 ACK、cursor、replay 与 snapshot 的边界,不把传输层重连扩大成更强的端到端交付承诺。

旧连接还活着时,新连接需要 fencing

移动网络切换、中间代理超时和服务端检测延迟,都可能让旧连接与新连接短暂并存。

如果同一设备或逻辑会话只允许一个写者,仅仅"通知旧连接关闭"不足以消除脑裂。更可靠的方法是由权威会话存储分配递增 ownerEpoch,并在每个有副作用的写入上校验 epoch。

text 复制代码
old connection: ownerEpoch=41  -> stale write rejected
new connection: ownerEpoch=42  -> write accepted

租约 TTL 能帮助回收失联 owner,但 TTL 本身不能阻止已经过期的连接在网络恢复后继续写。真正的边界是权威写入路径拒绝 stale epoch。

怎么用同一组流量验收修改

先建立稳定长连接,持续做双向消息交换,然后执行真实滚动发布。每条测试消息带有可核对的事件 ID,否则只能看出"连上了多少",无法证明恢复正确。

至少覆盖这些场景:

故障场景 目的
正常滚动发布 验证 readiness、admission gate、分散 close 和 full jitter
宽限期内强制 SIGKILL 验证未收到 Close frame 时的异常恢复
会话存储故意加延迟 检查握手峰值是否转移到 auth/session store
replay cursor 过期 验证 snapshot + 增量退化路径
同一设备旧新连接并存 验证 stale epoch 写入必须被拒绝

指标可以分成三层:

  • 排空: active_connections{pod,revision}new_upgrade_after_draining_totaldrain_duration_secondsforced_termination_total
  • 重连: 每秒握手峰值、attempt 分布、建连失败率、恢复 p50/p95、CloseEvent code 分布。
  • 正确性: 已确认消息丢失数、重复副作用数、stale owner write 数、cursor expired 比例、snapshot fallback 成功率。

同时记录 event-loop lag、CPU、文件描述符、auth 延迟、session store 连接和 replay backlog。这样才能区分是协议设计出错,还是新实例或下游本来就没有恢复容量。

把这道题收成一条面试证据链

面试时,我会先声明业务承诺:是否允许短暂重连,消息需要有序还是最终到达即可,同一设备能否有多条并行连接。

然后给出四层证据:

  1. 服务端进入 draining 后本地拒绝新 Upgrade,不依赖网关状态立即收敛。
  2. 服务端在有界窗口内分散尝试发送 1012;未收到 Close frame 的客户端按 1006 或未知断线进入同一恢复路径,宽限期前保留硬截止。
  3. 客户端用 capped exponential backoff + jitter 打散重连,稳定窗口后再重置 attempt。
  4. 恢复协议使用 durable cursor、稳定事件 ID 和 owner epoch,最后用真实 rollout 验证丢失、重复与 stale write。

整理这道题时,我借用了 Offer.cc 里的技术面试追问结构:先声明业务承诺,再沿排空、重连、恢复和验收四层推进。这样得到的不是 WebSocket 名词表,而是一段可以讲清观测、取舍和退出条件的工程经历。

最后的发布门禁也就清楚了:新版本可用只是前提。当前退出实例的连接恢复曲线、消息正确性和下游压力都回到验收线内,下一个实例才继续退出。

连接数量回来,只说明建连完成。

消息、会话和写入权都回到正确位置,这次滚动发布才真正结束。

参考与核验

相关推荐
CryptoPP3 小时前
BSE股票K线数据接入实战:从接口调用到前端图表展示
大数据·前端·网络·人工智能·websocket·网络协议
宠友信息5 小时前
多端即时通讯源码技术实践,Redis路由、MySQL持久化与Socket接入
spring boot·websocket·mysql·uni-app
脉动数据行情9 小时前
Python WebSocket 实时订阅 NQ(小型纳斯达克指数)完整监听程序
websocket·小纳指·纳斯达克·国际股指·python 量化盯盘
console.log('npc')9 小时前
AI Agent 前端流式渲染核心技术文档(SSE \+ WebSocket \+ 打字机渲染)
前端·人工智能·websocket
sdghterhd19 小时前
WebSocket 快速入门教程(附示例源码)
网络·websocket·网络协议
宠友信息1 天前
MySQL 租户数据隔离在即时通讯源码后端中的落地方式
java·数据库·spring boot·redis·websocket·mysql
宠友信息1 天前
内容社区源码多端数据协议与 Spring Boot 状态流转实现思路
java·spring boot·websocket·uni-app
奇牙coding1231 天前
OpenAI Realtime API WebSocket 断连 4008/1006 怎么解决?不是 Key 失效,是实时多模态独有的会话超时规则
python·websocket·网络协议·ai
莫生灬灬2 天前
DY联系人聊天Web面板支持获取联系人/聊天记录/发送消息
前端·chrome·websocket·c#