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

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

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ớ.

Đ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

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 ms

Nê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 ms

Vì 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ắng
const 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 đôi

Nê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 ms

Vì 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.

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úc

Vì 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.

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ảm

Nê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).

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.

Đọ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òng
res.json(rows);
// peak RSS ~372 MB, cộng thêm JSON.stringify đồng bộ chặn event loop

Nê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ảng

Vì 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.

  • Không có *Sync (fs, crypto, zlib) hay JSON.parse payload 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.all trê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.