症状
警告里带着事件名,能立刻缩小排查范围:MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 hello listeners added。用 setInterval 定期打印 listenerCount 观察走势:单调增长说明根本没有移除路径;锯齿状增长说明移除逻辑存在但漏了某个分支,通常是错误分支。计数与请求量同步增长、RSS 缓慢爬升,是典型的逐请求注册泄漏。
诊断思路
<arg_value>临时 setMaxListeners(0) 后每条警告都会打印注册堆栈,直接指认反复 addListener 的文件与行号;再用 listenerCount 区分“无移除”与“单分支漏移除”;最后按事件名全局 grep,出现在中间件、循环或重试包装器里的匹配点就是累积性注册的来源。常见成因:逐请求给共享 emitter 挂处理器、重试或重连循环里挂错误处理器、本该用 once 却用了 on、包装函数每次调用都挂监听但只在成功路径移除。
根因
对长生命周期 emitter 的监听器添加频率高于移除频率,emitter 于是保留了每一个闭包及其捕获的全部内容。警告在 11 个监听器时触发正是因为危险在于“保留”而不是计数本身——每个被遗留的监听器都拖着一张对象图无法回收。
修复与验证
把注册移到所有者的生命周期边界,并用“释放器”模式对称移除:emitter.on 之后返回 () => emitter.off(…),在 finally 中调用,保证成功与失败路径都执行清理。单次语义一律改用 once;新版 Node 优先用基于 AbortSignal 的 API,signal 中止时监听器自动移除。验证时跑几千个请求,批处理结束后 listenerCount 必须回到基线,并主动注入故障覆盖错误分支;保持默认限制不动,“高负载下警告保持沉默”就是这类泄漏的回归测试。确实需要超过十个长期监听器的组件,只对该实例调高限制并写明原因。永远不要用 setMaxListeners(0) 永久消音——探测器哑了,闭包还在。
代码示例
(node:4217) MaxListenersExceededWarning: Possible EventEmitter
memory leak detected. 11 hello listeners added to [EventEmitter].
Use emitter.setMaxListeners() to increase limit
setInterval(() => {
console.log('hello listeners:', emitter.listenerCount('hello'));
}, 5000);
function withHello(onHello) {
emitter.on('hello', onHello);
return () => emitter.off('hello', onHello); // disposer
}
const dispose = withHello(handle);
try {
await job();
} finally {
dispose(); // runs on success and failure alike
}
emitter.once('hello', handler, { signal: abortController.signal });