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
Vấn đề
Phần tiêu đề “Vấn đề”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.
ALS, tham số hay request scope?
Phần tiêu đề “ALS, tham số hay request scope?”| 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.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”store AA 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ơnenterWith():enterWithké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ọirun()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}`);Mất context: bốn trường hợp có thật
Phần tiêu đề “Mất context: bốn trường hợp có thật”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: Bcallback trong hàng đợi tự viết thấy: Bcallback đã AsyncLocalStorage.bind thấy: APromise.then thấy: AsetTimeout thấy: Afs.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.
Phiên bản và hiệu năng
Phần tiêu đề “Phiên bản và hiệu năng”- Node 24:
AsyncLocalStoragemặc định chạy trên AsyncContextFrame, thay cho cách dựa trênasync_hooks(release notes 24.0.0: “lib: make ALS default to AsyncContextFrame”). Node 24.17 còn cờ--no-async-context-frameđể tắt, theonode --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.
Nối với NestJS
Phần tiêu đề “Nối với NestJS”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…
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Checklist production
Phần tiêu đề “Checklist production”- Mở context ở mọi entry point: HTTP middleware, consumer, cron, CLI, không chỉ HTTP.
- Validate hoặc sinh lại
requestIdnhận từ client (độ dài, ký tự); không tin nguyên xi. - Dùng
run(), tránhenterWith(). - Bọc listener của
EventEmitterdùng chung và callback đưa vào hàng đợi tự viết bằngAsyncLocalStorage.bind. - Truyền ID qua header hoặc message metadata khi vượt ranh giới process; ALS không làm việc đó.
- Giữ store nhỏ; dữ liệu nghiệp vụ và transaction đi bằng tham số tường minh.
- 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. - Nếu nghi ngờ overhead, load test với và không có ALS trên đúng workload của bạn.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”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:
npx --prefix examples ts-node examples/src/async-local-storage/context-demo.tsnpx --prefix examples ts-node examples/src/async-local-storage/context-loss-demo.tsnpx --prefix examples ts-node examples/src/async-local-storage/overhead-bench.ts