仓颉服务 - 忆时塔 OOM 追凶记

💡 原文中文,约8400字,阅读约需20分钟。
📝

内容提要

一次存储OOM排查发现,主因是SSE与WebSocket长连接泄漏:客户端断开后服务端未关闭连接、无限等待,累积662个CLOSE_WAIT和1009个fd,撑满256MB堆触发OOM,导致写路径中断。修复措施包括心跳超时探测、try/finally兜底关闭、主循环捕获Error,并加固存储层限长与异步flush。

🔎

延伸解读

空消息异常:OOM 的隐蔽信号

文中指出,框架层捕获 Error 后透传空 message,最终抛出 Exception("")。OutOfMemoryError 的 message 常为空,因此空消息异常可作为 OOM 的特征签名。排查时若见此类异常,应优先怀疑内存问题而非业务逻辑,这能避免在错误方向上浪费时间。

长连接泄漏:存储 OOM 的隐形推手

SSE 和 WebSocket 长连接在客户端断开后,服务端未关闭连接,导致 CLOSE_WAIT 和 fd 累积,最终撑满堆内存触发 OOM。修复需加入心跳超时探测和 try/finally 兜底关闭。这提醒我们,长连接必须有心跳机制,写失败是判断断连的可靠信号。

分层防御:从入口限长到异步 flush

文章强调单点补丁不足,需分层防御:入口限长(Content-Length 预检和 payload 限长)防止大请求爆堆;调整 memTableSize 匹配同步 flush 峰值;异步 flush 和 rotate 原子化避免数据丢失。每层只降低概率,但叠加可防止一个 bug 击穿服务。

排查方法论:从直觉到系统化

文中总结出可复用经验:空消息异常先查内存;主循环需捕获 Error;长连接必须心跳超时;监控 CLOSE_WAIT 和 fd 计数;生产事故先复制数据到本地复现;修复做 A/B 对照。这些方法能提升排查效率,减少误判。

Q&A

忆时塔服务出现OOM的根本原因是什么?

根本原因是SSE与WebSocket长连接泄漏:客户端断开后服务端未关闭连接,导致连接无限等待,累积了662个CLOSE_WAIT和1009个fd,最终撑满256MB堆触发OOM。

为什么空message的Exception是OOM的特征签名?

因为OutOfMemoryError属于Error层级,其message往往是空字符串。在badger-cj的WritePipeline.runBatch中,catch (e: Error)会调用markFailed(e.message),将空message原样透传,最终commitOrThrow抛出Exception("")。所以看到空message异常,应首先怀疑OOM。

SSE长连接泄漏的具体机制是什么?

mcp-cj的StreamableHttpServer.handleSSE中,服务端在while循环里调用sseCond.wait()无限阻塞等待推送。当客户端断开时,没有任何机制唤醒它,连接对应的HttpResponseWriter一直留在sseWriter中,TCP连接停在CLOSE_WAIT,fd永不释放。每次会话断开都会泄漏一条连接。

修复长连接泄漏采用了哪些措施?

主要措施包括:将SSE的无限等待改为带超时的心跳探测(默认30秒),通过写操作探测对端是否存活,写失败则清理连接;对WebSocket的listenLoop使用try/finally兜底关闭连接;在所有主循环(接收循环、调度循环等)中添加catch (e: Error)边界,只记录不退出。

修复后CLOSE_WAIT和socket fd数量有何变化?

修复前CLOSE_WAIT为662,socket fd为1009;修复后CLOSE_WAIT降为0,socket fd降为10。模拟10次SSE断连时fd从20回落到10,模拟5次WS连接/断开时fd稳定在10。

排查过程中总结出哪些可复用的经验?

七条经验:1. 空message异常≈OOM,先查内存;2. catch (e: Error)捕不到Exception,主循环需捕获Error;3. 长连接必须有心跳超时,写失败即断连;4. 连接泄漏看CLOSE_WAIT和fd计数;5. 分层防御而非单点补丁;6. 排查手段要讲究(先落盘再grep、本地复现、A/B对照);7. 上游库缺陷要有作战流程(最小复现→归档→报issue→验证)。

🏷️

标签

➡️

继续阅读