【玩转 Node 连载 1/6】我是如何在 Node.js 中定位异常的

第 72 届早早聊大会将于 2023 年 10 月 29 日(下周日)举办 - 前端跨端方案|跨端同构,方法框架,5 位讲师下午直播,关键词:跨端框架/跨端组件库/小程序/ Harmony /Electron 。跟早早聊一起,码上多平台,上车链接:https://www.zaozao.run/conf/c72


  本文是 2023 年 4 月 8 日,第六十二届 - 前端早早聊【Node.js】专场,来自阿里妈妈的技术前端 —— 会理的分享。感谢 AI 的发展,借助 AI 的能力,最近我们终于可以非常高效地将各位讲师的精彩分享文本化后,分享给大家。(完整版含演示请看录播视频和 PPT):https://www.zaozao.run/video/c62

正文如下

大家好,我是来自阿里妈妈的会理。今天给大家分享的主题是《我是如何在 Node.js 中定位异常的》。首先做一下自我介绍。我于 21 年加入阿里妈妈体验和创意前端团队,主要负责 Node.js 后端的开发。平时也简单写一些 react 或者是 Java 相关的内容。之前的四年时间里,在浙江每日互动担任 Node.js 后端开发工程师的职位。在学校期间实习于高网信息技术有限公司,担任 Java 后端开发职位。

今天分享的主要内容有五块:

  • 错误的处理策略
  • Node.js 异常处理机制
  • Node.js 调试工具
  • 线上异常定位流程
  • 性能问题排查


错误的处理策略

首先要讨论的是错误处理策略。因为错误通常在开发过程中非常常见,面对不同类型的错误,我们需要采取不同的处理方法。

详细介绍如下几种策略:

1.向上抛出:当函数或方法内部发生错误时,我们可以将错误抛出到上层以供解决,这样的好处是可以避免我们在每个函数中都去做一个错误处理,助于提高代码的复用性和可读性。

2.捕获并处理:对于已知如何处理的可预测错误,我们可以直接在当前层级内处理,而无需一层一层抛出。例如,我们尝试打开一个不存在的文件,拿到一个错误之后,可以直接把文件新建,从而节省时间并避免不必要的错误信息。

3.反馈给用户:有时用户可能提交不正确的文本或数据,我们应该及时向用户反馈以纠正错误。

4.重试:在从网络或远程服务获取数据时,可能会出现错误。在这种情况下,可以考虑采取重试策略,但需要注意设置重试次数和重试之间的时间间隔,以避免对基础服务造成不必要的压力。一个例子是拉取 S3 上的文件,某位同事采用了频繁且无限制的重试策略,导致 S3 服务承受巨大的负担。原本可能只是一个小错误,但由于这种过度的重试机制,使技术服务不堪重负。

5.记录错误日志:某些错误可能不会直接影响用户,但我们仍然需要记录它们以进行后续观察和修复。最好在错误日志中添加告警机制,以便及时处理可能导致更严重问题的错误。

总结一下,面对不同类型的错误,我们应该采用适当的处理策略,不要盲目地抛出异常,也不要着急处理错误,而应在适当的时机使用适当的方法来处理它们。


Node.js 异常处理机制

第二部分是 Node.js 中的异常处理机制。异常处理机制中最常见的是 Try-catch 块。在 try 块中,我们可能会包含可能引发异常的代码,然后在 catch 块中捕获和处理这些异常。但需要注意的是,try-catch 只能捕获同步代码中的错误。

对于异步代码,我们需要采用其他错误处理机制,例如 Callback 回调函数、Promise、以及 EventEmitter。对于Callback,通常会将第一个参数作为错误对象传递出来,然后根据是否存在错误来进行处理。对于Promise,可以使用 .catch 方法来捕获错误并进行处理。对于 EventEmitter,它提供了一个 error 事件来处理错误,只有在触发error 事件时才将错误参数传递给错误处理函数。

然后我们来看一个实际案例,以了解如何根据示例代码连接 MongoDB。我在 NPM 上找到了一段示例代码,通常这些示例代码都非常简洁,只包含主要的代码结构,而对于错误处理通常没有详细说明。

在这种情况下,我们需要仔细考虑可能出现的异常情况以及如何捕获这些错误。示例代码中包括了一些方法,比如connect、.db 以及 db.connection。如果我们要连接 MongoDB,我们应该如何处理可能的异常呢?是否应该简单地在外部包装一个 try-catch 块,然后抛出异常并进行处理呢?

现在让我们来看一个更为优雅的处理错误的代码示例。首先我们可以观察到,在示例中的 connect 方法使用点(.)运算符来返回一个 Promise,而后使用 .catch 方法来捕获可能的错误。另外使用 .DB 方法则采用了 .on 方法,用于监听可能出现的错误事件,表明这是一个 EventEmitter。

总之,当我们处理错误时,首先需要了解调用的方法返回了什么,以及错误是如何抛出的。这样我们才能采取正确的措施来处理错误,而不是简单地在外部包装一个 try-catch 块来捕获错误,因为这样可能导致无法捕获一些错误,从而引发意外的影响。

我们强调了对于处理错误的正确方法,我们需要明确自己的任务是什么,了解调用的函数返回了什么,以及错误是如何抛出的。只有这样我们才能采取适当的应对措施来处理错误。

此外,许多框架也提供了用于处理中间件异常的机制。例如,在 egg 中,存在一个错误处理中间件,它通过记录错误信息并返回适当的HTTP响应来更好地应对异常情况。


Node.js 调试工具

第三部分是有关 Node.js 调试工具。我相信大家都熟悉调试工具,但我认为它对于提升写代码的幸福感非常重要。回想起刚开始写代码的时候,我在学校使用 VC++ 6.0 编写了第一个 slider 程序。那时候编程环境和语法检查都相对简单,我也不太会使用调试工具。写代码感觉非常痛苦。随后我学会了使用调试工具,逐步追踪问题的源头,发现了问题出在哪里,这是非常重要的。

Node.js 调试工具有几个主要部分。首先是通过 node --inspect 功能,它会开启一个进程来监听调试请求,默认监听端口是 9229,并为每个监听进程分配一个唯一的 UUID。调试客户端可以通过 WebSocket 与它建立通信。如果要启用调试器,需要在代码中添加 debug 标签。例如,我们可以通过命令行启动 Node 程序,然后它将监听WebSocket 上的调试请求。

同样的对于 Chrome 和 VSCode 等工具,它们也是通过监听调试请求,然后向 Node.js 进程发送指令来控制代码的运行。无论是 Chrome 还是 VSCode,它们的底层原理都是通过这种调试进程来实现的。VSCode 有两种主要的调试方式:

  • 一种是"lanuch",用于在调试模式下启动应用程序;
  • 一种是"attach",用于将 VSCode 调试连接到已经在运行的进程。

这两种方式允许我们在本地进行调试,并且通常需要配置文件。

比如我们在本地调试时通常需要使用配置文件,这里我提供了一个小示例,演示了一种"attach"方式,将我们的调试工具附加到一个已存在的 process ID上。第二个方式是通过 launch 的方式来启动。

如果有感兴趣的同学可以试着了解一下,是怎么进行调试的。有些同学可能好奇,为什么这个 inspect 命令一启动,就可以通过 Vscode 或者 Chrome 来调试,它们之间的机制是什么?

以 Chrome Devools 为例,它的调试协议是 Chrome Debugger Protocol,一个 Websocket 协议。我们早期的 Node.js 是使用 V8 Debugger Protocol,当时使用 TCP 端口 5858 来和 Client 和 ID 进行交互。之后我们连接这个端口,有一个 node --inspect 的工具,这个工具就是连接这两个协议的中间人。当时用这个工具可以进行调试,但是后来发现这样调试比较麻烦,Node.js 就把这块能力集成进来了。它集成了 Chrome Debugger Protocol 和 V8 Debugger Protocol 协议,集成了 Inspect Debugger Protocol 协议。

这个协议就是通过 Websocket 的方式,也就是 9229 这个端口来实现的交互整体。也就是说我们现在了解到的就是这个最终这个 Inspect Debugger Protocol。如果有兴趣的同学可以试着去了解一下。


线上异常定位流程

第四块涉及线上异常定位的流程。刚才我们讨论了本地调试,但如果在线上出现问题,我们应该如何解决呢?

线上异常定位一般包括以下几个步骤:

  1. 发现问题,最好能够在客户发现问题之前自己发现问题,这需要一些线上异常监控和日志记录。
  2. 快速响应,一旦问题被发现,需要快速响应,即使此时我们可能还不了解问题的原因,但需要先解决问题,可以进行回滚或快速修复。
  3. 定位解决,一旦问题得到控制,我们需要深入分析服务、代码和数据以找到根本原因。

发现问题

在问题的发现阶段,我们需要一些告警机制,包括监控系统的性能指标,如 CPU 使用率、内存使用率、磁盘空间等等。我们需要确保系统处于健康状态。

另外应用程序指标也很重要,例如函数的 QPS、请求响应时间、错误率等等。可以进行一些业务层面的统计,以提前发现高流量、响应时间过长或错误率较高的函数。这样可以及早干预和预防问题的发生。监控后,需要合理的日志告警系统,以便开发人员能够查看错误信息。

例如我们的告警机制是当错误日志数量超过 100 时,立即发送一条消息。通常一些异常是正常出现的,不需要每个错误都触发告警。但当真正严重的错误发生时,告警数量迅速上升,需要重点介入处理。这就是告警机制的好处。

快速响应

在快速响应阶段,可以采取一些措施,如服务回滚,将系统迅速还原到可用版本以验证服务是否正常,或进行紧急修复,消除问题的根本原因。或进行服务降级,在严重问题出现时,可以关闭服务,例如通过暂停函数调用,以防问题进一步扩大。如果有备用的系统,可以切换到备用系统。

还可以实施一些限流措施,例如使用 AHAS 限流,对 QPS 进行限制。

定位解决问题

当然在日志定位过程中,日志服务是不可或缺的。需要收集相关的日志、错误信息、用户反馈等数据,以帮助解决和定位问题。关键数据如 trace、出入参、ip、time、error stack 等都可以记录在日志中,以便查找问题。例如,下图显示了具体的函数,host,PID 以及 trace,这些信息都呈现得非常清晰明了。此外在这个阶段还可以看到一些错误信息,以直方图的形式展现出来。

有了日志之后,我们可以利用链路跟踪系统来追踪整个请求的路径,并分析函数之间的调用关系。通过逐步缩小问题的范围,我们可以找到可疑的代码段。使用链路跟踪系统,我们可以清晰地了解从服务 A 到服务 B 再到服务 C的函数调用关系,从而确定错误出现在哪个步骤。


这里有一个案例,我们之前遇到了一个问题,出现了以下错误:"No provider can get from config." 对于这个服务器错误,首先无法确定出现的原因。幸运的是,有日志记录和链路跟踪系统的支持。首先,使用链路跟踪系统来查看这个错误的起因。追踪了请求的路径,发现从服务A到B再到C时,出现了一个错误。这可以确定问题的一个可能原因,即当路由到指定主机时出现了错误。结合之前的日志信息,"No provider can get from config server" 意味着无法找到这个主机的提供者,或者说不应该路由到这个主机上。

这可以分成两种情况,第一,路由是正确的,但主机没有提供相应的服务。第二,根本不应该路由到这个主机上,因为它不提供所需的服务。最终,确定了问题所在,需要修正路由规则,不再将请求路由到该主机,从而解决了问题。这是一个案例,通过链路跟踪系统帮助排查了问题,定位到了出现问题的服务,确定了是否应该路由到该服务,以及该服务是否应提供所需的功能。

另一个案例涉及到我们发现这些请求几乎都来自同一台新增的分组机器。之前我们在日志中已经有了这台机器的信息。这引发了怀疑,问题可能不在服务本身,而是这台机器的网络侧存在问题。这是因为我们在日志中观察到了一些结果状态为 404 或 412 的情况,这表明进行了一些处理,或者出现了其他错误,但是它没有正确地返回错误信息。这也反映出一个重要的点,即在处理错误时,我们需要考虑异常边界情况。在这个情况下,错误没有被正确返回,导致了我们在排查问题时遇到了困难。本来应该有一个正确的错误信息返回,但在抛出错误时,它本身遇到了问题,无法正确获取头部信息,结果导致了一个难以理解的错误。

幸运的是,在这个问题中,还有其他一些线索可供我们利用。最终,我们建立了一个从原安全域到目标OSS的外部网络连接,并成功解决了文件获取问题。这是第二个案例。

整体架构

总结一下,在线异常定位需要一些关键服务和架构组件。首先,我们需要入口限流服务,以处理大流量情况。其次,日志工具链路跟踪是必要的,以便跟踪错误日志并了解服务之间的调用关系。最后,监控大盘和告警系统是早期干预的关键,我们需要了解服务的基本信息并建立警报机制。这些服务一起维护了整体服务的稳定性。


性能问题排查

第五部分是关于 Node.js 性能问题的排查。我们知道,如果 Node.js 超出内存限制,就会导致严重的性能问题,甚至崩溃。因此,我们需要确定是否出现了内存泄漏问题,以及如何解决它。首先,我们来看如何判断是否存在内存泄漏。

对于 JS 应用程序的总内存增长,我们有一些判断规则。如果总内存曲线在长时间内没有下降,并且可能超过了我们设定的内存限制的 60% 到 70%,那么应用程序很可能存在内存泄漏问题。用于排查的工具包括"hipdump"和"NODE hipdump"。如果需要使用"headump"包,首先需要安装它,然后进行一系列步骤,包括导出"keep snapshot"。需要注意的是,这些操作可能会对代码产生一定的侵入性。

有一个案例,曾经我们遇到过一个内存利用率达到 60% 的警报,从内存监控数据中可以看到,与上周相比,内存增长了大约 20%。由于内存告警,我们迅速进行了排查。

我们需要从平台上导出 heapdump 数据,因为这对于后续的分析非常有帮助。使用这个平台,我们可以生成 V8 heapdump,并在线查看。

在分析 heapdump 数据时,我们发现"Retainde Size"非常大。在这里需要理解两个概念,即"Shallow Size"和"Retainde Size"。"Shallow Size"是V8堆在对象自身创建时分配的大小,而"Retainde Size"表示对象在从堆中移除后、并在进行 Full GC 后释放的空间大小。通常在内存泄漏情况下,"Retainde Size"会非常大。我们进一步深入查看后,确实发现了问题,甚至具体的代码位置都提示了出来。

有了这个线索,我们查看了代码的实现。这是一个获取某个数据的方法,其中包含一个内存装饰器。在该方法中,数据被放入内存,然后设定了过期时间,过期后应该被清理。我们发现,在一系列请求中,包括 123、456、456、456 最后又是123,由于 timeout 设置 为 5秒,当 5 秒后再次请求123时,它应该重新获取。在代码看起来没有问题的情况下,它首先检查数据是否存在或是否已超时,如果不存在或已经超时,就重新获取,否则从 map 中获取数据。

乍一看,似乎没有问题,但仔细思考后发现,这里实际上就是没有清理的过程。即使数据过期,它没有被正确清理,导致在第一步将 123 放入后,经过第二、第三、第四步之后,再次到达第五步时,尽管它已经过期,但数据仍然存在于 map 中。这就导致了数据的重复获取,map 变得越来越大,最终导致了内存泄漏。解决了这个问题后,内存也就可以稳定下来了。

第二个问题涉及到 CPU 告警的原因,其中可能包括 Full GC 问题,CPU 消耗的操作,例如死循环、大量计算,或频繁的 IO 操作。我们可以使用一些第三方库来排查这些问题,比如 V8-profiler。

接下来我们通过平台,可以查看 CPU profile 文件,其中有两种视图:Heavy 视图和 Chart 视图。我在这里截取了一个"Chart"视图的示例,它展示了我们线上服务的整体情况。从图中可以看出,整体情况相对稳定,火焰图的顶层函数占用宽度也相对尖锐。在排查 CPU 问题时,我们需要观察火焰图顶层函数的宽度。如果发现瓶颈情况,很可能是函数性能存在问题。总体来说,情况看起来还可以。大家可以通过导出 CPU profile 的方式,然后使用Chrome 工具查看。

另一个问题是磁盘占用率。我们曾经遇到一个问题,发现磁盘出现告警,两台机器都超过了我们设定的限制。这问题的原因是在高流量场景下记录了大量日志,尤其是接口的 QPS 请求量非常大。虽然在正常情况下日志记录没有问题,但当流量非常大时,没有定时清理任务的情况下,磁盘空间迅速增加。

在发现问题后,我们迅速取消了不必要的日志记录,并增加了定时清理磁盘的工具,以释放磁盘空间。这样做有助于解决磁盘空间问题,因为高磁盘利用率会导致磁盘写入延迟和运行速度下降,这是一个相当严重的问题。


总结

总的来说,在解决性能问题时,我们需要获取有关问题发生时的关键数据,无论是关于内存、CPU、GC、内存泄漏、IO、网络日志还是应用程序性能的数据。这些数据可以通过工具平台导出或手动编写代码来获取。一旦获得了数据,我们可以借助各种平台进行分析,以解决性能问题和建立信任。

例如,对于 CPU 使用率,我们可以检查是否存在代码逻辑问题;对于 GC 情况,我们可以查看是否频繁进行垃圾回收操作;对于内存使用情况,我们可以分析内存泄漏情况;对于 IO、网络和日志信息,我们可以检查是否出现问题;对于应用程序性能,我们可以进行性能测试,检查接口响应时间和吞吐量是否正常。通常,在大型促销活动之前,我们会进行压力测试,以确保接口性能没有问题。

以上就是我分享的五个部分内容。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值