Lỗi Gặp Phải
Error: EMFILE: too many open files, open '/var/app/uploads/image.png'
at Object.openSync (node:fs:596:3)
at Object.readFileSync (node:fs:464:35)
Tiến trình của bạn bị chết giữa chừng khi xử lý hàng loạt. Không có cảnh báo, không có quá trình tắt an toàn — chỉ là crash, thường xảy ra khi đọc file upload, resize ảnh, hoặc tạo các tiến trình con song song.
Nguyên Nhân
Mỗi hệ điều hành giới hạn số lượng file descriptor mà một tiến trình có thể mở cùng lúc. Linux mặc định là 1024. macOS còn nghiêm ngặt hơn với con số 256.
Node.js được thiết kế theo kiểu non-blocking. Nếu bạn duyệt qua mảng 5000 file và gọi fs.readFile cho từng file mà không giới hạn, Node sẽ khởi chạy gần như đồng thời cả 5000 thao tác. Hệ điều hành sẽ từ chối lệnh mở thứ 1025 với lỗi EMFILE.
Có hai vấn đề riêng biệt có thể gây ra lỗi này:
- Giới hạn của OS quá thấp so với khối lượng công việc
- Code của bạn mở file nhanh hơn tốc độ đóng — không kiểm soát concurrency
Cách Sửa 1: Tăng Giới Hạn File Descriptor (ulimit)
Bắt đầu bằng cách kiểm tra giới hạn hiện tại:
ulimit -n # soft limit
ulimit -Hn # hard limit
Tăng soft limit cho phiên shell hiện tại:
ulimit -n 65536
Để giữ cài đặt sau khi reboot, chỉnh sửa /etc/security/limits.conf trên Linux:
# /etc/security/limits.conf
* soft nofile 65536
* hard nofile 65536
Đang chạy systemd service? Thêm dòng này vào phần [Service] trong unit file:
[Service]
LimitNOFILE=65536
Reload và restart:
sudo systemctl daemon-reload
sudo systemctl restart your-app
Trên macOS, cách thiết lập vĩnh viễn tương đương là:
sudo launchctl limit maxfiles 65536 200000
Xác minh tiến trình đã nhận giới hạn mới
# Lấy PID của tiến trình Node
pgrep -f 'node'
# Kiểm tra giới hạn fd thực tế
cat /proc/<PID>/limits | grep 'open files'
Cách Sửa 2: Dùng graceful-fs (Thay Thế Trực Tiếp)
graceful-fs vá module fs tích hợp của Node để xếp hàng các thao tác khi gặp lỗi EMFILE, thay vì crash. Không cần cấu hình thêm gì.
npm install graceful-fs
Chỉ cần thay đổi phần import, mọi thứ còn lại giữ nguyên:
// Trước
const fs = require('fs');
// Sau
const fs = require('graceful-fs');
Cú pháp ES module:
import { readFile, writeFile } from 'graceful-fs';
Bên trong, graceful-fs thử lại các lỗi EMFILE với cơ chế exponential backoff. Webpack, npm và Gulp đều đã dùng nó vì lý do này — đã được kiểm chứng qua các pipeline xử lý file lớn.
Cách Sửa 3: Giới Hạn Concurrency Trong Code
Tăng giới hạn và vá fs chỉ giải quyết triệu chứng. Cách sửa tận gốc là kiểm soát số lượng file mở cùng lúc.
Pattern sai — mở tất cả file cùng một lúc
// Với 5000 đường dẫn, đoạn code này gọi 5000 lần readFile cùng lúc
const contents = await Promise.all(
filePaths.map(p => fs.promises.readFile(p))
);
Phương án A: Dùng p-limit để giới hạn concurrency
npm install p-limit
import pLimit from 'p-limit';
import { readFile } from 'fs/promises';
const limit = pLimit(20); // tối đa 20 file mở cùng lúc
const contents = await Promise.all(
filePaths.map(p => limit(() => readFile(p)))
);
Hai mươi lần đọc đồng thời thường là ngưỡng an toàn trên hầu hết các máy Linux mà không cần điều chỉnh thêm. Hãy điều chỉnh dựa trên khối lượng công việc và kích thước file trung bình của bạn.
Phương án B: Xử lý file theo từng batch tuần tự
import { readFile } from 'fs/promises';
async function readInBatches(paths, batchSize = 50) {
const results = [];
for (let i = 0; i < paths.length; i += batchSize) {
const batch = paths.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(p => readFile(p)));
results.push(...batchResults);
}
return results;
}
Phương án C: Stream file lớn thay vì tải vào bộ nhớ
import { createReadStream, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
async function processFile(inputPath, outputPath) {
const source = createReadStream(inputPath);
const dest = createWriteStream(outputPath);
await pipeline(source, dest);
// fd được giải phóng tự động khi stream kết thúc
}
Stream giải phóng file descriptor ngay khi xử lý xong. Với các pipeline xử lý file lớn — chẳng hạn xử lý video hay chuyển đổi ảnh hàng loạt — đây hầu như luôn là mô hình phù hợp nhất.
Cách Sửa 4: Tìm Và Vá Rò Rỉ File Descriptor
Số lượng fd tăng dần theo thời gian là dấu hiệu của rò rỉ: file được mở nhưng không bao giờ đóng. Kiểm tra tiến trình đang chạy:
# Đếm số fd đang mở
ls /proc/<PID>/fd | wc -l
# Xem chính xác những gì đang mở
ls -la /proc/<PID>/fd
Nếu con số đó tăng theo thời gian mà không giảm, bạn đang bị rò rỉ. Các nguyên nhân phổ biến:
- Gọi
fs.open()mà không cófs.close()tương ứng - Promise rejection không được xử lý khiến bước cleanup bị bỏ qua
- Tiến trình con được tạo ra nhưng không bao giờ được await
Luôn đóng file trong khối finally để lỗi không thể bỏ qua bước cleanup:
const fd = fs.openSync(path, 'r');
try {
// ... xử lý với fd
} finally {
fs.closeSync(fd);
}
Cách Tiếp Cận Được Khuyến Nghị
Với các ứng dụng Node.js production xử lý file hàng loạt, hãy áp dụng cả ba lớp — mỗi lớp sẽ bắt những gì lớp kia bỏ sót:
- Đặt
LimitNOFILE=65536trong systemd unit (cấu hình một lần, tồn tại qua các lần deploy) - Cài
graceful-fsnhư một lớp bảo vệ an toàn khi có spike - Dùng
p-limithoặc batch processing để giữ concurrency ở mức dự đoán được
Với khối lượng công việc nhỏ, có thể chỉ cần một trong số này là đủ. Nhưng với tải production thực tế — chẳng hạn hơn 10.000 file mỗi lần chạy — bạn nên có cả ba lớp trước khi sự cố xảy ra, chứ không phải sau.
Kiểm Tra Kết Quả
Sau khi áp dụng các bản sửa, theo dõi số lượng fd trong khi workload đang chạy:
# Đếm fd theo thời gian thực, cập nhật mỗi giây
watch -n 1 'ls /proc/$(pgrep -f node)/fd | wc -l'
Một tiến trình hoạt động bình thường sẽ giữ ổn định — con số dao động nhưng không tăng mãi. Nếu nó ổn định và ứng dụng ngừng crash, bạn đã xử lý xong.

