WebSocket 被 AWS ALB idle_timeout 静默 RST 断线率飙到 14% 的 5 天复盘:应用层心跳 + TCP keepalive 双保险 + 12 条长连接保活纪律

实时协作产品 WebSocket 用户低活跃 10 分钟就断,客户端只看到 1006 不知道是谁干的。5 天定位 ALB idle_timeout 静默 RST,应用层 ping/pong 30 秒心跳上线后断线率从 14% 压到 0.3%,顺手立下 12 条长连接保活纪律 + CloudFront/conntrack/移动 NAT 全链路 idle timer 登记。

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:

  1. 用户公司网络可能有更激进的代理(企业防火墙常见 5-15 分钟 idle);
  2. 移动网络下 NAT 网关 idle 短的有 30 秒;
  3. 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 条长连接保活纪律

  1. WebSocket 必须有应用层心跳,周期 ≤ 30 秒,不能依赖 TCP keepalive;
  2. 客户端必须有重连机制,指数退避 + 抖动(jitter),避免雪崩;
  3. 记录每个 close 的 code + reason,1006 必须告警(等于"原因不明",必须查);
  4. ALB / NLB / CloudFront 等所有 hop 的 idle timeout 都要登记,文档化最小值;
  5. 心跳间隔 < 所有 hop idle_timeout / 2,留出安全余量;
  6. 不要用 setInterval 直接发心跳,要在收到 pong 后再 setTimeout,避免堆积;
  7. 服务端要主动 terminate 没回 pong 的连接,防僵尸连接堆积内存;
  8. 压测必须模拟低活跃度场景,connect 后空跑 30 分钟看断不断;
  9. 监控加 connection_age 直方图,看连接平均寿命,异常下降立刻告警;
  10. 不要用 TCP keepalive 替代应用层心跳,但可以并存做双保险;
  11. 移动端要单独测,4G/5G 切换、运营商 NAT idle 都不一样;
  12. 不要在 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_timekeepalive_timeoutpermit_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% 是浪费时间。我们的经验:

  1. 客户端抓包(Wireshark/Chrome devtools):看客户端是否收到 RST、收到的 RST 来自哪个 IP;
  2. ALB 抓不到:AWS 不提供 ALB 内部抓包,只能看 metrics;
  3. 服务端 Pod 抓包(tcpdump):看服务端是否收到 RST、RST 之前最后一帧是什么;
  4. 对比两端时间戳: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);
  });
});

这些指标加上去后,有两个常见告警:

  1. close code 1006 占比 > 5%:说明有中间盒子在 RST,查链路;
  2. connection_age p50 突降:连接平均寿命变短,通常是新部署的 client 或中间件变更引入问题。

引申九:为什么不能用"先 close 再重连"代替心跳

有人提议过更激进的方案:既然不知道什么时候会被 RST,干脆每 5 分钟主动重连一次,不就规避了所有 idle 问题?这个方案听起来简单,但有几个致命缺陷:

  1. 重连期间状态丢失:WebSocket 是有状态的,重连后服务端不知道你是谁,要重新握手 + 鉴权,期间消息可能丢;
  2. 32000 个客户端定时重连等于持续小型 DDoS:每 5 分钟一次重连风暴,服务端 CPU 永远在 60%+;
  3. 用户体验恶化:协作场景下,5 分钟一次"短暂离线"会让光标抖动、文档同步出现毛刺;
  4. 不解决根本问题:就算把 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

MongoDB 6.0 分片集群 $lookup 跨分片 scatter-gather 拖垮 P99 7.4s 的 3 天复盘:反规范化 + Change Stream + 应用层 $in 四种修法对比 + 13 条工程纪律

2026-5-26 23:20:19

技术教程

ArgoCD 2.10 升级后 Kustomize 类 Application 全部 30 秒 timeout 的 8 天复盘:Kustomize 5.x + sealed-secrets webhook + helmChartInflationGenerator 三层叠加根因

2026-5-26 23:35:20

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