Có lần 2 giờ sáng, alert kêu: pod API restart lần thứ ba trong đêm. Traffic không tăng, code không đổi, mà RAM cứ leo từ 180MB lên 1.4GB rồi OOM. Khi mở dashboard lên nhìn, biểu đồ không phải đường dốc — nó là bậc thang, mỗi ngày một nấc cao hơn. Kiểu hình đó gần như luôn có nghĩa: có thứ gì đó đang được giữ lại và không bao giờ được trả.
Bài này mình kể lại cách tìm ra thủ phạm: chụp heap snapshot, so sánh delta, rồi lần theo Retainers. Không cần tool thần thánh, chỉ cần node:v8 và Chrome DevTools.
Rò rỉ trong Node.js nhìn từ đâu?
Node chạy V8 nên có garbage collector. Nhưng GC chỉ dọn được thứ không còn ai tham chiếu. Rò rỉ bộ nhớ trong Node hầu như không bao giờ là lỗi của GC — nó là lỗi logic: mình vẫn còn giữ tham chiếu tới object, chỉ là mình không biết.
Hai chỉ số cần phân biệt:
heapUsed— object JS thật đang sống trong V8.RSS— tổng RAM tiến trình chiếm, gồm cả buffer, native addon, phân mảnh.
RSS cao hơn heapUsed là bình thường. Nhưng nếu heapUsed sau mỗi lần GC toàn phần vẫn cao dần đều, đó là rò rỉ thật.
import { setInterval } from 'node:timers/promises';
const mb = (n: number) => (n / 1024 / 1024).toFixed(1) + 'MB';
setInterval(60_000, () => {
const m = process.memoryUsage();
console.log(`heapUsed=${mb(m.heapUsed)} rss=${mb(m.rss)}`);
});
Khởi động kèm --expose-gc và gọi global.gc() trước khi đo, bạn sẽ thấy heap có thật sự trở về mức nền hay không.
Ba thủ phạm mình gặp nhiều nhất
1. Cache ở module scope không có giới hạn. Đây là ca kinh điển, và cũng chính là ca của mình đêm đó:
// ❌ cái Map này sống bằng tuổi tiến trình
const profileCache = new Map<string, Profile>();
export async function getProfile(userId: string): Promise<Profile> {
if (profileCache.has(userId)) return profileCache.get(userId)!;
const profile = await db.user.findUniqueOrThrow({ where: { id: userId } });
profileCache.set(userId, profile); // key theo userId từ request
return profile;
}
Điểm chết người là key đến từ input người dùng. Một endpoint bị crawl nhẹ là mỗi userId mới thêm một entry vĩnh viễn. 900k user, 900k entry, mỗi entry vài KB — đủ giết pod.
2. Listener hoặc timer gắn thêm mỗi request mà không gỡ.
// ❌ mỗi request thêm 1 listener, không ai gỡ
export function trackSlowRequests(server: Server, log: Logger) {
server.on('request', (req) => {
const t = Date.now();
req.on('close', () => log.info({ url: req.url, ms: Date.now() - t }));
});
}
req.on('close') với hàm ẩn danh thì không ai off được, và closure giữ luôn cả req. Node cảnh báo MaxListenersExceededWarning khi vượt 10 listener — nếu log có dòng đó, tìm ngay.
3. Closure giữ payload lớn lâu hơn cần. Handler nhận body 5MB, tạo một promise setTimeout 10 phút để gửi report rồi giữ nguyên cả body trong closure. Nhìn thì vô hại, nhưng mỗi request còn treo là một 5MB chưa được trả.
Chụp 3 snapshot và đọc delta
Cách mình dùng: chụp ba mốc, không phải một.
import v8 from 'node:v8';
import { writeFileSync } from 'node:fs';
declare const global: { gc?: () => void };
function snap(tag: string) {
global.gc?.(); // chạy node --expose-gc
const f = v8.writeHeapSnapshot(`/tmp/${tag}.heapsnapshot`);
console.log('wrote', f);
}
await warmUp(); // gọi vài request cho nóng
snap('01-baseline');
await chayTai(20_000); // bơm đúng loại traffic nghi ngờ
snap('02-after-load');
await choGC(30_000);
snap('03-after-gc');
Mở Chrome DevTools → Memory → Load từng file, chọn snapshot 03 rồi đổi view sang Comparison so với 01. Cột # Delta và Size Delta xếp giảm dần sẽ chỉ ra constructor nào đang phình. Trong ca của mình, Object và Profile đứng đầu, Retained Size toàn bộ gần bằng heap tăng thêm — quá rõ ràng.
Bước quan trọng nhất là Retainers: bấm vào một object nghi ngờ, xem chuỗi ai đang giữ nó. Đường giữ của mình là profileCache (Map) → internal table → Profile. Có đường dẫn đó thì không cần đoán nữa.
Vài mẹo nhỏ cho môi trường thật:
node --heapsnapshot-signal=SIGUSR2 app.js— gửiSIGUSR2là có snapshot, không cần restart để thêm cờ.node --report-on-signal --report-signal=SIGUSR1để lấy diagnostic report (native memory, libuv handles).- Snapshot nặng gấp 2-3 lần heap. Chụp trên pod prod đang phục vụ traffic thì nhớ chấp nhận rủi ro đó, hoặc chụp trên pod thứ hai nhận traffic mirror.
Sửa đúng chỗ, không phải tăng RAM
Tăng --max-old-space-size chỉ làm pod sống lâu hơn trước khi chết. Ba bản sửa tương ứng ba thủ phạm ở trên:
import { LRUCache } from 'lru-cache';
// ✅ có trần, có TTL
const profileCache = new LRUCache<string, Profile>({
max: 5_000,
ttl: 5 * 60_000,
});
// ✅ key là object thì dùng WeakMap — không giữ object sống
const metaByReq = new WeakMap<Request, Meta>();
// ✅ listener có tên, gỡ trong finally
function onClose(log: Logger, req: Request, t: number) {
return () => log.info({ url: req.url, ms: Date.now() - t });
}
export function trackSlowRequests(server: Server, log: Logger) {
const listeners = new WeakMap<Request, () => void>();
server.on('request', (req) => {
const fn = onClose(log, req, Date.now());
listeners.set(req, fn);
req.on('close', fn);
req.on('close', () => {
const f = listeners.get(req);
if (f) req.off('close', f);
});
});
}
Và chốt lại bằng test để lỗi này không quay lại: gọi endpoint 2.000 lần trong Vitest, gc(), rồi assert heapUsed delta dưới ngưỡng. Một test như vậy bắt được đúng loại bug mà coverage 90% vẫn để lọt.
it('không rò rỉ bộ nhớ sau 2000 request', async () => {
await warmUp();
const before = await heapAfterGc();
await chayTai(2_000);
const after = await heapAfterGc();
expect(after - before).toBeLessThan(8 * 1024 * 1024); // 8MB
});
Cuối cùng, export heapUsed và heapTotal lên dashboard. Rò rỉ bộ nhớ là thứ mình không thể phát hiện bằng review code, nhưng đồ thị bậc thang thì nhìn một lần là nhớ.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| heapUsed | Dung lượng object JS đang sống trong V8 heap |
| RSS | Tổng RAM tiến trình chiếm, gồm buffer và native memory |
| GC toàn phần | Lần dọn rác quét hết heap; heap sau đó là mức nền thật |
| heap snapshot | Ảnh chụp toàn bộ object trong heap kèm quan hệ tham chiếu |
| Retained Size | Tổng bộ nhớ được giải phóng nếu object đó bị xoá |
| Retainers | Chuỗi tham chiếu đang giữ object sống — chỉ đúng thủ phạm |
| WeakMap / WeakRef | Cấu trúc giữ tham chiếu yếu, không ngăn GC dọn object |
| TTL | Thời gian sống tối đa của một entry cache |
| SIGUSR2 | Tín hiệu dùng để yêu cầu Node ghi heap snapshot khi đang chạy |
| diagnostic report | Báo cáo của Node về heap, native memory, libuv handles |
Nếu bạn từng phải debug một ca tương tự, comment lại cách bạn tìm ra — nhất là mấy ca rò rỉ nằm trong closure hoặc trong thư viện bên thứ ba, mấy ca đó khó hơn nhiều so với một cái Map quên giới hạn.
