Bối cảnh & Tại sao: Kẻ sát nhân thầm lặng trong ứng dụng Node.js
Một máy chủ production gặp sự cố lúc 3 giờ sáng là cơn ác mộng tồi tệ nhất. Thông thường, logs không cung cấp nhiều thông tin—chỉ là lỗi ‘JavaScript heap out of memory’ đầy bí ẩn. Mặc dù Node.js quản lý bộ nhớ thông qua Garbage Collection (GC), nhưng nó không phải là phép màu. Những đối tượng không còn cần thiết nhưng vẫn được code của bạn tham chiếu sẽ nằm lại trong heap mãi mãi. Chúng từ từ bóp nghẹt tiến trình cho đến khi nó dừng hoạt động.
Tôi đã dành quá nhiều đêm để nhìn vào các biểu đồ CloudWatch trông như những bậc thang không hồi kết. Mức sử dụng bộ nhớ tăng lên, duy trì ở mức cao và không bao giờ quay lại mức cơ sở ban đầu. Những vụ rò rỉ này thường bắt nguồn từ các event listener bị bỏ quên, biến toàn cục (global variables) hoặc closures giữ chặt các cấu trúc dữ liệu nặng. Phát hiện rò rỉ thì dễ; thách thức thực sự là tìm ra chính xác dòng code gây ra vấn đề. Đó là lúc Chrome DevTools và Heap Snapshots trở thành cứu cánh.
Sau khi áp dụng quy trình này cho một dịch vụ xử lý 5.000 request mỗi giây, chúng tôi đã ổn định bộ nhớ ở mức 120MB. Không còn những lần khởi động lại khẩn cấp sau mỗi 6 giờ. Chỉ còn hiệu năng sạch sẽ và ổn định.
Cài đặt: Chuẩn bị môi trường Debug
Bạn không cần các công cụ giám sát SaaS đắt tiền để tìm lỗi rò rỉ. Mọi thứ cần thiết đã được tích hợp sẵn trong Node.js và trình duyệt của bạn. Hãy cùng xây dựng một ứng dụng nhỏ được cố tình tạo lỗi để thấy quy trình thực tế.
Đầu tiên, hãy tạo một thư mục dự án mới và cài đặt Express:
mkdir node-leak-hunt
cd node-leak-hunt
npm init -y
npm install express
Tiếp theo, chúng ta sẽ tạo file app.js. Chúng ta sẽ mô phỏng một sai lầm phổ biến: lưu trữ metadata của người dùng trong một mảng toàn cục mà không có chiến lược dọn dẹp. Trong một ứng dụng thực tế, điều này có thể xảy ra nếu bạn cố gắng xây dựng một ‘session store’ tùy chỉnh trong bộ nhớ thay vì sử dụng Redis.
const express = require('express');
const app = express();
const leakyData = [];
app.get('/user', (req, res) => {
// Mỗi request sẽ thêm một mảng 10.000 phần tử vào bộ nhớ toàn cục
const userRequest = {
id: Date.now(),
metadata: new Array(10000).fill('leak-data-segment'),
timestamp: new Date()
};
leakyData.push(userRequest);
res.send(`Người dùng đã được xử lý. Kích thước cache: ${leakyData.length}`);
});
app.listen(3000, () => {
console.log('Server đang chạy tại http://localhost:3000');
});
Trong kịch bản này, Garbage Collector thấy rằng leakyData là một biến toàn cục và giả định rằng bạn có thể cần dữ liệu đó sau này. Nó sẽ không chạm vào các đối tượng đó. Mỗi lần truy cập vào `/user` sẽ thêm khoảng 80KB vào heap của bạn.
Cấu hình: Kết nối Chrome DevTools với Node.js
Để quan sát bên trong engine, chúng ta cần chạy Node với flag inspector. Điều này sẽ mở một WebSocket mà DevTools có thể kết nối vào.
Khởi chạy ứng dụng của bạn bằng lệnh sau:
node --inspect app.js
Nếu bạn đang debug một lỗi rò rỉ xảy ra ngay khi khởi động, hãy sử dụng --inspect-brk để tạm dừng code tại dòng đầu tiên. Đối với server đang chạy của chúng ta, flag tiêu chuẩn là hoàn hảo.
Mở Google Chrome và truy cập địa chỉ:
chrome://inspect
Tiến trình Node.js của bạn sẽ xuất hiện dưới mục ‘Remote Target’. Nhấp vào **’inspect’**. Một cửa sổ DevTools riêng biệt sẽ mở ra. Đây không phải dành cho frontend; nó là đường dây kết nối trực tiếp đến backend V8 engine của bạn. Hãy chuyển thẳng đến tab **Memory** và chọn **Heap Snapshot**.
Xác minh & Giám sát: Kỹ thuật Three-Snapshot
So sánh các bản snapshot là cách đáng tin cậy nhất để loại bỏ nhiễu. Chúng ta muốn xem những gì đã được tạo ra *trong* một khoảng thời gian cụ thể và không bao giờ bị xóa.
Bước 1: Thiết lập mức cơ sở (Baseline)
Chụp một bản snapshot ngay sau khi ứng dụng khởi động. Đây là trạng thái ‘sạch’ của bạn. Với một ứng dụng Express mới, dung lượng này có thể khoảng 15MB đến 30MB.
Bước 2: Kiểm tra áp lực (Stress Test)
Kích hoạt lỗi rò rỉ. Bạn có thể làm mới trình duyệt theo cách thủ công, nhưng một vòng lặp nhanh trong terminal sẽ hiệu quả hơn để tạo ra sự tăng trưởng rõ rệt. Chạy lệnh này để gọi endpoint 100 lần:
for i in {1..100}; do curl http://localhost:3000/user; done
Bước 3: So sánh
Chụp thêm hai bản snapshot nữa, đợi vài giây giữa các lần chụp để GC có thời gian hoạt động. Bây giờ, hãy chuyển chế độ xem từ ‘Summary’ sang **’Comparison’** và chọn Snapshot 1 làm mức cơ sở. Tìm kiếm các đối tượng có chỉ số ‘New’ cao nhưng ‘Deleted’ bằng không.
Bạn sẽ thấy một sự gia tăng đột biến trong các kiểu `(closure)` hoặc `Object`. Khi mở rộng các mục này, hãy kiểm tra bảng **Retainers** ở phía dưới. Nó sẽ chỉ trực tiếp đến mảng `leakyData` trong file `app.js`. Đây chính là bằng chứng xác thực nhất.
Cách khắc phục: Ngắt tham chiếu
Khắc phục memory leak thường có nghĩa là chuyển dữ liệu ra khỏi phạm vi toàn cục hoặc sử dụng một cấu trúc dữ liệu có giới hạn. Nếu bạn bắt buộc phải cache trong bộ nhớ, hãy sử dụng cache LRU (Least Recently Used) với một giới hạn cứng.
// Cách tiếp cận an toàn hơn sử dụng Map và giới hạn kích thước
const cache = new Map();
const MAX_ENTRIES = 500;
app.get('/user', (req, res) => {
const id = Date.now();
cache.set(id, { id, data: '...' });
if (cache.size > MAX_ENTRIES) {
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
}
res.send('Đã xử lý với giới hạn an toàn');
});
Các chỉ số quan trọng
- Shallow Size: Kích thước của chính đối tượng đó (thường nhỏ).
- Retained Size: Chi phí ‘thực tế’. Đây là bộ nhớ sẽ được giải phóng nếu đối tượng và các phần tử con của nó bị xóa.
- Distance: Số bước nhảy từ đối tượng đến root. Khoảng cách 1 hoặc 2 thường ám chỉ một biến toàn cục hoặc một hằng số cấp module.
Đừng đợi đến khi server sập mới kiểm tra sức khỏe hệ thống. Tôi khuyên bạn nên log `process.memoryUsage().heapUsed` mỗi 60 giây trong môi trường staging. Nếu con số đó không bao giờ quay lại mức cơ sở sau khi test tải (load test), bạn đang gặp lỗi rò rỉ. Tìm thấy nó bây giờ chỉ mất 10 phút; tìm thấy nó khi hệ thống production đang gặp sự cố có thể mất tới 10 giờ.

