Săn memory leak Node.js trên production — Từ hoảng loạn đến root cause
4 giờ sáng, PagerDuty kêu. API server restart lần thứ 3 trong ngày. Grafana cho thấy RSS memory tăng đều từ 200MB lên 1.5GB trong khoảng 18 tiếng, rồi OOM killer xử lý. PM2 restart, memory reset về 200MB, rồi lại leo dần. Pattern kinh điển của memory leak.
Bài này chia sẻ workflow tôi dùng để debug memory leak trên production Node.js — không phải toy example, mà là server đang serve 2000 req/s.
Bước 1: Xác nhận đúng là memory leak
Không phải mọi trường hợp memory tăng đều là leak. Node.js V8 garbage collector không chạy liên tục — nó chờ đến khi cần. Memory có thể tăng rồi giảm theo chu kỳ GC, đó là hành vi bình thường.
Dấu hiệu thật sự là leak:
- Memory tăng đều (monotonic increase) qua nhiều GC cycles
- Không bao giờ giảm về baseline dù traffic giảm
- Cuối cùng crash với OOM
Confirm bằng cách expose GC metrics:
// Theo dõi heap used sau mỗi GC cycle
const v8 = require('v8');
setInterval(() => {
const heap = v8.getHeapStatistics();
console.log(JSON.stringify({
type: 'heap_stats',
heap_used_mb: Math.round(heap.used_heap_size / 1024 / 1024),
heap_total_mb: Math.round(heap.total_heap_size / 1024 / 1024),
external_mb: Math.round(heap.external_memory / 1024 / 1024),
timestamp: Date.now(),
}));
}, 60_000);Nếu heap_used_mb tăng liên tục qua hàng giờ dù traffic ổn định — bạn có memory leak.
💡 Phân biệt heap growth vs RSS growth. Heap do V8 quản lý (JS objects). RSS bao gồm cả native memory (Buffers, C++ addons). Leak có thể ở cả hai.
Bước 2: Thu thập heap snapshot trên production
Đây là bước nhiều người ngại vì sợ ảnh hưởng performance. Thực tế: heap snapshot sẽ pause event loop — nhưng có cách giảm thiểu.
Cách an toàn: Signal-based snapshot
// Thêm vào entry point của server
process.on('SIGUSR2', () => {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
require('v8').writeHeapSnapshot(filename);
console.log(`Heap snapshot written to ${filename}`);
});Khi cần snapshot:
# Tìm PID
pgrep -f "node server.js"
# Gửi signal
kill -USR2 <pid>Strategy: 2 snapshots cách nhau
Thu thập 2 snapshots cách nhau khoảng thời gian memory tăng đáng kể (ví dụ 2-4 tiếng):
# Snapshot 1: khi server mới start được 1 giờ (baseline)
kill -USR2 <pid>
# → /tmp/heap-1711900000000.heapsnapshot
# Đợi 3 tiếng...
# Snapshot 2: khi memory đã tăng đáng kể
kill -USR2 <pid>
# → /tmp/heap-1711910800000.heapsnapshot⚠️ Heap snapshot size tỷ lệ với heap size. Heap 500MB → snapshot file khoảng 500MB-1GB. Đảm bảo
/tmpcó đủ dung lượng, và event loop pause có thể kéo dài 2-10 giây tuỳ heap size. Nếu server đang serve traffic, hãy drain 1 instance khỏi load balancer trước khi snapshot.
Bước 3: Phân tích heap snapshot
Chrome DevTools
Cách truyền thống và vẫn hiệu quả nhất:
- Mở Chrome →
chrome://inspect→ Open dedicated DevTools for Node.js - Memory tab → Load snapshot file
- Chọn Comparison view, compare snapshot 2 với snapshot 1
Comparison view hiển thị:
- # New: objects mới được allocate giữa 2 snapshots
- # Deleted: objects đã được GC
- # Delta: = New - Deleted. Đây là con số quan trọng nhất
- Size Delta: memory tăng thêm
Sort theo Size Delta giảm dần. Top entries thường chỉ thẳng vào root cause.
Trường hợp của tôi: Closures giữ reference
Comparison view cho thấy:
(closure)delta: +45,000 objects, +12MB(string)delta: +120,000 objects, +35MBIncomingMessagedelta: +2,000 objects, +8MB
Click vào (closure) → Retainers tab → thấy chuỗi reference:
(closure) → requestHandler → cache (Map) → [entries]Một Map object đang giữ reference đến tất cả closures. Tìm trong code:
// middleware/cache.js — THỦ PHẠM
const responseCache = new Map();
function cacheMiddleware(req, res, next) {
const key = req.url + JSON.stringify(req.query);
if (responseCache.has(key)) {
return res.json(responseCache.get(key));
}
// Override res.json để cache response
const originalJson = res.json.bind(res);
res.json = (data) => {
responseCache.set(key, data); // ← LEAK: Map chỉ set, không bao giờ delete
originalJson(data);
};
next();
}Map cache không có giới hạn size, không có TTL, không có eviction. Mỗi unique URL + query combination tạo một entry mới, và không bao giờ bị xoá. Với 2000 req/s và URL combinations đa dạng — map phình lên vô tận.
Bước 4: Fix và verify
Fix ngay: Bounded cache với TTL
// middleware/cache.js — SAU KHI FIX
const CACHE_MAX_SIZE = 1000;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 phút
const responseCache = new Map();
function cacheMiddleware(req, res, next) {
const key = req.url;
const cached = responseCache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return res.json(cached.data);
}
const originalJson = res.json.bind(res);
res.json = (data) => {
// Evict oldest entries nếu vượt giới hạn
if (responseCache.size >= CACHE_MAX_SIZE) {
const oldestKey = responseCache.keys().next().value;
responseCache.delete(oldestKey);
}
responseCache.set(key, { data, timestamp: Date.now() });
originalJson(data);
};
next();
}Fix tốt hơn: Dùng LRU Cache
import { LRUCache } from 'lru-cache';
const responseCache = new LRUCache({
max: 1000, // max entries
ttl: 5 * 60 * 1000, // 5 phút TTL
maxSize: 50_000_000, // 50MB max total size
sizeCalculation: (value) => JSON.stringify(value).length,
});💡 Quy tắc vàng: mọi in-memory cache đều phải có upper bound. Không có ngoại lệ.
MapvàSetkhông có giới hạn built-in — nếu bạn dùng chúng làm cache, bạn phải tự implement eviction.
Các pattern leak phổ biến khác
1. Event listener không được remove
// LEAK: listener tích lũy mỗi request
function handleRequest(req, res) {
const onData = (chunk) => processChunk(chunk);
externalStream.on('data', onData);
// Quên remove listener khi request kết thúc
}
// FIX
function handleRequest(req, res) {
const onData = (chunk) => processChunk(chunk);
externalStream.on('data', onData);
res.on('finish', () => {
externalStream.removeListener('data', onData);
});
}Node.js cảnh báo khi EventEmitter có >10 listeners (mặc định). Nếu bạn thấy warning MaxListenersExceededWarning — đừng tăng limit, hãy fix leak.
2. Global arrays/objects tích lũy
// LEAK: log buffer không bao giờ flush
const logBuffer = [];
function log(message) {
logBuffer.push({
message,
timestamp: Date.now(),
stack: new Error().stack, // Stack trace nặng
});
// "Sẽ flush sau" — không bao giờ flush
}3. Closures giữ reference không cần thiết
// LEAK: closure giữ reference đến toàn bộ `largeData`
function processRequest(req) {
const largeData = loadLargeDataset(); // 10MB
return {
getSummary() {
// Chỉ dùng largeData.length nhưng closure giữ cả object
return { count: largeData.length };
}
};
}
// FIX: extract giá trị cần thiết trước khi tạo closure
function processRequest(req) {
const largeData = loadLargeDataset();
const count = largeData.length; // Extract ra
return {
getSummary() {
return { count }; // Closure chỉ giữ số, không giữ cả dataset
}
};
}4. Timer không được clear
// LEAK: setInterval trong connection handler
function onConnection(socket) {
const heartbeat = setInterval(() => {
socket.ping();
}, 30_000);
// Nếu socket disconnect mà không trigger 'close' event
// → interval chạy mãi, giữ reference đến socket
socket.on('close', () => {
clearInterval(heartbeat); // ← BẮT BUỘC
});
}Tools hữu ích
clinicjs — profiling suite cho Node.js
# Detect memory issues
npx clinic doctor -- node server.js
# Chạy traffic vào server, rồi Ctrl+C
# → Tự động generate HTML report phân tích
# Heap profiling chi tiết
npx clinic heapprofiler -- node server.js--inspect flag trên production
# Mở debugger port (chỉ listen localhost)
node --inspect=127.0.0.1:9229 server.jsRồi SSH tunnel từ local:
ssh -L 9229:127.0.0.1:9229 user@production-serverMở Chrome DevTools, connect đến localhost:9229. Bạn có thể take heap snapshot, record allocation timeline, và inspect objects — trực tiếp trên production process.
⚠️ Không bao giờ expose debug port ra public interface.
--inspect=0.0.0.0:9229trên production = remote code execution vulnerability.
process.memoryUsage() cho monitoring
// Expose qua health check endpoint
app.get('/health', (req, res) => {
const mem = process.memoryUsage();
res.json({
rss_mb: Math.round(mem.rss / 1024 / 1024),
heap_used_mb: Math.round(mem.heapUsed / 1024 / 1024),
heap_total_mb: Math.round(mem.heapTotal / 1024 / 1024),
external_mb: Math.round(mem.external / 1024 / 1024),
array_buffers_mb: Math.round(mem.arrayBuffers / 1024 / 1024),
uptime_hours: Math.round(process.uptime() / 3600 * 10) / 10,
});
});Scrape endpoint này bằng Prometheus → Grafana dashboard → alert khi heap_used tăng quá threshold.
Checklist phòng ngừa
- Mọi
Map/Setdùng làm cache → phải có max size + TTL - Mọi
addEventListener/.on()→ phải có correspondingremoveListener - Mọi
setInterval/setTimeout→ phải clear khi không cần - Closures trong long-lived objects → kiểm tra reference đến large objects
- Monitor
process.memoryUsage()liên tục, alert sớm trước khi OOM - Heap snapshot script sẵn sàng trên mọi production server
Memory leak trên production không phải lúc nào cũng drama. Đa số trường hợp là một Map quên eviction, một listener quên remove, một closure giữ reference thừa. Workflow cố định: confirm leak → snapshot → compare → tìm retainer chain → fix → verify. Không có phép màu, chỉ có kỷ luật.