nest流转requestId的最佳实践

用户头像
新鲜噩梦
little笔记全栈作者
创建于:2025-08-27 09:35:17字数:9117

requestId 的作用

在前端开发中,有浏览器这个载体,报错信息可以在浏览器的控制台看到。
后端开发的载体是服务器,上线出现的错误只能到服务器上去看打印的日志。
问题是服务器是支持并发请求的,当同一时间处理多个请求,每个请求的链路又比较长,且有的中间环节还有异步(请求其他服务)时,就很难分辨哪个日志属于哪个请求,所以日志和请求的关联关系对于后端开发查看日志来说就很重要了。
举例说明:
js
@Controller()
export class MiddleController {
  @Get('/getList')
  async getList(@Query('username') username: string) {
    console.log('username', username);
    const res = await this.getNotes(username);
    console.log('res', res);
    return res;
  }

  async getNotes(username: string) {
    return fetch('http://localhost:3001/notes?username=' + username, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    }).then((res) => res.json());
  }
}
当 usename 为 123 的用户访问接口 /getList 时,先打印 username 123 ,然后请求远程的笔记接口。
随后 usename 为 789 的用户访问接口 /getList ,打印 username 789 然后请求远程的笔记接口。
然后两个用户的请求回来了,最终的打印结果为
js
username 123
username 789
res 返回的数据
res 返回的数据
现在无法区分返回的数据哪个是123的,哪个是789的。
当然你可以这样修改来查看归属:
js
console.log('res', res); 
// 改成
console.log('res', username , res);
但如果这个接口调用很多的其他模块方法,每一个模块都要透传这个标记参数,那就太麻烦了。

最佳实践:AsyncLocalStorage

AsyncLocalStorage 通过 Node.js 的 async_hooks 模块来跟踪异步操作的执行上下文。当你在一个“存储区域”(store)中运行一个函数时,AsyncLocalStorage 会记录这个上下文。任何在该函数内部触发的后续异步操作,都能访问到之前在这个上下文中存储的值。
使用方法
  1. 创建存储空间 (Store): 创建一个 AsyncLocalStorage 实例,就像一个独立的盒子。
  2. 运行并存储 (Run and Store): 使用 .run() 方法在这个“盒子”里启动一个函数,并提供一个初始值(存储空间)。
  3. 在任何地方访问 (Access Anywhere): 在该函数及其所有同步或异步后继代码中,你都可以使用 .getStore() 方法来获取当前异步上下文中的那个值。
nest中如何使用
1. 首先定义一个async模块,用来包裹整个请求的异步上下文
async.service.ts
ts
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';

// 定义上下文数据接口
interface AsyncContext {
  XTransactionID: string;
}

@Injectable()
export class AsyncContextService {
  private readonly asyncLocalStorage = new AsyncLocalStorage<AsyncContext>();

  /**
   * 运行带有上下文的函数
   * @param context 上下文数据
   * @param fn 要执行的函数
   * @returns 函数执行结果
   */
  run<T>(context: AsyncContext, fn: () => T): T {
    return this.asyncLocalStorage.run(context, fn);
  }

  /**
   * 获取当前上下文中的XTransactionID
   * @returns XTransactionID 或 'NULL'
   */
  getXTransactionID(): string {
    const context = this.asyncLocalStorage.getStore();
    return context?.XTransactionID ?? 'NULL';
  }

  /**
   * 设置当前上下文中的XTransactionID
   * @param XTransactionID 事务ID
   */
  setXTransactionID(XTransactionID: string): void {
    const context = this.asyncLocalStorage.getStore();
    if (context) {
      context.XTransactionID = XTransactionID;
    }
  }

  /**
   * 获取当前完整上下文
   * @returns 当前上下文或undefined
   */
  getContext(): AsyncContext | undefined {
    return this.asyncLocalStorage.getStore();
  }
}
async.module.ts
ts
import { Module, Global } from '@nestjs/common';
import { AsyncContextService } from './async.service';

@Global()
@Module({
  providers: [AsyncContextService],
  exports: [AsyncContextService],
})
export class AsyncContextModule {}
2. 定义一个logger模块,用来打印日志
logger.service.ts
ts
import {
  Injectable,
  Inject,
  LoggerService as LService,
} from '@nestjs/common';

import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { AsyncContextService } from 'src/async.service';

@Injectable()
export class LoggerService {
  @Inject(WINSTON_MODULE_NEST_PROVIDER)
  private logger!: LService;

  @Inject(AsyncContextService)
  private asyncContext!: AsyncContextService;

  private get getId() {
    return this.asyncContext.getXTransactionID();
  }

  error(message: string, trace?: string) {
    this.logger.error(message, trace, this.getId);
  }

  warn(message: string) {
    this.logger.warn(message, this.getId);
  }

  log(message: string) {
    this.logger.log(message, this.getId);
  }

  verbose(message: string) {
    this.logger.verbose?.(message, this.getId);
  }

  debug(message: string) {
    this.logger.debug?.(message, this.getId);
  }
}
logger.module.ts
ts
import { Global, Module, Logger } from '@nestjs/common';

import * as winston from 'winston';
import { WinstonModule, utilities } from 'nest-winston';

import { ConfigService } from '@nestjs/config';
import { LoggerService } from 'src/logger.service';

import 'winston-daily-rotate-file';

@Global()
@Module({
  imports: [
    WinstonModule.forRootAsync({
      useFactory: (configService: ConfigService) => {

        const logger = new Logger('WinstonConfig');
        const winstonConfig = configService.get('winston') as WinstonConfig;
        logger.debug(`${JSON.stringify(winstonConfig)}`);

        const { level, dirname, filename, datePattern, maxSize, maxFiles } =
          winstonConfig;

        return {
          level: 'debug',
          transports: [
            new winston.transports.Console({
              format: winston.format.combine(
                winston.format.timestamp(),
                utilities.format.nestLike(),
              ),
            }),
            new winston.transports.DailyRotateFile({
              level,
              dirname,
              filename,
              datePattern,
              maxSize,
              maxFiles, // 最多保存多少天
              zippedArchive: true, // 压缩
              format: winston.format.combine(
                winston.format.timestamp(),
                winston.format.simple(),
              ),
            }),
            new winston.transports.DailyRotateFile({
              level: 'error',
              dirname,
              filename: `error-${filename}`,
              datePattern,
              maxSize,
              maxFiles,
              zippedArchive: true,
              format: winston.format.combine(
                winston.format.timestamp(),
                winston.format.simple(),
              ),
            }),
          ],
        };
      },
      inject: [ConfigService],
    }),
  ],
  providers: [LoggerService],
  exports: [LoggerService],
})
export class LoggerModule {}
3. 定义一个拦截器:interceptor.ts,作用是在请求刚进入服务时,创建 XTransactionID(就是requestId),并且使用async模块进行包裹
ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
  Inject,
} from '@nestjs/common';

import { Observable, tap } from 'rxjs';
import { uuid } from 'src/utils';

import { LoggerService } from 'src/logger.service';
import { AsyncContextService } from 'src/async.service';

@Injectable()
export class InvokeRecordInterceptor implements NestInterceptor {
  @Inject(LoggerService)
  private logger!: LoggerService;

  @Inject(AsyncContextService)
  private asyncContext!: AsyncContextService;

  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    const XTransactionID = uuid();

    // 使用AsyncLocalStorage存储XTransactionID
    return new Observable((observer) => {
      this.asyncContext.run({ XTransactionID }, () => {
        const result = this.handleRequest(next);
        result.then((obs) => obs.subscribe(observer));
      });
    });
  }

  private async handleRequest(
    next: CallHandler<any>,
  ): Promise<Observable<any>> {
    const now = Date.now();

    const tapOperator = tap((res) => {
      this.logger.debug(
        JSON.stringify({
          time: `${Date.now() - now}ms`,
          Response: res,
        }),
      );
    });

    return next.handle().pipe(tapOperator);
  }
}
通过以上的操作,当请求进来时,拦截器创建的XTransactionID,随后无论当前请求流转在哪些模块的service中,调用的logger 模块都可以获取到在拦截器创建的XTransactionID,保证整个请求周期内XTransactionID一致。
效果展示
1bdbfc55-4fd2-4865-b266-9a6e8936f0f5
cc5fbd73-1e0b-45ee-8958-9aa5b2402ffc 就是流转中的 XTransactionID,在经过多个模块时,依然保持一致。

绝对不推荐使用:Scope.REQUEST

ts
@Injectable({ scope: Scope.REQUEST }) // 请求级作用域
export class xxxService {
}
之所以着重说它,是因为大部分AI工具都会推荐你使用这个方法,我刚开始也是用的这个方法,但是实践过程中发现很多问题,所以最后抛弃这种写法。
Scope.REQUEST 的具体的示例代码就不展示了,因为不推荐,以下说明原因:
1. 性能损耗与内存开销
Scope.REQUEST 会导致每个请求都创建新的提供者实例,而非单例复用。在高并发场景下,这会显著增加内存占用和垃圾回收(GC)压力,降低应用吞吐量。对于需要频繁访问的服务(如日志、追踪等),这种开销会被放大。
在nest中,默认创建的模块都是单例模块,它的特点就是在服务启动时实例化一次(也就是new 这个类),在重新启动服务前,这个实例都不会再初始化了,并且这个模块可以使用 onModuleInit, onApplicationBootstrap 等生命周期钩子。
使用 Scope.REQUEST 定义的模块就是懒加载模块,他在服务启动时并不会实例化,在每个请求流转执行到他时都会实例化,也就是请求流转涉及到的 class 的 constructor 在每次请求时都会执行,这个要特别注意!这就造成一定的性能开销。onModuleInit, onApplicationBootstrap 等生命周期钩子都不会执行了,因为他们已经错过了执行时机,这就像一场派对(应用启动)。onApplicationBootstrap 是派对正式开始时的鸣枪仪式。如果一个客人(懒加载模块)在派对开始一小时后才迟到入场,我们不可能为了他再鸣一次枪。不要小瞧这些小钩子,有的时候他们很有用。而懒加载会导致有些生命周期钩子不可用(onModuleInit, onApplicationBootstrap)
2. 依赖链污染
Nest 的依赖注入系统具有 "作用域继承" 特性:如果一个单例作用域(默认)的提供者依赖了 Scope.REQUEST 作用域的提供者,Nest 会被迫将该单例提供者也转为 Scope.REQUEST 作用域(否则会出现 "单例依赖请求作用域实例" 的矛盾)。这可能导致整个依赖链被 "污染",大量本应是单例的服务被迫变为请求级实例,进一步加剧性能问题。
在 NestJS 中,一旦一个模块直接或间接引入了任何 Scope.REQUEST 的模块,整个模块都会变成“懒加载模块”(Lazy-loaded module)。这个是一个非常值得注意的点,我之前就是使用Scope.REQUEST 定义的logger.service,由于日志打印每个模块基本都用,造成了所有模块几乎都是懒加载模块!
3. 与某些特性不兼容
部分 Nest 特性(如拦截器的 @Injectable() 单例模式、某些缓存策略)与 Scope.REQUEST 存在兼容性问题,可能导致不可预期的行为(如上下文丢失、实例复用异常等)。
目前还没有发现什么场景下必须使用Scope.REQUEST
最后编辑于:2026-01-11 09:11:52
©著作权归作者所有,转载或内容合作请联系作者。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,little笔记系信息发布平台,仅提供信息存储服务。
  • 首页
  • little笔记小助手
  • 聊天
  • 工具
  • 登录/注册