一、现象
我的在线工具站(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 长连接在生产环境中有三个必须处理的问题:
- 保活:浏览器和 Agent 两端都必须有心跳,间隔不超过防火墙超时的一半
- 重连:断连是常态,自动恢复才是关键,前端退避重连 + 后端复用 sessionId
- 状态清理:任何断开路径都必须重置 loading/pending 等临时状态,否则 UI 永久卡死
这些问题在开发环境难以复现,因为 localhost 没有防火墙、NAT、运营商设备这些中间节点。部署到公网后立刻暴露。
项目在线地址:https://onltool.site