为了账号安全,请及时绑定邮箱和手机立即绑定

在 Node.js 中,我们将 P99 延迟降低了 90%,优化了 prom-client 的性能

prom-client(Prometheus 客户端)是 Node.js 中最流行的库,每周下载量大约为 200 万次。该库收集 Node.js 应用程序中的各种指标,使代码能够进行监控目的的插桩。它是一个用于 Prometheus 的客户端,允许开发人员创建和管理不同类型的指标,比如计数器、度量表、直方图和摘要。

在 Games24x7,我们使用 Node.js 作为我们的代理服务,该服务在高峰期每秒处理接近 200,000 个请求。在这个规模上,我们不能负担任何故障或延迟,因为其影响可能是巨大的。为了监控应用程序和 Node.js 内部指标,我们也使用了 prom-client 库,社区中的许多人也在使用它。

在这篇博客中,我们将讨论如何识别 prom-client 的性能问题、采取哪些措施来解决这些问题,以及如何定制该库以增强服务性能,最终在尾部延迟 >=P99 的情况下,将响应时间改善了 10 倍。

背景和设定

在我们目前的设置中,我们使用AWS EC2实例来运行此服务,并依赖AWS自动扩展组进行基于规则的扩展,主要是根据CPU使用情况。每个EC2实例都是一个专用于此服务的16核机器。来自客户端的流量被导向负载均衡器,负载均衡器将流量分配给所有服务节点。

Prometheus服务器每10秒向每个实例发出API调用来获取它们的指标。这么频繁的API调用会不会对高吞吐量服务的P99延迟(即99%的请求在99%的情况下所需的最大响应时间)产生影响?别着急,我们会在后面的章节里详细讲讲这个问题。

由于 Node.js 应用程序是单线程且异步的,你可能会想我们是如何利用 16 核的机器的,为什么不直接用单核机器,而要用 16 核的。

我们的Prometheus服务器从我们代理服务的每个EC2实例中抓取指标。如果我们使用单核机器,Prometheus服务器将需要从更多的机器中抓取指标,这会增加Prometheus服务器的负载,并需要对其进行扩展。为了提供一些背景信息,在峰值负载时,我们大约有100至200个实例,每个实例拥有16个核心。如果我们使用单核机器,我们则需要大约2000到3000台机器来处理同等的负载。接下来,我们来谈谈重点——如何利用多核机器来运行本质上是单线程的Node.js应用程序?这时,Node.js的集群模式就可以大显身手了。

集群模式是怎样的?它内部的工作原理是怎样的?

对于还没有了解集群模块内部运作的读者,本节会简单介绍一下。如果你对集群模块的工作原理已经有所了解,可以直接跳到后面。

Node.js 通常被描述为单线程且异步的,采用基于 epoll 的事件驱动、非阻塞 I/O 模型。事件循环基于 epoll 构建,持续监听事件,并在接收到事件时作出响应。这种设计使得 Node.js 非常高效,并且非常适合需要大量 I/O 操作的可扩展应用。然而,由于其单线程的特性,Node.js 在处理 CPU 密集型任务时会比较吃力,因此不太适合 CPU 密集型的应用。

为了充分利用多核机器并充分发挥多个内核的能力,Node.js 使用了 集群模式。这种模式允许 Node.js 创建多个相同的进程,每个进程都在不同的内核上运行。集群模式会创建多个与可用内核数量对应的工作进程,并有一个主进程来管理这些工作进程并将负载在它们之间以轮转方式分配。通过操作系统提供的IPC通道,工作进程和主进程之间进行通信。下面的图说明了,在有 3 个可用内核的情况下,主进程如何与工作进程通信。

现在你已经熟悉了Node.js在集群模式下的工作方式,以及我们设置的配置,让我们在接下来的部分更深入地探讨这个话题。

需注意的是,工作进程和主要进程之间的通信是通过操作系统提供的IPC通道进行的。

问题识别

在这个季度,我们在代理服务中观察到了一些不同寻常的行为。由 prom-client 跟踪的延迟在 P99 时为 50-70 毫秒,而 AWS 目标组显示的 P99 响应时间则在 400-600 毫秒之间。这个问题的调查从这里开始。我们最初怀疑各种内部原因,并从未怀疑过 prom-client 是问题的源头。最后,我们决定暂时停用 Prometheus。一停用 Prometheus,AWS 目标组中的 P99 延迟就降至 50-70 毫秒范围内,这证实了问题确实与我们使用的库相关。

现在让我们快速看看,在node.js的集群模式下,prom-client的基本工作原理是什么。

使用集群模式的Prom客户端

由于 Node.js 的集群模式涉及主进程和工进程,而所有工作均由工作进程完成,因此,度量指标是由每个工作进程捕获的。当来自 Prometheus 的请求到达应用时,与其它被定向到特定工作进程处理的请求不同,这个请求必须由主进程直接处理。因此,我们在主进程中定义了一个端点来提供这些度量。

当 Prometheus 服务器调用此端点以收集指标时,prom-client 内部会向每个工作器请求提供其收集到的指标。主代理随后将这些来自工作器的指标进行聚合,并将其返回给 Prometheus 作为响应。整个获取指标请求的过程如图所示,包括应用程序层和 prom-client 层的流程。

现在你已经对prom-client在Node.js的cluster模式下如何内部工作有了基本的了解,让我们深入调试过程,找出是什么原因导致了系统瓶颈。

在本地环境重现问题

因为我们的生产服务充当代理,也是我们所有系统的入口,它连接到许多后端系统并监控大量指标。在生产中,指标响应的大小约为400-500KB。我们无法通过使用较小的指标大小来重现这个问题,因此在我的本地环境中创建了大约500个指标以模拟接近生产环境的响应大小。这时我们开始观察到相同的现象。让我们开始深入调试这个问题。

识别prom-client聚合函数中的瓶颈

正如我们在前面一节里提到的,prom-client 从所有工作进程中收集的指标并进行聚合,而这些聚合操作是在主进程中进行的。当我们对聚合函数计时后发现,它花费了相当长的时间,并且该任务完全是 CPU 绑定的。但我们如何证明这个 CPU 绑定的任务确实是导致瓶颈的原因呢?

为了演示这一点,我们编写了一个使用集群模式的小代码段,并在主进程中插入了一个无限死循环。当我们在工进程上开始调用API时,它们也开始堵塞。这是因为所有发往工进程的请求都必须通过主进程。如果主进程忙于其他任务(例如无限死循环),它不会将请求转发给工进程,直到任务完成。这也说明,如果主进程被卡住哪怕一毫秒,所有工进程也会在同一时间段被卡住。这种现象解释了为什么一个API调用会怎样影响我们的P99延迟。这个API调用会使主进程堵塞一段时间,在这段时间里,所有工进程都无法处理请求。

你可以参考下面的代码示例,其中包含演示如何阻塞主进程的相关部分。当主进程被阻塞时,工作进程 API 调用(例如 /worker)也将无法响应,直到主进程恢复自由。

    var cluster = require('cluster');  
    let port = 8081;  
    let master_port = 3000;  

    if (cluster.isMaster)  
    {  
        let express = require('express');  
        let app = express();  
        let numCPUs = require("os").cpus().length;  

        // 注释:分叉工人。  
        for (let i = 0; i < numCPUs; i++)  
        {  
            cluster.fork();  
        }  
        cluster.on('fork', function (worker)  
        {  
            console.log('工人 ' + worker.process.pid + ' 分叉完成。');  
        });  
        cluster.on('exit', function (worker)  
        {  
            console.log('工人 ' + worker.process.pid + ' 断开。替换断开的工人...');  
            cluster.fork();  
        });  

        // 注释:阻塞服务器5秒  
        app.get('/阻塞', async (req, res) => {  
            let curr = Date.now();  
            while(Date.now() < curr + 5000){  
            }  
            res.send("完成");  
        });  

        // 注释:测试map性能的API  
        app.get('/mapTime', async (req, res) => {  
            let map = new Map();  
            let obj = {  
                "xyz": 123,  
            }  
            let start = new Date().getTime();  
            for(let i=0; i<20000; i++){  
            // 注释:将 'abc' 更改为非常长的字符串并检查性能  
                map.set("abc"+i, obj);  
            }  
            console.log('耗时: ',new Date().getTime() - start);  
            res.send("完成");  
        });  

        app.listen(master_port, function(){  
            console.log('主服务器正在监听端口 %d', master_port);  
        });  
    }  
    else  
    {  
        let express = require('express');  
        let app = express();  

        app.get('/工作', function(req, res){  
            res.send("成功");  
        });  

        app.listen(port, function () {  
            console.log('工作服务器正在监听端口 %d', port);  
        });  
    }
更深入地探讨聚合操作,并识别首个瓶颈

为了进一步调查,我们开始对主聚合函数内的不同功能进行计时。我们发现了一段特定的代码,它为开发者为某个指标及其值定义的所有标签生成了一个哈希。下面展示了一个包含这些标签的指标示例。这里的标签是 servicemethodurlstatus

    httpRequestDuration: new Histogram({  
        name: 'http_request_duration_ms',  
        说明:HTTP请求时长(毫秒),  
        labelNames: ['service','method', 'url', 'status'],  
    })

这个生成的哈希值被作为键插入到一个Map中,将度量对象作为值。如下所示,这段代码占了总聚合延迟的95%以上,如下面的示例所示。

prom-client 中的哈希和映射代码如下所示:在这里,Grouper 实际上是 JavaScript Map 的一个封装。hashObject 函数将所有标签及其值连接成一个字符串,该字符串随后被用作哈希键,以唯一标识一个指标。

    const 按标签分组 = new Grouper();  
    指标.forEach(指标 => {  
        指标.值.forEach(值 => {  
            const key = 对象哈希(值.标签);  
            按标签分组.add(`${值.指标名称}_${key}`, 值);  
        });  
    });

下面是一个示例哈希键,该哈希键中的 internal_status 指标用户自定义标签分别是 servicestatusurlapplication

我们是怎么解决这个问题?

为了处理这个问题,我们对 prom-client 的代码进行了修改,将哈希过程分配给工人处理,将哈希操作交给工人进程处理,而是在主进程中不再为所有工人的指标进行哈希计算。这样修改后,主进程不再执行任何哈希操作,这样我们就可以利用并行处理的优势,每个核心自己处理数据的哈希。

在实施了这项更改之后,我们重新进行了负载测试。然而,我们并没有发现延迟有任何显著改善。这让我开始怀疑是否插入数据到Map中也可能造成了瓶颈。

基于第二个瓶颈的识别
基于第二个瓶颈的识别
识别第二个瓶颈

在之前提供的样例代码中,我们也加入了名为 /mapTime 的 API 接口。此 API 让您可以测试 JavaScript 中 Map 对象的性能。通过增加代码中的键大小,您可以看到,当键的大小达到之前提到的程度时,插入时间会显著增加(插入 20,000 个键时,插入时间会增加到数十毫秒)。

prom-client 代码中,我们注意到每次通过 API 请求获取指标数据时,Map 都会被重新创建。这意味着所有的指标数据都会在每次请求时被重新插入。随着指标数据数量增多,这种延迟时间只会增加。

我们是怎么搞定的?

我们首先问自己一个问题:我们每次都需要创建一个新的地图吗?答案是 。如果一个指标已经被捕获,它的哈希值就不会改变。那么,为什么每次都需要反复插入它呢?相反,我们可以只插入一次键,之后需要时只更新对应的值。

为了实现这一点,我们将 Map 设为全局变量,因此在初始插入后,只需要更新值,直到观察到新的指标值。

我记录了这次更改的时间点,映射延迟有了明显的提升。然而,当我们重新运行负载测试时,P99延迟的改善并没有达到我们预期的程度。

识别最终瓶颈

是什么还在卡住系统?唯一没有考虑的就是 IPC(进程间通信)的时间。在 prom-client 中,主进程向工作进程请求指标,而工作进程将各自的指标发送回主进程进行聚合。整个通信过程都通过 IPC 来完成。此外,从主进程转发给工作进程的其他客户端请求也通过 IPC 实现。这难道不是瓶颈所在吗?

第一个挑战是弄清楚如何给IPC计时,因为它不是简单的函数调用。IPC涉及发送一个事件并在管道的另一端使用send方法等待接收它。为了测量时间,我们开始在IPC消息中附加上时间戳,并在接收端计算与当前时间戳之间的差值。结果令人惊讶地显示:每个工作进程通过IPC发送其指标所花的时间相当长(在某个工作进程中,接收最后一个指标集耗时高达112毫秒)。下图显示了来自我本地设置的8核机器的指标。

最初,我们假设IPC会是I/O密集型任务,不会影响性能。然而,这一发现明确显示,当通过IPC发送大量数据(例如600KB,如图所示)时,确实会阻塞其他通信(例如客户端请求)。这确实是一个亟待解决的大问题。

我们是怎么解决这个问题的?

我们一开始尝试解决这个问题的办法是,在通过IPC发送数据之前对其进行压缩。然而,这种方法不起作用,因为压缩数据花费的时间比可能节省的时间还要多。

我们意识到,随着度量单位的大小增长,依赖IPC进行通信会导致持续的问题不断出现。因此,我们决定取消这种特定通信中的IPC依赖。但随后的问题是:不用IPC的话,我们如何在工作进程和主进程之间进行通信?

我们尝试了几种方案,最终决定采用最简单的方法:使用文件系统。这种方法是,所有工作者进程将各自的指标数据写入文件,并通过IPC仅发送文件路径给主节点。主节点接着从文件中读取数据,删除该文件,然后聚合这些收集的数据。

我们也必须解决并发问题。由于多个 Prometheus 服务器可以同时抓取指标数据,如果两个“获取指标”的调用并发发生,这可能会导致问题。第一个请求可能会生成文件,而在第二个请求的工人们开始写入文件时,主进程端可能已经开始删除第一个请求生成的文件。

为解决这个问题,我们在文件名中添加了由 prom-client 生成的唯一请求ID,从而使我们能够唯一识别每个请求的文件。

实施这些更改后,我们再次运行了负载测试,发现延迟恢复正常。即便是在更大的负载压力下,这个库不再影响尾部延迟(>P99),尾部延迟(>P99)也不再受其影响。

解决方案概述

为解决主要的性能问题,我们着重于三个关键点。

  1. 将哈希任务分配给工人
  • 将主进程的任务移到了每个工作进程。现在,每个工作进程生成各自的哈希,并将各自的哈希值添加到 metrics JSON 中的 hash 键下。

  • 这一更改有效地将 CPU 密集型哈希处理任务分发给所有工作进程,不再由主进程承担所有哈希处理的责任。

2. 优化指标图的步骤

  • 之前,Map 在每次请求时都会被重新创建,这很不高效。我们将 Map 设为全局对象,因此现在它只会在新的度量被引入时才更新。如果度量已存在,我们只需更新其值。
  • 由于维护插入顺序,插入大键会变慢,这一改动大大减少了插入操作,因为现在每次度量只会在插入一次。

3. 减少IPC拥堵:

  • 当主进程请求从工作进程获取明显瓶颈时,工作进程通过IPC将这些指标发送回主进程,这导致了堵塞,影响了从主进程到工作进程的请求路由。
  • 为了解决这个问题,我们不再通过IPC传输指标,而是让工作进程将数据写入文件,并将文件名通过IPC发送给主进程。主进程随后从文件中读取数据并在之后删除这些文件。
  • 这种方法减轻了IPC的堵塞,确保了请求路由的畅通无阻。
结果是

我们进行了负载测试,以模拟客户端对我们API的请求负载。Prometheus服务器要么每5秒查询一次指标,要么关闭,以避免对Prometheus的调用。在实施所有优化之后,最终结果如下详细列出。下面的图片显示了来自五次不同负载测试的P99、P99.5、P99.9和最大延时。

Run 1 : 运行1代表基线场景,此时 prom-client 保持原始状态,且 Prometheus 服务器已关闭。这相当于没有 prom-client 并未跟踪任何指标。

Run 2 :在此情况下,prom-client 保持其原始状态,Prometheus 服务器每 5 秒抓取一次指标。这次运行的目的是展示未经优化的 prom-client 在常规 Prometheus 抓取情况下的延迟问题。

Run 3 :这里,我们测试了优化过的 prom-client,此时 Prometheus 服务器是关闭的。这次运行的结果应该与 第一次运行(Run 1)相似,因为不应有任何与 Prometheus 相关的影响。

Run 4 :这是我们想要达到的局面。优化后的 prom-client 正在运行,而 Prometheus 服务器每 5 秒采集一次指标。这里的关键观察是,在任何层级上都没有出现延迟增加的情况——这与完全不运行 Prometheus 服务器的效果是一样的。这次运行的延迟改进接近了 10 倍,而 Run 2 中在 P99 以上的延迟很高。

Run 5 :这次最后的运行是在关闭Prometheus服务器后进行,以验证结果,结果与第3次和第4次运行相似。

完成负载测试后,我们对更改的效果感到满意。我们创建了包含讨论中优化的定制版 prom-client 模块。自2024年6月在生产环境中部署了这个修改过的 prom-client 库以来,我们发现 AWS 目标组和 prom-client 指标的延迟现在更加一致。我们没有发现任何数据差异,也没有出现其他副作用,问题得到了有效解决。

我们还提交了一个拉取请求(Pull Request,简称PR),以将这些优化集成到主仓库prom-client中。你可以在这里跟踪此 PR 的进展,也可以在这里查看相关问题

要点

避免在主进程中执行CPU密集型任务

  • 在 Node.js 集群模式下,避免在主进程中放置 CPU 占用型操作。这些操作会阻塞所有工作进程,进而影响性能。应尽量减少主进程的工作,并确保任何用于集群模式的库都不会在主进程中执行 CPU 占用型任务。

利用prom-client高效管理指标

在使用prom-client的集群模式时,要小心不要添加不必要的指标。较大的指标集可能导致在高吞吐量水平下可能出现的性能问题。

优化桶以减少延迟指标

  • 可以减少 prom-client 中用于延迟指标的桶的数量。默认情况下,prom-client 使用 10 个延迟桶。根据您的需求定义自己的桶可以减少总体指标数量,从而减少通过 IPC 通信的数据量。

尽量让标签和值变小一些:

保持标签和它们的值尽量小。这样能减少哈希和数据的大小,减轻IPC的负担,并提高性能。

请考虑机器和工位的配置

  • 如果吞吐量在可控范围内,考虑使用更多但性能稍弱的机器。这样做有助于降低延迟,因为每台机器的负载较少,从而减少进程间通信的拥堵。

有了这些见解,我们结束了在 Node.js 集群模式设置中优化 prom-client 的探索。希望你在探索性能调优和指标管理的复杂性过程中既有趣又有启发性。感谢你的跟随,我们希望这些观察和建议能帮助你更好地理解 Node.js 内部和 prom-client 的效率。

一个作者简介

Saurabh Singh 是 Games24x7 的后端开发工程师,拥有超过 6 年的软件开发经验,专注于后端工程。他专注于构建和优化高可扩展性的分布式应用,致力于提升性能和降低成本。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消