Nodejs 中大量短时间定时器实现方案?

发布于 1周前 作者 yuanlaile 来自 nodejs/Nestjs

Nodejs 中大量短时间定时器实现方案?

最近用 node+socket.io 做手游服务端,在小范围的线上测试的时候发现有内存泄漏,一开始是以为是 socket.io 的问题 最后这几天通过 heapdump 分析,基本定位了是node-scheduled这个库导致的(不知道是不是我使用问题)

heapdump 分析

程序刚启动打一个 snapshot,运行 3 个小时左右再打一个 snapshot,对比这个 2 个 snapshot 发现有大量的 closure (新增 100W+),都是 node-scheduled 这个库产生的

  • node-scheduled 主要用在游戏内道具的倒计时,一局游戏 3 分钟时长,6 个玩家为一个房间,一个房间内有 20 个左右的道具 游戏一开始每个道具我都用 node-scheduled 启动一个 task 来通知客户端这个道具在什么时候出现 当玩家在游戏过程中吃掉一个道具后,客户端告诉我,我就重新给这个道具一个 task 用来倒计时多长时间后这个道具恢复(一个道具被吃掉后一般 10 多秒就恢复了)

  • 每个房间有一个 object 对象 room,我将这个房间内的所有道具的 task 都绑定在这个 room 对象上,当这局游戏结束的时候,我首先把房间内所有的道具 task 都 cancel 掉,然后再销毁这个 room 对象 但不知道为什么通过 snapshot 查看到还是有 100W+的新增

服务器用的是双核 4G,这个服务器上只允许了这个一个 node 进程,在运行 4 小时左右 loop delay 已经达到 20ms+了,当前进程 CPU 快超过 80%了,运行时间越长 loop delay 能达到上百,进程 CPU 超过 100%

1.上述定时器我改用 settimeout 和 setinterval 会不会好一些? 2.对于只跑一个 node 进程来说 买多核有用吗?

node:8.4,node-scheduled:1.2.4


29 回复

建议用 setinterval , 全局共用一个 setinterval.

内存管理一定要把他当成 c 一样小心处理.


让客户端去做这个逻辑?服务端只记录上次吃掉的时间和这次吃掉是否合法就行了

全局用一个 setinterval ?那是 1s 执行一次这个 setinterval,然后每次执行的时候去检测有没有道具啥的,去告诉客户端吗?

关键是客户端的表现是,一开始这个道具在地图里,然后被吃掉后,这个道具就没了,服务端倒计时完毕,告诉客户端道具刷新了,然后客户端就在同样的位置刷出同样的道具。 让客户端去做的话应该不好实现,而且估计容易作弊

如果你的回调一直在而且越来越多,就很耗 CPU 和内存。解决的思路是让你的 NODE 进程里未处理的回调变少

我认为你用 settimeout,setinverval 可能都还是有问题。
建议:
1.先看下有多少用户量,如果用户量少,那还是代码问题,
2.首先利用多核,前面另个负载后面起两个进程就行了,PM2 的利用多核具体没用过。
3.其次建议你用 redis 的通知机制来实现定时器,让 redis 通知你的 node


1.用户量很少的,因为是小范围的测试,同时在线就 100 多点。所以是代码问题
2.按道理单个正常情况下支持个上百人应该没问题吧,所以就暂时没考虑负载均衡了(正式上线肯定是需要的)
3.redis 通知这个一开始有考虑过,后来想到一个道具就 10 多秒就恢复了,就没用 redis 这一层

逻辑放在客户端的话也一样的,定时器放在客户端去做了而已。如果是联机游戏,因为吃掉道具是一定需要通知服务器更新其他用户信息的,只是需要你服务端来验证这次吃掉是不是真吃掉了,本地作弊是没有用的。

嗯,感谢。 是联机游戏,我和客户端讨论下。

和 #1 类似,之前做过一个秒杀系统,页面上一大堆倒计时,统一用一个 setInterval 处理 https://github.com/keenwon/Tictac

给事件维护一个有序列表(按事件的触发时间排序),然后循环处理列表最前的事件(应该最先被触发的事件),如果该触发的事件已经处理完了,就按照列表里接下来第一个事件来设置一个 setInterval。可以直接用 Redis 的 ZSET,很容易拓展。

只是一种实现思路,不过现阶段直接用 setinterval 可以试一下

换个思路,因为是消费过段时间通知的模式,感觉采用定时消息队列的方式也可行;吃掉后服务器产生一条定时消息,并设置通知时间;服务器收到消息恢复对应的道具就 ok 了。减少定时器对性能的消耗。

嗯。就想#1 说的,全局一个 setinterval,因为我这里的最小单位是 1s,那就让这个 setinterval 1s 执行一次,每次执行的时候 我就去处理是否有相应的逻辑触发?

对消息队列这块比较陌生,有相应的资料吗?

这是阿里云的定时消息和延时消息 https://help.aliyun.com/document_detail/43349.html?spm=5176.doc29532.6.565.h0vG6B

应用角度讲现成方案已经很成熟,当然如果是想研究的话 kafka 等都是消息队列的实现。我也没深入研究过。

是否考虑单独一个 node 进程服务所有的定时事件,这样子即使定时服务倒了,也不至于影响主要业务

node-schedule 确实是存在上述问题,我司已被坑过了。解决方案是另起进程跑 schedules,定时重启。

说的是下面这个库?结尾没有 d 呀?
https://github.com/node-schedule/node-schedule
看起来这个库主要是运行那种定期执行的任务,可能设计时没太注意大量使用的情况?不过看代码 cancel 以后应该删除了,没看出什么问题……

其实几秒钟的时间用 setTimeout 更方便呀,不知道为什么舍近求远用这个库呢?

嗯,不好意思,文中多打了一个 d

我死循环不断新建 job 然后 cancel 没有发现有问题
<br>let schedule = require("node-schedule");<br><br>let count = 0;<br>function scheduleOne() {<br> if (count % 10000 === 0) {<br> console.log(count);<br> }<br> ++count;<br> let j = schedule.scheduleJob("0 * * * 0,4-6", function() {<br> console.log("Today is recognized by Rebecca Black!");<br> });<br> setImmediate(() =&gt; {<br> j.cancel();<br> scheduleOne();<br> });<br>}<br><br>scheduleOne();<br>

看你描述“当玩家在游戏过程中吃掉一个道具后,客户端告诉我,我就重新给这个道具一个 task 用来倒计时多长时间后这个道具恢复”这里旧的 task 有 cancel 么?定时把 node-schedule 里的 scheduledJobs 的大小打出来看看?

旧的 task 没有 cancel,因为我这里的 task 都是只执行一次的,即在某个时间点执行一次,执行之后才会有新的 task 产生,我产生新的 task 是直接把新旧 taks 覆盖掉。不知道这里有没有问题。 因为都是只执行一次而且都是已经执行过了,task 再 cancel 已经没意义了吧?

也许这就是问题,你知道只执行一次,但是 node 不知道呀,闭包一直留着呢

听你这么一说好像有点道理。 也没深入了解过这个库,我以为这种只执行一次的任务是不需要 cancel 的,我先把这部分修改了

建议你看下源码,只有调用 cancel,才会从 scheduledJobs 这个字典里删除,否则就一直缓存在里面了。

这个设计勉强也可以理解,因为这个库就主要是为了那些会重复执行的 task 设计的。当然在新语法下,这个 scheduledJobs 应该用 WeakMap 比较好。

总之,你那需求应该用 setTimeout ……当然我觉得更好的做法是与客户端同步服务器时间,然后是直接告知在某某时间点某某道具开始有效,不必用定时器。生效前客户端就消耗了道具应该视为无效。正确消耗了,通知下一个时间点即可。

嗯。让客户端来做倒计时服务端验证应该是最好的。 我看了下 snapshot 确实是好多 scheduledJob 都在_cache 里,应该就是没 cancel 掉。 我先本地测试下,thanks。

node-scheduled 以前用来做定时爬虫,跑一段时间就挂,我就猜是它

好吧,看错了,不是这个

可以考虑用 libev 定制个这种外部服务 比自己维护好点 到点了来触发下

在 Node.js 中处理大量短时间定时器时,高效的实现方案尤为重要,以避免性能瓶颈和事件循环阻塞。以下是几种常见的策略:

  1. 使用 setTimeout 的递归或循环: 对于简单场景,可以直接使用 setTimeout 递归调用或循环创建定时器。但这种方法在定时器数量庞大时可能会导致性能问题。

    function createTimers(count, interval) {
      for (let i = 0; i < count; i++) {
        setTimeout(() => {
          console.log(`Timer ${i} executed`);
        }, interval * i);
      }
    }
    
    createTimers(1000, 100); // 创建1000个定时器,每个间隔100ms
    
  2. 基于 setImmediate 的优先级队列setImmediatesetTimeout 有更高的优先级,可以在需要尽快执行的任务中使用。

    const tasks = [];
    for (let i = 0; i < 1000; i++) {
      tasks.push(() => console.log(`Task ${i} executed`));
    }
    
    function processQueue() {
      if (tasks.length > 0) {
        const task = tasks.shift();
        setImmediate(task);
        processQueue(); // 递归调用以处理队列
      }
    }
    
    processQueue();
    
  3. 使用 process.nextTick: 对于需要在当前事件循环尽快执行的任务,process.nextTick 是一个好选择,但要注意避免无限递归。

在实际应用中,根据具体需求选择合适的方案,并考虑性能监控和优化。对于极端情况,可能需要使用更复杂的调度算法或第三方库。

回到顶部