Node.js应用全链路追踪实践Node.js应用全链

Node.js 应用全链路追踪

全链路追踪技术的两个核心要素分别是全链路信息获取全链路信息存储展示

本文一共分为三个篇章进行介绍;

  • 第一章介绍Nodejs应用全链路信息获取。
  • 第二章介绍Node.js 应用全链路追踪实战。

Node.js 应用全链路追踪系统

一、简介

目前主流的 Node.js 架构设计主要有以下两种方案:

  • 通用架构:只做 ssr 和 bff,不做服务器和微服务;
  • 全场景架构:包含 ssr、bff、服务器、微服务。

上述两种方案对应的架构说明图如下图所示:

图片

在上述两种通用架构中,nodejs 都会面临一个问题,那就是:

在请求链路越来越长,调用服务越来越多,其中还包含各种微服务调用的情况下,出现了以下诉求:

  • 如何在请求发生异常时快速定义问题所在;
  • 如何在请求响应慢的时候快速找出慢的原因;
  • 如何通过日志文件快速定位问题的根本原因。

我们要解决上述诉求,就需要有一种技术,将每个请求的关键信息聚合起来,并且将所有请求链路串联起来。让我们可以知道一个请求中包含了几次服务、微服务请求的调用,某次服务、微服务调用在哪个请求的上下文。

这种技术,就是Node.js应用全链路追踪。它是 Node.js 在涉及到复杂服务端业务场景中,必不可少的技术保障。

综上,我们需要Node.js应用全链路追踪,说完为什么需要后,下面将介绍如何做Node.js应用的全链路信息获取。

二、全链路信息获取

全链路信息获取,是全链路追踪技术中最重要的一环。只有打通了全链路信息获取,才会有后续的存储展示流程。

对于多线程语言如 Java 、 Python 来说,做全链路信息获取有线程上下文如 ThreadLocal 这种利器相助。而对于Node.js来说,由于单线程和基于IO回调的方式来完成异步操作,所以在全链路信息获取上存在天然获取难度大的问题。那么如何解决这个问题呢?

三、业界方案

由于 Node.js 单线程,非阻塞 IO 的设计思想。在全链路信息获取上,到目前为止,主要有以下 4 种方案:

  • domain: node api;
  • zone.js: Angular 社区产物;
  • 显式传递:手动传递、中间件挂载;
  • Async Hooks:node api;

而上述 4 个方案中, domain 由于存在严重的内存泄漏,已经被废弃了;zone.js 实现方式非常暴力、API比较晦涩、最关键的缺点是 monkey patch 只能 mock api ,不能 mock language;显式传递又过于繁琐和具有侵入性;综合比较下来,效果最好的方案就是第四种方案,这种方案有如下优点:

  • node 8.x 新加的一个核心模块,Node 官方维护者也在使用,不存在内存泄漏;
  • 非常适合实现隐式的链路跟踪,入侵小,目前隐式跟踪的最优解;
  • 提供了 API 来追踪 node 中异步资源的生命周期;
  • 借助 async_hook 实现上下文的关联关系;

优点说完了,下面我们就来介绍如何通过 Async Hooks 来获取全链路信息。

四、async hooks

官方文档描述 async_hooks: 它被用来追踪异步资源,也就是监听异步资源的生命周期。

The async_hooks module provides an API to track asynchronous resources.

既然它被用来追踪异步资源,则在每个异步资源中,都有两个 ID:

  • asyncId: 异步资源当前生命周期的 ID
  • trigerAsyncId: 可理解为父级异步资源的 ID,即 parentAsyncId

通过以下 API 调取

const async_hooks = require('async_hooks');
​
const asyncId = async_hooks.executionAsyncId();
​
const trigerAsyncId = async_hooks.triggerAsyncId();
​
复制代码

更多详情参考官方文档: async_hooks API

既然谈到了 async_hooks 用以监听异步资源,那会有那些异步资源呢?我们日常项目中经常用到的也无非以下集中:

  • Promise
  • setTimeout
  • fs/net/process 等基于底层的API

然而,在官网中 async_hooks 列出的竟有如此之多。除了上述提到的几个,连 console.log 也属于异步资源: TickObject

FSEVENTWRAP, FSREQCALLBACK, GETADDRINFOREQWRAP, GETNAMEINFOREQWRAP, HTTPINCOMINGMESSAGE,
HTTPCLIENTREQUEST, JSSTREAM, PIPECONNECTWRAP, PIPEWRAP, PROCESSWRAP, QUERYWRAP,
SHUTDOWNWRAP, SIGNALWRAP, STATWATCHER, TCPCONNECTWRAP, TCPSERVERWRAP, TCPWRAP,
TTYWRAP, UDPSENDWRAP, UDPWRAP, WRITEWRAP, ZLIB, SSLCONNECTION, PBKDF2REQUEST,
RANDOMBYTESREQUEST, TLSWRAP, Microtask, Timeout, Immediate, TickObject
复制代码

async_hooks.createHook

我们可以通过 asyncId 来监听某一异步资源。

通过 async_hooks.createHook 创建一个钩子,事例代码:

const asyncHook = async_hooks.createHook({
  // asyncId: 异步资源Id
  // type: 异步资源类型
  // triggerAsyncId: 父级异步资源 Id
  init (asyncId, type, triggerAsyncId, resource) {},
  before (asyncId) {},
  after (asyncId) {},
  destroy(asyncId) {}
})
复制代码

我们只需要关注最重要的四个 API:

  • init: 监听异步资源的创建,在该函数中我们可以获取异步资源的调用链,也可以获取异步资源的类型,这两点很重要。
  • destory: 监听异步资源的销毁。要注意 setTimeout 可以销毁,而 Promise 无法销毁,如果通过 async_hooks 实现 CLS(Continuation-local Storage) 可能会在这里造成内存泄漏!
  • before
  • after
setTimeout(() => {
  // after 生命周期在回调函数最前边
  console.log('Async Before')
  op()
  op()
  op()
  op()
  // after 生命周期在回调函数最后边
  console.log('Async After')
})
复制代码

async_hooks 调试及测试

const fs = require('fs')
const async_hooks = require('async_hooks')
async_hooks.createHook({
  init (asyncId, type, triggerAsyncId, resource) {
    fs.writeSync(1, `${type}(${asyncId}): trigger: ${triggerAsyncId}\n`)
  },
  destroy (asyncId) {
    fs.writeSync(1, `destroy: ${asyncId}\n`);
  }
}).enable()
async function A () {
  fs.writeSync(1, `A -> ${async_hooks.executionAsyncId()}\n`)
  setTimeout(() => {
    fs.writeSync(1, `A in setTimeout -> ${async_hooks.executionAsyncId()}\n`)
    B()
  })
}
async function B () {
  fs.writeSync(1, `B -> ${async_hooks.executionAsyncId()}\n`)
  process.nextTick(() => {
    fs.writeSync(1, `B in process.nextTick -> ${async_hooks.executionAsyncId()}\n`)
    C()
    C()
  })
}
function C () {
  fs.writeSync(1, `C -> ${async_hooks.executionAsyncId()}\n`)
  Promise.resolve().then(() => {
    fs.writeSync(1, `C in promise.then -> ${async_hooks.executionAsyncId()}\n`)
  })
}
fs.writeSync(1, `top level -> ${async_hooks.executionAsyncId()}\n`)
A()
复制代码

async_hooks.createHook 可以注册 4 个方法来跟踪所有异步资源的初始化(init)、回调之前(before)、回调之后(after)、销毁后(destroy)事件,并通过调用 .enable() 启用,调用 .disable() 关闭。

这里我们只关心异步资源的初始化和销毁的事件,并使用 fs.writeSync(1, msg) 打印到标准输出,writeSync 的第 1 个参数接收文件描述符,1 表示标准输出。为什么不使用 console.log 呢?因为 console.log 是一个异步操作,如果在 init、before、after 和 destroy 事件处理函数中出现,就会导致无限循环,同理也不能使用任何其他的异步操作。

运行该程序,打印如下:

image-20211026190904803