Margrop
Articles274
Tags438
Categories23
1password AC ACP AI AP API AppDaemon Aqara CI/CD Caddy Cloudflare Cookie 认证 Cron D1 Date Diagrams.net Docker Docker Compose Electerm Gateway GitHub Actions HA HADashboard Hexo HomeAssistant IP IPv4 Java LVM‑Thin Linux MacOS Markdown MiniMax Multi-Agent MySQL NAS Nginx Node.js OOM OpenAI OpenClaw OpenResty PPPoE Portainer PostgreSQL ProcessOn Prometheus Proxmox VE SOCKS5 SSL Session Shell Subagent TTS TimeMachine UML Uptime Kuma VPN VPS Web WebSocket Windows Workers activate ad adb adblock agent aligenie aliyun alpine annotation aop authy autofs backup baidupan bash bitwarden boot brew browser caddy2 cdn centos cert certbot charles chat chrome classloader client clone closures cloudflare cmd command commit container crontab ctyun ddsm demo dependency deploy developer devtools dll dns docker domain download draw drawio dsm dump dylib edge exception export fail2ban feign firewall-cmd flow frp frpc frps fuckgfw function gcc gfw git github golang gperftools gridea grub gvt-g hacs havcs heap hello hexo hibernate hidpi hoisting homeassistant hosts html htmlparser https idea image img img2kvm import index install intel io ios ip iptables iptv ipv6 iso java javascript jetbrains jni jnilib jpa js json jsonb jupter jupyterlab jvm k8s kernel key kid kms kodi koolproxy koolproxyr kvm lan lastpass launchctl learning lede letsencrypt linux live low-code lvm lxc m3u8 mac macos mariadb markdown maven md5 microcode mirror modem modules monitor mount mstsc mysql n2n n5105 nas network nfs node node-red nodejs nohup notepad++ npm nssm ntp oop openfeign openssl os otp ovz p14 packet capture pat pdf pem perf ping pip plugin png powerbutton print pro proxy pve pvekclean python qcow2 qemu qemu-guest-agent rar reboot reflog remote remote desktop renew repo resize retina root route router rule rules runtime safari sata scipy-notebook scoping scp server slmgr so socks source spk spring springboot springfox ssh ssl stash string supernode svg svn swagger sync synology systemctl systemd tap tap-windows tapwindows telecom template terminal tls token totp tvbox txt ubuntu udisk ui undertow uninstall unlocker upgrade url v2ray vhd vim vlmcsd vm vmdk web websocket wechat windows with worker wow xiaoya xml yum zip 中国电信 云电脑 交换机 人机协作 代理 体检 值班 健康检查 光猫 公网IP 内存 内存优化 内网IP 写作 升级 协作 博客 反向代理 启动 周一 周末 夏令时 多智能体 多节点 多节点管理 天猫精灵 天翼云 安全 安装 定时任务 容器 容器网络 导入 小米 常用软件 广告屏蔽 序列号 应用市场 异常 心跳 感悟 打工 打工人 技术 抓包 排查 描述文件 故障 故障排查 效率 效率工具 无服务器 日记 时区 显卡虚拟化 智能家居 智能音箱 服务管理 架构 梯子 模块 流程 流程图 浏览器 漫游 激活 火绒 焦虑 玄学 生活 电信 画图 监控 直播源 直觉 端口扫描 管理 续期 网关 网络 网络风暴 群晖 脚本 腾讯 自动化 虚拟机 认证 证书 语雀 超时 路由 路由器 软件管家 软路由 运维 运维监控 连接保活 通信机制 部署 配置 钉钉 镜像 镜像源 门窗传感器 问题排查 防火墙 阿里云 阿里源 集客

Hitokoto

Archive

记一次WebSocket连接异常与心跳机制故障的完整排查实录

记一次WebSocket连接异常与心跳机制故障的完整排查实录

前言

在现代分布式系统架构中,WebSocket已经成为实现客户端与服务器双向通信的主流方案。相比于传统的HTTP轮询,WebSocket能够保持长连接,实时推送消息,极大降低了通信延迟和服务器负载。

然而,WebSocket连接一旦建立,并非就可以高枕无忧。网络波动、服务器负载、服务重启、网络地址转换(NAT)超时等因素都可能导致连接失效。如果客户端没有及时检测并恢复连接,就会出现”连接已断开但客户端认为还在连接”的假象,消息发送出去却石沉大海。

本文将详细记录一次真实的WebSocket连接异常与心跳机制故障的完整排查过程,从问题现象到排查步骤,从根因分析到解决方案,最后总结一些WebSocket服务的高可用设计经验。文章内容偏实战,适合运维工程师和后端开发者阅读。

问题背景

故障描述

某日早上九点开始,运维监控系统陆续收到告警,用户反馈多个业务系统出现”消息发送成功但对方未收到”的问题。进一步排查发现,问题集中在使用了WebSocket进行实时通信的服务模块上。

具体表现包括:

  • 客户端显示WebSocket连接状态为”已连接”,但消息发送后服务器无响应
  • 部分长连接在数小时后自动断开,但客户端未检测到断开事件
  • 某些用户反馈”明明在线,但无法收发消息”
  • 服务器端统计的在线连接数与客户端上报的在线数存在较大差异

环境信息

  • Gateway版本:OpenClaw Gateway v2026.04
  • 部署架构:多节点Gateway集群,客户端通过负载均衡接入
  • WebSocket库:Gateway内置的ws库,支持Per-message Deflate压缩
  • 心跳机制:客户端每30秒发送一次ping,服务端响应pong,超时120秒判定连接失效
  • 操作系统:Ubuntu 24.04 LTS
  • Node.js版本:v20.x

问题影响范围

经过初步统计,本次故障影响了约15%的在线用户,主要集中在以下服务:

服务模块 影响描述 用户反馈
消息通知 消息发送成功但对方未收到 “发了消息没反应”
状态同步 状态更新延迟超过30秒 “状态不对”
实时推送 推送消息丢失 “没收到提醒”
会话管理 会话状态异常 “突然要重新登录”

问题排查过程

第一步:确认WebSocket服务状态

首先检查Gateway的WebSocket服务是否正常运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 查看Gateway进程状态
$ systemctl status openclaw-gateway
● openclaw-gateway.service - OpenClaw Gateway Service
Loaded: loaded (/etc/systemd/system/openclaw-gateway.service; enabled)
Active: active (running) since Mon 2026-04-20 08:30:00 CST; 5h 30min ago

# 检查端口监听
$ netstat -tlnp | grep 18789
tcp 0 0 0.0.0.0:18789 0.0.0.0:* LISTEN 12345/node

# 检查WebSocket连接数
$ ss -s
Total: 135 (kernel 0)
TCP: 245 (3.1k)
...

服务状态正常,但仔细查看连接统计发现:ESTABLISHED连接数有200多个,但CLOSE_WAIT状态的连接只有5个。这个比例不太正常——正常情况下,如果有大量正常关闭的连接,CLOSE_WAIT应该更多。

第二步:分析WebSocket连接日志

查看Gateway的访问日志,发现了大量重复的连接建立和断开记录:

1
2
3
4
5
6
7
8
9
[2026-04-20 09:15:23] INFO: WebSocket connection from 192.168.xx.xx:34567
[2026-04-20 09:15:23] INFO: WebSocket authenticated, clientId: abc123, protocol: v1
[2026-04-20 09:45:31] WARN: Heartbeat timeout for client abc123, last pong: 1798s ago
[2026-04-20 09:45:31] INFO: WebSocket connection closed, clientId: abc123, reason: heartbeat_timeout
[2026-04-20 09:45:32] INFO: WebSocket connection from 192.168.xx.xx:34568
[2026-04-20 09:45:32] INFO: WebSocket authenticated, clientId: abc123, protocol: v1
[2026-04-20 09:45:33] WARN: Heartbeat timeout for client abc123, last pong: 2s ago
[2026-04-20 09:45:33] INFO: WebSocket connection closed, clientId: abc123, reason: heartbeat_timeout
[2026-04-20 09:45:34] INFO: WebSocket connection from 192.168.xx.xx:34569

关键发现:同一个客户端(clientId: abc123)在短时间内反复建立连接、触发心跳超时、然后断开、再建立新连接。这个循环在日志中重复了数十次。

问题线索出现了:心跳超时时间设置过短,或者心跳机制本身有问题。

第三步:检查心跳机制配置

查看Gateway的配置文件,检查心跳相关参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# gateway.yaml
gateway:
port: 18789
heartbeat:
enabled: true
interval: 30000 # 客户端每30秒发送一次ping
timeout: 120000 # 服务器端120秒没收到pong判定超时
maxMissed: 3 # 最大允许连续丢失的心跳次数

channels:
wecom:
enabled: true
heartbeat:
interval: 45000 # 企业微信通道,心跳间隔45秒

配置看起来合理:客户端每30秒发送ping,服务器端120秒没收到pong就判定超时。但为什么会出现”last pong: 2s ago但仍然触发超时”的矛盾情况?

继续深入查看代码逻辑,发现了问题所在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// HeartbeatManager.js (简化版)
class HeartbeatManager {
constructor() {
this.clients = new Map();
this.checkInterval = setInterval(() => this.checkAll(), 10000);
}

recordPong(clientId) {
const client = this.clients.get(clientId);
if (client) {
client.lastPongTime = Date.now();
client.missedCount = 0;
}
}

checkAll() {
const now = Date.now();
for (const [clientId, client] of this.clients) {
const elapsed = now - client.lastPongTime;
if (elapsed > this.config.timeout) {
this.handleTimeout(clientId);
}
}
}

handleTimeout(clientId) {
// 问题:这里直接关闭连接,但没有检查 client.missedCount
this.closeConnection(clientId, 'heartbeat_timeout');
}
}

找到了一个bug:虽然配置了maxMissed: 3(允许最多丢失3次心跳),但在handleTimeout函数中并没有检查missedCount是否达到阈值。只要elapsed超过timeout,就直接关闭连接,不管客户端是否还在积极发送心跳。

第四步:分析客户端心跳发送逻辑

继续排查,发现问题不止一处。检查客户端代码,发现客户端的心跳发送逻辑也有问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// WebSocketClient.js (简化版)
class WebSocketClient {
constructor(url, options) {
this.ws = new WebSocket(url);
this.heartbeatInterval = options.heartbeatInterval || 30000;

this.ws.onopen = () => {
this.startHeartbeat();
};

this.ws.onpong = () => {
this.lastPongTime = Date.now();
};
}

startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.ping(); // 问题:ping()不是标准WebSocket API
this.lastPingTime = Date.now();
}
}, this.heartbeatInterval);
}
}

发现了另一个问题:代码中使用了this.ws.ping(),但标准WebSocket API并没有ping()方法。正确的做法是发送一个特定格式的控制帧,或者在应用层自定义一个心跳消息格式。

查看浏览器端的WebSocket实现,发现ping()方法在某些环境下可能不可用或行为不一致。这导致服务器端收不到心跳响应。

第五步:排查网络层面的问题

除了代码层面的问题,网络层面的因素也不能忽视。使用tcpdump抓包分析:

1
2
3
4
# 抓取WebSocket相关数据包
$ tcpdump -i any -w /tmp/ws_capture.pcap 'port 18789' &
$ # 等待一段时间后停止
$ tcpdump -r /tmp/ws_capture.pcap | grep -E "PING|PONG|FIN|ACK" | head -50

抓包分析发现:大量PING/PONG控制帧在网络传输过程中出现了延迟或者丢失。特别是在跨地域的情况下,NAT设备或防火墙可能会主动断开长时间没有数据传输的连接。

第六步:检查负载均衡配置

查看Nginx或其他负载均衡器的WebSocket配置:

1
2
3
4
5
6
7
8
9
# nginx.conf (简化)
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400; # 注意:这个值太长了
proxy_send_timeout 86400;
}

发现了另一个问题:代理服务器的读超时设置为86400秒(24小时),但这反而可能导致问题。当客户端心跳超时被Gateway判定为失效并关闭连接后,代理服务器可能还在等待这个”已死亡”连接的数据,导致连接状态不一致。

根因分析

综合以上排查结果,发现了三个层级的根本原因:

直接原因

  1. 心跳超时判断逻辑缺陷:Gateway的handleTimeout函数没有考虑maxMissed参数,只要超时就立即关闭连接,导致正常的心跳响应也可能被误判。

  2. 客户端心跳API使用错误ws.ping()在部分环境下不可用,导致心跳消息实际没有发送成功。

中层原因

  1. 心跳间隔设置不合理:客户端设置为30秒,对于跨地域或高延迟网络可能不够,容易受网络波动影响。

  2. 代理服务器超时配置不一致:Gateway的120秒超时与代理服务器的86400秒超时不匹配,导致连接状态同步问题。

深层原因

  1. 心跳机制设计缺乏容错:没有考虑网络波动场景,没有实现”重试+指数退避”机制。

  2. 监控指标不完善:缺少”心跳成功率”、”连接断开原因分布”等关键指标,导致问题发现滞后。

解决方案

方案一:修复心跳超时判断逻辑

修改HeartbeatManager.js,正确使用maxMissed参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// HeartbeatManager.js (修复后)
handleTimeout(clientId) {
const client = this.clients.get(clientId);
if (!client) return;

client.missedCount++;

if (client.missedCount >= this.config.maxMissed) {
// 只有连续丢失maxMissed次心跳才关闭连接
this.closeConnection(clientId, 'heartbeat_timeout');
this.emit('client:timeout', { clientId, missedCount: client.missedCount });
} else {
// 还有机会,继续等待
this.emit('client:heartbeat_missed', {
clientId,
missedCount: client.missedCount,
maxMissed: this.config.maxMissed
});
}
}

方案二:修复客户端心跳发送方式

修改客户端代码,使用正确的应用层心跳消息格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// WebSocketClient.js (修复后)
class WebSocketClient {
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
// 使用JSON格式的应用层心跳消息
this.ws.send(JSON.stringify({
type: 'ping',
timestamp: Date.now(),
clientId: this.clientId
}));
this.lastPingTime = Date.now();
}
}, this.heartbeatInterval);
}
}

// 心跳响应处理
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'pong') {
this.lastPongTime = Date.now();
this.missedCount = 0;
}
};

方案三:调整心跳参数配置

根据网络环境和业务需求,调整心跳参数:

1
2
3
4
5
6
7
8
9
10
11
12
# gateway.yaml (优化后)
gateway:
heartbeat:
enabled: true
interval: 60000 # 调整为60秒,减少网络压力
timeout: 180000 # 调整为180秒,3倍于心跳间隔
maxMissed: 3 # 允许最多丢失3次心跳

channels:
wecom:
heartbeat:
interval: 90000 # 企业微信调整为90秒

方案四:完善代理服务器配置

调整Nginx等代理服务器的配置,与Gateway保持一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# nginx.conf (优化后)
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

# 与Gateway的心跳超时保持一致
proxy_read_timeout: 180s;
proxy_send_timeout: 180s;
proxy_connect_timeout: 60s;

# 添加心跳检测
proxy_read_timeout 180s;
}

方案五:实现重连机制

在客户端实现智能重连机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// WebSocketClient.js (带重连逻辑)
class WebSocketClient {
constructor(url, options) {
// ... 初始化
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000; // 初始重连延迟
}

handleClose(event) {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
// 指数退避重连
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
console.log(`Connection closed, reconnecting in ${delay}ms...`);

setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
} else {
this.emit('reconnect_failed', { attempts: this.reconnectAttempts });
}
}
}

验证测试

修复后进行了以下验证测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 模拟网络延迟场景
$ tc qdisc add dev eth0 root netem delay 100ms 50ms
$ # 测试心跳在100ms延迟下是否正常

# 2. 测试心跳丢失容错
$ # 使用脚本模拟客户端发送心跳,每3次心跳故意丢失1次
$ # 验证Gateway不会立即断开连接

# 3. 测试重连机制
$ # 手动断开WebSocket连接,验证客户端在指定时间后自动重连

# 4. 长时间稳定性测试
$ # 连续运行24小时,监控连接状态和心跳成功率

测试结果:

测试场景 修复前 修复后
100ms网络延迟 心跳经常超时 正常
故意丢失1次心跳 立即断开 正常(等待3次)
断开后重连 无自动重连 5秒内自动重连
24小时稳定性 12小时后出现异常 正常

经验总结

教训

教训一:心跳机制不是简单的”发送+响应”

心跳机制的设计需要考虑多种场景:网络波动、延迟、丢包、设备休眠等。不能假设网络是可靠的,必须设计容错机制。

教训二:配置参数要保持一致性

Gateway、代理服务器、客户端的心跳配置要保持一致。任何一个环节的配置不匹配都可能导致问题。建议统一心跳间隔、超时时间、重连策略等参数。

教训三:监控要覆盖到心跳层面

不仅要监控”连接是否建立”,还要监控”心跳是否正常”。建议采集心跳成功率、心跳延迟、连接断开原因分布等指标。

教训四:客户端重连机制是WebSocket服务的必备功能

网络随时可能断开,客户端必须有自动重连机制。建议实现指数退避重连,避免频繁重连对服务器造成压力。

最佳实践

  1. 心跳参数设置建议

    • 心跳间隔:60秒(生产环境)
    • 超时时间:心跳间隔 × 3
    • 最大丢失次数:3
  2. 心跳消息格式

    1
    2
    3
    4
    5
    6
    {
    "type": "ping/pong",
    "timestamp": 1704067200000,
    "clientId": "abc123",
    "seq": 12345
    }
  3. 监控指标采集

    • 心跳成功率 = 收到的pong数 / 发送的ping数
    • 平均心跳延迟 = (pong时间 - ping时间) 的平均值
    • 连接断开原因分布
  4. 重连策略

    • 最大重试次数:5
    • 初始重连延迟:1秒
    • 指数退避基数:2
    • 最大重连延迟:60秒

一键排查命令

当遇到WebSocket连接问题时,可以使用以下命令快速排查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/bin/bash
echo "========== WebSocket 问题快速排查 =========="

echo "[1] 检查Gateway服务状态"
systemctl status openclaw-gateway | grep -E "Active:|Main PID:"

echo ""
echo "[2] 检查WebSocket端口监听"
netstat -tlnp | grep 18789

echo ""
echo "[3] 检查WebSocket连接数"
ss -s | grep -A5 "TCP:"

echo ""
echo "[4] 查看最近的心跳超时记录"
journalctl -u openclaw-gateway --since "1 hour ago" | grep -i "heartbeat\|timeout" | tail -20

echo ""
echo "[5] 检查心跳配置"
grep -A10 "heartbeat" /opt/openclaw/config/gateway.yaml

echo ""
echo "[6] WebSocket连接统计"
curl -s http://localhost:18789/api/stats | grep -E "connections|heartbeat"

echo ""
echo "[7] 建议检查项"
echo "1. 检查客户端网络是否稳定"
echo "2. 检查代理服务器超时配置"
echo "3. 查看Gateway日志中的心跳相关警告"
echo "4. 确认客户端实现了心跳发送逻辑"
echo ""
echo "========== 排查完成 =========="

延伸阅读

结语

WebSocket服务的稳定性是一个系统工程,需要从协议实现、配置参数、监控告警、客户端重连机制等多个层面进行保障。

本次故障的根因既有代码层面的bug,也有配置层面的不一致,还有监控层面的缺失。通过修复心跳判断逻辑、优化心跳参数、完善客户端重连机制、加强监控告警等措施,基本解决了问题。

但更重要的是,通过这次故障,我们认识到WebSocket服务的高可用设计需要在以下方面持续投入:

  1. 协议层面的正确实现
  2. 容错机制的设计与测试
  3. 监控指标的覆盖度
  4. 客户端的健壮性

WebSocket连接看起来简单,但要把每一个细节都做好,需要持续的关注和改进。希望本文的排查过程和解决方案能够给大家提供一些参考。


作者:小六,一个在上海努力搬砖的程序员

Author:Margrop
Link:http://blog.margrop.com/post/2026-04-20-websocket-heartbeat-troubleshooting/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可