最近我们的Node.js线上服务出了一次严重的性能故障服务响应变慢CPU占用飙升甚至出现服务不可用的情况影响了用户的使用我们团队紧急排查经过几个小时的奋战终于找到了问题的根源解决了故障整个过程惊心动魄也让我学到了很多Node.js性能调优的经验。

今天想把这次故障复盘一下记录排查的过程问题的根源以及解决方案和,经验教训希望能帮大家在遇到类似问题时少走弯路。

一、故障发生

那天下午大概三点左右我们的监控系统突然报警了Node.js服务的响应时间超过了阈值平时响应时间大概100ms左右突然飙升到了2秒以上,而且错误率也开始上升有用户反馈说网站打不开,或者很慢。

我们赶紧登录服务器查看情况发现Node.js进程的CPU占用非常高几乎100%内存占用也在不断上升,而且有不断增长的趋势看起来像是内存泄漏,或者死循环的问题。

当时情况很紧急,因为服务已经影响了用户的正常使用我们首先,做了紧急处理把流量切到了备用服务,然后重启了出问题的Node.js进程先恢复服务,然后再慢慢排查问题的根源。

重启之后,服务暂时恢复了正常响应时间降下来了CPU也降下来了,但是我们知道问题没有根本解决,如果不找到根源以后还会再出问题,所以我们开始深入排查。

二、排查过程

排查的过程比较曲折走了一些弯路这里记录一下我们排查的步骤和思路。

第一步:查看日志

首先,我们查看了Node.js服务的日志看看有没有报错,或者异常信息日志里有一些请求超时的错误,但是没有明显的报错,或者异常堆栈看起来不像是代码报错导致的更像是性能问题导致请求处理,不过来超时。

我们也查看了访问日志看看是不是有异常流量,或者攻击发现流量和平时差不多没有明显的异常流量,或者DDoS攻击,所以排除了流量攻击的可能性。

第二步:监控指标分析

然后我们分析了监控系统的各项指标包括CPU内存响应时间错误率请求量等等看看能不能找到一些线索。

从监控数据来看故障发生的时候CPU占用是逐渐上升的不是突然飙升从正常的30%左右逐渐上升到100%大概用了一个多小时内存占用也是逐渐上升的从正常的500MB左右上升到了2GB以上,而且还在继续上升看起来很像是内存泄漏的问题,因为内存不断增长不释放导致GC(垃圾回收)压力越来越大CPU占用越来越高最后服务卡死。

但是我们也不能确定就是内存泄漏,因为也有可能是某个接口有性能问题处理时间太长导致请求堆积CPU占用上升,所以需要进一步排查。

第三步:复现问题

为了排查问题我们尝试在测试环境复现问题,但是测试环境的流量比较小跑了很久都没有复现问题看起来这个问题和流量,或者特定的请求有关需要一定的流量,或者特定的条件才能触发。

于是我们决定在线上环境做一些诊断,但是又不能影响线上服务,所以我们用了一台线上服务器单独跑不接流量,然后用压测工具模拟流量看看能不能复现问题。

我们用ab(Apache Bench)和artillery等压测工具模拟各种接口的请求跑了一段时间终于在压测某个接口的时候,复现了问题CPU和内存开始逐渐上升和线上故障的现象一样终于找到了触发问题的接口。

这个接口是一个数据导出接口用户可以用它导出自己的数据为Excel文件,因为导出的数据量可能比较大,所以这个接口的处理时间比较长资源消耗也比较大我们之前,也知道这个接口比较重,但是没想到会导致这么严重的问题。

第四步:分析接口代码

找到触发问题的接口后我们开始分析这个接口的代码看看到底是哪里有问题。

这个接口的逻辑是这样的:

  1. 接收用户的请求获取导出的参数(时间范围数据类型等等)
  2. 从数据库查询符合条件的数据可能有几万条甚至几十万条
  3. 把数据处理一下格式化字段计算一些衍生字段
  4. 用一个Excel生成库(exceljs)把数据生成Excel文件
  5. 把Excel文件返回给用户下载

看起来逻辑很简单,但是问题就出在这个过程中我们仔细分析了代码发现了几个问题。

问题1:一次性加载所有数据到内存

第一个问题是这个接口一次性把所有的数据都从数据库查询出来放到内存里,然后再处理和生成Excel如果数据量小还好,但是,如果数据量大,比如几十万条每条数据又有很多字段那内存占用就会非常大,而且这些数据在整个请求处理过程中都在内存里不会被GC回收导致内存占用飙升。

而且Node.js的内存是有限制的默认大概1.4GB左右(64位系统)如果内存占用超过这个限制就会OOM(Out of Memory)进程崩溃我们的服务就是,因为这个内存占用太高导致GC压力大CPU占用高最后服务卡死。

问题2:Excel生成库内存占用高

第二个问题是我们用的Excel生成库exceljs在生成大Excel文件的时候,内存占用非常高,因为它会把整个Excel文件的内容都放到内存里,然后再一次性写入文件,或者返回对于大数据量的Excel生成内存占用会非常惊人我们测试了一下生成10万条数据的Excel内存占用能到1GB以上非常恐怖。

而且exceljs的性能也不是特别好生成大Excel的时候,间比较长CPU占用也高这也加剧了服务的性能问题。

问题3:没有限流和并发控制

第三个问题是这个接口没有限流和并发控制任何用户都可以随时调用这个接口导出数据,而且可以,同时发起多个导出请求,如果有多个用户,同时导出大数据量的Excel那服务器的内存和CPU就会被占满导致整个服务卡死影响其他所有接口的正常使用。

我们这次故障就是,因为有几个用户,同时导出了比较大的数据导致服务器内存和CPU被占满服务卡死。

问题4:同步阻塞操作

第四个问题是这个接口里有一些同步阻塞操作,比如数据处理的循环是同步的Excel生成也是同步的这些操作会阻塞Node.js的事件循环导致其他请求无法被处理响应时间变长特别是在处理大数据量的时候,阻塞时间会很长严重影响服务的并发能力。

Node.js是单线程事件驱动的模型,虽然异步I/O能提高并发,但是,如果有大量的同步CPU密集型操作就会阻塞事件循环导致服务性能急剧下降我们的这个接口就是典型的CPU密集型操作在单线程的Node.js里会严重影响并发。

三、解决方案

找到问题的根源后我们开始想解决方案从几个方面入手优化这个接口和整个服务的性能。

方案1:流式处理避免一次性加载所有数据

首先,我们把数据查询和处理改成了流式处理不再一次性把所有数据都加载到内存里而是用数据库的流式查询(stream)一条一条地读取数据处理一条就写入Excel一条这样内存里只需要保留少量的数据内存占用大大降低。

具体来说,我们用了Knex.js的stream方法,或者mysql2的流式查询从数据库流式读取数据,然后用一个Transform流处理数据格式化字段计算衍生字段,然后再用一个支持流式写入的Excel库把数据流式写入Excel文件整个过程都是流式的内存占用非常低,不管数据量多大内存占用都能控制在几百MB以内。

方案2:更换Excel生成库用流式写入

然后我们把Excel生成库从exceljs换成了exceljs的流式模式,或者其他支持流式写入的库,比如xlsx的流式模式,或者直接生成CSV文件(如果用户能接受CSV的话)CSV文件的生成非常简单内存占用也非常低性能很好。

我们最后的方案是默认生成CSV文件,因为CSV文件用Excel也能打开,而且生成速度快内存占用低,如果用户明确需要Excel格式(.xlsx)我们再用支持流式写入的Excel库生成xlsx文件这样兼顾了性能和用户需求。

方案3:加限流和并发控制

然后我们给这个接口加了限流和并发控制限制每个用户,同时只能有一个导出任务不能,同时发起多个导出请求也限制了整个服务,同时处理的导出任务数量最多,同时处理3个导出任务超过的请求排队等待,或者返回提示让用户稍后再试。

我们还加了数据量限制单次导出的数据量不能超过10万条超过的话,提示用户缩小时间范围,或者分多次导出避免单个任务处理时间太长资源消耗太大。

另外我们还把导出任务改成了异步处理用户发起导出请求后服务立即返回提示说导出任务已提交处理完成后会发邮件,或者站内信通知用户下载这样用户不需要一直等待也不会,因为请求超时导致导出失败服务端也能更好地控制并发和资源。

方案4:用子进程处理CPU密集型任务

然后我们把导出这种CPU密集型的任务放到了单独的子进程(child_process)或者Worker里处理不占用主进程的事件循环这样,即使导出任务很耗CPU也不会影响主服务的其他接口的响应。

具体来说,我们用了Node.js的child_process.fork创建子进程处理导出任务主进程只负责接收请求提交任务返回结果不处理具体的导出逻辑这样主进程的事件循环不会被阻塞能正常处理其他请求。

如果导出任务很多我们还可以用任务队列(比如Bull基于Redis)把任务放到队列里用专门的Worker服务处理这样扩展性更好也更稳定。

方案5:加监控和告警

最后我们还加强了监控和告警给Node.js服务加了更详细的性能监控包括事件循环延迟GC耗时内存使用情况CPU占用等等一旦有,异常立即告警让我们能及时发现问题处理问题避免问题扩大影响用户。

我们还加了慢请求监控记录处理时间超过阈值的请求方便我们发现性能差的接口及时优化。

四、优化效果

经过以上几个方面的优化我们的导出接口性能提升非常明显内存占用从之前,的2GB以上降到了500MB以内,而且,不管数据量多大内存占用都很稳定不会持续增长CPU占用也降了很多,而且,因为用了子进程处理不会影响主服务的其他接口。

响应时间也提升了很多之前,导出10万条数据需要几分钟,而且会卡死服务现在只需要几十秒,而且不会影响其他接口用户体验好了很多。

而且,因为加了限流和并发控制不会再出现多个用户,同时导出导致服务卡死的情况服务的稳定性大大提升。

这次故障,虽然惊心动魄,但是也让我们发现了服务的很多性能问题和隐患通过这次优化,不仅解决了这个故障也提升了整个服务的性能和稳定性算是因祸得福吧。

五、经验教训

通过这次故障和优化我总结了一些Node.js性能调优的经验和教训分享给大家。

教训1:重视内存管理避免内存泄漏和大内存占用

Node.js的内存是有限制的,而且GC(垃圾回收)会占用CPU所以一定要重视内存管理避免内存泄漏和大内存占用特别是处理大数据量的时候,一定要用流式处理不要一次性把所有数据都加载到内存里,否则很容易导致内存占用过高GC压力大CPU占用高最后服务卡死。

平时也要注意监控内存使用情况看看有没有内存持续增长的趋势,如果有可能是内存泄漏要及时排查和解决。

教训2:CPU密集型任务不要放在主进程

Node.js是单线程事件驱动的模型适合I/O密集型的应用,但是不适合CPU密集型的应用,如果有CPU密集型的任务(比如大数据处理图片处理视频编码等等)一定不要放在主进程里处理会阻塞事件循环导致整个服务性能下降应该放到子进程,或者Worker里处理,或者用其他语言(比如GoC++)写专门的服务处理。

教训3:一定要加限流和并发控制

对于消耗资源比较大的接口(比如导出上传大文件处理等等)一定要加限流和并发控制限制单个用户的使用频率和并发数量也限制整个服务的并发数量避免,因为少数用户的大量请求导致整个服务卡死影响所有用户。

限流和并发控制是服务稳定性的重要保障一定不能少。

教训4:异步任务不要同步阻塞

对于耗时比较长的任务(比如导出大文件生成报告等等)不要做成同步阻塞的接口让用户一直等待应该做成异步任务用户提交任务后立即返回任务处理完后通知用户这样用户体验好服务端也能更好地控制并发和资源避免请求超时和资源耗尽。

教训5:加强监控和告警及时发现问题

一定要加强监控和告警,不仅要监控基本的CPU内存响应时间错误率还要监控Node.js特有的指标,比如事件循环延迟GC耗时内存使用情况等等这样才能及时发现Node.js特有的性能问题及时处理避免问题扩大影响用户。

而且告警阈值要设置合理不要太灵敏(经常误报导致大家麻木)也不要太迟钝(问题很严重了才告警)要根据实际情况调整。

教训6:压测很重要要提前发现性能问题

这次故障其实是可以提前发现的,如果我们上线前做了充分的压测模拟大数据量的导出和高并发的场景就能提前发现这个性能问题在上线前就优化好不会等到线上出了故障才发现。

所以压测非常重要特别是对于核心接口和消耗资源大的接口一定要做充分的压测模拟各种场景包括大数据量高并发异常情况等等提前发现性能问题和隐患及时优化。

六、写在最后

以上就是这次Node.js性能调优故障的复盘包括故障发生排查过程问题根源解决方案优化效果以及,经验教训。

这次故障,虽然惊心动魄,但是也让我学到了很多Node.js性能调优的经验对Node.js的性能特点和常见坑有了更深入的理解也让我们的服务性能和稳定性提升了很多算是一次很有价值的经历。

Node.js是一门很优秀的后端语言特别适合I/O密集型的应用,但是它也有自己的特点和坑,比如单线程事件循环内存限制CPU密集型任务处理等等需要我们了解这些特点避开这些坑才能用好Node.js写出高性能高稳定的服务。

希望我的这次复盘能帮大家在遇到类似问题时少走弯路也希望大家的服务都能稳定运行不出故障。

最后用一句话结束这篇文章:"性能调优没有最好,只有更好持续监控持续优化才能给用户最好的体验而故障复盘是最好的学习机会从故障中学习才能避免同样的故障再次,发生。"

愿大家的服务都能又快又稳定用户体验棒棒的。