10 phút đọc

Sự thật về Request Scope: Tại sao ứng dụng NestJS của bạn ngày càng chậm?

Request Scope mang đến một cách tiếp cận thuận tiện để truy cập dữ liệu theo từng request mà không cần truyền tham số qua nhiều tầng. Nhưng sự tiện lợi này đi kèm một cái giá về hiệu năng, đặc biệt khi hệ thống phải xử lý lượng request lớn.

Pro Trial đến 31/12/2026

Giai đoạn Pre-launch: đăng nhập để dùng toàn bộ nội dung PRO miễn phí đến hết ngày 31/12/2026.

Vega ·

Soldemy field notes

Tech deep dive

1. "Chiếc bẫy ngọt ngào" của Request Scope

Đây là tình huống không hiếm gặp trong quá trình phát triển hệ thống: bạn cần truy cập thông tin currentUser tại một Repository hoặc Service nằm sâu trong tầng business logic. Truyền dữ liệu qua từng lớp tham số hàm rõ ràng không phải là giải pháp bền vững - đó là con đường dẫn thẳng đến prop drilling, thứ mà bất kỳ kiến trúc sư hệ thống nào cũng muốn tránh.

Khi tra cứu tài liệu NestJS, @Injectable({ scope: Scope.REQUEST }) xuất hiện như một giải pháp lý tưởng: gắn decorator này cho AuthContextService, inject vào bất kỳ đâu trong dependency tree, và truy cập dữ liệu của request hiện tại mà không cần truyền tham số thủ công. Trên môi trường local, giải pháp này vận hành đúng như kỳ vọng, sạch sẽ, gọn gàng, không phát sinh vấn đề.

Vấn đề thực sự chỉ lộ diện khi lượng request đồng thời tăng lên đáng kể chẳng hạn hệ thống phải xử lý hàng nghìn request/giây trong một khoảng thời gian ngắn. Lúc này, ứng dụng bắt đầu bộc lộ những dấu hiệu bất thường:

  1. Memory (RAM) tăng đột biến, đồ thị răng cưa không ổn định.

  2. Response time chênh lệch khủng khiếp: có request trả về trong vài milisecond, có request lại treo lơ lửng tới vài giây.

  3. CPU spike liên tục chạm đỉnh, không phải do ứng dụng tính toán nặng, mà là do Garbage Collector (GC) của Node.js đang phải vắt kiệt sức để "dọn rác".

Điều gì đã khiến một cơ chế được thiết kế để giải quyết vấn đề convenience lại trở thành nguyên nhân âm thầm bào mòn hiệu năng hệ thống?

2. Bên trong DI Container của NestJS: Mỗi request thực sự làm gì? 

Để hiểu nguyên nhân, chúng ta cần soi kỹ vào cách Dependency Injection (DI) Container của NestJS vận hành. NestJS cung cấp 3 loại Provider Scope:

  1. DEFAULT (Singleton): Khởi tạo 1 lần duy nhất khi boot app và dùng chung cho mọi request.

  2. REQUEST: Khởi tạo một instance hoàn toàn mới cho mỗi request gửi đến.

  3. TRANSIENT: Khởi tạo một instance mới mỗi khi provider này được inject vào một class khác.

Hãy hình dung qua một ví dụ đời thực để hiểu rõ về cơ chế của Request Scope. Nếu Default Scope giống như việc một quán cafe chỉ có đúng 1 barista pha chế cho hàng ngàn khách (tái sử dụng resource), thì Request Scope giống như việc cứ mỗi vị khách bước vào, quán lại làm thủ tục tuyển dụng 1 barista mới, phục vụ xong ly cafe đó rồi... đuổi việc luôn (để Garbage Collector dọn dẹp).

Hiệu ứng lan truyền (Bubbling up)

Đây chính là bí mật "chết người" nhất của Request Scope. DI container của NestJS hoạt động theo một chuỗi phụ thuộc. Nếu bạn có một Singleton Controller, nhưng bạn lại inject một Request Scope Service vào nó, thì chính Controller đó (và toàn bộ chuỗi dependency cha của nó) cũng bị ép phải biến thành Request Scope. Sự lan truyền này diễn ra âm thầm nhưng tàn phá diện rộng.

3. Sai lầm phổ biến

Hãy xem xét một đoạn code vô cùng quen thuộc trong các dự án NestJS.

Đầu tiên, bạn tạo một service chứa thông tin request và đánh dấu nó là Request Scope:

TypeScript
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
// Khởi tạo mới mỗi request
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  constructor(@Inject(REQUEST) private readonly request: Request) {}
  getUserId(): string {
    return this.request.headers['x-user-id'] as string;
  }
}

Sau đó, bạn inject nó vào một Service chuyên xử lý logic nặng, rồi gọi nó ở Controller:

TypeScript
import { Injectable, Controller, Post } from '@nestjs/common';
@Injectable()
export class OrderService {
  // OrderService vô tình bị "nhiễm" Request Scope
  constructor(private readonly contextService: RequestContextService) {}
  async processOrder() {
    const userId = this.contextService.getUserId();
    // Logic tính toán cực nặng, khởi tạo nhiều array/object...
    return { success: true, user: userId };
  }
}

@Controller('orders')
export class OrderController {
  // OrderController cũng bị "nhiễm" Request Scope!
  constructor(private readonly orderService: OrderService) {}

  @Post()
  createOrder() {
    return this.orderService.processOrder();
  }
}

Dù bạn không hề thêm { scope: Scope.REQUEST } vào OrderController hay OrderService, hiệu ứng bubbling up đã làm điều đó thay bạn.

Hệ quả là nếu hệ thống của bạn nhận 1,000 RPS (Requests Per Second), thay vì tái sử dụng 1 instance duy nhất, NestJS phải cấp phát bộ nhớ để khởi tạo 1,000 OrderController, 1,000 OrderService và 1,000 RequestContextService mỗi giây. Vừa khởi tạo xong lại phải ném đi. Với một logic tính toán phức tạp, sự phung phí tài nguyên này đủ để đánh sập ứng dụng của bạn.

4. Đưa DI Container trở lại trạng thái tĩnh

Mục tiêu của chúng ta rất rõ ràng: Phải đưa toàn bộ ứng dụng về lại trạng thái Singleton (Default Scope) để DI Container "nằm im" sau khi boot, tiết kiệm tối đa CPU và RAM. Nhưng chúng ta vẫn phải giữ được khả năng cô lập dữ liệu để lấy đúng userId cho từng request riêng biệt. Đó là lúc AsyncLocalStorage (ALS) xuất hiện như một vị cứu tinh.

Đây là một API core của Node.js (từ bản v14 trở lên), cho phép bạn lưu trữ và chia sẻ một state (trạng thái) xuyên suốt toàn bộ vòng đời của một luồng xử lý bất đồng bộ mà không cần truyền tay qua các hàm.

Nếu bạn từng code Java (Spring Boot) và quen với khái niệm ThreadLocal giúp lưu biến theo từng thread, thì AsyncLocalStorage chính là phiên bản ThreadLocal được Node.js thiết kế riêng cho mô hình Event Loop đơn luồng của mình.

Sự kết hợp hoàn hảo nhất trong NestJS là: Dùng Middleware hoặc Interceptor để "gói" request vào một context của ALS, sau đó ở bất kỳ tầng Service nào, bạn chỉ cần gọi ALS ra để lấy dữ liệu.

5. Refactor: Gọn gàng hơn, nhanh hơn và không còn lan truyền

Hãy cùng sửa lại sai lầm ở phần 3 bằng sức mạnh của AsyncLocalStorage.

Bước 1: Tạo một file cls.setup.ts chứa instance tĩnh của ALS.

TypeScript
import { AsyncLocalStorage } from 'async_hooks';
export interface AppContext {
  userId: string;
  traceId: string;
}

// Khởi tạo một Singleton ALS dùng chung cho toàn app
export const appStorage = new AsyncLocalStorage<AppContext>();

Bước 2: Viết một Middleware để khởi tạo context ngay khi request vừa chạm vào server.

TypeScript
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { appStorage } from './cls.setup';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class TraceMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const context = {
      userId: req.headers['x-user-id'] as string || 'guest',
      traceId: uuidv4(),
    };
    // Mọi logic bên trong next() đều có thể truy cập được biến context này
    appStorage.run(context, () => {
      next();
    });
  }
}

Bước 3: "Giải thoát" cho RequestContextService.

TypeScript
import { Injectable } from '@nestjs/common';
import { appStorage } from './cls.setup';
@Injectable() 
export class RequestContextService {
  
  getUserId(): string | null {
    // Lấy dữ liệu trực tiếp từ ALS an toàn
    const store = appStorage.getStore();
    return store?.userId || null;
  }
}

Bạn không cần làm gì với OrderService hay OrderController cả. Tự động, chúng đã trở về trạng thái Singleton vốn có của mình, khởi tạo đúng 1 lần khi ứng dụng start.

6. Hiệu năng cải thiện sau refactor 

  • Đo lường số lần Instantiate: Từ việc phải cấp phát 10,000 instance/giây cho một traffic spike, giờ đây hệ thống chỉ dùng đúng 1 instance duy nhất của OrderController và OrderService cho toàn bộ vòng đời ứng dụng.

  • Giảm tải Garbage Collector (GC): Bộ nhớ Heap của V8 engine không còn bị nhồi nhét bởi hàng vạn object ngắn hạn. GC không cần phải liên tục dừng ứng dụng lại (stop-the-world) để dọn rác. Nhờ đó, Event Loop không bị block, giúp response time (độ trễ) tạo thành một đường thẳng (flat latency curve) cực kỳ mượt mà.

  • Đảm bảo tính toàn vẹn dữ liệu: Dù toàn bộ app chỉ xài chung 1 Singleton OrderService, bạn đừng lo việc rò rỉ dữ liệu chéo. Nhờ cơ chế bám sát vòng đời async của ALS, userId của khách hàng A sẽ không bao giờ leak sang request của khách hàng B.

7. Khi nào nên dùng Request Scope? 

Không có tính năng nào sinh ra là vô dụng. Request Scope không phải là một lựa chọn sai, vấn đề chỉ xuất hiện khi nó được sử dụng không đúng ngữ cảnh. Điều quan trọng không nằm ở bản thân công cụ, mà nằm ở cách chúng ta áp dụng nó trong hệ thống.

Khi không nên dùng:

  1. Không dùng cho tracking log như Trace ID, Request IP, Correlation ID.

  2. Không dùng để truyền User Context (User ID, Role) xuống các service con để check quyền.

  3. Không dùng cho bất kỳ service nào nằm trong hot path (luồng xử lý chính, tần suất gọi cao).

Khi nên dùng:

  1. Kiến trúc multi-tenant: mỗi request cần kết nối đến database, schema, credential khác nhau.

  2. Khi cần tạo dependency hoàn toàn độc lập cho từng request.

  3. Khi logic nghiệp vụ yêu cầu thay đổi cấu trúc dependency dựa trên request.

  4. Khi middleware hoặc interceptor không đủ khả năng can thiệp vào quá trình inject dependency.

8. Kết luận

Các framework hiện đại như NestJS mang đến rất nhiều “phép màu”, giúp chúng ta xây dựng hệ thống nhanh hơn và gọn gàng hơn bao giờ hết. Nhưng đằng sau mỗi sự tiện lợi đó luôn tồn tại một cái giá về hiệu năng và tài nguyên hệ thống.

Qua hành trình phân tích vấn đề ở trên, có thể rút ra một vài bài học quan trọng:

  1. Trong thế giới Node.js/NestJS, hãy luôn ưu tiên giữ cho DI Container ở trạng thái tĩnh (Singleton) nhiều nhất có thể.

  2. Hiệu ứng scope bubbling của Request Scope có thể âm thầm lan rộng và biến một service tưởng chừng vô hại trở thành điểm nghẽn của toàn bộ hệ thống.

  3. Trong phần lớn trường hợp, AsyncLocalStorage là một giải pháp thay thế hiệu quả và nhẹ hơn cho việc chia sẻ context theo request.

  4. Đừng sử dụng các decorator một cách “vô thức”, hãy hiểu rõ cơ chế cấp phát và lifecycle phía sau trước khi áp dụng.

Cuối cùng, sự trưởng thành của một developer không nằm ở việc biết bao nhiêu công nghệ, mà nằm ở việc hiểu sâu những gì mình đang sử dụng và nhận thức rõ những đánh đổi đi kèm với mỗi quyết định kỹ thuật.

Bài viết này có hữu ích?

Phản hồi của bạn giúp chúng tôi viết tốt hơn.

Thảo luận

0 bình luận

Chưa có bình luận nào. Hãy là người đầu tiên chia sẻ.