iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 8 篇

Day 8|蔓延的病毒:Scope.REQUEST 是如何悄悄吞噬你的效能?

  • 分享至 

  • xImage
  •  

在 NestJS 中,慢查詢或 N+1 這類問題通常有明確的數據或 Log 可循。但 Scope.REQUEST 引起的效能耗損,卻往往更難被察覺。

為了拿 x-request-id 或登入者資訊,把服務掛上 Scope.REQUEST 雖然方便,但服務的生命週期一旦改變,影響的絕不只單一服務本身。在流量小、依賴單純時沒感覺,等併發量衝高,這些隱形成本就會一次爆發。

今天就來拆解 Scope.REQUEST 觸發的連鎖反應,並看看如果不依賴它,有哪些替代方案。

問題怎麼發生?

為了追蹤每次請求究竟重建了哪些實例,我們建立了一個簡單的 InstanceTrackerService 作為觀測工具:

@Injectable()
export class InstanceTrackerService {
  private readonly counts = new Map<string, number>();

  nextId(component: string): string {
    const nextCount = (this.counts.get(component) ?? 0) + 1;
    this.counts.set(component, nextCount);

    return `${component}-${nextCount}`;
  }
}

只要識別碼的流水號改變,就代表 NestJS 建立了新實例。

有了這把尺,接著就能把它埋進實際的依賴鏈裡。首先是負責讀取 HTTP 標頭的 RequestContextService,也是整個情境中唯一明確設定 Scope.REQUEST 的類別:

@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  readonly instanceId: string;

  constructor(
    @Inject(REQUEST) private readonly request: Request,
    instanceTracker: InstanceTrackerService,
  ) {
    this.instanceId = instanceTracker.nextId('request-context');
  }

  getRequestId(): string {
    const requestId = this.request.headers['x-request-id'];

    if (Array.isArray(requestId)) {
      return requestId[0] ?? 'request-id-not-provided';
    }

    return requestId ?? 'request-id-not-provided';
  }
}

為了確認影響是否會擴及所有參與請求的元件,資料存取層也會記錄自己的實例識別碼。PostsRepository 回傳一筆固定的文章資料:

@Injectable()
export class PostsRepository {
  readonly instanceId: string;

  constructor(instanceTracker: InstanceTrackerService) {
    this.instanceId = instanceTracker.nextId('posts-repository');
  }

  findPost() {
    return {
      id: 1,
      title: 'Scope.REQUEST chain reaction',
    };
  }
}

負責組合業務邏輯的 PostsService 則會同時注入這兩個服務:

@Injectable()
export class PostsService {
  readonly instanceId: string;

  constructor(
    private readonly requestContext: RequestContextService,
    private readonly postsRepository: PostsRepository,
    instanceTracker: InstanceTrackerService,
  ) {
    this.instanceId = instanceTracker.nextId('request-scope-posts-service');
  }

  findPost(controllerInstanceId: string) {
    return {
      requestId: this.requestContext.getRequestId(),
      strategy: 'request-scope',
      instances: {
        controller: controllerInstanceId,
        service: this.instanceId,
        requestContext: this.requestContext.instanceId,
        repository: this.postsRepository.instanceId,
      },
      post: this.postsRepository.findPost(),
    };
  }
}

控制器層的做法也相同,建構時先取得自己的實例識別碼,再交給 PostsService 放進回應:

@Controller('posts/request-scope')
export class PostsController {
  private readonly instanceId: string;

  constructor(
    private readonly postsService: PostsService,
    instanceTracker: InstanceTrackerService,
  ) {
    this.instanceId = instanceTracker.nextId('request-scope-posts-controller');
  }

  @Get()
  findPost() {
    return this.postsService.findPost(this.instanceId);
  }
}

接著我們連續發送兩次帶有不同 x-request-id 的 HTTP 請求,觀察回應裡的 instances:

curl -H 'x-request-id: request-a' \
  http://localhost:3000/posts/request-scope

curl -H 'x-request-id: request-b' \
  http://localhost:3000/posts/request-scope

第一次請求回傳:

{
  "requestId": "request-a",
  "strategy": "request-scope",
  "instances": {
    "controller": "request-scope-posts-controller-1",
    "service": "request-scope-posts-service-1",
    "requestContext": "request-context-1",
    "repository": "posts-repository-1"
  },
  "post": {
    "id": 1,
    "title": "Scope.REQUEST chain reaction"
  }
}

第二次請求回傳:

{
  "requestId": "request-b",
  "strategy": "request-scope",
  "instances": {
    "controller": "request-scope-posts-controller-2",
    "service": "request-scope-posts-service-2",
    "requestContext": "request-context-2",
    "repository": "posts-repository-1"
  },
  "post": {
    "id": 1,
    "title": "Scope.REQUEST chain reaction"
  }
}

對比這兩次的回應,會發現以下變化:

  • RequestContextService 數字改變(1 → 2),因為它明確宣告了 Scope.REQUEST,這很合理。
  • PostsService 與 PostsController 的數字也跟著改變了(1 → 2),即使兩者都沒有宣告 Scope.REQUEST。
  • PostsRepository 始終維持 posts-repository-1,沒有跟著其他元件重新建立。

這就引出了一個核心問題:為什麼依賴 RequestContextService 的服務與控制器會一起換成新實例,但同樣被 PostsService 注入的 PostsRepository 卻不受影響?

根因:請求作用域會沿著依賴鏈向上冒泡

為什麼沒設 Scope.REQUEST 的元件也被重新建立了?NestJS 官方文件對此有很明確的解釋:

The REQUEST scope bubbles up the injection chain. A controller that depends on a request-scoped provider will, itself, be request-scoped. ——NestJS 官方文件-Injection scopes(Scope hierarchy)

換句話說,Scope.REQUEST 會沿著依賴鏈向上冒泡--依賴它的服務與控制器會被連帶變成請求作用域,但它所依賴的下層 provider 不會因此受到影響。

對照我們前面的案例,這條「感染鏈」就非常清晰了:

  • PostsService 直接感染:PostsService 本身雖然只有一般的 @Injectable(),但它注入了具備 Scope.REQUEST 的 RequestContextService。為了讓每一次請求都能拿到獨立的上下文,NestJS 被迫要在每次請求時,也為 PostsService 重新建立一個新實例。
  • PostsController 間接感染:PostsController 依賴了這個已被感染的 PostsService,連鎖反應因而繼續向上蔓延,最終導致控制器也跟著變成了請求作用域。
  • PostsRepository 不受影響:相對地,底層的 PostsRepository 被 PostsService 所依賴(位在被感染者的下層),因此完全不受影響,依然維持著單例 (Singleton) 的狀態。

https://ithelp.ithome.com.tw/upload/images/20260922/20184306TZC5qGUubL.png

隱形成本:被放大的效能開銷

這個冒泡機制最棘手的地方在於:它會像連鎖反應一樣放大效能開銷

在 NestJS 中,預設的單例(Singleton)只會在應用程式啟動時建立一次;但 Scope.REQUEST 卻會在每次請求進來時建立新的實例,並在請求結束後等待回收。

本篇範例只有三個實例會隨每次請求重新建立,但在實際專案中,依賴鏈可能長得像這樣:

PostsController
└── PostsService
    └── PermissionService
        └── AuditService
            └── RequestContextService(Scope.REQUEST)

此時每次請求重建的就不只是一個 RequestContextService,而是它上層的整條依賴鏈。如果服務又被記錄器、權限模組廣泛依賴,影響範圍還會繼續擴張。流量低時可能感覺不到,等到併發量升高,記憶體配置與垃圾回收的壓力才逐漸浮現。

了解了冒泡機制與隱形成本後,並不代表我們必須完全摒棄 Scope.REQUEST。在決定是否引入前,你可以透過以下問題來評估:

  1. 影響範圍:這個請求作用域 provider 位於依賴鏈的哪一層?有多少服務與控制器會被它連帶影響?
  2. 建立成本:被連帶重新建立的實例,建構過程是否包含昂貴的初始化或資源配置?
  3. 流量規模:在尖峰流量下,這些實例每秒會被建立多少次,會帶來多少記憶體配置與垃圾回收壓力?

這三個問題比單純計算有幾個 Scope.REQUEST,更能反映請求作用域的實際成本。

排雷指南

解法一:顯式參數傳遞(適合少量資料)

如果我們只是需要 HTTP 標頭裡的 x-request-id,最直接的方式就是透過方法參數傳遞:

@Controller('posts/explicit-context')
export class PostsController {
  readonly instanceId: string;

  constructor(
    private readonly postsService: PostsService,
    instanceTracker: InstanceTrackerService,
  ) {
    this.instanceId = instanceTracker.nextId('explicit-posts-controller');
  }

  @Get()
  findPost(@Headers('x-request-id') requestId?: string) {
    return this.postsService.findPost(
      requestId ?? 'request-id-not-provided',
      this.instanceId,
    );
  }
}

服務只接收它需要的資料,不注入任何 Request 物件:

@Injectable()
export class PostsService {
  readonly instanceId: string;

  constructor(
    private readonly postsRepository: PostsRepository,
    instanceTracker: InstanceTrackerService,
  ) {
    this.instanceId = instanceTracker.nextId('explicit-posts-service');
  }

  findPost(requestId: string, controllerInstanceId: string) {
    return {
      requestId,
      strategy: 'explicit-context',
      instances: {
        controller: controllerInstanceId,
        service: this.instanceId,
        requestContext: null,
        repository: this.postsRepository.instanceId,
      },
      post: this.postsRepository.findPost(),
    };
  }
}

沿用相同的 InstanceTrackerService 觀察,連續發送兩次帶有不同標頭(x-request-id: request-c 與 x-request-id: request-d)的請求:

curl -H 'x-request-id: request-c' \
  http://localhost:3000/posts/explicit-context

curl -H 'x-request-id: request-d' \
  http://localhost:3000/posts/explicit-context

可以發現,雖然回應中的 requestId 隨著輸入動態改變,但控制器、服務與資料存取層的實例識別碼,全都維持在 1:

{
  "requestId": "request-c",
  "strategy": "explicit-context",
  "instances": {
    "controller": "explicit-posts-controller-1",
    "service": "explicit-posts-service-1",
    "requestContext": null,
    "repository": "posts-repository-1"
  },
  "post": {
    "id": 1,
    "title": "Scope.REQUEST chain reaction"
  }
}

業務結果相同,但 NestJS 不再為每次請求重建整條依賴鏈。

解法二:深層跨層傳遞時,改用 AsyncLocalStorage (ALS)

如果請求資料(如 requestId、tenantId 或 currentUser)需要穿透多層服務(例如 Controller → Service A → Service B → Repository),如果中間許多服務本身並不使用這些資料,只是負責繼續往下傳,就會出現類似前端 prop drilling 的問題,讓方法參數越來越多,也讓各層服務之間的資料傳遞變得冗長。

此時更好的替代方案是 Node.js 內建的 ALS。

ALS 能在非同步呼叫鏈中隱式傳遞上下文,運作機制類似多執行緒語言中的 Thread-Local Storage。使用 ALS 的好處在於:

  • 避免作用域冒泡:使用 ALS 傳遞上下文本身不需要把服務改成 Scope.REQUEST,因此在沒有其他請求作用域依賴的前提下,控制器與服務仍可維持預設的單例生命週期,也避免因這份上下文而產生整條依賴鏈的重建成本。
  • 深層存取:只要在請求入口先建立 ALS 上下文,後續位於同一條非同步呼叫鏈中的服務就能取得這次請求的資料,不必逐層透過方法參數傳遞。

如果需求只是讓不同服務取得同一次請求的上下文資料,通常可以先考慮方法參數或 ALS;如果 provider 本身的生命週期確實需要與單次請求綁定,再評估使用 Scope.REQUEST。

常見誤解:Scope.TRANSIENT 也會引發向上冒泡?

很多人會把 Scope.TRANSIENT 與 Scope.REQUEST 搞混,以為只要 provider 不是預設的單例,就會觸發連鎖冒泡機制。

事實上,兩者的運作機制有著本質上的不同:

  • Scope.TRANSIENT 的邊界是「注入點」:每個注入它的元件,都會拿到一個獨立的實例。但這種機制只作用於該 provider 本身,不會改變上層元件的生命週期。
  • Scope.REQUEST 的邊界是「HTTP 請求」:它具備向上冒泡的傳播特性。當一個元件注入了請求作用域 Provider,其作用域就會向上傳染,導致上層所有的依賴鏈都會被連帶提升為請求作用域,進而在每次請求時產生重建實例的效能開銷。

下面用簡短的範例說明,假設我們有一個 Scope.TRANSIENT 的 LoggerService:

@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService {
  readonly instanceId: string;
  private context = 'unknown';

  constructor(instanceTracker: InstanceTrackerService) {
    this.instanceId = instanceTracker.nextId('logger');
  }

  setContext(context: string): void {
    this.context = context;
  }

  log(message: string): void {
    console.log(`[${this.context}] ${message}`);
  }
}

PostsController 與 PostsService 同時注入這個 LoggerService 時:

@Injectable()
export class PostsService {
  constructor(private readonly logger: LoggerService) {} // 取得 Logger 實例 A
  ...
}
@Controller('posts')
export class PostsController {
  constructor(
    private readonly postsService: PostsService,
    private readonly logger: LoggerService, // 取得 Logger 實例 B
  ) {}
  ...
}

發送兩次 HTTP 請求,觀察到的實例變化如下:

PostsController  ── (Singleton-1,跨請求不變)
  ├── LoggerService (Instance B,專屬於 Controller)
  └── PostsService ── (Singleton-1,跨請求不變)
        └── LoggerService (Instance A,專屬於 Service)

LoggerService 確實依據注入點建立了不同的實例(A 與 B),但 PostsController 與 PostsService 依然維持單例,不會因為注入了暫時作用域 provider 而在每次請求時被重建。

總結

  1. 請求作用域只會向上冒泡:即使服務與控制器沒有明寫 Scope.REQUEST,只要依賴請求作用域的 provider,也會在每次請求重新建立。
  2. 連鎖反應決定效能代價:評估 Scope.REQUEST 的成本時,不能只看該服務本身,而是要算算看整條被連帶影響的依賴鏈有多龐大。
  3. 少量請求資料優先使用方法參數傳遞:若只是傳遞少量的請求標頭或使用者資訊,直接透過控制器方法參數傳下去即可,完全不需要改變 provider 的生命週期。
  4. 跨越多層時,可以評估 ALS:若上下文需穿透多層服務,使用 ALS 能在維持全域單例的前提下達成隱式傳遞;只有當「物件實例本身」需要獨立生命週期時,才考慮 Scope.REQUEST。

參考資料


上一篇
Day 7|循環依賴:forwardRef() 是解藥也是警訊
下一篇
Day 9|全域機制的隱形陷阱:為什麼用 app.useGlobal*(new ...) 會讓 DI 斷掉?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言