在 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
REQUESTscope 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) 的狀態。
這個冒泡機制最棘手的地方在於:它會像連鎖反應一樣放大效能開銷
在 NestJS 中,預設的單例(Singleton)只會在應用程式啟動時建立一次;但 Scope.REQUEST 卻會在每次請求進來時建立新的實例,並在請求結束後等待回收。
本篇範例只有三個實例會隨每次請求重新建立,但在實際專案中,依賴鏈可能長得像這樣:
PostsController
└── PostsService
└── PermissionService
└── AuditService
└── RequestContextService(Scope.REQUEST)
此時每次請求重建的就不只是一個 RequestContextService,而是它上層的整條依賴鏈。如果服務又被記錄器、權限模組廣泛依賴,影響範圍還會繼續擴張。流量低時可能感覺不到,等到併發量升高,記憶體配置與垃圾回收的壓力才逐漸浮現。
了解了冒泡機制與隱形成本後,並不代表我們必須完全摒棄 Scope.REQUEST。在決定是否引入前,你可以透過以下問題來評估:
這三個問題比單純計算有幾個 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 的好處在於:
Scope.REQUEST,因此在沒有其他請求作用域依賴的前提下,控制器與服務仍可維持預設的單例生命週期,也避免因這份上下文而產生整條依賴鏈的重建成本。如果需求只是讓不同服務取得同一次請求的上下文資料,通常可以先考慮方法參數或 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 而在每次請求時被重建。
Scope.REQUEST,只要依賴請求作用域的 provider,也會在每次請求重新建立。Scope.REQUEST 的成本時,不能只看該服務本身,而是要算算看整條被連帶影響的依賴鏈有多龐大。Scope.REQUEST。