Mình từng viết một hàm log() nhận đúng hai tham số: message và level. Ba tháng sau nó có bảy, trong đó có traceId, userId, tenantId. Mỗi lần thêm một tầng service là một lần sửa chữ ký hàm ở hai chục chỗ, và luôn có chỗ quên nên log ra traceId: undefined đúng ngay lúc cần debug nhất.

Đó không phải vấn đề code xấu. Đó là vấn đề context không có chỗ đứng: thông tin thuộc về cả request nhưng bị đối xử như tham số của từng hàm.

Vấn đề: context đi xuyên tầng, không thuộc về một tầng

Những thứ thuộc về request mà không thuộc về hàm nào: traceId, user đang đăng nhập, tenant, transaction đang mở, cờ "request này là retry". Không cái nào là logic nghiệp vụ, nhưng tầng nào cũng có thể cần.

Có ba cách xử lý, hai cách đầu đều đã làm mình đau:

  • Truyền tham số: rõ ràng, nhưng chạm vào mọi chữ ký hàm — tầng không cần vẫn phải nhận để chuyển tiếp.
  • Biến module (let currentTraceId): gọn, và sai. Node chạy nhiều request đồng thời trên cùng một thread, request B ghi đè biến của request A. Bug này chỉ lộ khi có tải, đúng lúc bạn không muốn.
  • Cách thứ ba là AsyncLocalStorage.
Nhiều dòng sáng song song giữ màu riêng, không hòa vào nhau Ảnh: Nhiều dòng chảy song song, mỗi dòng giữ trọn màu của mình dù đi cùng một vùng — đúng cách Node chạy hàng trăm request trên một thread mà không trộn dữ liệu của nhau. Ngược lại là biến module dùng chung: mọi dòng sẽ thành một màu.

AsyncLocalStorage: một cái túi dính theo chuỗi async

AsyncLocalStorage (trong node:async_hooks) cho bạn một kho dữ liệu gắn với luồng thực thi thay vì gắn với hàm hay module. Bạn mở vùng bằng run(store, callback); mọi thứ được gọi từ callback đó — dù async, dù qua bao nhiêu promise — đều đọc lại được store.

import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();

function log(level, message, extra = {}) {
  const store = als.getStore() ?? {};
  console.log(JSON.stringify({
    level, message, traceId: store.traceId, userId: store.userId, ...extra,
  }));
}

function work() {
  // không nhận tham số nào, vẫn log đúng traceId
  setTimeout(() => log('info', 'xong việc'), 10);
}

als.run({ traceId: crypto.randomUUID(), userId: 42 }, () => {
  log('info', 'nhận request');
  work();
});

Hàm work() không nhận gì, log() không nhận traceId, mà log vẫn đúng. Đó là toàn bộ giá trị: context đi theo luồng, không đi theo chữ ký hàm.

Gắn vào HTTP server: mở một chỗ, cả chuỗi hưởng

Chỉ cần bọc đúng một điểm — chỗ request bắt đầu:

import http from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();

const server = http.createServer((req, res) => {
  const store = {
    traceId: req.headers['x-request-id'] ?? crypto.randomUUID(),
    route: req.url,
  };
  // run() phải bọc TOÀN BỘ phần xử lý, gọi đồng bộ ngay tại đây
  als.run(store, () => handle(req, res));
});

Chi tiết quyết định: als.run phải gọi đồng bộ ngay trong callback của server. Nếu bạn await thứ gì trước rồi mới run, phần trước đó không có context — và những nhánh async bạn tưởng đã nằm trong vùng bọc lại mất store. Với Express, một middleware là đủ; với Fastify thì dùng hook onRequest. Nguyên tắc: mở vùng sớm nhất, đóng muộn nhất.

Một người mang túi nhỏ đi nhẹ, so với núi vali chuyền tay nhau Ảnh: Một chiếc túi nhỏ đi theo người, so với cả núi vali phải chuyền tay qua từng trạm — ẩn dụ cho việc để context tự đi theo luồng xử lý thay vì nhét nó vào từng chữ ký hàm. Trong bài, chiếc túi chính là store của AsyncLocalStorage.

Vì sao chạy được: context đi theo promise, không theo thread

Ở Java hay Go, bạn quen với thread-local hoặc context.Context truyền tay. Node chỉ có một thread, hàng trăm request chen nhau trên đó, nên "local theo thread" vô nghĩa.

AsyncLocalStorage móc vào async_hooks: mỗi resource async (promise, timer, socket, fs request) có một asyncId và một triggerAsyncId. Resource mới sinh ra trong lúc đang chạy bên trong run() sẽ được ghi nhớ store đi kèm, nên store "chảy" theo cây resource — await, setTimeout, callback IO đều mang nó theo.

Chi phí không bằng 0: mỗi resource async tốn thêm chút bộ nhớ và công ghi nhận, thường chỉ vài phần trăm — đổi lại bạn bỏ được cả tá tham số và log nào cũng lần ra được.

Ba cái bẫy mình đã dính

1. Callback đăng ký trước khi run. Một EventEmitter hoặc setInterval tạo lúc khởi động, listener chạy sau nhưng thuộc cây resource cũ, nên getStore() trả undefined. Cách chữa: đăng ký listener bên trong run, hoặc bọc bằng AsyncResource.bind(fn).

2. Store là object dùng chung. Tạo store một lần rồi run lại nhiều lần với cùng object đó là mọi request ghi lên cùng một chỗ:

// sai: một object cho tất cả request
als.run(shared, () => handle(req, res));

// đúng: object mới mỗi lần, coi như chỉ đọc
als.run({ traceId, userId }, () => handle(req, res));

Cần thêm dữ liệu giữa đường thì tạo object mới từ object cũ, đừng sửa tại chỗ — store bị mutate là nguồn của những bug "sao log của request này lại có user của request kia".

3. worker_threads và child process không mang context. ALS sống trong một isolate; đẩy việc sang worker là mất store ngay, phải tự đóng gói traceId vào message. Đây cũng là lý do mình luôn giữ traceId là string phẳng: chép sang worker, sang queue, sang header đều dễ.

Thêm một điều nên biết: vài thư viện bên thứ ba dùng callback kiểu cũ đôi khi cắt đứt chuỗi async. Lúc đó getStore() mất dù code bạn đúng — kiểu mất context này khó truy nhất vì nó không báo lỗi, chỉ lặng lẽ thiếu field.

Một bình mực chung đổ ra nhiều dòng, màu lem sang nhau Ảnh: Một bình mực dùng chung đổ vào nhiều dòng, màu lem sang nhau — đúng cái bẫy số hai trong bài: store bị mutate hoặc dùng lại giữa các request thì dữ liệu request này sẽ dính sang request khác, không cách nào gỡ ra.

Khi nào nên và không nên

Nên: logging, tracing, metric, tenant, transaction id, "request này của ai". Tức là các mối quan tâm cắt ngang — hàm nào cũng cần, không hàm nào sở hữu.

Không nên: dùng ALS thay tham số ở tầng nghiệp vụ. getStore() có thể undefined, và một hàm lõi lặng lẽ đọc context ẩn là hàm không test được một mình. Luật mình giữ: tầng domain nhận tham số tường minh, ALS chỉ dùng ở tầng hạ tầng.

Một cái hay ngoài dự tính: khi mọi dòng log đều có traceId, việc đọc log lúc sự cố đổi hẳn. Trước kia phải ghép theo timestamp rồi đọc lướt; giờ chỉ grep <traceId> là ra nguyên một câu chuyện từ đầu tới cuối.

Chữ ký hàm của bạn có đang dài quá bảy tham số, trong đó hai cái là traceId và tenantId không? Có lẽ đã tới lúc bỏ chúng ra khỏi chữ ký và để luồng xử lý tự mang theo.

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
AsyncLocalStorage Lớp lưu trữ dữ liệu gắn với luồng thực thi async, đọc lại được ở bất kỳ đâu trong chuỗi
async_hooks Module lõi của Node theo dõi vòng đời resource async (promise, timer, socket)
asyncId / triggerAsyncId Mã định danh của một resource async và của resource đã sinh ra nó
Context propagation Việc mang dữ liệu context đi xuyên các tầng và các bước async
Isolate Vùng bộ nhớ JS riêng; mỗi worker_thread có isolate riêng nên ALS không chia sẻ
Cross-cutting concern Mối quan tâm rải khắp hệ thống (log, trace, auth) chứ không thuộc một tầng
Store Object dữ liệu bạn truyền vào run(), thứ mà getStore() trả về

Xem các bài viết khác