2026 年 5 月,我们一套基于 WebSocket 的实时协作产品(collab-edit,类似 Figma 的多人协同)被用户大量投诉:"用了不到 10 分钟连接就断,要刷新页面"。客户端日志显示 WebSocket close code 1006(异常关闭),服务端 nginx access log 几乎没痕迹。我们一度怀疑是浏览器问题、网络抖动、甚至代码 bug——查了 5 天才定位:AWS ALB 的 idle timeout 默认 600 秒,而我们的 WebSocket 在低活跃度场景下(用户思考、走神),空闲时间会超过 10 分钟。ALB 静默 RST 掉连接,客户端收到的是 1006,完全看不出"被中间盒子干掉"这个事实。
这是一类极其常见但又极其隐蔽的网络问题:L4/L7 中间件的超时配置和应用层连接生命周期不匹配。表面看是"WebSocket 不稳定",底层是 ALB / NAT / 防火墙 / CDN 这些 stateful middlebox 的 idle timer 在搞鬼。这次复盘我们把整条链路所有 timer 都摸了一遍,顺便立下了一套"长连接保活纪律"。
故障背景:这个产品的连接特征
| 维度 | 规模/参数 |
|---|---|
| 同时在线用户 | 峰值 32000 连接 |
| WebSocket 服务端 | Node.js 20 + ws 库 8.16 |
| 负载均衡 | AWS ALB(L7),挂在 EKS Service 前 |
| 客户端 | Chrome/Firefox/Safari 比例 7:2:1 |
| 典型空闲时段 | 用户思考/讨论时,5-30 分钟无消息 |
| 断线投诉率 | 峰值 14%(每天约 4500 次) |
| 客户端 close code | 1006(异常)占 92%,1000(正常)8% |
故障的关键特征是"非线性":协作密集的房间几乎不断,但只要房间安静下来(代码评审、PRD 讨论这种场景),10 分钟左右必断。一开始我们以为是用户网络问题——直到客服收到一个用户反馈:"我开了两个浏览器看,左边密集打字的不断,右边只看的就断"。这条线索让我们意识到问题和"活跃度"强相关。
事故时间线
| 时间 | 事件 |
|---|---|
| D1 10:00 | 客服反馈断线投诉激增,断线率从 0.8% 跳到 14% |
| D1 11:30 | 盯 CloudWatch ALB metrics,RST_count 飙升,RequestCount 没变 |
| D1 14:00 | 怀疑服务端:看 Node.js 进程内存/CPU,正常 |
| D1 16:00 | 怀疑浏览器:看 Chrome devtools Network,WS 状态 1006,close reason 空 |
| D2 09:00 | 查 ELB access log,发现大量 elb_status_code=- 的 H2 链路 |
| D2 14:00 | 用户提示"密集打字的不断,只看的断",意识到和活跃度相关 |
| D3 10:00 | 本地复现:连接后不发消息,11 分 30 秒后断;改成每秒 ping,从不断 |
| D3 14:00 | 查 ALB 文档,idle_timeout 默认 60 秒;但我们配置改成 600 秒 |
| D4 09:00 | 抓包:客户端最后一次发包后 600 秒,收到 ALB 发的 TCP RST |
| D4 14:00 | 方案对比:加大 idle_timeout vs 应用层心跳 vs TCP keepalive 三选一 |
| D5 10:00 | 选择应用层心跳:30 秒发 ping,客户端 35 秒未收到 pong 主动重连 |
| D5 18:00 | 灰度 30%,断线率从 14% 降到 0.3%;次日全量,完全消失 |
第一轮排查:被 close code 1006 误导
事故初期我们盯着客户端日志看,清一色 WebSocket closed: code=1006。1006 这个 code 意味着"无 close frame 异常断开",通常含义是"网络层突然断了,WebSocket 协议层没收到正常的 0x88 close frame"。第一反应是:
// 客户端日志(简化)
ws.addEventListener('close', (e) => {
console.log('close', e.code, e.reason);
// 输出: close 1006 (reason 永远是空字符串)
});
// 1006 的官方定义:
// "The connection was closed abnormally, e.g., without sending or receiving a Close control frame"
这条日志没有任何有用信息——既不知道是谁断的,也不知道为什么断。这是 WebSocket 协议设计的一个痛点:中间盒子静默 drop 连接时,客户端无法分辨是"服务端主动关"、"网络故障"、还是"中间盒子干掉"。我们花了 1 整天在浏览器、客户端代码、服务端 buffer 之间反复看,毫无进展。
第二轮排查:ALB metrics 露馅
突破口是有人提议看 ALB 的 metrics 而不是看 Node.js 服务端:
# CloudWatch CLI 查询 ALB 的 RST 计数
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name TargetConnectionErrorCount \
--dimensions Name=LoadBalancer,Value=app/collab-edit-alb/xxx \
--start-time 2026-05-12T00:00:00Z \
--end-time 2026-05-12T23:59:59Z \
--period 300 \
--statistics Sum
# 关键发现:
# TargetConnectionErrorCount 一天 4500+
# 同期 RequestCount 没明显变化
# ClientTLSNegotiationErrorCount 正常
TargetConnectionErrorCount 这个指标的官方定义是 "ALB 试图和后端建立连接但失败的次数"。它飙到几千说明 ALB 这一层确实在不断断后端连接。这条线索把锅从"客户端/网络/服务端"甩到了"ALB"上。
问题本质:stateful middlebox 的 idle timer
WebSocket 一旦升级成功(HTTP 101 Switching Protocols),底层就是一个普通 TCP 连接,中间所有 stateful 设备(ALB / NAT 网关 / 状态防火墙 / 公司代理)都会维护这条连接的状态表。这些设备都有 idle timer——超过 X 秒无流量就清理状态表,RST 掉连接。
整条链路上每个 hop 都有自己的 idle timer,真正的 timeout 是所有 hop 中最小的那个。我们一开始只把 ALB 改到 600 秒,但用户在低活跃度时本来就可能 10-15 分钟不动,所以 600 秒还是不够——而且即使改到 3600 秒,也不能保证客户端在公司代理后面那条链路上也撑 1 小时。所有 timer 都会过期,正确做法是让连接根本不进入 idle 状态。
修法 1:应用层心跳(我们最终选的)
WebSocket 协议自带 ping/pong control frame(0x9 / 0xA),专门用来保活和探活。我们用 Node.js 的 ws 库实现:
// 服务端:每 30 秒给所有连接发 ping
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
function heartbeat() {
this.isAlive = true;
}
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', heartbeat); // 收到 pong 标记还活着
});
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
return ws.terminate(); // 上轮没收到 pong,直接关
}
ws.isAlive = false;
ws.ping(); // 发 ping
});
}, 30000); // 30 秒一次,远小于 ALB 600 秒
客户端不需要专门处理 ping/pong(浏览器原生 WebSocket API 自动响应 pong),但我们额外加了一层应用层心跳作为双保险:
// 客户端:35 秒未收到任何消息就主动重连
let lastMessageAt = Date.now();
ws.addEventListener('message', () => { lastMessageAt = Date.now(); });
setInterval(() => {
if (Date.now() - lastMessageAt > 35000) {
console.warn('No message in 35s, reconnecting');
ws.close();
setTimeout(connect, 1000); // 1 秒后重连
}
}, 5000);
效果:断线率从 14% 降到 0.3%,而且 0.3% 都是真实网络问题(WiFi 切换、断网)而不是 idle timeout。代价是每个连接每 30 秒一次 ping/pong,32000 连接 = 每秒 1000 次额外帧,完全可以忽略。
修法 2:TCP keepalive
另一种思路是用 TCP 层 keepalive 而不是应用层心跳。Linux 默认 TCP keepalive 是 2 小时(tcp_keepalive_time=7200),需要调小:
# 系统级调整(/etc/sysctl.conf)
net.ipv4.tcp_keepalive_time = 60 # 60 秒空闲后开始探测
net.ipv4.tcp_keepalive_intvl = 10 # 每 10 秒一次探测
net.ipv4.tcp_keepalive_probes = 6 # 最多探测 6 次后认定死
sysctl -p
Node.js 进程级也要打开 keepalive:
// 在 WebSocket upgrade 后启用 socket keepalive
wss.on('connection', (ws, req) => {
req.socket.setKeepAlive(true, 60000); // 60 秒
});
TCP keepalive 的优势是对应用层透明,但缺点也明显:
- 不能穿透所有中间盒子:有的 NAT/防火墙会把 TCP keepalive 包当成"空数据包"忽略,不刷新自己的 idle timer;
- 探测周期粒度粗:Linux 内核 timer 实现里 keepalive 不是精确的,实际可能有几秒抖动;
- 没法做应用层判活:TCP 链路通但应用层挂了(Node.js 进程卡死)的话 keepalive 也不会发现。
所以我们用 TCP keepalive 作为底层兜底,应用层心跳作为主防线,两层叠加最稳。
修法 3:加大 ALB idle_timeout 到最大值
AWS ALB 的 idle_timeout 上限是 4000 秒(约 67 分钟)。看起来够长,但治标不治本:
# 改 ALB idle_timeout
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:... \
--attributes Key=idle_timeout.timeout_seconds,Value=4000
问题在于4000 秒不够长且管不了上游 hop:
- 用户公司网络可能有更激进的代理(企业防火墙常见 5-15 分钟 idle);
- 移动网络下 NAT 网关 idle 短的有 30 秒;
- 4000 秒还是个有限值,逻辑上不是"永不超时"。
这个修法只适合"内网部署 + 客户端可信"的场景,公网服务必须配合心跳。
修法 4:用 Server-Sent Events 替代 WebSocket
有些团队会选择改成 SSE(Server-Sent Events),理由是 SSE 是单向 HTTP 流,自带断线重连机制(EventSource API):
// 客户端:SSE 自动断线重连
const es = new EventSource('/api/events');
es.onmessage = (e) => { console.log(e.data); };
// 浏览器自动处理重连,无需手写
// 服务端 (Node.js)
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
setInterval(() => {
res.write(`data: ${JSON.stringify({ts: Date.now()})}\n\n`);
}, 30000); // 30 秒一次,顺便当心跳
});
但 SSE 单向,需要客户端 → 服务端的命令通道时还得另开一条 HTTP 长连接或 POST 请求,在"双向实时"的协作场景下不如 WebSocket 干净。我们评估后没用。
方案对比基准
| 方案 | 断线率 | 实现成本 | 对中间盒子穿透 | 判活能力 |
|---|---|---|---|---|
| 原始(无心跳) | 14% | — | 无 | 无 |
| 应用层 ping/pong | 0.3% | 低 | 完美 | 能判应用层活 |
| TCP keepalive | 3-5% | 极低 | 部分 | 只判 TCP 活 |
| 加大 ALB idle_timeout | 5-8% | 低 | 不解决 | 无 |
| 改 SSE | 0.5% | 高(架构重写) | 完美 | 自带 |
决策树:长连接保活方案怎么选
我们立的 12 条长连接保活纪律
- WebSocket 必须有应用层心跳,周期 ≤ 30 秒,不能依赖 TCP keepalive;
- 客户端必须有重连机制,指数退避 + 抖动(jitter),避免雪崩;
- 记录每个 close 的 code + reason,1006 必须告警(等于"原因不明",必须查);
- ALB / NLB / CloudFront 等所有 hop 的 idle timeout 都要登记,文档化最小值;
- 心跳间隔 < 所有 hop idle_timeout / 2,留出安全余量;
- 不要用 setInterval 直接发心跳,要在收到 pong 后再 setTimeout,避免堆积;
- 服务端要主动 terminate 没回 pong 的连接,防僵尸连接堆积内存;
- 压测必须模拟低活跃度场景,connect 后空跑 30 分钟看断不断;
- 监控加 connection_age 直方图,看连接平均寿命,异常下降立刻告警;
- 不要用 TCP keepalive 替代应用层心跳,但可以并存做双保险;
- 移动端要单独测,4G/5G 切换、运营商 NAT idle 都不一样;
- 不要在 onclose 里立刻重连,先延迟 1 秒,避免快速死循环。
引申一:为什么 1006 这个 close code 这么坑
WebSocket RFC 6455 定义了一组 close code,但 1006 是个特殊的——它叫 "reserved",规定客户端代码层永远不能主动设这个 code,只能由浏览器/Runtime 在"连接异常断开"时填进去。问题是这个"异常"涵盖了太多场景:
| 真实原因 | 客户端看到的 code | 能区分吗? |
|---|---|---|
| 服务端主动 close(发 0x88) | 1000-4999 具体值 | 能 |
| 服务端进程崩溃 | 1006 | 不能 |
| 中间盒子 RST | 1006 | 不能 |
| 客户端网络断 | 1006 | 不能 |
| TLS 握手中途断 | 1006(部分实现 1015) | 部分 |
这就是为什么我们花了 3 天才意识到是 ALB 干的——客户端日志里全是 1006,服务端 access log 也没有 close 事件(连接是被 ALB RST 掉的,没走到 application close handler)。1006 = 你必须看链路两端 + 中间所有 hop 的 metrics 才能定位,光看客户端没用。
引申二:WebSocket vs HTTP/2 长连接的对比
事故后我们也评估过用 HTTP/2 server push / gRPC streaming 替代 WebSocket。结论是:
- HTTP/2 stream 受 connection-level idle timer 影响:同样有 ALB idle_timeout 问题,且 HTTP/2 的 PING frame 配合更复杂;
- gRPC bidi streaming 内置 keepalive 配置:
keepalive_time、keepalive_timeout、permit_without_calls三个参数,比裸 WebSocket 心跳更标准化; - 浏览器原生不支持 gRPC:需要 grpc-web 代理一层,前端实现复杂度反而上升;
- WebSocket 生态成熟:socket.io、ws、ReconnectingWebSocket 等库都很稳。
结论:浏览器实时通信仍然首选 WebSocket,服务端之间长连接首选 gRPC bidi streaming。两者解决问题的姿势不同。
引申三:为什么 socket.io 默认能扛但裸 WebSocket 不能
有同事问:"我们之前用 socket.io 从来没遇到这种问题,为什么改成裸 ws 反而炸?"原因是 socket.io 默认就有心跳机制:
// socket.io 服务端默认配置
const io = new Server({
pingInterval: 25000, // 25 秒发一次 ping
pingTimeout: 20000 // 20 秒收不到 pong 算超时
});
// 客户端会自动响应 ping,同时也有自己的判活逻辑
socket.io 把"应用层心跳"作为默认行为,所以新人即使不懂 idle timeout 也能用对。这是好框架的标志——把容易踩的坑变成默认安全。裸 ws 库不强加,自由度高,代价是要工程师自己懂网络协议。我们这次踩坑就是因为团队从 socket.io 切到裸 ws 时丢了这层默认保护。
引申四:抓包到底要在哪一端抓
这次定位过程里抓包是关键证据,但选错地方抓 90% 是浪费时间。我们的经验:
- 客户端抓包(Wireshark/Chrome devtools):看客户端是否收到 RST、收到的 RST 来自哪个 IP;
- ALB 抓不到:AWS 不提供 ALB 内部抓包,只能看 metrics;
- 服务端 Pod 抓包(tcpdump):看服务端是否收到 RST、RST 之前最后一帧是什么;
- 对比两端时间戳:RST 在 ALB 之前到达哪一端,就是哪一端发的(往往是中间盒子)。
我们最后的关键证据是:客户端最后一个 packet timestamp = 10:00:00,服务端最后一个 packet timestamp = 10:00:00,客户端 RST timestamp = 10:10:01,服务端 RST timestamp = 10:10:01——双方都在 600 秒后收到 RST,RST 来自 ALB 的 IP。这才板上钉钉。
引申五:重连风暴的二级灾害
我们加心跳上线后又踩了一个二级坑:第一次上线灰度 30% 时,所有老连接陆续断、重连,瞬间产生连接风暴——8000 个连接 5 秒内全部 reconnect,把 Node.js 进程的 CPU 打到 100%。教训是重连必须有随机抖动:
// 错误:立即重连
ws.onclose = () => connect(); // 雪崩
// 正确:指数退避 + jitter
let attempts = 0;
function reconnect() {
const base = Math.min(1000 * Math.pow(2, attempts), 30000);
const jitter = Math.random() * 1000;
setTimeout(connect, base + jitter);
attempts++;
}
加了 jitter 后,8000 个连接的重连分散到 1-30 秒区间,CPU 平稳。分布式系统里"同步行为"是头号大敌——8000 个客户端在同一时刻做同一件事,等价于 8000 倍的瞬时压力。
引申六:不只 ALB,这些 hop 都有 idle timer
事故后我们把整条链路所有 hop 的 idle timer 都查了一遍,做了一张表:
| 设备/服务 | 默认 idle timeout | 可调范围 |
|---|---|---|
| AWS ALB | 60s | 1s ~ 4000s |
| AWS NLB | 350s | 1s ~ 6000s |
| AWS NAT Gateway | 350s | 不可调 |
| CloudFront | 60s(WebSocket 30 min) | WebSocket 不可调 |
| Nginx upstream | 60s | proxy_read_timeout |
| Linux conntrack | 432000s(established) | nf_conntrack_tcp_timeout_established |
| 企业防火墙(Cisco/Palo Alto) | 3600s(established) | 因配置而异 |
| 移动运营商 NAT | 30-600s | 不可控 |
关键观察:移动运营商 NAT 是最严的 hop,且完全不可控。这是为什么移动端 App 几乎都默认 < 30 秒心跳——微信、QQ 都是这个量级。我们的 WebSocket 30 秒心跳从这个经验值倒推,既能穿透绝大多数 NAT,也不会浪费太多流量。
引申七:WebSocket 之外,这些场景也常踩
idle timeout 不是 WebSocket 独有的问题。我们梳理了一遍其他容易踩的场景:
- 数据库连接池:HikariCP 默认 maxLifetime 30 分钟,如果短于 RDS 的 wait_timeout 就会持续重连;
- 消息队列消费者:Kafka consumer 长时间无 poll 会被 group coordinator kick,触发 rebalance;
- gRPC 长连接:client/server 两端 keepalive 配置不一致,一端会主动 GOAWAY;
- HTTP 长轮询:浏览器对单 origin 6 个连接限制 + 服务端超时配置,容易耗尽连接;
- SSH 隧道:ServerAliveInterval / TCPKeepAlive 不设,跳板机断了不知道。
这些场景的本质都是"两端对'活跃'的定义不一致"。修法都是统一:设主动心跳 / keepalive,而不是被动等超时。
引申八:连接生命周期的可观测性建设
事故后我们补了几个监控指标,所有长连接服务都需要:
// Prometheus metrics
const wsConnectionsTotal = new Counter({
name: 'ws_connections_total',
help: '累计连接数',
labelNames: ['type'] // open/close/error
});
const wsConnectionAge = new Histogram({
name: 'ws_connection_age_seconds',
help: '连接寿命',
buckets: [10, 60, 300, 1800, 3600, 7200, 86400]
});
const wsCloseCode = new Counter({
name: 'ws_close_code_total',
help: 'close code 分布',
labelNames: ['code']
});
// 在 connection 和 close 时埋点
wss.on('connection', (ws) => {
wsConnectionsTotal.inc({type: 'open'});
ws.connectedAt = Date.now();
ws.on('close', (code) => {
wsConnectionsTotal.inc({type: 'close'});
wsCloseCode.inc({code: String(code)});
wsConnectionAge.observe((Date.now() - ws.connectedAt) / 1000);
});
});
这些指标加上去后,有两个常见告警:
- close code 1006 占比 > 5%:说明有中间盒子在 RST,查链路;
- connection_age p50 突降:连接平均寿命变短,通常是新部署的 client 或中间件变更引入问题。
引申九:为什么不能用"先 close 再重连"代替心跳
有人提议过更激进的方案:既然不知道什么时候会被 RST,干脆每 5 分钟主动重连一次,不就规避了所有 idle 问题?这个方案听起来简单,但有几个致命缺陷:
- 重连期间状态丢失:WebSocket 是有状态的,重连后服务端不知道你是谁,要重新握手 + 鉴权,期间消息可能丢;
- 32000 个客户端定时重连等于持续小型 DDoS:每 5 分钟一次重连风暴,服务端 CPU 永远在 60%+;
- 用户体验恶化:协作场景下,5 分钟一次"短暂离线"会让光标抖动、文档同步出现毛刺;
- 不解决根本问题:就算把 5 分钟改成 4 分钟,移动 NAT 还是可能 30 秒就断你。
这条经验泛化:"定时重启"是一种规避问题的偷懒手段,可以短期续命,但不解决根因,而且会给你的系统留下永久的脆弱性。我们见过太多团队靠"crontab 每天重启进程"续命三年的内存泄漏,最终代价远比当时投入修复要大。
引申十:CloudFront WebSocket 的特殊性
事故还有一个小插曲:我们前面挂了一层 CloudFront(CDN)给静态资源加速,WebSocket 也走 CloudFront 入口。CloudFront 对 WebSocket 有"10 分钟连接空闲"的硬限制,这是不可调的。如果你的 WebSocket 走 CloudFront,即使后端 ALB 改到 4000 秒也没用——CloudFront 这层会先断。我们的解决方案是"WebSocket 不走 CloudFront,直接打 ALB":
# 改 Route53 路由
# api.collab-edit.com:
# AAA 记录指向 CloudFront(给 REST API + 静态)
# ws.collab-edit.com:
# AAA 记录指向 ALB(WebSocket 专用,绕过 CloudFront)
客户端代码里用 wss://ws.collab-edit.com/ 替代 wss://api.collab-edit.com/。这个小改动让 CloudFront 这层 10 分钟硬限制彻底消失。WebSocket 流量和 HTTP API 流量,在生产架构里通常要分开走——前者要长连接稳定,后者要 CDN 加速,需求冲突。
引申十一:WebSocket 压缩(permessage-deflate)的暗坑
顺带一个相关坑:很多团队为了省流量会开 WebSocket 压缩扩展 permessage-deflate。这个扩展默认会让连接进入"zlib stream 状态",需要在内存里维护一个压缩 context。我们之前压测时发现:
- 开 permessage-deflate 后,每个连接额外吃 32-64KB 内存(zlib window);
- 32000 连接 = 额外 2GB 内存(占 Pod 资源的 30%);
- 小消息场景下(< 100 字节),压缩反而增加 CPU 但没省流量。
// 关掉压缩或精细调参
const wss = new WebSocket.Server({
perMessageDeflate: {
zlibDeflateOptions: { chunkSize: 1024, level: 1 },
threshold: 1024 // 消息 > 1KB 才压缩
}
});
实测后我们把 threshold 设到 1024,小消息不压缩,大消息(协作文档 diff)才压缩,内存占用降到原来的 1/3。每个 "默认开启的优化" 都值得在生产规模下重新评估——什么场景适合压缩、什么场景不适合,没有银弹。
引申十二:K8s 滚动发布时 WebSocket 的"优雅关闭"
事故的姊妹篇是 K8s 滚动发布时怎么处理 WebSocket。普通 HTTP 服务可以靠 readinessProbe + 30 秒 preStop 优雅退场,但 WebSocket 连接寿命动辄几小时,如果硬关 32000 个连接,客户端会瞬间全部 1006,然后蜂拥重连——又是一次连接风暴。我们的优雅关闭方案:
// 收到 SIGTERM 后渐进式关闭
process.on('SIGTERM', async () => {
// 1. 立刻摘流量(readinessProbe 失败)
process.env.READY = 'false';
// 2. 给所有现存连接发"我要走了"的应用层消息
wss.clients.forEach((ws) => {
ws.send(JSON.stringify({ type: 'server_shutdown', reconnect_in_ms: 5000 }));
});
// 3. 等 30 秒让客户端慢慢迁走(分散到 5 秒区间避免风暴)
await new Promise(r => setTimeout(r, 30000));
// 4. 主动关剩余连接(发 close frame,客户端收到 1001 而不是 1006)
wss.clients.forEach((ws) => ws.close(1001, 'server shutdown'));
});
客户端收到 server_shutdown 消息后,延迟一个随机 0-5 秒重连(指向新 Pod)。这样发布过程中客户端只感知到一次 5 秒以内的短暂离线,不会有 1006 + 重连风暴。WebSocket 的优雅发布是个独立工程问题,远比 HTTP API 复杂。
引申十三:从这次事故看"问题定位顺序"的智慧
事后我们复盘了为什么花了 5 天,主要是定位顺序错了。我们的排查顺序是:客户端 → 服务端 → 抓包 → ALB metrics → ALB 文档。正确顺序应该反过来:先看链路所有 hop 的 metrics,再缩到具体一端定位。一般规律:
- 跨网络问题先看中间件,不看两端:两端各自看起来都正常,但 metric 异常往往在中间;
- "端到端日志正常"是危险信号:说明被某个中间盒子静默处理了,而不是没问题;
- 查问题先看"谁会主动断":列出链路上所有 stateful 设备,逐一排查它们的 disconnect metric;
- 抓包是最后手段:能从 metric 定位就不抓包,抓包成本高且容易陷入细节。
这个反思让我们重写了团队的故障排查 SOP——把"先看 hop 总览,再看两端细节"作为长连接类问题的默认入口。后续两个类似问题(gRPC keepalive 配置不匹配、Kafka consumer rebalance)都按这个顺序在 1 小时内定位掉。定位顺序的优化往往比单点工具的优化更值钱——前者影响 MTTR 的数量级,后者只影响细节。
引申十四:为什么不能完全信任"基础设施 SLA"
事故还引发了一个更深的反思:AWS ALB 的 SLA 文档里写得很清楚 idle_timeout 是可配置的,默认 60 秒,我们改成 600 秒。从 SLA 角度看 AWS 一点没毛病——超过 600 秒断开是规则之内。问题是我们没把"基础设施 SLA"翻译成"业务影响"。我们的业务场景里,10 分钟思考是常态,但当时没人想到 idle 这件事会触发 SLA 边界。
这次让我们补了一个流程:任何新业务上线前,产品 + 工程必须一起做一次"SLA 翻译"会议,把所用基础设施的每一条 SLA 翻译成"对我们业务意味着什么":
- "ALB idle_timeout 4000s 上限" → "我们用户超过 1 小时不动就会断连";
- "S3 跨区域复制 99.9% 在 15 分钟内" → "我们的灾备切换至少有 15 分钟数据滞后";
- "RDS 故障转移 60-120 秒" → "我们的业务每月会有 1-2 分钟不可用窗口";
- "Lambda 冷启动 200-3000ms" → "如果用户访问稀疏,P99 必差"。
这种翻译做完,很多"看起来不重要"的条款会变成"业务必须处理的边界"。基础设施提供商负责给你能力和承诺,但只有你自己知道这些数字对你业务是否致命——这层翻译永远不能外包。
总结
这次事故的表象是 WebSocket 不稳定,根因是 ALB 的 idle timeout 和应用低活跃度时段不匹配。修复方案是应用层 ping/pong 心跳为主、TCP keepalive 为辅,断线率从 14% 压到 0.3%。
比技术修复更重要的认知是:所有 stateful 中间设备都有 idle timer,只要你的连接出现长时间 idle,就一定会撞上其中最严的那个 hop。修法不是去调每个 hop 的超时(那是上限战),而是用应用层心跳让连接永不进入 idle 状态。这条规则适用于 WebSocket、gRPC、数据库连接池、消息队列消费者——任何"长连接"。
下次你写一个长连接服务时,先问自己三个问题:连接的 idle 多长会断?中间有几跳设备?心跳间隔留够余量了吗?这三个问题答清楚,80% 的长连接稳定性问题就消灭了。网络协议不会原谅"我以为它会一直连着"——它只承认"显式的心跳证明你还活着"。
—— 别看了 · 2026