Nodejs 中大量短时间定时器实现方案?
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
建议用 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(() => {<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,才会从 scheduledJobs 这个字典里删除,否则就一直缓存在里面了。
这个设计勉强也可以理解,因为这个库就主要是为了那些会重复执行的 task 设计的。当然在新语法下,这个 scheduledJobs 应该用 WeakMap 比较好。
总之,你那需求应该用 setTimeout ……当然我觉得更好的做法是与客户端同步服务器时间,然后是直接告知在某某时间点某某道具开始有效,不必用定时器。生效前客户端就消耗了道具应该视为无效。正确消耗了,通知下一个时间点即可。
嗯。让客户端来做倒计时服务端验证应该是最好的。 我看了下 snapshot 确实是好多 scheduledJob 都在_cache 里,应该就是没 cancel 掉。 我先本地测试下,thanks。
node-scheduled 以前用来做定时爬虫,跑一段时间就挂,我就猜是它
好吧,看错了,不是这个
可以考虑用 libev 定制个这种外部服务 比自己维护好点 到点了来触发下
在 Node.js 中处理大量短时间定时器时,高效的实现方案尤为重要,以避免性能瓶颈和事件循环阻塞。以下是几种常见的策略:
-
使用
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
-
基于
setImmediate
的优先级队列:setImmediate
比setTimeout
有更高的优先级,可以在需要尽快执行的任务中使用。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();
-
使用
process.nextTick
: 对于需要在当前事件循环尽快执行的任务,process.nextTick
是一个好选择,但要注意避免无限递归。
在实际应用中,根据具体需求选择合适的方案,并考虑性能监控和优化。对于极端情况,可能需要使用更复杂的调度算法或第三方库。