Node.js中约30倍的并发加速

在生产中使用的Node.js服务中无缝地提高并发性的最佳方法是什么? 这是我的团队几个月前需要回答的一个问题。

我们推出了4000个Node容器(或“工作人员”),以确保我们与银行的集成服务的正常运行。 最初设计该服务是为了使每个工作人员一次只能处理一个请求。 这减少了可能意外阻止事件周期的那些操作对系统的影响,并使我们可以忽略各种类似操作在资源使用上的差异。 但是,由于我们的能力仅限于同时执行4,000个请求,因此无法充分扩展该系统。 对大多数请求的响应速度并不取决于设备的容量,而取决于网络的容量。 因此,如果我们能够找到一种可靠地并行处理请求的方法,则可以改善系统并降低其支持成本。



在研究了这个问题之后,我们找不到一个很好的指南来讨论从Node.js中的“缺乏并行性”到“高级并行性”的过渡。 结果,我们基于谨慎的计划,好的工具,监控工具以及大量的调试工作,制定了自己的迁移策略。 结果,我们设法将系统的并行度提高了30倍。 这相当于每年减少约30万美元的系统维护成本。

该材料专门讲述了我们如何提高Node.js工作者的生产率和效率以及通过这种方式学到的东西的故事。

为什么我们决定投资并行性?


在没有使用并行性的情况下,我们已经发展到了这样的规模,这似乎令人惊讶。 它是怎么发生的? 格子工具执行的数据处理操作中,只有10%由坐在计算机上并将其帐户连接到应用程序的用户启动。 其余的所有事务都是用于定期更新在没有用户在场的情况下执行的事务。 逻辑已添加到负载平衡系统中,我们使用该逻辑来确保用户发出的请求优先于事务更新请求。 这使我们能够以1000%甚至更多的速度处理API访问操作的突发事件。 这是通过旨在更新数据的事务完成的。

尽管此折衷方案已经使用了很长时间,但仍可以看出其中的一些不愉快时刻。 我们知道,最终它们可能会对服务的可靠性产生不利影响。

  • 来自客户端的API请求的高峰越来越高。 我们担心活动的大量增加会耗尽我们的查询处理能力。
  • 向银行履行请求的延迟突然增加,也导致工人的能力下降。 由于银行使用各种基础架构解决方案,因此我们为传出的请求设置了非常保守的超时。 结果,可能需要几分钟才能完成加载某些数据的操作。 如果碰巧向银行执行许多请求的延迟突然增加了很多,那么事实证明,许多工人只会被困在等待答复中。
  • ECS中的部署变得太慢,即使我们提高了系统的部署速度,我们也不想继续增加群集大小。

我们认为应对应用程序瓶颈和提高系统可靠性的最佳方法是提高处理请求的并行度。 此外,我们希望,作为一个副作用,这将使我们能够减少基础架构成本,并帮助实施更好的工具来监视系统。 将来两者都会结出硕果。

我们如何引入更新,同时注意可靠性


▍工具与监控


我们有自己的负载平衡器,可将请求重定向到Node.js工作者。 每个工作程序都运行一个用于处理请求的gRPC服务器。 Worker使用Redis告诉负载均衡器他可用。 这意味着向系统添加并行性可以归结为简单地更改几行代码。 即,该工作人员应该通知他有空,而不是在请求之后便变得不可访问,直到他正忙于处理向他提出的N个请求(每个请求)由其自己的Promise对象表示)。

是的,事实上,并非一切都那么简单。 部署系统更新时,我们始终将维护其可靠性视为我们的主要目标。 因此,我们不能仅仅接受并遵循YOLO原则之类的方法,将系统置于并行查询处理模式。 我们预计这样的系统升级将特别危险。 事实是,这将对处理器的使用,内存和执行任务的延迟产生不可预测的影响。 由于Node.js中使用的V8引擎在事件循环中处理任务,因此我们主要担心的是,事实证明我们在事件循环中做的工作太多,从而降低了系统吞吐量。

为了减轻这些风险,我们甚至在第一个并行工作者投入生产之前,就确保了系统中以下监视工具和工具的可用性:

  • 我们已经使用的ELK堆栈为我们提供了足够的日志信息,这对于快速确定系统中发生的情况可能很有用。
  • 我们向系统添加了几个Prometheus指标。 包括以下内容:

    • 使用process.memoryUsage()获得的V8堆大小。
    • 有关使用gc-stats软件包的垃圾回收操作的信息
    • 有关完成任务所用时间的数据,按与银行集成相关的操作类型以及并发级别进行分组。 我们需要它来可靠地衡量并发如何影响系统吞吐量。
  • 我们创建了Grafana控制面板 ,旨在研究并发对系统的影响程度。
  • 对于我们来说,无需重新部署服务即可更改应用程序行为的能力非常重要。 因此,我们创建了一组LaunchDarkly标志,旨在控制各种参数。 通过这种方法,计算出了工作人员的参数,以使其达到最大并行度,从而使我们能够快速进行实验并找到最佳参数,为此花了几分钟。
  • 为了找出应用程序的各个部分如何加载处理器,我们构建了生产服务数据收集工具,并以此为基础构建了火焰图。

    • 我们使用0x包是因为Node.js工具易于集成到我们的服务中,并且因为最终的HTML数据可视化支持搜索并为我们提供了很好的详细程度。
    • 当工作程序在打开0x程序包的情况下启动时,并在退出时在S3中记录最终数据时,我们在系统中添加了配置文件模式。 然后,我们可以从S3下载所需的日志,并使用格式为0x --visualize-only ./flamegraph的命令在本地查看它们。
    • 在一段时间内,我们仅开始为一个工人进行配置文件。 分析会增加资源消耗并降低生产率,因此我们希望将这些负面影响限制在单个工人身上。

▍开始部署


完成初步准备后,我们为“并行工作者”创建了一个新的ECS集群。 这些工人使用LaunchDarkly标志动态设置其最大并行度。

我们的系统部署计划包括逐步将流量从旧群集重定向到新群集的重定向。 在此期间,我们将密切监视新集群的性能。 在每个负载级别,我们计划提高每个工作人员的并行度,使其达到最大值,这不会导致任务持续时间增加或其他指标恶化。 如果遇到麻烦,我们可以在几秒钟内将流量动态重定向到旧群集。

不出所料,我们遇到了一些棘手的问题。 我们需要调查并消除它们,以确保更新后的系统正确运行。 这就是乐趣的开始。

展开,探索,重复


▍增加Node.js的最大堆大小


当我们开始部署新系统时,我们开始以非零退出代码接收任务完成的通知。 我能说什么-一个有希望的开始。 然后,我们将其埋在了Kibana中,并找到了必要的日志:

 FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - Javascript heap out of memory 1: node::Abort() 2: node::FatalException(v8::Isolate*, v8::Local, v8::Local) 3: v8::internal::V8::FatalProcessOutOfMemory(char const*, bool) 4: v8::internal::Factory::NewFixedArray(int, v8::internal::PretenureFlag) 

这让人想起了进程意外终止时我们已经遇到的内存泄漏的影响,并给出了类似的错误消息。 这似乎是完全可以预料的:并行性级别的提高导致内存使用级别的增长。

我们建议增加Node.js堆的最大大小(默认设置为1.7 GB)可以帮助解决此问题。 然后,我们开始运行Node.js,将最大堆大小设置为6 GB(使用命令行标志--max-old-space-size=6144 )。 这是适用于我们的EC2实例的最大价值。 令我们高兴的是,此举使我们能够应对生产中出现的上述错误。

▍内存瓶颈识别


解决内存分配问题后,我们开始遇到并行工作程序上任务吞吐量低的问题。 同时,控制面板上的其中一张图表立即引起了我们的注意。 这是关于并行工作进程如何使用束的报告。


堆使用

该图的某些曲线不断上升-直到它们在最大堆大小的水平上变成几乎水平的线。 我们真的不喜欢它。

我们在Prometheus中使用了系统指标,以消除由于此类系统行为引起的文件描述符或网络套接字泄漏。 我们最合适的假设是,没有足够频繁地对旧对象执行垃圾回收。 这可能会导致这样一个事实,即在处理任务时,工作人员将积累越来越多的内存,这些内存已分配给已经不必要的对象。 我们假设系统的运行在此过程中吞吐量下降,如下所示:

  • 工人收到新任务并执行某些操作。
  • 在执行任务的过程中,在堆上为对象分配了内存。
  • 由于他们按照“完成并忘记”的原则进行的某些操作(当时还不清楚哪一个)是不完整的,因此即使在任务完成后也可以保存对对象的引用。
  • 由于V8必须扫描堆中越来越多的对象,因此垃圾收集速度变慢。
  • 由于V8实施了一个垃圾收集系统,该系统按照“ 世界停止”计划工作(在垃圾收集过程中停止程序),因此新任务将不可避免地减少处理器时间,从而降低了工作人员的吞吐量。

我们开始在代码中搜索根据“完成并忘记”原则执行的操作。 它们也被称为“浮动承诺”(“ floating promise”)。 这很简单-找到禁用无浮动承诺短绒规则的行就足够了。 一种方法引起了我们的注意。 他打电话给compressAndUploadDebuggingPayload而没有等待结果。 似乎即使完成任务处理后,这样的调用也很容易长时间持续。

 const postTaskDebugging = async (data: TypedData) => {    const payload = await generateDebuggingPayload(data);       //       ,    //        .    // tslint:disable-next-line:no-floating-promises    compressAndUploadDebuggingPayload(payload)        .catch((err) => logger.error('failed to upload data', err)); } 

我们想检验以下假设:这种浮动承诺是麻烦的主要根源。 如果您没有完成这些挑战,而这些挑战没有影响系统的正确运行,我们是否可以提高任务速度? 这是我们暂时摆脱了postTaskDebugging调用之后的堆使用情况信息。


禁用postTaskDebugging后使用堆

原来! 现在,并行工作器中的堆利用率水平在很长一段时间内保持稳定。

感觉在系统中,随着任务的完成, compressAndUploadDebuggingPayload调用的“债务”逐渐积累。 如果工作人员收到任务的速度超过了他“还清”这些“债务”的速度,那么分配内存的对象将不会进行垃圾回收操作。 这导致了将堆填充到最上面,我们在上面分析了上面的图表,在上面已经考虑过。

我们开始怀疑是什么使这些浮动承诺如此缓慢。 我们不想从代码中完全删除compressAndUploadDebuggingPayload ,因为此调用非常重要,因此我们的工程师可以在其本地计算机上调试生产任务。 从技术角度来看,我们可以通过等待此调用的结果并在此之后完成任务来解决问题,从而摆脱浮动承诺。 但这将大大增加我们正在处理的每个任务的执行时间。

决定我们只能将这种解决方案用作最后的解决方法之后,我们开始考虑优化代码。 如何加快此操作?

▍修复瓶颈S3


compressAndUploadDebuggingPayload的逻辑compressAndUploadDebuggingPayload容易弄清楚。 在这里,我们压缩调试数据,由于它包含网络流量,因此它可能会很大。 然后,我们将压缩数据上传到S3。

 export const compressAndUploadDebuggingPayload = async (    logger: Logger,    data: any, ) => {    const compressionStart = Date.now();    const base64CompressedData = await streamToString(        bfj.streamify(data)            .pipe(zlib.createDeflate())            .pipe(new b64.Encoder()),    );    logger.trace('finished compressing data', {        compression_time_ms: Date.now() - compressionStart,    );           const uploadStart = Date.now();    s3Client.upload({        Body: base64CompressedData,        Bucket: bucket,        Key: key,    });    logger.trace('finished uploading data', {        upload_time_ms: Date.now() - uploadStart,    ); } 

从Kibana日志中可以明显看出,即使数据量很小,将数据下载到S3也会花费很多时间。 我们最初并不认为套接字可能成为系统中的瓶颈,因为标准的Node.js HTTPS代理将maxSockets参数设置为Infinity 。 但是,最后,我们阅读了关于Node.js的AWS文档,并发现了一些令我们惊讶的事情:S3客户端将maxSockets参数的值减小为50 。 不用说,这种行为不能称为直观的。

由于我们将工作人员带入一个处于竞争状态的状态,其中执行了50多个任务,因此下载步骤成为了瓶颈:它提供了等待释放套接字以将数据上传到S3的条件。 通过对S3客户端初始化代码进行以下更改,我们缩短了数据加载时间:

 const s3Client = new AWS.S3({    httpOptions: {        agent: new https.Agent({            //                 //          S3.            maxSockets: 1024 * 20,        }),    },    region, }); 

▍加快JSON序列化


S3代码的改进减慢了堆大小的增长,但是并没有导致问题的完整解决方案。 还有另一个明显的麻烦:根据我们的指标,以上代码中的数据压缩步骤持续了4分钟。 它比通常的任务完成时间(4秒)长得多。 我们不相信我们的眼睛,也不了解这可能要花4分钟,我们决定使用本地基准测试并优化慢速代码块。

数据压缩包括三个阶段(此处,为了限制内存使用,使用了Node.js )。 即,在第一阶段,生成字符串JSON数据,在第二阶段,使用zlib压缩数据,在第三阶段,将其转换为base64编码。 我们怀疑问题的根源可能是我们用来生成JSON字符串bfj的第三方库。 我们编写了一个脚本,检查了使用流生成JSON字符串数据的不同库的性能(可在此处找到相应的代码)。 原来,我们使用的Big Friendly JSON软件包根本不友好。 只需查看实验过程中获得的一些测量结果即可:

 benchBFJ*100:    67652.616ms benchJSONStream*100: 14094.825ms 

惊人的结果。 即使在简单的测试中,bfj包也比另一个包JSONStream慢5倍。 发现了这一点,我们迅速将bfj更改为JSONStream,并立即看到了性能的显着提高。

ing减少垃圾收集所需的时间


解决内存问题之后,我们开始关注常规工作人员和并行工作人员处理相同类型任务所需的时间差异。 这种比较是完全合法的,根据其结果,我们可以判断新系统的有效性。 因此,如果正式和平行工作人员之间的比率约为1,这将使我们充满信心,可以安全地将流量重定向到这些工作人员。 但是在第一个系统启动期间,Grafana控制面板中的相应图形如下所示。


传统工作者和并行工作者执行任务的时间比例

请注意,有时指标在8:1的范围内,尽管事实上任务并行化的平均水平相对较低并且在30左右。我们知道,我们要解决的与银行互动的任务不会创建处理器负担沉重。 我们也知道我们的“平行”容器没有任何限制。 不知道从哪里查找问题的原因,我们去阅读了有关优化Node.js项目的材料。 尽管此类文章很少,但我们还是看到了材料, 材料处理了Node.js中60万个竞争性Web套接字连接的实现。

特别是,我们注意到了--nouse-idle-notification标志的使用。 我们的Node.js进程可以花那么多时间收集垃圾吗? 顺便说一句,这里的gc-stats软件包使我们有机会查看花费在垃圾收集上的平均时间。


分析花费在垃圾收集上的时间

感觉我们的流程使用Scavenge算法花费了大约30%的时间来收集垃圾。 在这里,我们将不描述有关Node.js中各种垃圾收集类型的技术细节。 如果您对此主题感兴趣,请阅读材料。 Scavenge算法的本质在于,垃圾回收通常是开始清除Node.js堆中称为“新空间”的小对象所占用的内存。

因此,事实证明,在我们的Node.js进程中,垃圾回收开始的频率很高。 我可以禁用V8垃圾收集并自己运行吗? 有没有一种方法可以减少垃圾回收调用的频率 ? 原来,上面的第一个不能完成,但是最后一个可以! 我们可以使用命令行标志--max-semi-space-size=1024通过增加Node.js中“半空间”区域的限制来简单地增加“新空间”区域--max-semi-space-size=1024 。 这使您可以对短期对象执行更多的内存分配操作,直到V8开始垃圾回收为止。 结果,降低了启动此类操作的频率。


垃圾收集优化结果

另一个胜利! “新空间”区域的增加导致使用Scavenge算法的垃圾收集时间显着减少-从30%减少到2%。

processor优化处理器利用率


完成所有这些工作后,结果适合我们。 在并行工作人员中执行的任务具有20倍的并行工作,其功能几乎与在单独的工作人员中分别执行的任务一样快。 在我们看来,我们已经克服了所有“瓶颈”,但我们仍然不知道哪些特定操作会减慢系统的生产速度。 由于系统中没有更多地方显然需要优化,因此我们决定研究工人如何使用处理器资源。

根据从我们的一名并行工作人员那里收集的数据,制定了一个火热的时间表。 我们可以使用清晰的可视化图像,可以在本地计算机上工作。 是的,这是一个有趣的细节:此数据的大小为60 MB。 这是我们在火热的0x图表中搜索单词logger所看到的。


使用0x工具进行数据分析

列中突出显示的蓝绿色区域表示至少有15%的处理器时间花费在生成工作日志上。 结果,我们可以将时间减少75%。 没错,有关我们如何做到这一点的故事引自另一篇文章。 (提示:我们使用了正则表达式,并对属性做了很多工作)。

经过优化之后,我们能够在不影响系统性能的情况下,在一个工作人员中同时处理多达30个任务。

总结


改用并行工作人员使EC2的年度成本降低了约30万美元,并大大简化了系统架构。 现在,我们在生产中使用的容器比以前少了30倍。 我们的系统对处理外发请求的延迟和来自用户的高峰API请求具有更大的抵抗力。

在将我们的集成服务与银行并行化的同时,我们学到了许多新东西:

  • 永远不要低估拥有低级系统指标的重要性。 监视与垃圾回收和内存使用有关的数据的能力为我们在部署系统和完成系统方面提供了巨大的帮助。
  • 火焰状图形是一个很好的工具。 现在我们已经学习了如何使用它们,我们可以在它们的帮助下轻松识别系统中的新瓶颈。
  • 了解Node.js运行时机制使我们能够编写更好的代码。 例如,了解V8如何为对象分配内存以及垃圾回收如何工作后,我们看到了尽可能广泛地使用对象的重用技术的意义。 有时,为了更好地理解所有这些,您需要直接使用V8或尝试使用Node.js命令行标志。
  • , . maxSocket , Node.js, , , , AWS Node.js . , , , .

亲爱的读者们! Node.js-?

Source: https://habr.com/ru/post/zh-CN483688/


All Articles