[eggjs/egg][bug] loader.loadFile 内存泄漏

2025-11-04 198 views
4
现象:重复调用 N 次 app.loader.loadFile(...) 后,内存缓慢增长,无法释放。 Egg Version:2.27.0 分析: egg-core/lib/loader/egg_loader.js 中 requireFile() 方法:调用 this.timing.start(),this.timing.end() egg-core/lib/utils/timing.js 中 start() 和 end() 方法 只创建对象,从未释放,随着调用次数的增加,占用内存逐渐增大,造成泄漏 解决:timing 中释放对象引用

回答

9

额,为啥会反复调用 loader 呢? loader 只是初始化的时候调用的。

5

额,为啥会反复调用 loader 呢? loader 只是初始化的时候调用的。

因为 loadFile 是公开的 api,用 loadFile 就可以用 egg 模块的写法。 反复调用是因为,自定义的中间件里有调用,每个请求都会调用,谁知道 ……

2

那就是你的逻辑有问题了,模块一般不会太多,直接在初始化的时候加载就好了,会 lazy init 的,没必要在每次请求都去加载啊。

4

1、为什么非要在初始化时加载?你怎么知道模块不会太多呢?这个假设有点想当然了吧?NodeJS 的 require 也没有在启动时就把所有模块都加载一遍啊! 2、我看过源码,没看到哪里有 lazy init。而且,loadFile 实际调用的也是 NodeJS 的 require,require 本身就有缓存机制,就算每次请求都调用,也会用到缓存,性能问题不大。 3、就算不调 loadFile,timing 模块也有内存泄漏,这是潜在的隐患,作为一个被广泛使用的开源框架,逻辑应该严谨!

1

不要那么激动,先把场景沟通清楚。我们有一些业务也是有几千个模块的加载,也是在初始化的时候加载的,日常 alinode 也没有发现这块有什么问题。

因为之前的设计上考虑的 loader 都在在初始化的时候就加载好了(这是 plugin 独立于 middleware 的出发点),timing 这里没考虑过会被请求期调用的场景。

现在内存泄露的原因,不在于加载的模块多,而在于你的用法里面反复加载同个模块了吧?我认为这个是不太合理的,什么情况下,会每一个用户请求,都需要去加载一次文件?

你是在做动态挂载和卸载模块的场景么?能否具体介绍下?

cc @killagu 如果场景合理的话,我们可以加一个 timing 的 clear 方法出来。

6

初始化就加载所有模块,我认为是不合理的,缺点是应用启动时间长,而且占用内存,可能很多模块很少用,也加载到内存了,没必要浪费宝贵的服务器资源。

我的场景,就是对请求和响应格式进行了统一封装,写模块时按标准模块开发就行,不需要处理任何协议。然后,由中间件根据请求,按需动态加载和运行模块。之所以反复加载同个模块,是因为看到 NodeJS require 有缓存机制,所以每次请求都加载也没什么性能问题。

权衡取舍后,还是决定每次请求都加载,既可以缩短启动时间,减少内存占用,同时也不会对性能有很大影响,所以,我认为是合理的。

9

了解了,再问个问题,你的场景是 loadFile 后挂载到某个对象下?如 app.module.xx 这样?那是不是可以在这里加个判断做 lazy init,如果没有挂载过,才去调用 loadFile。

timing 那里讨论了下,loadFile 的耗时还是需要记录的,框架层面可以提供一个 timing 的 clear 方法,然后你在动态加载的时候调用下。

不过我更倾向于上面说的做法,判断有没有挂载过再调用 loadFile。

cc @killagu 回头可以考虑下内置的 lazy load 实现,我们 function 那块也有点类似?

7

嗯,判断条件也会加上,没有加载过再调用 loadFile。

7

你可以参考下 context loader 里面的几个 lazy init 的类的实现,直接往 app 上挂个多级的 lazy init 对象,遵循一套约定,这样的话,在 Controller 等地方就可以直接调用了,不需要在 middleware 里面来反复调用。

timing 的那个欢迎提个 PR 给我们,加个 clear 方法。

7

timing 的作用是什么?为什么要记录时间?在什么场景下用?能否解释下

2

上面提了,Loader 设计之初就是在启动期去加载文件的,Timing 用来采集启动过程的一些数据,用于分析用的,最终会 dump 到 timing.json 中。

Loader 的 API 也会给到上层框架去扩展他们的约定,后面出了 customLoader 的配置后,框架和插件其实可以不用调用这个 API,直接配置就好了,相当于收敛了这个 API,属于半内部的 API 了。

其实从你的需求来看,自己 require 文件也是可以的,loadFile 里面没啥逻辑,不一定需要调用它,app 的引用,还有 app.config.baseDir 你都是可以拿得到的,自己封装个方法也挺简单。

3

我理解 Timing 只是框架内部采集分析用的,那理应由框架自身来解决 clear 的事情,不应该由上层应用来处理。 自己 require 是可以,但如果要用 TS,自己 require 就很麻烦,而用 loadFile 就很容易支持。

8

从框架的角度来看, Timing 不会太多,采集后就在内存里面了,启动成功后会输出一份到文件,内存里面还会保存一份(某些插件用来上报用),框架也不知道啥时候需要清理掉啊。

即使框架在启动完毕后清理,但你后续还是会一直持续的调用,框架怎么知道啥时来清理?如果每次加载都清理 timing,那还打啥 timing。实在要框架层来处理,也只能是我刚才说的,提供一个 clear 的方法给上层自己判断调用。

但如果要用 TS,自己 require 就很麻烦,而用 loadFile 就很容易支持。

具体指?

8

即使框架在启动完毕后清理,但你后续还是会一直持续的调用,框架怎么知道啥时来清理?如果每次加载都清理 timing,那还打啥 timing。

所以 timing 不是必须要的。上层应用不用,大部分插件也不用,只是某些插件用,那为什么不在插件里处理,而非要加在框架里呢?显得框架很重。

require 只能 require js。但本地开发时,只有 ts,js 在哪里?

2

采集是整体的,插件是上报,不是采集,采集只能在核心层做。你可以理解为 egg 的内部逻辑,loader api 也算是私有 API 吧。

require 只能 require js。但本地开发时,只有 ts,js 在哪里?

可以再看下 loadFile 那块的逻辑,你会发现其实也只是 require,本地开发的 ts 其实是 ts-node/register 做的,它本身就 hack 掉了 require 的逻辑了。

loadFile -> resolveModule -> requireFile -> utils.loadFile (这里其实也没太多逻辑,我晚点看下有没有必要抽个方法,没的话自己写个其实也不会太麻烦)

7

先不发散了,每次请求都重复加载文件这个逻辑 先处理了吧,判断下再加载。

6

https://github.com/eggjs/egg-core/blob/master/lib/utils/timing.js#L15

我看了下 timing 的逻辑,如果没有

const timingKey = `Require(${this[REQUIRE_COUNT]++}) ${utils.getResolvedFilename(filepath, this.options.baseDir)}`;

这里的 this[REQUIRE_COUNT]++ 拼接模块名称其实就算多次调用也不会造成内存泄露,毕竟记录 time 的对象本身是很小的,这里为啥要多这个自增的 COUNT 设置到 key 里 @atian25

如果需要记录 require 的模块数,直接 ++ 写到 time 对象里就行了,不需要放到记录模块调用的唯一 key 上

8

主要还是看 timing 的定义,一个文件被 2 个不同的地方 require 了,算 2 条还是 1 条。我们认为是 2 条,实打实的记录加载过程做了什么事。

7

可以在启动完成后把 timing 清理了,主要还是为了统计启动耗时情况,运行时调用 loadFile 已经脱离了 timing 原始需求,不属于这个范畴了,可以不用再统计。

8

采集是整体的,插件是上报,不是采集,采集只能在核心层做。你可以理解为 egg 的内部逻辑,loader api 也算是私有 API 吧。

require 只能 require js。但本地开发时,只有 ts,js 在哪里?

可以再看下 loadFile 那块的逻辑,你会发现其实也只是 require,本地开发的 ts 其实是 ts-node/register 做的,它本身就 hack 掉了 require 的逻辑了。

loadFile -> resolveModule -> requireFile -> utils.loadFile (这里其实也没太多逻辑,我晚点看下有没有必要抽个方法,没的话自己写个其实也不会太麻烦)

core 里面打点,只产生打点数据,数据具体是放内存、存盘、上传、丢弃交给插件来做,这样是不是会比较好。虽然是私有的 api 而且默认 timing 数据很小,但是后面没啥用了一直放内存,会不会不符合 egg 精益求精的特质

3

@blueaqua2000 egg-core 已经有对应的 API 了,你可以 clear 和 disable 掉。

egg 的那个自动 clear 的单测还有点问题,晚点我修下

8

@hsiaosiyuan0 上面的 PR 已经支持了。

9

@blueaqua2000 egg 发版本了。

7

@blueaqua2000 准备部分回滚,仅 disable,而不 clear 启动期收集的 timing,对你的场景应该没影响,不会新增了。 你有需要的话可以自己调用一次 clear。

https://github.com/eggjs/egg/pull/4497