Sửa lỗi 'EMFILE: too many open files' trong Node.js khi xử lý file đồng thời

intermediate💚 Node.js2026-03-24| Node.js (tất cả phiên bản), Linux / macOS, thường xảy ra khi xử lý hàng loạt file, pipeline ảnh, hoặc upload số lượng lớn

Error Message

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)
#nodejs#emfile#file-descriptor#ulimit#graceful-fs

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=65536 trong systemd unit (cấu hình một lần, tồn tại qua các lần deploy)
  • Cài graceful-fs như một lớp bảo vệ an toàn khi có spike
  • Dùng p-limit hoặ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.

Related Error Notes