WebSocket 长连接完全指南:从一次"连接建好了消息却悄悄丢"看懂为什么心跳和重连才是根本

2023 年我做一个实时通知功能服务端有新消息要立刻推到用户正开着的浏览器页面上不让用户手动刷新我自然想到 WebSocket 在浏览器和服务端之间建一条长连接服务端有消息就顺着这条连接推过来第一版我做得很顺手前端 new 一个 WebSocket 服务端接受连接把它存进一个连接表有消息时遍历连接表挨个推送本地一测通知秒到我心里很笃定 WebSocket 嘛握手建好一条长连接两端就能一直互发消息直到我主动关闭这实时推送稳了可等它一上线有了真实用户一串问题冒了出来第一种最先把我打懵用户开着页面去忙别的一段时间没操作连接悄无声息地断了可前端的 onclose 事件压根没触发页面上还显示着已连接而服务端推过来的消息用户一条都收不到第二种最难缠有些连接是断了能重连可重连之后断开的那段时间里服务端推送的几条消息彻底没了第三种最头疼某次机房网络抖了一下大量用户在同一瞬间断线然后又在几乎同一瞬间一起重连这一下涌进来的连接洪峰直接把服务端打到崩溃第四种最莫名其妙我在 onclose 里写了断了就重连结果某些场景下重连像发了疯一秒钟能试上几十次我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 WebSocket 是一条建好了就一直在的可靠管道可这个认知是错的本文从头梳理为什么连接建好了消息还会悄悄丢心跳为什么不可省重连该怎么做才优雅断连期间的消息怎么补回来重连风暴怎么防以及一些把 WebSocket 长连接做扎实要避开的工程坑

2023 年我做一个实时通知功能——服务端有新消息(有人回复了你、订单状态变了),要立刻推到用户正开着的浏览器页面上,不让用户手动刷新。我自然想到 WebSocket:在浏览器和服务端之间建一条长连接,服务端有消息就顺着这条连接推过来。第一版我做得很顺手:前端 new 一个 WebSocket,服务端接受连接、把它存进一个连接表,有消息时遍历连接表挨个推送。本地一测,我在另一个标签页触发一条消息,通知秒到,我心里很笃定:WebSocket 嘛,握手建好一条长连接,两端就能一直互发消息,直到我主动关闭——这实时推送稳了。可等它一上线、有了真实用户,一串问题冒了出来。第一种最先把我打懵:用户开着页面去忙别的、一段时间没操作,连接悄无声息地断了,可前端的 onclose 事件压根没触发,页面上还显示着"已连接",而服务端推过来的消息,用户一条都收不到。第二种最难缠:有些连接是断了能重连,可重连之后,断开的那段时间里服务端推送的几条消息,彻底没了——重连补不回它们。第三种最头疼:某次机房网络抖了一下,大量用户在同一瞬间断线,然后又在几乎同一瞬间一起重连,这一下涌进来的连接洪峰,直接把服务端打到崩溃。第四种最莫名其妙:我在 onclose 里写了"断了就重连",结果某些场景下重连像发了疯,一秒钟能试上几十次,既连不上、又把客户端和服务端都拖垮。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为WebSocket 是一条"建好了就一直在"的可靠管道——只要握手成功,这条连接就稳稳地存在着,两端想发就发,直到我主动关掉它,中间不会出任何岔子。可这个认知是错的。WebSocket 连接跑在 TCP 之上,而 TCP 连接会因为各种原因悄悄失效:连接和服务端之间隔着的 NAT 网关、负载均衡器、反向代理,都会在连接空闲一段时间后,不声不响地把它回收掉;用户的手机从 Wi-Fi 切到流量、进电梯信号抖一下,连接也会静默地死掉。更要命的是,这种"死"常常是单向、无声的——连接的一端已经没了,另一端却毫不知情,还以为这条连接好端端地活着,继续往一个黑洞里发消息。所以可靠的实时推送,根上不是"建一条连接"这一个动作,而是一整套工程:用心跳主动探测连接到底还活不活、用重连在连接死掉后把它恢复起来、用消息确认和补偿保证断连期间的消息不丢、用退避和抖动防止所有人同时重连压垮服务端。本文从头梳理:为什么"连接建好了"消息还会悄悄丢,心跳为什么不可省,重连该怎么做才优雅,断连期间的消息怎么补回来,重连风暴怎么防,以及一些把 WebSocket 长连接做扎实要避开的工程坑。

问题背景

先把 WebSocket 这件事说清楚。WebSocket 是一种在浏览器和服务端之间建立"全双工长连接"的协议:一次 HTTP 握手之后,连接升级成 WebSocket,此后两端都能随时主动给对方发消息,不用像普通 HTTP 那样"客户端问一句、服务端答一句"。它特别适合实时推送、聊天、协同编辑这类场景。

错误认知是:握手成功后,这条连接就是一条稳定、可靠、一直在的管道,直到主动关闭。真相是:WebSocket 连接是一种需要持续维护的、会无声失效的脆弱资源——它跑在 TCP 上,中间经过的每一个网络设备都可能回收它,而失效往往不会触发任何事件通知你。把这一点摊开,第一版的几类问题就都能解释了:

  • 连接断了 onclose 却不触发:连接是被中间设备静默回收的,或者用户网络突然消失,本端的 TCP 没收到任何关闭包,它不知道连接已经没了。
  • 重连补不回消息:重连只是建了一条新连接,它不会自动把"断开期间服务端发过的消息"带回来,那些消息早就发进了一个已经死掉的连接里。
  • 网络抖动打挂服务端:大量客户端在同一时刻断线、又在同一时刻重连,瞬间的连接洪峰远超服务端平时的负载。
  • 重连疯狂自旋:onclose 里直接无脑重连,连接一建立失败就立刻再试,形成一个几乎没有间隔的死循环。

所以让实时推送可靠,核心不是"建一条连接",而是一整套连接的维护工程:心跳探活、优雅重连、消息补偿、重连风暴防护。下面六节,就从第一版"连接建好就一劳永逸"的想当然讲起。

一、为什么"连接建好了"消息还会悄悄丢

第一版我处理连接的方式,朴素得很:前端建一个 WebSocket,绑上 onmessage 收消息、onclose 重连,就完事了。

// 反面教材:以为连接建好了就一劳永逸

let ws = new WebSocket("wss://example.com/notify");

ws.onopen = () => {
    console.log("连接已建立");      // 我以为:走到这里,推送就稳了
};

ws.onmessage = (event) => {
    showNotification(event.data);   // 收到消息就显示
};

ws.onclose = () => {
    // 连接关了就重连 —— 我以为 onclose 一定会在断开时触发
    ws = new WebSocket("wss://example.com/notify");
};

// 我的假设:连接一旦 onopen,就会稳稳活着;
// 真要断了,onclose 必然触发,我在那里重连就行。
// 可现实是:连接可以"死掉"却根本不触发 onclose。

这段代码的全部假设,押在"连接断开 = onclose 触发"上。可这个假设不成立。WebSocket 连接是架在一条 TCP 连接之上的,而一条 TCP 连接,完全可能在两端都不知情的情况下"死掉"。把这种无声的死亡摊到一条时间线上看,就清楚了。

连接"无声地死掉"是怎么发生的:一条时间线

  时刻   客户端这一端              中间的 NAT / 代理         服务端这一端
  --------------------------------------------------------------
  t1    连接 ESTABLISHED          转发着双向流量            连接 ESTABLISHED
  t2    页面空闲 不再发消息       连接空闲计时开始          也没有消息要推
  t3    (仍以为连接活着)       空闲超时 静默回收连接     (仍以为连接活着)
  t4    服务端推一条消息 ——      包到这里被直接丢弃        消息发进了连接
        客户端永远收不到                                   服务端以为发成功了
  --------------------------------------------------------------
  关键:t3 回收连接时,NAT 没给两端发任何关闭包,
        所以客户端和服务端的 onclose 都不会触发 ——
        两端都以为连接好好的,实际它早在 t3 就死了。

看这条时间线的关键,是 t3:NAT 网关把一条空闲太久的连接回收掉时,它通常不会好心地给两端各发一个"连接关闭"的通知包,它就是单方面地、悄悄地把这条连接从自己的转发表里删掉。于是 t4 服务端再推消息,包走到 NAT 这里就被丢弃,而客户端和服务端的 TCP 栈都没收到任何关闭信号——它们的 onclose,自然都不会触发。

这一节要建立的认知是:WebSocket 连接的"断开",分两种——一种是有声的、会触发 onclose 的"正常关闭",另一种是无声的、什么事件都不触发的"静默死亡";第一版只处理了前一种,而真正坑人的恰恰是后一种。"有声的关闭",是连接的某一端主动发起关闭、对端收到了关闭包,这种情况 onclose 会忠实地触发,第一版在那里重连,没问题。但"静默死亡"完全是另一回事:连接是被中间的网络设备回收的,或者用户的网络瞬间消失了,本端的 TCP 栈根本没收到任何关闭包——在它眼里,这条连接还是 ESTABLISHED 状态,好端端的。onclose 不会触发,因为本端"不知道"连接已经死了。这就是第一版第一种问题的全部根源:连接早就死了,可前端的 onclose 没动静,页面还显示"已连接",代码也就不会去重连。这里有一个让人难受、但必须接受的事实:在 WebSocket 这一层,"连接很久没有任何消息"和"连接已经死了",这两件事看起来一模一样,你无法被动地区分它们。要区分,你只能主动出手——这就是下一节心跳要做的事。

二、心跳:你必须主动证明连接还活着

既然连接会无声地死、onclose 又靠不住,那你就不能再被动地"等连接告诉你它断了"。你得主动、周期性地去探一探:这条连接,现在还活着吗?这个动作,就是心跳——客户端每隔一小段时间发一个 ping,服务端收到就回一个 pong;客户端在限定时间内等到了 pong,说明连接是活的;等不到,就判定连接已死。

// 心跳:定期发 ping,在限定时间内没等到 pong 就判定连接已死

let ws;
let heartbeatTimer = null;
let pongTimer = null;

function startHeartbeat() {
    // 每 25 秒发一次心跳 —— 周期要短于 NAT 的空闲回收时间
    heartbeatTimer = setInterval(() => {
        if (ws.readyState !== WebSocket.OPEN) return;
        ws.send(JSON.stringify({ type: "ping" }));

        // 发完 ping,起一个 5 秒的"等 pong"计时
        pongTimer = setTimeout(() => {
            // 5 秒还没等到 pong —— 连接已经死了,主动关掉它
            console.warn("心跳超时,连接已死,主动断开");
            ws.close();   // 主动 close 才会触发 onclose,进而触发重连
        }, 5000);
    }, 25000);
}

function onPong() {
    // 收到 pong:连接是活的,取消这次的超时判定
    clearTimeout(pongTimer);
}

function stopHeartbeat() {
    clearInterval(heartbeatTimer);
    clearTimeout(pongTimer);
}

这里有两个关键点。一是心跳周期要短于中间网络设备的空闲回收时间——NAT 的空闲超时常常在 60 秒上下,你的心跳如果 25 秒一次,就能赶在连接被回收前,用一次次小流量把它"焐热",顺带探活。二是探到连接已死后,代码主动调了 ws.close()——因为只有主动 close,onclose 才会触发,后面的重连逻辑才能被启动;否则你就算知道连接死了,也卡在原地。

光客户端探活还不够。服务端那边,也存着一张连接表,如果某个客户端"无声地"没了,服务端这张表里就留着一条永远不会有消息来的死连接——服务端还会傻乎乎地往它推消息。所以服务端也要做对称的探活。

# 服务端侧也要探活:长时间收不到客户端任何消息,就清掉这条连接

import time

# 每条连接记一个"最后活跃时间"
connections = {}   # conn_id -> {"ws": ws, "last_seen": 时间戳}

def on_client_message(conn_id, msg):
    # 收到客户端任何消息(包括 ping),都刷新它的活跃时间
    connections[conn_id]["last_seen"] = time.time()
    if msg.get("type") == "ping":
        send(conn_id, {"type": "pong"})    # 回应客户端的心跳

def reap_dead_connections():
    # 定期扫描:超过 60 秒没有任何动静的连接,判定为死连接
    now = time.time()
    for conn_id, info in list(connections.items()):
        if now - info["last_seen"] > 60:
            info["ws"].close()
            del connections[conn_id]       # 从连接表里清掉,不再往它推消息

这一节的认知是:心跳的本质,是承认"你无法信任连接自己会报告死亡",于是改用一种主动的、周期性的方式去反复证明它还活着——心跳不是一个可选的优化,它是在 WebSocket 上区分"活连接"和"死连接"的唯一手段。第一版没有心跳,所以它面对一条静默死亡的连接时是完全无能为力的:它没有任何信息能判断这条连接到底是"刚好没消息"还是"已经死透了"。心跳把这件不可能的事变成了可能——它给连接持续地施加一个"必须有回应"的小压力,活着的连接会规律地回 pong,死掉的连接会在下一个心跳周期暴露出来。这里要破除的一个想当然是:"连接没报错,那它就是好的"。在 WebSocket 上,"没报错"不等于"是好的",它可能只是"坏了但没人告诉你"。所以你必须主动去探。而探活一旦做了,就要两端都做、对称地做:客户端探服务端,服务端也探客户端——因为静默死亡对两端是同时发生的,只探一头,另一头的死连接照样会堆积。心跳探出连接死了之后,接下来的问题是:怎么把它恢复回来——这就是重连。

三、重连:断了之后怎么优雅地恢复

心跳告诉我们连接死了、onclose 也触发了,接下来要做的就是重连——建一条新连接,接替那条死掉的。第一版也写了重连,但它写成了第一版第四种问题的样子:onclose 里直接 new 一个新连接,连接失败了又立刻触发 onclose、又立刻 new……一秒钟自旋几十次,把客户端和服务端都拖垮。

优雅的重连,不是"断了就立刻再连",而是带指数退避随机抖动的重连。

// 重连:指数退避 + 随机抖动 + 上限,而不是断了就疯狂猛连

let reconnectAttempts = 0;
const MAX_DELAY = 30000;   // 退避时间的上限:30 秒

function scheduleReconnect() {
    // 指数退避:第 1 次等 1 秒,第 2 次 2 秒,第 3 次 4 秒…… 翻倍增长
    let delay = Math.min(1000 * (2 ** reconnectAttempts), MAX_DELAY);

    // 随机抖动:在退避时间上叠加一个随机量,
    // 避免所有客户端踩着同一个节拍一起重连
    delay = delay + Math.random() * 1000;

    reconnectAttempts++;
    setTimeout(connect, delay);
}

function connect() {
    ws = new WebSocket("wss://example.com/notify");

    ws.onopen = () => {
        reconnectAttempts = 0;   // 连上了,把退避计数清零
        startHeartbeat();
    };
    ws.onclose = () => {
        stopHeartbeat();
        scheduleReconnect();     // 不是立刻重连,而是排一个带退避的重连
    };
}

指数退避解决"自旋":每失败一次,下次重连的等待就翻一倍,从 1 秒、2 秒、4 秒一路涨上去,连不上的时候不会疯狂消耗资源。上限(MAX_DELAY)防止等待时间无限膨胀到几小时。连接成功后把计数清零,是为了下次再断时,退避能从头开始、快速恢复。

还有一件容易被漏掉的事:不是所有的断开都该重连。如果服务端是因为"这个 token 鉴权失败"才关掉连接的,那你重连一万次也没用——该做的是引导用户重新登录。这就要看连接关闭时带的 close code。

// 不是所有的断开都该重连:看 close code 决定下一步

ws.onclose = (event) => {
    stopHeartbeat();

    // 1000 是正常关闭;4001 是我们自定义的"鉴权失败"码
    if (event.code === 4001) {
        // 鉴权失败:重连多少次都没用,该去重新登录,而不是死连
        redirectToLogin();
        return;
    }
    // 其余情况(网络断开、服务端重启等)才走带退避的重连
    scheduleReconnect();
};

这一节的认知是:重连不是"断了就立刻再连一次"这一个动作,它是一个需要回答三个问题的决策——这次断开到底该不该重连、该隔多久重连、连不上时这个间隔该怎么变化。第一版的重连之所以会自旋成灾,是因为它把这三个问题全省略了:它默认"任何断开都该重连"(于是鉴权失败也死连)、默认"立刻重连"(于是没有间隔)、默认"间隔不变"(于是失败一次和失败一百次的猛烈程度一样)。优雅重连就是把这三个问题逐一答好:用 close code 回答"该不该重连",用退避的初始值回答"隔多久",用指数增长回答"间隔怎么变"。这里的核心思路是,重连本身是有成本的——它要消耗客户端的资源、要给服务端添一个新连接的负担。所以重连要"克制":能确定重连无用的(鉴权失败),果断别连;该连的,也要随着失败次数的增加越来越有耐心,而不是越挫越勇地猛冲。把重连从一个莽撞的动作,变成一个克制的、带决策的过程,第一版的自旋问题才算根治。

四、消息不丢:断连期间的消息怎么补回来

有了心跳和重连,连接断了能被发现、能被恢复。但第一版第二种问题还悬着:重连之后,断开的那段时间里服务端推过的消息,丢了。这是因为——重连恢复的只是"连接"这根管子,它并不会自动把"断连期间发过的消息"带回来。那些消息,当初是被推进了一条已经死掉的连接里,早就掉进黑洞了。

要让消息不丢,得给每条消息编一个递增的序号(seq),客户端记住"我收到的最后一条消息的序号是多少",重连之后把这个序号上报给服务端,服务端据此把缺的消息补发回来。先看客户端。

// 客户端:记住"我收到的最后一条消息的序号",重连后上报

let lastSeq = 0;   // 收到的最后一条业务消息的序号

ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    if (msg.type === "pong") { onPong(); return; }

    // 每条业务消息都带一个递增的 seq,收到就更新 lastSeq
    lastSeq = msg.seq;
    showNotification(msg.data);
};

ws.onopen = () => {
    reconnectAttempts = 0;
    startHeartbeat();
    // 重连成功后,第一件事:告诉服务端"我收到哪儿了"
    ws.send(JSON.stringify({ type: "resume", lastSeq: lastSeq }));
};

再看服务端。服务端要为每个用户保留一份"最近消息缓冲",当客户端重连并上报 lastSeq 时,服务端就把 lastSeq 之后的消息,一条条补发过去。

# 服务端:为每个用户保留最近消息,重连时把缺的补发回去

# 每个用户一个最近消息缓冲(只留最近 N 条)
recent_messages = {}   # user_id -> [(seq, data), ...]

def on_resume(conn_id, user_id, last_seq):
    # 客户端说它收到了 last_seq,把 last_seq 之后的消息补发给它
    buffered = recent_messages.get(user_id, [])
    missed = [m for m in buffered if m[0] > last_seq]
    for seq, data in missed:
        send(conn_id, {"type": "msg", "seq": seq, "data": data})

def push_message(user_id, data):
    # 推送新消息:分配一个递增的 seq,既发出去、也存进缓冲
    seq = next_seq(user_id)
    recent_messages.setdefault(user_id, []).append((seq, data))
    trim_buffer(user_id, keep=200)     # 只保留最近 200 条,别让它无限涨
    for conn_id in conns_of(user_id):
        send(conn_id, {"type": "msg", "seq": seq, "data": data})

但这里有个边界要交代:服务端的缓冲是有限的(上面只留 200 条)。如果用户断开的时间太长,断连期间的消息条数超过了缓冲容量,最早的那几条就已经被挤出缓冲了——这时光靠 resume 补不全。所以对"绝不能丢"的消息,还要再加一道:客户端重连后,除了 resume,再从服务端的数据库主动拉一次未读列表,用数据库做最终的兜底。

这一节的认知是:重连恢复的是"管道",不是"管道里的水";消息的可靠性,从来不是连接层能附赠的副产品,它是一个必须基于"序号 + 上报 + 补发"单独设计出来的机制。第一版默认"连接恢复了,消息自然就接上了",这个默认背后,是把"连接"和"消息流"当成了同一个东西。但它们是两层:连接是物理的管子,消息流是这根管子里有序流动的数据。管子断了再接上,是一根新管子,它对"旧管子断开期间本该流过哪些水"一无所知。所以消息的连续性,必须由消息流这一层自己来保证——给每条消息一个序号,让接收方能说出"我收到了第几号",让发送方能据此算出"你缺了哪几号"并补上。这套机制和连接的死活是解耦的:连接断一百次,只要每次重连都能正确地 resume,消息流在用户眼里就是连续不断的。而当补发也可能不够(断太久、缓冲被挤掉)时,你还需要一个不依赖连接、不依赖缓冲的最终事实来源——落库的消息记录。把"连接的恢复"和"消息的不丢"彻底当成两件事来分别设计,你的实时推送才真正可靠。

五、重连风暴:别让所有客户端同时回来

到这里,单个连接的探活、重连、消息补偿都齐了。但第一版第三种问题——网络抖一下,大量用户同时断、又同时重连,把服务端打挂——还没解决。这是 WebSocket 在规模上特有的一种灾难,叫重连风暴(也叫惊群)。

它的可怕之处在于:平时你的服务端要扛的,是"用户陆陆续续地连进来";而重连风暴要它扛的,是"几万个用户在同一秒里全部连进来"——后者的瞬时压力,可能是前者的几百倍。第三节里加的随机抖动,正是用来打散这种"同步"的。但要注意:抖动的随机范围必须足够大。

// 重连风暴防护:抖动范围要足够大,把洪峰摊平到一段时间里

function scheduleReconnect() {
    let base = Math.min(1000 * (2 ** reconnectAttempts), MAX_DELAY);

    // 关键:抖动范围不是固定的 1 秒,而是和退避时间同量级 ——
    // base 是 8 秒,抖动就在 0 到 8 秒里随机,把重连摊到 8 到 16 秒
    let jitter = Math.random() * base;
    let delay = base + jitter;

    reconnectAttempts++;
    setTimeout(connect, delay);
}

// 一个固定的小抖动(比如只抖 1 秒),在十万级客户端面前形同虚设:
// 十万个连接挤在 1 秒里回来,服务端照样被打挂。
// 抖动范围必须大到能把洪峰真正摊开。

客户端把洪峰摊平了,服务端这边也要有自己的护栏:给"接受新连接"的速率设一个闸门。哪怕客户端的抖动没配好、洪峰还是来了,这个闸门也能保证服务端不被一口气冲垮。

# 服务端:给新连接的接入速率设一个闸门,扛住重连洪峰

import time
from collections import deque

accept_times = deque()        # 最近接入的连接时间戳
MAX_ACCEPT_PER_SEC = 2000     # 每秒最多接入的新连接数

def can_accept_connection():
    now = time.time()
    # 清掉 1 秒之前的记录
    while accept_times and now - accept_times[0] > 1.0:
        accept_times.popleft()

    if len(accept_times) >= MAX_ACCEPT_PER_SEC:
        return False          # 这一秒接得够多了,先拒掉
    accept_times.append(now)
    return True

# 被拒的客户端,会收到关闭、并按指数退避稍后再试 ——
# 闸门把"一秒涌入十万"摊成"每秒稳稳接入两千",服务端就不会被冲垮。

这一节的认知是:单个客户端的重连逻辑写得再优雅,放到几万、几十万客户端的规模上,也可能因为"大家太同步"而酿成一场灾难——重连风暴的根源,不是任何单个客户端的行为有错,而是大量客户端的行为在时间上"撞到了一起"。这是一个从"单点正确"到"群体正确"的视角跳变。第三节里那个带退避的重连,单独看每一个客户端,它的行为都无可挑剔。但问题恰恰在于:当几万个客户端因为同一次网络抖动而同时断开,它们又都在执行那段"无可挑剔"的逻辑——于是它们会高度同步地、几乎在同一刻一起发起重连。每个个体都对,合起来却是一场洪灾。破解之道,是主动地往系统里注入"不同步":客户端用足够大的随机抖动,让本来会撞在一起的重连时刻散开到一个宽阔的时间窗里;服务端用接入限速,给瞬时洪峰再加一道物理上的上限。一个负责"摊开",一个负责"兜底"。理解了"个体正确不等于群体正确",你才会意识到:抖动不是可有可无的小装饰,它是大规模长连接系统能不能在网络抖动中活下来的关键。

把"一个连接断了之后,该怎么一步步处理"的完整决策流程画出来,就是下面这张图:

[mermaid]
flowchart TD
A[连接断开 onclose 触发] --> B{close code 是鉴权失败吗}
B -->|是| C[跳转重新登录 不再重连]
B -->|否| D[按指数退避加抖动算出等待时间]
D --> E[等待结束后发起重连]
E --> F{连接成功了吗}
F -->|否| D
F -->|是| G[退避计数清零 重启心跳]
G --> H[发 resume 上报已收到的 seq]
H --> I[服务端补发断连期间漏掉的消息]

六、把 WebSocket 长连接做扎实,要避开的工程坑

前面五节讲清了 WebSocket 长连接的四道防线:心跳、重连、消息补偿、风暴防护。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是最容易被忽略的:别把关键消息的可靠性,完全押在 WebSocket 上。WebSocket 推送本质是"尽力而为"的,真正"必达"的消息,要先落库,WebSocket 只是一个"加速到达"的通道。

# 坑一:关键消息别只靠 WebSocket 推 —— 先落库,再推

def notify_user(user_id, data, critical=False):
    if critical:
        # 关键消息:先写进数据库的通知表(这是"必达"的底座)
        save_notification_to_db(user_id, data)

    # WebSocket 推送只是"加速到达",不是唯一的送达保证
    seq = next_seq(user_id)
    for conn_id in conns_of(user_id):
        send(conn_id, {"type": "msg", "seq": seq, "data": data})

# 用户重连、或重新打开页面时,前端除了 resume,
# 还要从通知表拉一次未读 —— WebSocket 丢掉的,由数据库兜底。

第二个坑,是忘了连接本身是一种"占资源"的东西。每条 WebSocket 连接,在服务端都占着一个文件描述符和一份状态内存,单机能扛的连接数是有硬上限的,这个上限必须被监控起来。

# 坑二:连接是占资源的,单机连接数要心里有数、要监控

# 每条 WebSocket 连接在服务端都占着:
#   - 一个文件描述符(受 ulimit -n 限制)
#   - 一份连接状态内存(缓冲区、用户信息等)

def health_metrics():
    return {
        "active_connections": len(connections),   # 当前连接数
        "fd_used": count_open_fds(),               # 占用的文件描述符
        "accept_rate": current_accept_rate(),      # 新连接接入速率
    }

# 这几个指标必须上监控:连接数逼近单机上限时要能提前告警,
# 而不是等服务端报 "Too many open files" 崩了才发现。

还有几个坑值得点一下。其一,WebSocket 握手本质是一次普通的 HTTP 请求,鉴权要在握手阶段就做掉(校验 token、拒掉非法连接),别让一条没鉴权的连接先建起来再说。其二,客户端和服务端都要给"发送缓冲区"设上限——如果某个客户端消费消息很慢,服务端往它缓冲区里堆的消息会越积越多,最终撑爆内存,这种慢客户端要果断断开。其三,负载均衡器要确认支持 WebSocket(它是长连接,不能套用普通 HTTP 短连接的负载策略),而且要给它配足够长的空闲超时,别让 LB 自己先把连接掐了。下面把四道防线集中对照一下:

WebSocket 长连接的四道防线对照

  机制         解决什么问题              核心要点
  --------------------------------------------------------------
  心跳         连接静默死亡             周期 ping 探活,超时即判死
  重连         连接死后如何恢复         指数退避加抖动,看 code 决定
  消息补偿     断连期间消息丢失         序号加上报,重连后补发
  风暴防护     大量客户端同时重连       大范围抖动加服务端接入限速

  原则:连接必然会断,所以四道防线缺一不可;
        关键消息永远要落库兜底,别把可靠性押在连接上。

这一节这几个坑,串起来是同一个意思:WebSocket 长连接不是一个写完就不用管的"功能",它是一个需要持续运维的"有状态系统"。无状态的 HTTP 接口,处理完一个请求就两手空空,你不用操心它"占着什么";但长连接不一样,每一条活着的连接,都是服务端持有的一份状态——它占着文件描述符、占着内存、占着连接表里的一个位置。这就带来一连串无状态服务从不需要考虑的问题:连接要鉴权(否则非法连接也占资源)、缓冲区要设上限(否则慢客户端能撑爆内存)、连接总数要监控(否则会撞上单机硬上限)、负载均衡要专门适配(否则长连接会被当短连接对待)。这些坑表面上五花八门,根子上指向同一件事:你维护的不是"一次次请求",而是"成千上万条持续存在、持续占着资源的连接"。把 WebSocket 服务当成一个"有状态系统"来对待——给状态做鉴权、设上限、做监控——它才能在真实的生产规模下长期稳住。

关键概念速查

概念 说明
WebSocket 浏览器与服务端之间的全双工长连接协议,一次握手后两端可随时互发消息
静默死亡 连接已失效但两端都不知情,不触发任何关闭事件
心跳 周期性发送 ping 并等待 pong,用来主动探测连接是否还活着
ping/pong 心跳的探测包与回应包,限定时间内收不到 pong 即判定连接已死
onclose 连接关闭事件,只在本端确知连接已关时才触发,静默死亡时不触发
指数退避 重连失败时等待时间逐次翻倍,避免疯狂自旋耗尽资源
抖动 jitter 在重连等待上叠加随机量,避免大量客户端在同一刻同步重连
重连风暴 大量客户端在同一时刻断开并同时重连,瞬时洪峰冲垮服务端
消息序号 seq 每条消息的递增编号,重连后据此判断并补发漏掉的消息
resume 重连后客户端上报已收到的最后序号,服务端据此补发缺失消息

避坑清单

  1. 不要以为 onclose 一定会触发:连接会静默死亡,什么事件都不报。
  2. 不要不做心跳:没有心跳,你无法区分"暂时没消息"和"连接已死"。
  3. 不要让心跳周期长过 NAT 空闲超时:心跳要赶在连接被回收前发出。
  4. 不要断了就立刻猛连:用指数退避加抖动,杜绝重连自旋。
  5. 不要所有断开一视同仁:鉴权失败这类断开,重连多少次都没用。
  6. 不要以为重连能带回消息:重连只恢复连接,消息要靠序号补发。
  7. 不要用固定的小抖动:十万客户端面前,小抖动形同虚设。
  8. 不要不给服务端接入限速:重连洪峰会在一瞬间把服务端冲垮。
  9. 不要把关键消息只押在 WebSocket 上:关键消息必须先落库兜底。
  10. 不要不监控连接数:连接占文件描述符,逼近上限要能提前告警。

总结

回头看第一版那个"建一条 WebSocket 就完事"的实时推送,它的错误很典型。它不在某一行代码,而在一个对长连接的根本误解:以为握手成功后,连接就是一条稳定可靠、一直在的管道。真相是,WebSocket 跑在 TCP 之上,连接会因为中间网络设备的回收、用户网络的切换而无声地死掉——而且这种死亡常常单向、无声,不触发任何事件。连接必然会断,这不是异常,是常态。

而把实时推送做对,工程量并不小。它不是"new 一个 WebSocket"那么简单,而是要用心跳主动探活、用指数退避加抖动做优雅重连、用消息序号和补发保证断连期间消息不丢、用大范围抖动和服务端限速防住重连风暴,还要把关键消息落库兜底、给连接数上监控。一套真正在生产里扛得住的长连接,是这四道防线一个不少地拼起来的。

这件事其实很像两个人靠对讲机保持长时间的联络。第一版的想法是"按下通话键、接通了,这条线路就一直在,想说话随时说"。可对讲机的信号会因为走进山谷、隔了厚墙而悄悄中断,而麻烦的是——信号断的时候不会"咔"地响一声告诉你,你对着对讲机说了半天,对面一个字没收到,你却以为一切正常,这就是连接的静默死亡。有经验的人会怎么做?第一,每隔一会儿就问一句"听得到吗",对面回一句"收到"——没等到回应,就知道线路断了,这是心跳。第二,断了之后不是抓着对讲机狂喊,而是隔一会儿试一次、再不行隔更久试一次,这是退避重连。第三,重新接上后第一句话是"我刚才听到你说到'出发',后面就没了"——让对面把漏掉的接着说,这是消息补偿。第四,如果是一整支队伍的对讲机同时失联,绝不能让所有人在同一秒一起呼叫,否则频道直接堵死,得让大家错开时间,这是重连风暴防护。接通只是开始,真正的功夫,全在"怎么维持这条时断时续的联络"上。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你自己测,本机连本机,网络又快又稳,连接一建好就稳稳地在,你点一下、消息秒到,你会觉得"WebSocket 真简单,建条连接就行"。真正会把问题撑爆的,是上线后真实用户的网络环境:有人挂着页面进了地铁,有人从 Wi-Fi 切到了 4G,有人的连接被公司代理在第 60 秒准时回收,而你的服务端某次发版重启,会让在线的所有连接在同一瞬间全部断开、又全部涌回来——这些场景,本机测试一个都模拟不到。所以如果你正在做一个基于 WebSocket 的功能,别等用户反馈"消息收不到"才回头怀疑你的连接。在写下 new WebSocket 的那一刻就想清楚:这条连接死了我怎么知道、死了之后我怎么重连、重连期间丢的消息我怎么补、所有人同时重连我的服务端扛不扛得住——把"建立一条连接"和"维护一条必然会断的连接"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM 成本优化完全指南:从一次"换了便宜模型账单却没降多少"看懂为什么 token 用量才是大头

2026-5-22 21:45:42

技术教程

LLM 高并发调用完全指南:从一次"批量任务一上量就 429 刷屏"看懂为什么并发不是越高越快

2026-5-22 22:04:56

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索