Giới thiệu vấn đề hiệu năng trong Node.js

Node.js nổi tiếng với khả năng xử lý bất đồng bộ, nhưng khi ứng dụng phải đối mặt với hàng triệu request đồng thời, một tiến trình duy nhất sẽ nhanh chóng trở thành nút thắt cổ chai. Việc tận dụng toàn bộ lõi CPU trên máy chủ là cách hiệu quả nhất để giảm thời gian đáp ứng và tránh hiện tượng "blocking".

Cluster: Phân chia tải cho nhiều tiến trình

Module cluster có sẵn trong Node.js cho phép khởi tạo nhiều tiến trình (worker) chia sẻ cùng một cổng. Mỗi worker chạy trên một lõi CPU riêng, do đó khả năng xử lý đồng thời tăng gấp số lượng lõi.

Cấu trúc cơ bản

Trong chế độ master, chúng ta tạo fork số lượng worker bằng số lõi CPU, sau đó lắng nghe sự kiện exit để tự động khởi động lại worker bị chết.

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
    cluster.fork();
  });
} else {
  http.createServer((req, res) => {
    // Xử lý request
    res.writeHead(200);
    res.end('Hello from worker ' + process.pid);
  }).listen(3000);
}

Nhờ cluster, một máy có 8 lõi CPU có thể đồng thời chạy 8 tiến trình Node.js, mỗi tiến trình xử lý một phần lưu lượng truy cập.

Worker Threads: Xử lý tính toán nặng trong cùng một tiến trình

Cluster thích hợp cho các tác vụ I/O, nhưng khi cần thực hiện các phép tính CPU‑intensive (ví dụ: mã hoá, xử lý ảnh), việc tạo tiến trình mới tốn thời gian khởi tạo. Module worker_threads cho phép tạo các luồng nhẹ (thread) trong cùng một tiến trình, chia sẻ bộ nhớ qua SharedArrayBuffer hoặc MessageChannel.

Ví dụ tính toán Fibonacci

const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', result => {
    console.log(`Fibonacci result: ${result}`);
  });
  worker.postMessage(40); // Gửi số n tới worker
} else {
  parentPort.on('message', n => {
    function fib(num) {
      if (num <= 1) return num;
      return fib(num - 1) + fib(num - 2);
    }
    const result = fib(n);
    parentPort.postMessage(result);
  });
}

Trong ví dụ trên, luồng phụ thực hiện tính toán nặng mà không làm gián đoạn luồng chính, giúp server vẫn đáp ứng các request I/O bình thường.

Khi nào nên kết hợp Cluster và Worker Threads?

  • Cluster dùng để mở rộng quy mô I/O‑bound, ví dụ API REST, WebSocket, hoặc static file server.
  • Worker Threads dùng cho các tác vụ CPU‑intensive trong mỗi worker, như xử lý ảnh, nén video, hoặc thực hiện thuật toán mã hoá.
  • Kết hợp cả hai cho phép mỗi tiến trình Cluster có thể tạo một hoặc nhiều Worker Thread để xử lý công việc nặng mà không làm giảm khả năng phục vụ các request khác.

Thực hành: Tối ưu một API tải file lớn

Giả sử API /download trả về một file video có kích thước lên tới vài gigabyte. Để tránh việc một tiến trình duy nhất chiếm toàn bộ băng thông và bộ nhớ, chúng ta có thể:

  1. Dùng cluster để tạo nhiều worker, mỗi worker xử lý một phần request.
  2. Trong mỗi worker, sử dụng stream để đọc file thành các chunk và truyền trực tiếp tới client.
  3. Nếu cần thực hiện chuyển đổi định dạng video (CPU‑intensive), khởi tạo một Worker Thread để thực hiện chuyển đổi mà không làm gián đoạn luồng I/O.

Việc kết hợp cluster, streamworker_threads giúp duy trì thời gian phản hồi dưới 100ms cho các request nhẹ, đồng thời vẫn có khả năng xử lý các tác vụ nặng song song.

Kết luận

Hiểu và áp dụng đúng clusterworker_threads là bước quan trọng để xây dựng hệ thống Node.js chịu tải cao, đáp ứng yêu cầu thực tế của các dự án microservices và real‑time. Khi đã nắm vững các công cụ này, bạn sẽ giảm thiểu tình trạng server "treo" và tối ưu chi phí hạ tầng.

Để nâng cao hơn nữa kiến thức và thực hành trên dự án thực tế, Tham khảo khóa học "Lập trình Back-End với NodeJS Express" tại đây.