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
: 异步资源当前生命周期的 IDtrigerAsyncId
: 可理解为父级异步资源的 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 事件处理函数中出现,就会导致无限循环,同理也不能使用任何其他的异步操作。
运行该程序,打印如下:
近期评论