WebSocket 长连接不稳?一次完整的排查与修复之旅

一、现象

我的在线工具站(onltool.site)通过 WebSocket + 本地 Agent 实现了浏览器直连内网数据库。上线后频繁遇到以下问题:

现象 频率
用着用着突然显示"Agent 已断开连接" 每天数次
Agent 闪退,窗口瞬间消失 每次启动都有
点击断开 → 重新连接后,运行按钮永久卡在"加载中" 每次必现
网页提示"Invalid sessionId",Agent 已经不见了 偶发

二、逐层排查

架构是三层 WebSocket 链路:

复制代码
浏览器 ──WS──► Nginx ──WS──► Spring Boot ──WS──► 本地 Agent ──TCP──► MySQL/Redis

逐层检查保活和容错机制:

保活机制 容错机制
浏览器 WS
Nginx proxy_read_timeout=3600s ✓ -
Spring Boot 未配置
Agent WS ping_interval=30 ✓ 断开即退出
Agent → DB connect_timeout=5 ✓ 无 ping 检测

五个检查项,四个缺位。


三、问题根因

3.1 浏览器缺少心跳 → 被网络设备切断

复制代码
浏览器 ──静默 30s+──► 防火墙/NAT ──"这个连接死了"──► 发送 RST

TCP 连接如果长时间没有数据流动,中间的网络设备(防火墙、NAT 网关、运营商设备)会在 60-120 秒后主动切断。浏览器不主动发任何数据帧,导致频繁被断。

3.2 无重连机制 → 断了就死

onclose 触发后直接置 connected=false,不尝试恢复。用户只能手动重新连接。

3.3 loading 状态未清理 → 按钮永久卡死

复制代码
执行 SQL → loading=true → WebSocket 断开 → onclose 没重置 loading
→ 用户重连 → loading 仍然是 true → 运行按钮永远转圈

这是最隐蔽的 bug:disconnect()onclose 都漏掉了 loading=false

3.4 Agent 闪退 → 缺少 signal 模块

重写 Agent 主循环时用了 signal.signal(signal.SIGINT, ...) 但忘了 import signal,启动即抛 NameError 崩溃。sys.stdout 没 flush,终端窗口瞬间消失看不到报错。

3.5 Agent 断后 re-connect 生成新 sessionId → 浏览器连不上

Agent 断开后服务器立即删除 sessionId。Agent 重连获得新 ID,但浏览器还握着旧 ID,连上去提示 "Invalid sessionId"。


四、解决方案

4.1 浏览器心跳 + 自动重连

javascript 复制代码
// 每 15s 发一次心跳,防止中间设备切断
startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify({ type: 'ping' }))
        }
    }, 15000)  // 30s → 15s,容错窗口更大
}

// 断开自动重连,指数退避
scheduleReconnect() {
    const delay = Math.min(1000 * Math.pow(2, this.reconnectDelay), 30000)
    this.reconnectDelay++
    this.reconnectTimer = setTimeout(() => {
        this.connectViaAgent()  // 带着同样的 sessionId 重连
    }, delay)
}

选择 15s 而不是 30s:即使丢一次心跳,实际间隔最大 30s,远低于防火墙 60s 超时线。

4.2 服务端 echo pong

java 复制代码
// BrowserWebSocketHandler.java
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
    String payload = message.getPayload();
    sessionManager.relayToAgent(sessionId, payload);  // 照常 relay

    if (payload.contains("\"ping\"")) {
        send(session, "{\"type\":\"pong\"}");  // 同时回 pong,双向保活
    }
}

relay 到 Agent 保持 Agent WS 活跃;回 pong 证明服务器健在。两层保护。

4.3 重置 loading 状态

javascript 复制代码
// disconnect() 中补充
this.connecting = false
this.loading = false

// ws.onclose 中补充
this.loading = false

断开时无论处于什么状态,全部清零。

4.4 Agent 保持 sessionId + 自动重连

python 复制代码
# 首次连接:服务器分配 sessionId
session_id = msg["sessionId"]

# 断开后自动重连,URL 带上旧 sessionId
def build_url():
    url = server_url
    if session_id:
        url += "?sessionId=" + session_id
    return url

# 重连循环,指数退避
while should_run:
    ws = WebSocketApp(build_url(), ...)
    ws.run_forever(ping_interval=0)
    delay = min(delay * 2, 30)
    time.sleep(delay)

4.5 服务器保留 sessionId,支持 Agent 断线重连

java 复制代码
// SessionManager.java
// Agent 断开:不删 pair,清空 agentSession,通知浏览器等待
public void removeAgent(WebSocketSession agentSession) {
    pair.agentSession = null;  // 保留 pair,不删除!
    send(pair.browserSession, "{\"type\":\"agentDisconnected\"}");
}

// Agent 重连:复用原 pair
public boolean reconnectAgent(String sessionId, WebSocketSession agentSession) {
    pair.agentSession = agentSession;
    send(pair.browserSession, "{\"type\":\"agentReconnected\"}");
}

4.6 Agent 查询前 ping 数据库

python 复制代码
def query_mysql(ws, msg):
    try:
        db.ping(reconnect=True)  # 先 ping,死了自动重连
    except Exception:
        return send_error(ws, "MySQL connection lost, please reconnect")
    # ... 正常查询

def query_redis(ws, msg):
    try:
        db.ping()  # 先 ping,死了报错
    except Exception:
        return send_error(ws, "Redis connection lost, please reconnect")
    # ... 正常查询

4.7 全局异常截获,避免闪退

python 复制代码
if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(f"\nFATAL: {e}", flush=True)
        print("\nPress Enter to exit...", end="", flush=True)
        input()

五、最终架构

复制代码
浏览器 ──ping/15s──► 服务器 ──relay ping──► Agent
   ▲                    │                       │
   └── pong ────────────┘                       │
                                                │
                                          db.ping()  ──► MySQL/Redis
                                          查询前检测
                                                
断开恢复:
Agent 断 → 服务器保留 sessionId → Agent 带旧 ID 重连 → 服务器复用 pair
浏览器断 → 15s 内未 detect → auto-reconnect → 重发 connect

六、总结

WebSocket 长连接在生产环境中有三个必须处理的问题:

  1. 保活:浏览器和 Agent 两端都必须有心跳,间隔不超过防火墙超时的一半
  2. 重连:断连是常态,自动恢复才是关键,前端退避重连 + 后端复用 sessionId
  3. 状态清理:任何断开路径都必须重置 loading/pending 等临时状态,否则 UI 永久卡死

这些问题在开发环境难以复现,因为 localhost 没有防火墙、NAT、运营商设备这些中间节点。部署到公网后立刻暴露。


项目在线地址:https://onltool.site

相关推荐
严谨的麻辣烫1 天前
数据中心出口与住宅出口的技术差异:从 ASN 到 TCP 行为的一次拆解
网络·网络协议·tcp/ip
Freed&1 天前
Zabbix TCP连接状态自定义监控完整部署手册
网络·tcp/ip·zabbix
学着改变2751 天前
手持式超声波流量计为何首选行澳科技?便携巡检与外夹计量的性能标杆
大数据·网络·人工智能·科技·产品运营·量子计算
2501_915921431 天前
抓包鹰 Traceeagle 解除证书绑定,SSL Pinning 解除
网络·网络协议·网络安全·ios·adb·https·ssl
Lucky_Turtle1 天前
【SSL】letsencrypt域名证书申请,Cloudflare域名
网络·网络协议·ssl
路由侠内网穿透1 天前
本地部署企业级快速开发平台芋道管理后台并实现外部访问
运维·服务器·网络·网络协议
basketball6161 天前
软考中级网络工程师 第4章 无线通信网 备考总结
linux·服务器·网络·网络工程师·软考
睡一觉就好了。1 天前
Linux 信号机制
linux·运维·网络
A_humble_scholar1 天前
Linux(二) C++ TCP/UDP网络编程
linux·网络·c++
Sylvia33.1 天前
篮球数据API的技术架构与工程实践:基于火星数据WebSocket实时推送体系
java·python·websocket·网络协议·架构