Lỗi hiệu năng và rò rỉ tài nguyên hay gặp trong Node.js Trung cấp
Thời gian đọc ước tính: 8 phút
Cần đọc trước
Trang này trả lời: vì sao API chạy tốt lúc test mà chậm hoặc OOM khi lên production. Các lỗi dưới đây đều không sai logic; chúng chỉ lộ ra khi có tải, dữ liệu lớn hoặc process chạy đủ lâu.
Số liệu đo trên máy dev với Node 24.17, PostgreSQL 17. Con số tuyệt đối sẽ khác trên máy bạn; tỉ lệ mới là điều cần nhớ.
Ba kiểu hỏng
Phần tiêu đề “Ba kiểu hỏng”Đang tải diagram…
| Nhóm | Triệu chứng | Lỗi trong trang |
|---|---|---|
| Chặn event loop | p99 của mọi route tăng cùng lúc | sync crypto/fs, ReDoS |
| Không giới hạn | Lỗi timeout pool, OOM khi dữ liệu lớn | Promise.all, đọc cả bảng, fetch không timeout |
| Không dọn dẹp | Heap tăng đều, restart thì hết | cache Map, listener theo request |
Chặn event loop
Phần tiêu đề “Chặn event loop”Hàm đồng bộ nặng trong request handler
Hay gặp
app.post('/login', (req, res) => { const hash = pbkdf2Sync(req.body.password, salt, 600_000, 64, 'sha512'); // ~100 ms // ... readFileSync, JSON.parse payload lớn, zlib.gzipSync cũng vậy res.json({ ok: timingSafeEqual(hash, stored) });});// 4 request /login đồng thời -> GET /ping mất ~365 msNên viết
import { promisify } from 'node:util';const pbkdf2Async = promisify(pbkdf2);
app.post('/login', async (req, res) => { const hash = await pbkdf2Async(req.body.password, salt, 600_000, 64, 'sha512'); res.json({ ok: timingSafeEqual(hash, stored) });});// 4 request /login đồng thời -> GET /ping mất ~5 msVì sao
JavaScript chạy trên một main thread. pbkdf2Sync giữ thread đó ~100 ms, nên mọi request khác (kể cả health check) phải xếp hàng. Bản async đẩy việc băm sang libuv threadpool, main thread rảnh để trả lời. Việc CPU thuần JS (tính toán, parse lớn) thì chuyển sang worker_threads.
Regex có lượng từ lồng nhau (ReDoS)
Hay gặp
// Validate tên hiển thị: chữ/số, cách nhau bởi khoảng trắngconst NAME_RE = /^([a-zA-Z0-9]+\s?)*$/;NAME_RE.test('a'.repeat(30) + '!'); // ~4 giây, main thread đứng im// mỗi ký tự thêm vào: thời gian ~gấp đôiNên viết
const NAME_RE = /^[a-zA-Z0-9]+(?: [a-zA-Z0-9]+)*$/;if (input.length > 100) throw new BadRequestException('Tên quá dài');NAME_RE.test('a'.repeat(100_000) + '!'); // ~0.2 msVì sao
(x+\s?)* cho phép chia chuỗi aaaa theo rất nhiều cách; khi khớp thất bại ở !, engine backtracking thử hết các cách đó. Một request 31 byte đủ treo server vài giây. Tránh lượng từ lồng nhau ((a+)+, (a|a)*), và giới hạn độ dài trước khi chạy regex.
Promise.all không giới hạn
Phần tiêu đề “Promise.all không giới hạn”map cả danh sách thành Promise.all
Hay gặp
const pool = new Pool({ max: 10, connectionTimeoutMillis: 2000 });await Promise.all(ids.map((id) => syncOrder(id))); // 2.000 id, mỗi query ~20 ms// 1111 / 2000 lỗi: "timeout exceeded when trying to connect"Nên viết
async function mapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>) { const results: R[] = new Array(items.length); let next = 0; async function worker() { while (next < items.length) { const i = next++; results[i] = await fn(items[i]); } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return results;}await mapLimit(ids, 10, syncOrder); // 2000/2000 ok, tối đa 10 query cùng lúcVì sao
ids.map(...) bắt đầu tất cả ngay lập tức. 2.000 query tranh 10 connection; query xếp hàng quá connectionTimeoutMillis thì lỗi, đồng thời request của user khác cũng không lấy được connection. Gọi API ngoài thì bị rate limit hoặc làm sập bên kia. Giới hạn concurrency bằng hàm như trên hoặc thư viện p-limit.
Rò rỉ bộ nhớ
Phần tiêu đề “Rò rỉ bộ nhớ”Cache bằng Map không giới hạn
Hay gặp
const cache = new Map<string, UserProfile>();async function getProfile(id: string) { if (!cache.has(id)) cache.set(id, await loadProfile(id)); return cache.get(id)!;}// 200.000 user khác nhau -> heap ~102 MB, và không bao giờ giảmNên viết
class LruCache<K, V> { private map = new Map<K, V>(); constructor(private max: number) {} get(k: K) { const v = this.map.get(k); if (v !== undefined) { this.map.delete(k); this.map.set(k, v); } // đưa lên mới nhất return v; } set(k: K, v: V) { this.map.delete(k); this.map.set(k, v); if (this.map.size > this.max) this.map.delete(this.map.keys().next().value!); // bỏ cũ nhất }}const cache = new LruCache<string, UserProfile>(10_000); // heap ~9 MB// Production: thêm TTL, hoặc dùng Redis nếu nhiều instance cần chung cache.Vì sao
Map module-level sống suốt đời process. Key theo user/request thì Map lớn mãi tới khi OOM, thường sau vài ngày, đúng lúc không ai deploy. Cache trong process cần giới hạn số phần tử và TTL để dữ liệu không cũ mãi.
Thêm event listener trong mỗi request
Hay gặp
app.get('/events', (req, res) => { configBus.on('config-changed', (cfg) => res.write(`data: ${JSON.stringify(cfg)}\n\n`));});// Request thứ 11: MaxListenersExceededWarning: Possible EventEmitter memory leak// detected. 11 config-changed listeners added ... MaxListeners is 10.Nên viết
app.get('/events', (req, res) => { const onChange = (cfg: Config) => res.write(`data: ${JSON.stringify(cfg)}\n\n`); configBus.on('config-changed', onChange); res.once('close', () => configBus.off('config-changed', onChange)); // listenerCount về 0});Vì sao
Listener giữ tham chiếu tới closure, tức là giữ cả req/res của request đã kết thúc. Không gỡ thì bộ nhớ tăng theo số request, và mỗi lần emit vẫn chạy closure của những request đã đóng. Warning ở ngưỡng 10 là tín hiệu leak; đừng tắt nó bằng setMaxListeners(0).
Gọi ra ngoài
Phần tiêu đề “Gọi ra ngoài”fetch không có timeout
Hay gặp
const res = await fetch(`${RATES_API}/rates`);// Bên kia nhận kết nối nhưng không trả lời: sau 3 giây vẫn pending,// request của user treo theo, connection và bộ nhớ bị giữ.Nên viết
try { const res = await fetch(`${RATES_API}/rates`, { signal: AbortSignal.timeout(1000) });} catch (err) { if (err instanceof Error && err.name === 'TimeoutError') { // "The operation was aborted due to timeout" sau ~1003 ms -> fallback / 503 } throw err;}Vì sao
fetch mặc định không có timeout theo ngân sách của bạn; demo trên vẫn pending sau 3 giây và còn chờ tiếp. Một dependency chậm làm request dồn lại, rồi cạn connection pool và bộ nhớ. Đặt timeout theo ngân sách của request, xem Timeout budget.
Dữ liệu lớn
Phần tiêu đề “Dữ liệu lớn”Đọc cả bảng vào RAM để export
Hay gặp
const { rows } = await pool.query('select * from orders order by id'); // 1 triệu dòngres.json(rows);// peak RSS ~372 MB, cộng thêm JSON.stringify đồng bộ chặn event loopNên viết
let lastId = 0;for (;;) { const { rows } = await pool.query( 'select * from orders where id > $1 order by id limit 5000', [lastId], ); if (rows.length === 0) break; for (const r of rows) res.write(toCsvLine(r)); // xử lý từng lô lastId = rows[rows.length - 1].id;}res.end();// cùng 1 triệu dòng: peak RSS ~129 MB, không tăng theo kích thước bảngVì sao
Load hết thì RAM tăng tuyến tính theo dữ liệu: hôm nay 1 triệu dòng chạy được, năm sau 10 triệu là OOM. Chia lô theo keyset (id > lastId) giữ bộ nhớ cố định. Khi ghi nhiều, tôn trọng backpressure: chờ 'drain' khi res.write trả false, xem Stream và backpressure.
Checklist nhanh
Phần tiêu đề “Checklist nhanh”- Không có
*Sync(fs, crypto, zlib) hayJSON.parsepayload lớn trong request handler. - Regex nhận input người dùng không có lượng từ lồng nhau, input bị giới hạn độ dài.
- Mọi
Promise.alltrên danh sách có độ dài thay đổi đều có giới hạn concurrency. - Cache trong process có max size và TTL.
- Listener đăng ký theo request được gỡ khi
close. - Mọi
fetch/HTTP client có timeout. - Export/batch job đọc theo lô hoặc stream, không
select *cả bảng.
Tiếp theo: quay lại Tổng quan hoặc đọc Event loop Node.js.