Bỏ qua để đến nội dung
Knowledge Hub

AsyncLocalStorage: context theo request, khi nào mất context và khi nào không nên dùng Trung cấp

Thời gian đọc ước tính: 8 phút

Hai request tới gần như cùng lúc, cùng đi qua await và ghi log. Ta muốn mỗi dòng log mang đúng requestId của request sinh ra nó. Biến module-scope let currentRequestId sai ngay: A gán ID rồi chờ I/O, B gán ID khác, khi A chạy tiếp thì biến đã là của B.

AsyncLocalStorage (ALS) gắn một store (object chứa context) với một chuỗi xử lý bất đồng bộ. run(store, fn) mở context; getStore() đọc store của chuỗi đang chạy. ALS chỉ hoạt động trong một process, không tự truyền qua HTTP hay message broker.

Cách Hợp khi Chi phí
Truyền tham số Dữ liệu nghiệp vụ (user, tenant, transaction), ít tầng Sửa chữ ký hàm, nhưng tường minh và dễ test
ALS Metadata vận hành đọc ở nhiều nơi: correlation ID, trace context, logger Dependency ngầm vào entry point; mất context nếu bỏ sót boundary
Scope.REQUEST trong NestJS Provider thật sự cần state sống đúng một request Tạo instance theo request, scope lan lên cây dependency (provider scopes)

Quy tắc chọn: dữ liệu nghiệp vụ đi bằng tham số; metadata cho log và trace đi bằng ALS; request scope chỉ khi có state phức tạp gắn vòng đời request. Tài liệu NestJS cũng giới thiệu per-request store dựa trên ALS như cách giữ provider ở singleton.

Hai request chạy xen kẽ trong một Node process
Hai request chạy xen kẽ trong một Node processBước 1/4: A vào run({requestId: A}); async resource của A giữ store A.Request ANode processRequest Bstore A
Request A → Node processstore A
Bước 1/4

A vào run({requestId: A}); async resource của A giữ store A.

Mỗi run() mở một context. Async resource (Promise, timer, I/O) tạo ra bên trong giữ context đó, nên sau await, getStore() vẫn trả store của đúng nhánh. Ngoài run() store là undefined. Ba điểm cần nhớ:

  • Store là tham chiếu object, không phải bản sao: mọi hàm trong request cùng thấy thay đổi. Giữ store nhỏ (ID, tenant, logger con).
  • run() được ưu tiên hơn enterWith(): enterWith kéo context qua toàn bộ phần đồng bộ còn lại và còn đang ở mức Experimental theo tài liệu Node.
  • getStore() ở ngoài mọi run() trả undefined, nên cron job và consumer cần tự mở context ở entry point.
import { AsyncLocalStorage } from 'node:async_hooks';
type Ctx = { requestId: string };
export const als = new AsyncLocalStorage<Ctx>();
export const log = (msg: string) =>
console.log(`[${als.getStore()?.requestId ?? 'no-ctx'}] ${msg}`);

Node tự giữ context qua Promise, setTimeout và callback của fs. Context mất (hoặc trở thành context của người khác) khi callback không chạy trong chuỗi async đã tạo ra nó. Demo context-loss-demo.ts: request A đăng ký mọi thứ trong run(), request B phát sự kiện và xả hàng đợi. Output thật (Node 24.17):

EventEmitter listener thấy: B
callback trong hàng đợi tự viết thấy: B
callback đã AsyncLocalStorage.bind thấy: A
Promise.then thấy: A
setTimeout thấy: A
fs.readFile callback thấy: A
Trường hợp Context thấy Vì sao Cách xử lý
EventEmitter listener dùng chung Context của bên emit (B) Tài liệu Node: listener có thể chạy ở context khác lúc on() AsyncLocalStorage.bind(fn) hoặc AsyncResource.bind
Hàng đợi/pool tự viết, driver kiểu callback tự xếp hàng Context của bên gọi callback Callback được giữ rồi chạy ở chuỗi khác AsyncLocalStorage.bind(fn) khi xếp hàng
Thư viện callback-style cũ Có thể mất Không đi qua Promise util.promisify (khuyến nghị của Node) hoặc AsyncResource
Thenable tự cài (không phải Promise gốc) Có thể mất Node không theo dõi được AsyncResource

Lỗi điển hình: logger đọc getStore() bên trong listener order.created, log ra requestId của request vừa kích hoạt sự kiện thay vì request đăng ký listener. Lỗi này im lặng, không throw.

  • Node 24: AsyncLocalStorage mặc định chạy trên AsyncContextFrame, thay cho cách dựa trên async_hooks (release notes 24.0.0: “lib: make ALS default to AsyncContextFrame”). Node 24.17 còn cờ --no-async-context-frame để tắt, theo node --help.
  • Node 20 và 22: dùng backend async_hooks; từ 22.7.0 có thể bật AsyncContextFrame bằng cờ --experimental-async-context-frame (Experimental, theo tài liệu CLI của Node 22).
  • Tài liệu Node chỉ nói ALS là cài đặt “performant and memory safe”, không đưa con số overhead.

Micro-benchmark của repo (overhead-bench.ts: 200.000 chuỗi await + setImmediate, mỗi chuỗi trong một als.run), Node 24.17, mỗi cấu hình chạy 2 lần x 3 vòng:

Backend Không ALS Có ALS
AsyncContextFrame (mặc định) 75 đến 323 ms 127 đến 158 ms
--no-async-context-frame 186 đến 226 ms 182 đến 241 ms

Độ nhiễu giữa các lần chạy lớn hơn chênh lệch giữa hai cấu hình, nên không rút ra được con số overhead từ bảng này. Kết luận thực tế: overhead của ALS nhỏ so với I/O thật của một request, nhưng nếu hot path của bạn khác, hãy đo bằng load test trên chính ứng dụng, đừng tin benchmark của người khác.

Có hai đường. Cả hai đều phải mở context trước guard, interceptor và controller; middleware chạy sớm nhất trong pipeline (request lifecycle), nên đó là chỗ đúng.

1. Tự nối bằng middleware:

export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const requestId = validateOrGenerate(req.header('x-request-id'));
als.run({ requestId }, next);
}
}

2. Dùng nestjs-cls (bản 7.0.1 tại thời điểm viết, peer @nestjs/common từ 10 đến dưới 13):

@Module({
imports: [ClsModule.forRoot({ global: true, middleware: { mount: true } })],
})
export class AppModule {}
// ở bất kỳ provider nào, vẫn là singleton:
constructor(private readonly cls: ClsService) {}
log() { this.cls.get('requestId'); }

Theo tài liệu nestjs-cls, middleware bọc mỗi request trong một CLS context và ClsService.get/set đọc ghi store mà không cần request-scoped provider. Entry point ngoài HTTP (Kafka consumer, cron, CLI) cần mở context riêng; xem tài liệu của thư viện cho từng transport.

Đang tải diagram…

  1. Mở context ở mọi entry point: HTTP middleware, consumer, cron, CLI, không chỉ HTTP.
  2. Validate hoặc sinh lại requestId nhận từ client (độ dài, ký tự); không tin nguyên xi.
  3. Dùng run(), tránh enterWith().
  4. Bọc listener của EventEmitter dùng chung và callback đưa vào hàng đợi tự viết bằng AsyncLocalStorage.bind.
  5. Truyền ID qua header hoặc message metadata khi vượt ranh giới process; ALS không làm việc đó.
  6. Giữ store nhỏ; dữ liệu nghiệp vụ và transaction đi bằng tham số tường minh.
  7. Test với request song song và đọc ID ở trước và sau mỗi await; assertion theo ID, không theo thứ tự log.
  8. Nếu nghi ngờ overhead, load test với và không có ALS trên đúng workload của bạn.

Tự kiểm tra: (1) Vì sao listener EventEmitter có thể log sai requestId? (2) getStore() trả gì ngoài run()? (3) Vì sao consumer Kafka phải mở context mới dù producer đã có ALS?

Chạy demo:

Terminal window
npx --prefix examples ts-node examples/src/async-local-storage/context-demo.ts
npx --prefix examples ts-node examples/src/async-local-storage/context-loss-demo.ts
npx --prefix examples ts-node examples/src/async-local-storage/overhead-bench.ts