先看一段并不罕见的客户端逻辑:
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 只用于局部追踪,不要把 userId 、sessionId 或设备 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=true、ready=false,serving 仍可能为 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;应用需要显式跟踪和排空现有客端。 - 浏览器原生
WebSocketAPI 不暴露握手响应头,重连节奏不能依赖客户端读取Retry-After;这里由客户端自己的退避与抖动策略负责错峰。
排空需要一个有界窗口
如果一条连接可以持续数小时,那么"等待连接数变成 0"可能会让发布永远不结束。排空需要一个有界窗口:
- 先停止接收新 Upgrade;
- 完成或持久化正在处理的业务操作;
- 在排空窗口内分批发送 Close frame;
- 等待关闭握手和必要的应用层 ACK;
- 硬截止时间之前尚未退出的连接被显式
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_MS、MAX_MS 和 STABLE_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_total、drain_duration_seconds、forced_termination_total。 - 重连: 每秒握手峰值、attempt 分布、建连失败率、恢复 p50/p95、CloseEvent code 分布。
- 正确性: 已确认消息丢失数、重复副作用数、stale owner write 数、cursor expired 比例、snapshot fallback 成功率。
同时记录 event-loop lag、CPU、文件描述符、auth 延迟、session store 连接和 replay backlog。这样才能区分是协议设计出错,还是新实例或下游本来就没有恢复容量。
把这道题收成一条面试证据链
面试时,我会先声明业务承诺:是否允许短暂重连,消息需要有序还是最终到达即可,同一设备能否有多条并行连接。
然后给出四层证据:
- 服务端进入 draining 后本地拒绝新 Upgrade,不依赖网关状态立即收敛。
- 服务端在有界窗口内分散尝试发送
1012;未收到 Close frame 的客户端按1006或未知断线进入同一恢复路径,宽限期前保留硬截止。 - 客户端用 capped exponential backoff + jitter 打散重连,稳定窗口后再重置 attempt。
- 恢复协议使用 durable cursor、稳定事件 ID 和 owner epoch,最后用真实 rollout 验证丢失、重复与 stale write。
整理这道题时,我借用了 Offer.cc 里的技术面试追问结构:先声明业务承诺,再沿排空、重连、恢复和验收四层推进。这样得到的不是 WebSocket 名词表,而是一段可以讲清观测、取舍和退出条件的工程经历。
最后的发布门禁也就清楚了:新版本可用只是前提。当前退出实例的连接恢复曲线、消息正确性和下游压力都回到验收线内,下一个实例才继续退出。
连接数量回来,只说明建连完成。
消息、会话和写入权都回到正确位置,这次滚动发布才真正结束。
参考与核验
-
热门讨论来源:r/node 对 WebSocket 发布排空的讨论(137 个赞同、20 条评论,数据截取于 2026-07-11)
-
Kubernetes 中文文档:Pod 的生命周期
-
Kubernetes 中文文档:EndpointSlice 条件
-
MDN 中文文档:CloseEvent
-
gRPC 中文文档:重试
-
Node.js 官方文档:
server.close() -
WHATWG WebSocket API:浏览器握手失败反馈边界
-
掘金:WebSocket 与长连接:从协议握手到断线重连的完整实战