当前位置: 首页 > 后端技术 > Node.js

使用async_hooks模块进行请求跟踪

时间:2023-04-03 23:33:41 Node.js

async_hooks模块是Node.jsv8.0.0版本正式加入的实验性API。我们在生产环境中也使用v8.x.x版本。那么什么是async_hooks?async_hooks提供了一个用于跟踪异步资源的API,这些资源是具有关联回调的对象。简而言之,async_hooks模块可用于跟踪异步回调。那么如何使用这个追踪能力,在使用过程中会遇到哪些问题呢?8.x.x版本下理解async_hooksasync_hooks主要包括两部分,一是createHook跟踪生命周期,二是AsyncResource创建异步资源。const{createHook,AsyncResource,executionAsyncId}=require('async_hooks')consthook=createHook({init(asyncId,type,triggerAsyncId,resource){},before(asyncId){},after(asyncId){},destroy(asyncId){}})hook.enable()functionfn(){console.log(executionAsyncId())}constasyncResource=newAsyncResource('demo')asyncResource.run(fn)asyncResource.run(fn)asyncResource.emitDestroy()上面代码的含义和执行结果是:创建一个hooks实例,包含在每个异步操作的init、before、after、destroy语句循环中执行的hook函数。启用此挂钩实例。手动创建一个demo类型的异步资源。此时会触发inithook,异步资源id为asyncId,类型为type(即demo),异步资源的创建上下文id为triggerAsyncId,异步资源为resource。使用此异步资源执行fn函数两次。这时候before和after会被触发两次。异步资源id为asyncId,与fn函数中通过executionAsyncId获取的值相同。手动触发销毁生命周期挂钩。在我们常用的async、await、promise语法或者request等异步操作的背后,是一个个异步资源,这些生命周期钩子函数也会被触发。然后,在inithook函数中,我们可以通过异步资源创建上下文triggerAsyncId(parent)到当前异步资源asyncId(child)的指向关系,串联异步调用,得到完整的调用树,并传递回调函数中的executionAsyncId()(即上述代码中的fn)获取执行当前回调的异步资源的asyncId,从调用链追溯调用来源。同时我们还需要注意,init是异步资源创建的钩子,不是异步回调函数创建的钩子。它只会在创建异步资源时执行一次。这在实际使用中会造成哪些问题?请求跟踪出于异常排查和数据分析的目的,我们希望在我们基于Ada的Node.js服务中,服务端从客户端收到的请求头中的request-id自动添加到中后台中对服务的每个请求的请求标头。函数实现的简单设计如下:利用inithook让同一个调用链上的异步资源共享一个存储对象。解析请求头中的request-id,添加到当前异步调用链对应的存储中。重写http和https模块的request方法,执行请求时获取当前调用链对应的storage中的request-id。示例代码如下:consthttp=require('http')const{createHook,executionAsyncId}=require('async_hooks')constfs=require('fs')//跟踪调用链并创建调用链存储objectconstcache={}consthook=createHook({init(asyncId,type,triggerAsyncId,resource){if(type==='TickObject')return//由于console.log在Node.js中也是异步的,所以会触发inithook,所以只能通过同步方法fs.appendFileSync('log.out',`init${type}(${asyncId}:trigger:${triggerAsyncId})\n`);//判断调用链存储对象是否已经初始化if(!cache[triggerAsyncId]){cache[triggerAsyncId]={}}//通过引用与当前异步资源共享父节点的存储cache[asyncId]=cache[triggerAsyncId]}})hook.enable()//重写httpconsthttpRequest=http.requesthttp.request=(options,callback)=>{constclient=httpRequest(options,callback)//获取对应存储的request-id到当前请求所属的异步资源,写入header,requestId)returnclient}functiontimeout(){returnnewPromise((resolve,reject)=>{setTimeout(resolve,Math.random()*1000)})}//创建服务http.createServer(async(req,res)=>{//获取当前请求的request-id并写入存储缓存[executionAsyncId()].requestId=req.headers['request-id']//模拟一些其他的耗时操作awaittimeout()//发送一个请求http.request('http://www.baidu.com',(res)=>{})res.write('hello\n')res.end()}).listen(3000)执行代码并进行发送测试,发现可以正确获取到request-idtrap。同时我们还需要注意,init是异步资源创建的钩子,不是异步回调函数创建的钩子。它只会在创建异步资源时执行一次。但是上面的代码是有问题的。正如引入async_hooks模块时的代码所演示的,一个异步资源可以连续执行不同的功能,即异步资源可以被复用。特别是像TCP这样由C/C++创建的异步资源,多个请求可能使用同一个TCP异步资源,这样的话,当多个请求到达服务端时,初始的inithook函数只会执行一次,调用链trace导致多个请求的请求将被追踪到相同的triggerAsyncId,从而引用相同的存储。我们修改之前的代码如下,进行验证。存储初始化部分保存了triggerAsyncId,方便观察异步调用的跟踪关系:if(!cache[triggerAsyncId]){cache[triggerAsyncId]={id:triggerAsyncId}}先consuming然后是一个短暂的耗时操作:functiontimeout(){returnnewPromise((resolve,reject)=>{setTimeout(resolve,[1000,5000].pop())})}重启服务后,使用postman(不要使用curl,因为curl会在每次请求结束时关闭连接,导致不可重现)连续发送两个请求,可以观察到如下输出:{id:1,requestId:'idof第二个请求'}{id:1,requestId:'id'}可以发现,在多个并发操作和其他写入和读取存储操作之间时间不固定的操作的情况下,存储的先到达服务器的请求的值会被后到达服务器的请求覆盖掉,从而使之前的请求读取到错误的值。当然,你可以保证写和读之间不插入其他耗时的操作,但是在复杂的业务中,这种靠大脑来保证维护的方式显然是不靠谱的。此时,我们需要让JS在每次读写前进入一个新的异步资源上下文,即获取一个新的asyncId来避免这种复用。调用链存储部分需要修改的有以下几个方面:{}consthttpRequest=http.requesthttp.request=(options,callback)=>{constclient=httpRequest(options,callback)constrequestId=cache[executionAsyncId()].requestIdconsole.log('cache',cache[executionAsyncId()])client.setHeader('request-id',requestId)returnclient}//将存储的初始化提取为一个独立的方法asyncfunctioncacheInit(callback){//使用await操作使await之后的代码进入一个新的异步上下文awaitPromise.resolve()cache[executionAsyncId()]={}//使用回调执行的方式,让后续的操作都属于这个新的异步上下文returncallback()}consthook=createHook({init(asyncId,type,triggerAsyncId,resource){if(!cache[triggerAsyncId]){//inithook不再初始化returnfs.appendFileSync('log.out',`notinitializedwithcacheInitmethod`)}cache[asyncId]=缓存[triggerAsyncId]}})hook.enable()函数超时(){returnnewPromise((resolve,reject)=>{setTimeout(resolve,[1000,5000].pop())})}http.createServer(async(req,res)=>{//将后续操作作为回调传递给cacheInitawaitcacheInit(asyncfunctionfn(){cache[executionAsyncId()].requestId=req.headers['request-id']awaittimeout()http.request('http://www.baidu.com',(res)=>{})res.write('hello\n')res.end()})}).listen(3000)值得一提的是,这种使用回调的组织方式与koajs异步函数中间件(ctx,next){awaitPromise.resolve()cache[executionAsyncId()]={}returnnext()}NodeJsv14这种使用awaitPromise.resolve()创建新的异步上下文的方式总显得有点“歪”的感觉。幸运的是,NodeJsv9.x.x版本提供了asyncResource.runInAsyncScope的官方实现来创建异步上下文。更棒的是,NodeJsv14.x.x版本直接提供了异步调用链数据存储的官方实现,直接帮你完成跟踪异步调用关系、新建异步在线文件、管理数据三大任务!API就不详细介绍了,我们直接使用新的API改造之前的实现const{AsyncLocalStorage}=require('async_hooks')//直接创建一个asyncLocalStorage存储实例,不再需要管理async生命周期hookconstasyncLocalStorage=newAsyncLocalStorage()conststorage={enable(callback){//使用run方法创建一个新的storage,需要让后面的操作作为run方法的回调来执行才能使用new异步资源上下文asyncLocalStorage.run({},callback)},get(key){returnasyncLocalStorage.getStore()[key]},set(key,value){asyncLocalStorage.getStore()[key]=value}}//rewritehttpconsthttpRequest=http.requesthttp.request=(options,callback)=>{constclient=httpRequest(options,callback)//获取异步资源存储的request-id写入headerclient.setHeader('request-id',storage.get('requestId'))returnclient}//使用http.createServer((req,res)=>{storage.enable(asyncfunction(){//获取当前请求的request-id并将其写入存储storage.set('requestId',req.headers['request-id'])http.request('http://www.baidu.com',(res)=>{})res.write('hello\n')res.end()})}).listen(3000)可以看出,asyncLocalStorage.runAPI的官方实现在结构上也和我们第二个版本的实现非常一致。因此在Node.jsv14.x.x版本下,很容易实现使用async_hooks模块进行请求跟踪的功能。