NestJS 中 AsyncLocalStorage 实现分布式追踪:实战指南与 Zipkin/Jaeger 集成
你好,作为一名后端开发者,构建分布式系统是咱们绕不开的课题。随着微服务架构的普及,跨服务调用成为常态,随之而来的问题就是:如何追踪一个请求在各个服务之间的调用链路?这就是分布式追踪要解决的问题。今天,我将带你深入了解如何在 NestJS 应用中使用 AsyncLocalStorage 实现分布式追踪,并将其与 Zipkin 或 Jaeger 等追踪系统集成。准备好了吗?Let's go!
1. 为什么需要分布式追踪?
在单体应用中,咱们可以通过日志、监控等手段来定位问题。但在分布式系统中,一个请求往往需要经过多个服务,每个服务都可能产生日志,这些日志分散在不同的机器上,想要理清请求的调用链路,定位问题就变得非常困难。
分布式追踪系统通过为每个请求生成一个唯一的 Trace ID,并在请求在各个服务之间传递时,将 Trace ID 和其他上下文信息(比如 Span ID)一起传递。这样,咱们就可以将分散在不同服务中的日志关联起来,形成一个完整的调用链路。
分布式追踪的好处显而易见:
- 快速定位问题: 快速确定哪个服务、哪个环节出现了问题。
- 性能分析: 分析每个服务的处理时间,找出性能瓶颈。
- 服务依赖关系: 了解服务之间的调用关系,方便系统架构的优化。
- 监控告警: 基于追踪数据,设置告警规则,及时发现异常。
2. 核心概念:Trace ID, Span ID, Context
在深入技术细节之前,咱们先来了解几个核心概念:
- Trace ID: 整个调用链路的唯一标识,一个
Trace ID代表一个请求的完整调用链路。 - Span ID: 一个
Span的唯一标识,一个Span代表一个服务中的一个操作,比如一个 HTTP 请求、一个数据库查询等。Span ID通常是递增的,用于标识Span之间的父子关系。 - Context: 包含追踪信息(
Trace ID,Span ID)以及其他请求相关的上下文信息,比如用户 ID、请求头等。Context在服务之间传递,确保追踪信息在整个调用链路中可用。
3. AsyncLocalStorage 闪亮登场
AsyncLocalStorage 是 Node.js 12.17.0 版本引入的一个实验性 API,用于在异步执行上下文中存储数据。它解决了传统 Node.js 中异步编程(比如 Promise, async/await)导致的上下文丢失问题。在 NestJS 中,咱们可以使用 AsyncLocalStorage 来存储和传递 Trace ID 和其他上下文信息。
3.1. 为什么选择 AsyncLocalStorage?
- 自动传递上下文:
AsyncLocalStorage能够自动在异步操作之间传递上下文,无需手动传递。这极大地简化了代码,减少了出错的可能性。 - 与 NestJS 完美契合: NestJS 基于 Node.js,可以无缝集成
AsyncLocalStorage。 - 性能:
AsyncLocalStorage的性能开销相对较小。
3.2. AsyncLocalStorage 的基本用法
首先,咱们需要导入 AsyncLocalStorage:
import { AsyncLocalStorage } from 'async_hooks';
const asyncLocalStorage = new AsyncLocalStorage();
然后,咱们可以使用 asyncLocalStorage.run(store, callback) 方法来设置上下文。store 是一个对象,用于存储上下文信息。callback 是一个函数,在这个函数中,咱们可以访问上下文信息。
asyncLocalStorage.run(new Map(), () => {
// 在这里访问上下文信息
const traceId = asyncLocalStorage.getStore()?.get('traceId');
console.log('Trace ID:', traceId);
});
在这个例子中,咱们创建了一个新的 Map 对象作为 store,然后在 callback 函数中访问了 traceId。需要注意的是,在 callback 函数中,咱们可以通过 asyncLocalStorage.getStore() 方法来获取当前上下文信息。如果没有设置上下文,asyncLocalStorage.getStore() 返回 undefined。
4. NestJS 中实现分布式追踪的步骤
现在,咱们来一步一步地实现 NestJS 中的分布式追踪。
4.1. 创建一个全局的 AsyncLocalStorage 实例
首先,咱们创建一个全局的 AsyncLocalStorage 实例。通常,咱们可以在 main.ts 文件中创建它,确保它在整个应用中可用。
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AsyncLocalStorage } from 'async_hooks';
export const asyncLocalStorage = new AsyncLocalStorage();
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
4.2. 创建一个中间件,用于生成和传递 Trace ID
接下来,咱们创建一个中间件,用于生成 Trace ID,并将 Trace ID 存储到 AsyncLocalStorage 中,并将其传递给下游服务。
// trace.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { asyncLocalStorage } from './main'; // 导入 AsyncLocalStorage 实例
import { v4 as uuidv4 } from 'uuid'; // 用于生成 UUID
@Injectable()
export class TraceMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const traceId = req.headers['x-trace-id'] || uuidv4(); // 从请求头中获取 Trace ID,如果不存在则生成一个
res.setHeader('x-trace-id', traceId); // 将 Trace ID 设置到响应头中
asyncLocalStorage.run(new Map([['traceId', traceId]]), () => {
// 将 Trace ID 存储到 AsyncLocalStorage 中
next(); // 调用下一个中间件或路由处理程序
});
}
}
这个中间件做了以下几件事:
- 从请求头中获取
x-trace-id,如果不存在,则生成一个 UUID 作为Trace ID。 - 将
Trace ID设置到响应头中,以便下游服务可以获取。 - 使用
asyncLocalStorage.run()方法,将Trace ID存储到AsyncLocalStorage中。 - 调用
next()函数,将请求传递给下一个中间件或路由处理程序。
4.3. 在 AppModule 中注册中间件
// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TraceMiddleware } from './trace.middleware';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TraceMiddleware).forRoutes('*'); // 将中间件应用于所有路由
}
}
在这里,咱们将 TraceMiddleware 应用于所有路由。这意味着,每个请求都会经过这个中间件。
4.4. 在服务中使用 Trace ID
现在,咱们可以在服务中使用 Trace ID 了。比如,咱们可以在日志中输出 Trace ID,以便将日志关联起来。
// app.service.ts
import { Injectable } from '@nestjs/common';
import { asyncLocalStorage } from './main';
@Injectable()
export class AppService {
getHello(): string {
const traceId = asyncLocalStorage.getStore()?.get('traceId');
console.log(`[${traceId}] Hello World!`);
return 'Hello World!';
}
}
在这个例子中,咱们在 getHello() 方法中获取了 Trace ID,并将其输出到控制台中。这样,咱们就可以在日志中看到每个请求的 Trace ID 了。
4.5. 在 Controller 中使用 Trace ID
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { asyncLocalStorage } from './main';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
const traceId = asyncLocalStorage.getStore()?.get('traceId');
console.log(`[${traceId}] Controller: Received request`);
return this.appService.getHello();
}
}
4.6. 测试
启动你的 NestJS 应用,并发送一个请求。你可以看到在控制台中输出了带有 Trace ID 的日志。你可以通过在请求头中设置 x-trace-id 来手动设置 Trace ID,或者让系统自动生成。
5. 与 Zipkin/Jaeger 集成
上面的步骤已经实现了基本的分布式追踪功能,但咱们还需要将追踪数据发送到追踪系统,比如 Zipkin 或 Jaeger,才能进行可视化分析。接下来,我将演示如何与 Zipkin 集成。
5.1. 安装依赖
首先,咱们需要安装 zipkin 相关的依赖:
npm install zipkin-transport-http zipkin-instrumentation-koa2 zipkin-instrumentation-express
5.2. 创建 Zipkin 配置
// zipkin.config.ts
import { Tracer, BatchRecorder, HttpLogger } from 'zipkin';
import { HttpTransport } from 'zipkin-transport-http';
const zipkinEndpoint = process.env.ZIPKIN_ENDPOINT || 'http://localhost:9411/api/v2/spans'; // Zipkin 服务端地址
const httpTransport = new HttpTransport({
endpoint: zipkinEndpoint,
headers: { 'Content-Type': 'application/json' },
});
const recorder = new BatchRecorder({
logger: new HttpLogger(),
transport: httpTransport,
});
export const tracer = new Tracer({ recorder, localServiceName: 'nestjs-app' }); // 替换成你的服务名
在这个配置中,咱们创建了一个 Tracer 实例,用于记录追踪信息。localServiceName 是你的服务名,zipkinEndpoint 是 Zipkin 服务端地址。咱们使用 HttpTransport 将追踪数据发送到 Zipkin 服务端。
5.3. 创建 Zipkin 中间件
// zipkin.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { tracer } from './zipkin.config';
import { asyncLocalStorage } from './main';
import { expressMiddleware } from 'zipkin-instrumentation-express';
@Injectable()
export class ZipkinMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
expressMiddleware({
tracer,
serviceName: 'nestjs-app', // 替换成你的服务名
remoteServiceName: 'unknown', // 替换成依赖的服务名
recordRequest: (req, res, span) => {
// 记录请求信息
span.name(req.method + ' ' + req.path);
},
recordResponse: (req, res, span) => {
// 记录响应信息
},
})(req, res, next);
}
}
这个中间件使用了 zipkin-instrumentation-express 库提供的中间件,它会自动创建 Span,并将追踪数据发送到 Zipkin。
5.4. 在 AppModule 中注册 Zipkin 中间件
// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TraceMiddleware } from './trace.middleware';
import { ZipkinMiddleware } from './zipkin.middleware';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TraceMiddleware).forRoutes('*');
consumer.apply(ZipkinMiddleware).forRoutes('*'); // 将中间件应用于所有路由
}
}
在这里,咱们将 ZipkinMiddleware 应用于所有路由。
5.5. 启动 Zipkin 服务
确保你已经启动了 Zipkin 服务。你可以使用 Docker 快速启动 Zipkin:
docker run -d -p 9411:9411 openzipkin/zipkin
5.6. 测试
发送请求后,访问 Zipkin UI (http://localhost:9411/),你应该能看到你的服务的追踪信息了。
6. Jaeger 集成(类似步骤)
与 Jaeger 的集成与 Zipkin 类似,主要区别在于依赖和配置。
6.1. 安装依赖
npm install jaeger-client
6.2. 创建 Jaeger 配置
// jaeger.config.ts
import { initTracer } from 'jaeger-client';
const config = {
serviceName: 'nestjs-app', // 你的服务名
sampler: {
type: 'const',
param: 1,
},
reporter: {
logSpans: true,
agentHost: 'localhost', // Jaeger Agent 地址
agentPort: 6832,
},
};
const options = {
logger: { log: (msg: any) => console.log(msg) },
};
export const tracer = initTracer(config, options);
6.3. 创建 Jaeger 中间件
// jaeger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { tracer } from './jaeger.config';
import { asyncLocalStorage } from './main';
import { Tags, FORMAT_HTTP_HEADERS } from 'opentracing';
@Injectable()
export class JaegerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const parentSpanContext = tracer.extract(FORMAT_HTTP_HEADERS, req.headers);
const span = tracer.startSpan(req.method + ' ' + req.path, {
childOf: parentSpanContext,
startTime: Date.now(),
});
span.setTag(Tags.HTTP_METHOD, req.method);
span.setTag(Tags.HTTP_URL, req.url);
res.on('finish', () => {
span.setTag(Tags.HTTP_STATUS_CODE, res.statusCode);
span.finish();
});
req.on('close', () => {
span.setTag(Tags.HTTP_STATUS_CODE, res.statusCode);
span.finish();
});
asyncLocalStorage.run(new Map([['span', span]]), () => {
next();
});
}
}
6.4. 在 AppModule 中注册 Jaeger 中间件
// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TraceMiddleware } from './trace.middleware';
import { JaegerMiddleware } from './jaeger.middleware';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TraceMiddleware).forRoutes('*');
consumer.apply(JaegerMiddleware).forRoutes('*'); // 将中间件应用于所有路由
}
}
6.5. 启动 Jaeger Agent
确保你已经启动了 Jaeger Agent。你可以使用 Docker 快速启动 Jaeger Agent:
docker run -d -p 6831:6831/udp -p 6832:6832/udp -p 16686:16686 jaegertracing/all-in-one
6.6. 测试
发送请求后,访问 Jaeger UI (http://localhost:16686/),你应该能看到你的服务的追踪信息了。
7. 处理跨服务调用
当你的 NestJS 服务需要调用其他服务时,你需要在 HTTP 请求中传递 Trace ID 和其他上下文信息。这需要修改你的 HTTP 请求客户端,比如使用 axios 或 node-fetch。
7.1. 在请求中传递 Trace ID
// app.service.ts
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { asyncLocalStorage } from './main';
@Injectable()
export class AppService {
async callOtherService(): Promise<string> {
const traceId = asyncLocalStorage.getStore()?.get('traceId');
const headers = {
'x-trace-id': traceId,
};
const response = await axios.get('http://other-service.com/api/hello', { headers });
return response.data;
}
}
7.2. 在其他服务中接收 Trace ID
在其他服务中,你需要接收 x-trace-id 请求头,并将其存储到 AsyncLocalStorage 中。这与之前的步骤相同。
8. 进阶技巧和注意事项
- 采样: 为了避免追踪数据的过度膨胀,追踪系统通常会进行采样。你可以配置采样策略,比如只追踪一定比例的请求。
- Span 的自定义: 你可以自定义 Span,记录更详细的信息,比如 SQL 查询、缓存操作等。
- 异常处理: 在 Span 中记录异常信息,方便定位问题。
- 异步任务: 对于异步任务,你需要确保在任务中传递
Trace ID。可以使用AsyncLocalStorage或其他上下文传递机制。 - 性能影响: 分布式追踪会带来一定的性能开销。你需要评估开销,并进行优化。
- 安全: 确保追踪数据不包含敏感信息。
9. 总结
今天,我带你了解了如何在 NestJS 应用中使用 AsyncLocalStorage 实现分布式追踪,并将其与 Zipkin 和 Jaeger 集成。通过使用 AsyncLocalStorage,咱们可以方便地在异步执行上下文中传递追踪信息,从而构建完整的调用链路。希望这些内容对你有所帮助。记住,分布式追踪是一个复杂的话题,需要根据你的实际情况进行调整和优化。祝你在构建分布式系统的路上越走越远!
10. 常见问题解答
- Q: 为什么使用 AsyncLocalStorage 而不是其他上下文传递机制?
- A:
AsyncLocalStorage能够自动在异步操作之间传递上下文,与 NestJS 框架结合紧密,简化了代码。其他的机制,例如手动传递上下文,容易出错,代码量也比较大。
- A:
- Q: 如何选择 Zipkin 和 Jaeger?
- A: Zipkin 和 Jaeger 都是优秀的分布式追踪系统。Zipkin 比较轻量级,易于部署和使用。Jaeger 提供了更多的功能,比如数据存储、可视化等,但部署相对复杂。你可以根据自己的需求选择合适的系统。
- Q: 如何在 Kubernetes 环境中部署 Zipkin 和 Jaeger?
- A: 可以使用 Helm 或 Kubernetes 的 YAML 文件来部署 Zipkin 和 Jaeger。在部署过程中,需要配置服务的域名、端口等信息。
- Q: 如何处理跨服务调用中的错误?
- A: 在跨服务调用中,需要在 Span 中记录错误信息,例如 HTTP 状态码、错误消息等。这样,在追踪系统中就可以看到错误信息,方便定位问题。
- Q: 如何优化分布式追踪的性能?
- A: 可以使用采样策略,只追踪一部分请求,减少追踪数据的量。可以优化 Span 的数量,避免过度追踪。可以优化数据传输方式,例如使用压缩等。
希望这份指南能帮助你构建更强大的分布式 NestJS 应用。加油!