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

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

内容提要

一次MCP记忆服务写路径完全中断,仅报空message异常。根因是SSE与WebSocket长连接泄漏:客户端断开后服务端无限等待、未关闭连接,累积662个CLOSE_WAIT和1009个fd,最终撑满256MB堆触发OOM。修复措施包括心跳超时回收连接、主循环捕获Error、存储层异步flush与限长,修复后CLOSE_WAIT归零、数据零丢失。

🔎

延伸解读

空消息异常:OOM 的隐蔽签名

文章指出,当框架层用 catch (e: Error) 捕获错误并透传 e.message 时,OutOfMemoryError 的空消息会变成无信息的 Exception("")。这提示排查者:遇到空 message 异常,应优先怀疑内存问题而非业务逻辑。该案例中,写路径全断而读路径正常,正是因为写操作涉及大内存分配,而读操作不分配大对象。

长连接泄漏:CLOSE_WAIT 与 fd 的监控价值

文章强调,CLOSE_WAIT 堆积意味着本端未调用 close(),而 /proc/self/fd 计数可量化泄漏。本次故障中,662 个 CLOSE_WAIT 和 1009 个 fd 是定位根因的关键入口数据。将这两个指标纳入监控告警,并作为回归测试断言,能有效预防类似问题。

分层防御:从入口限长到存储参数

修复不仅针对连接泄漏,还包含入口 Content-Length 预检(>512KB 返回 413)、payload 限长 256KB、memTableSize 从 16MB 降至 4MB、异步 flush 与 rotate 原子化。这些措施各自降低概率,叠加后避免单一 bug 击穿服务。文章提醒,若只修存储参数而不修连接泄漏,堆内存仍会被逐渐填满。

主循环必须捕获 Error:可用性边界

文章发现多个主循环(json-rpc 接收循环、cron-cj 调度器、cjxt 监听循环)缺少 catch (e: Error),导致 OOM 直接杀死协程,表现为服务“卡住”而非报错。修复后,这些循环在边界捕获系统级 Error,只记录不退出。这一经验被推广到所有主循环型代码,成为保障服务存活的关键模式。

Q&A

忆时塔服务写路径中断,报空 message 异常,可能是什么原因?

空 message 异常很可能是 OOM 的特征签名。OutOfMemoryError 属于 Error 层级,其 message 常为空,被框架层 catch 后原样透传,最终变成 Exception("")。

为什么忆时塔服务会 OOM?根本原因是什么?

根本原因是长连接泄漏。mcp-cj 的 SSE 处理中无限 wait() 且无超时,cjxt 的 WebSocket listenLoop 断开后未 closeConn,导致 CLOSE_WAIT 和 fd 持续累积,最终撑满 256MB 堆触发 OOM。

如何检测和修复长连接泄漏?

检测:监控 CLOSE_WAIT 连接数和 /proc/self/fd 文件描述符数。修复:为阻塞等待循环添加心跳超时,写失败即断连信号;确保所有退出路径执行 closeConn()。

仓颉中 catch (e: Error) 能捕获普通 Exception 吗?

不能。仓颉里 Error 与 Exception 是两条不同的继承链,catch (e: Error) 只能捕获 Error 子类(如 OOM、栈溢出),普通 Exception 会穿透。

修复后忆时塔服务的 CLOSE_WAIT 和 fd 数量有何变化?

修复前 CLOSE_WAIT 为 662,socket fd 为 1009;修复后 CLOSE_WAIT 归零,fd 降至 10,后台维护任务正常,数据零丢失。

从这次 OOM 排查中总结了哪些可复用的经验?

经验包括:空 message 异常先怀疑 OOM;主循环必须捕获 Error;长连接需心跳超时;监控 CLOSE_WAIT 和 fd;分层防御;排查时先落盘再 grep、本地复现、A/B 对照;上游库缺陷按最小复现→归档→报 issue→验证流程处理。

🏷️

标签

➡️

继续阅读