Thử đoán xem chuyện gì đang xảy ra: một endpoint chỉ mất 40ms khi test một mình. Đẩy lên production, dưới 200 request đồng thời, nó thành 2 giây. Code không đổi, database không nghẽn, CPU chỉ chiếm đúng một core. Thủ phạm thường là event loop bị chặn — và trớ trêu là người chặn nó lại chính là chữ async mà ai cũng nghĩ là bất đồng bộ.
Event loop không chạy song song, nó chạy vòng
Node dùng một main thread cho JavaScript. Event loop đi qua các phase theo vòng lặp: timers → pending callbacks → poll → check → close callbacks. Mỗi phase có hàng đợi callback riêng, và trong một phase thì callback chạy nối tiếp nhau, xong hết mới sang phase sau.
Hệ quả rất trực tiếp: bất kỳ đoạn code đồng bộ nào chạy trên main thread cũng làm cả hàng đợi đứng yên. Không request nào được xử lý, timer không kịp bắn, health check cũng timeout theo.
Một phần việc nặng được đẩy xuống libuv threadpool (mặc định 4 thread) — fs, dns.lookup, crypto bản async, zlib. Nhưng threadpool chỉ giúp phần I/O. Phần tính toán trong JS thì không có ai giúp cả.
await chỉ nhường quyền đúng ở điểm chờ I/O
// ❌ trông có vẻ bất đồng bộ, nhưng vẫn chặn server
app.get('/export', async (req, res) => {
const rows = await db.query('SELECT * FROM orders'); // nhường quyền ở đây
const csv = rows.map(toCsvLine).join('\n'); // 800ms đồng bộ, cả server đứng
res.type('text/csv').send(csv);
});
await chỉ tạm dừng hàm tại đúng chỗ chờ I/O. Khi db.query trả về, phần còn lại chạy thẳng một mạch trên main thread. Với 200.000 dòng, map cộng join ngốn gần một giây, và suốt thời gian đó mọi request khác nằm chờ trong hàng đợi.
Muốn chia nhỏ việc thì phải tự nhường quyền giữa các lô:
async function toCsvInChunks(rows) {
const parts = [];
for (let i = 0; i < rows.length; i += 1000) {
parts.push(rows.slice(i, i + 1000).map(toCsvLine).join('\n'));
await new Promise((resolve) => setImmediate(resolve)); // trả main thread về cho event loop
}
return parts.join('\n');
}
Những thứ chặn event loop mà ít ai để ý
fs.readFileSynchoặcJSON.parsefile vài chục MB — 200 đến 400ms mỗi lầncrypto.pbkdf2Sync,bcrypt.hashSyncnằm ngay trong luồng loginzlib.gzipSynckhi nén response lớn- Regex backtracking: pattern kiểu
/^(\w+\s?)+$/gặp input xấu là treo hàng chục giây console.logobject lớn trong vòng lặp, hoặcJSON.stringifyđể ghi log- Vòng
forduyệt một triệu phần tử ngay trong handler
Điểm chung: tất cả đều là code đồng bộ, và không có await nào cứu được.
Đo trước khi đoán
Node có sẵn thước đo, không cần cài gì:
const { monitorEventLoopDelay } = require('node:perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log('event loop lag p99:', (h.percentile(99) / 1e6).toFixed(1), 'ms');
h.reset();
}, 5000).unref();
Ngưỡng tham khảo mình hay dùng: p99 lag dưới 20ms là khỏe, 50 đến 100ms bắt đầu có vấn đề, trên 200ms thì chắc chắn có code đồng bộ nặng. Đẩy con số này lên Prometheus rồi vẽ chart, lần sau không phải ngồi đoán nữa.
Với tải thật thì dùng công cụ bắn request, đừng curl một phát rồi kết luận:
npx autocannon -c 100 -d 20 http://localhost:3000/export
Sửa theo thứ tự này
- Thay bản sync bằng bản async —
fs.promises.readFile,crypto.pbkdf2,zlib.gzip. Cùng kết quả, không chặn main thread. - Chia lô và nhường quyền như đoạn
setImmediatephía trên — cách rẻ nhất, hiệu quả rõ nhất với việc xử lý danh sách. - Việc thật sự nặng thì đẩy sang worker thread — nén ảnh, tính hash, xử lý tài liệu:
const { Worker } = require('node:worker_threads');
function runInWorker(file, payload) {
return new Promise((resolve, reject) => {
const w = new Worker(file, { workerData: payload });
w.once('message', resolve);
w.once('error', reject);
});
}
- Stream thay vì nạp hết vào RAM khi xử lý file lớn — đi theo luồng luôn nhẹ hơn đọc một cục.
Vài chỗ mình từng vấp
- Log quá tay. Có lần service chậm cả ngày, cuối cùng chỉ vì
console.log(req.body)cho mọi request upload vài MB. Xoá dòng log là xong. - Tưởng scale ngang là đủ. Thêm 8 instance, mỗi instance vẫn đứng 800ms trong lúc nén. Scale ngang che triệu chứng ở mức trung bình, nhưng p99 vẫn xấu và người dùng vẫn thấy khó chịu.
- Nhìn avg thay vì p99. Trung bình đẹp long lanh mà p99 tệ chính là dấu hiệu kinh điển của event loop bị chặn. Xem p99 trước.
- Tăng
UV_THREADPOOL_SIZEnhư một cách chữa. Nó chỉ có tác dụng khi nghẽn thật sự nằm ở threadpool, còn code JS đồng bộ thì tăng bao nhiêu cũng vô ích.
Chốt lại
Event loop không chậm. Nó chỉ chờ ai đó viết xong code đồng bộ. Bất đồng bộ trong Node không nằm ở chữ async gõ ra, mà nằm ở việc có nhường main thread hay không.
Lần tới thấy API chậm bất thường, thử bật monitorEventLoopDelay trước khi lao vào soi database. Bạn từng bị event loop chặn vì thứ gì?
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| Event loop | Vòng lặp của Node lấy callback từ hàng đợi và chạy trên main thread |
| Phase | Từng chặng trong một vòng loop: timers, poll, check, close callbacks |
| libuv threadpool | Nhóm thread phụ lo I/O file, DNS, crypto, zlib (mặc định 4 thread) |
setImmediate |
Đẩy callback sang phase check, nhường main thread cho việc khác |
monitorEventLoopDelay |
API trong node:perf_hooks đo độ trễ của event loop theo percentile |
| Event loop lag | Khoảng thời gian callback phải chờ lâu hơn dự kiến — chỉ số nghẽn chính |
| Worker thread | Luồng JS riêng, dùng cho việc tính toán nặng mà không chặn main thread |
| p99 | 99% request nhanh hơn mốc này; phản ánh trải nghiệm tệ nhất tốt hơn số trung bình |