使用async_hooks实现Nodejs应用全链路埋点

644 阅读5分钟

一、背景

随着Nodejs应用的不断迭代,我相信不少开发者都会遇到这样的问题——平台功能越来越多,相关逻辑也越来越复杂,导致请求链路变得越来越长,耗时也越来越久。

因此,为了优化服务响应速度,首先我们要知道我们服务的链路埋点信息。

这篇文章会简单介绍如何「使用async_hooks实现Nodejs应用全链路埋点」。

二、实现过程

2-1、async_hooks

官方API可以看这里:nodejs.org/api/async_h…

首先来简单介绍下async_hooks:async_hooks是一个实验性API(虽然这个API从node8开始就一直存在了) ,它主要用于追踪「异步资源」。

2-1-1、简单举例

const async_hooks = require('async_hooks');

// 返回当前异步作用域的asyncId
const eid = async_hooks.executionncId();

// 返回触发此异步操作的异步作用域的asyncId
const tid = async_hooks.triggerAsyncId();

// 新建一个async_hooks实例
const asyncHook = async_hooks.createHook({ init, before, after, destroy, promiseResolve });

// 开启​async_hooks
asyncHook.enable();



// 禁用async_hooks
asyncHook.disable();




// 下面这些方法是createHook方法的参数


// 初始化一个异步操作时执行的钩子函数
function init(asyncId, type, triggerAsyncId, resource) { }

// 异步回调函数调用之前触发的钩子函数
function before(asyncId) { }

// 异步回调函数调用完成后触发的钩子函数
function after(asyncId) { }

// 异步操作结束并销毁时触发的钩子函数
function destroy(asyncId) { }

// 调用promiseResolve时执行的钩子函数
function promiseResolve(asyncId) { }

2-1-2、详细介绍

async scopeasync scope,异步作用域,每当我们初始化一个异步的任务(如新建一个Promise)我们都会创建一个异步作用域,在这个异步任务中执行的操作,都会共享异步作用域中的数据(如asyncId)。
asyncIdasyncId是一个自增的不重复的正整数,任意一个async scope都会共享一个asyncId。
triggerAsyncId触发此异步操作的异步作用域的asyncId。
createHook初始化async_hooks的方法,接收init, before, after, destroy, promiseResolve,这五个参数,用于在对应的时机执行对应的方法。
init初始化一个异步操作时执行的钩子函数。
before异步回调函数调用之前触发的钩子函数。
after异步回调函数调用完成后触发的钩子函数。
destroy异步操作结束并销毁时触发的钩子函数。
promiseResolve调用promiseResolve时执行的钩子函数。
asyncLocalStorage同一个异步作用域(包括该异步作用域创建的子异步作用域)之间共享的数据,只有Nodejs12+的版本支持

2-1-3、性能开销

由于在Nodejs后端项目中,异步操作非常常见,因此使用async_hooks是会造成一定的性能开销的。

在下面这个仓库中介绍了async_hooks在Node9中的性能开销:

github.com/bmeurer/asy…

而在我自己的测试中,我写了一个异步方法,该异步方法中会初始化一个promise并立即resolve,下面的表格中是执行该方法的次数以及耗时。

执行次数Without async_hooks(ms)With async_hooks(ms)单次差距(ms)差距倍率
1001271280.011.007
1000132013300.011.007
5000634073800.2081.16
1000012810177500.491.38
50000641001755002.222.73

可以看到async_hooks的性能开销会随着异步作用域的增多而变大,但是除去极端情况(50000个promise)外,还是能接受的。因为就算是比较长的链路,实际上异步作用域的数量也就是在1000-5000的规模,所以0.1倍的性能开销,还是ok的。

2-2、链路耗时追踪

使用async_hooks中的asyncLocalStorage,我们就能够在同一个异步作用域中共享并存储链路耗时追踪必要的数据了。然后,我们需要写一个耗时埋点工具,进行埋点。

2-2-1、埋点工具

export enum TraceType {
  TRACE_START, // 进入打点之前
  TRACE_END // 进入打点之后
}



export interface ITraceDetail {
  key: string; // trace的key
  traceType: TraceType; // trace的种类
  currentTime: number; // 当前时间
  cost?: number; // before到after之间的耗时
}



export class Tracker {
  private timeCostList: ITraceDetail[];

  constructor() {
    this.timeCostList = [];
  }

  public traceStart(key: string) {
    this.trace(key, TraceType.TRACE_START);
  }



  public traceEnd(key: string) {
    this.trace(key, TraceType.TRACE_END);
  }


  public getTimeCostList(showAll = false) {
    if (!showAll) {
      return this.timeCostList.filter((item) => item.traceType === TraceType.TRACE_END);
    }
    return this.timeCostList;
  }



  private trace(key: string, traceType: TraceType) {
    let cost = -1;
    if (traceType === TraceType.TRACE_END) {
      const traceStartDetail = this.timeCostList.filter(
        (item) => item.key === key && item.traceType === TraceType.TRACE_START
      );

      if (traceStartDetail.length === 1) {
        cost = Date.now() - traceStartDetail[0].currentTime;
      } else {
        console.warn('wrong trace key!');
      }
    }

    const timeCostObj: ITraceDetail = {
      key,
      traceType,
      currentTime: Date.now(),
      cost
    };

    this.timeCostList.push({
      key,
      traceType,
      currentTime: Date.now(),
      cost
    });
  }
}

这样,我们只需要在刚接收到请求的时候,在asyncLocalStorage中存一个tracker变量,那么在后续的所有子异步作用域中,就都可以使用traceStart、traceEnd这两个方法进行埋点了。最后,在请求处理完毕之前,调用getTimeCostList,就能拿到埋点的链路耗时了(或者进行上报,这部分就取决于开发者了)

2-2-2、简单例子

下面是一个简单的基于koa的使用async_hooks的例子:

const Koa = require('koa');
const app = new Koa();
const asyncHooks = require('async_hooks');
const Tracker = require('tracker'); // 刚才写的埋点工具

const asyncLocalStorage = new asyncHooks.AsyncLocalStorage();

// 初始化async_hooks
asyncHooks.createHook({}).enable();



// 这个中间件会初始化tracker并存入asyncLocalStorage
app.use(async (ctx, next) => {
  asyncLocalStorage.enterWith({
    tracker: new Tracker(),
  });
  await next();  
  const store = asyncLocalStorage.getStore();
  console.log(store?.tracker.getTimeCostList()); // 输出计算好的全链路耗时
});



// 这个中间件会响应请求

app.use(async (ctx, next) => {
  const store = asyncLocalStorage.getStore();
  store?.tracker.traceStart('HANDLE_REQUEST');
  ctx.body = 'Hello World';
  store?.tracker.traceEnd('HANDLE_REQUEST');
  await next();
});



app.listen(3000);

2-2-3、写个装饰器

当然,对于Typescript,我们还可以再搞一些更有趣的操作,比如写一个装饰器:

import { asyncLocalStorage } from '../constant';
import { TraceType } from './tracker';

export const evaluateTime = (key?: string) => {
  return (target: any, propertyKey: string, descriptor: any) => {
    const fn = descriptor.value;
    descriptor.value = async function(...args: any[]) {
      try {
        const store = asyncLocalStorage.getStore();
        store.tracker.trace(
          key ? key : `[TRACE]${target.constructor.name}-${fn.name}`,
          TraceType.TRACE_START,
          target.constructor.name,
          propertyKey,
        );
        const output = await fn.apply(this, args);
        store.tracker.trace(
          key ? key : `[TRACE]${target.constructor.name}-${fn.name}`,
          TraceType.TRACE_END,
          target.constructor.name,
          propertyKey,
        );
        return output;
      } catch (e) {
        return fn.apply(this, args);
      }
    };
  };
};

这个装饰器会为其装饰的方法自动包上一层traceStart和traceEnd,这样我们就可以直接这样用了:

  export default class ApiController {
      @evaluateTime()
      async demo(@Ctx() ctx: IContext) {
            // ...
      }
  }

三、总结

async_hooks其实能做很多事情,比如在同一个异步作用域中记录logId、进行全链路耗时追踪等等。当然,它也会带来一定的额外性能开销,所以在使用async_hooks的时候需要仔细权衡,在确保性能的前提下进行使用。