Giới thiệu

Trong môi trường microservices và real‑time, một API Express thường phải đối mặt với lưu lượng truy cập lên tới hàng triệu yêu cầu mỗi giây. Node.js vốn là đơn luồng, nhưng nhờ các cơ chế ClusterWorker Threads, chúng ta có thể khai thác toàn bộ sức mạnh đa nhân của server mà không phải viết lại toàn bộ kiến trúc.

Tại sao cần mở rộng

Khi một tiến trình Node.js chỉ chạy trên một lõi CPU, mọi event loop sẽ bị chiếm dụng bởi các tác vụ tính toán nặng hoặc các I/O đồng thời. Khi lượng request tăng đột biến, event loop sẽ bị nghẽn, dẫn đến thời gian phản hồi tăng và thậm chí server bị treo.

Việc phân chia tải sang nhiều tiến trình hoặc luồng giúp giảm thiểu tình trạng này, đồng thời tăng khả năng chịu lỗi vì mỗi worker có thể được khởi động lại độc lập.

Sử dụng Cluster

Cấu hình cơ bản

Module cluster cho phép tạo ra một master process và một tập hợp các worker process. Mỗi worker sẽ chạy một bản sao của ứng dụng Express, chia sẻ cùng một cổng.

const cluster = require('cluster');
const os = require('os');
const express = require('express');

if (cluster.isMaster) {
  const cpuCount = os.cpus().length;
  console.log(`Master process ${process.pid} is running`);
  for (let i = 0; i < cpuCount; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died. Forking a new worker...`);
    cluster.fork();
  });
} else {
  const app = express();
  app.get('/', (req, res) => {
    res.send('Hello from worker ' + process.pid);
  });
  app.listen(3000, () => {
    console.log(`Worker ${process.pid} started on port 3000`);
  });
}

Trong ví dụ trên, os.cpus().length quyết định số worker tối đa bằng số lõi CPU. Khi một worker gặp sự cố, master tự động tạo lại, giúp duy trì tính sẵn sàng.

Lưu ý khi dùng Cluster

  • Không nên chia sẻ trạng thái trong bộ nhớ giữa các worker; thay vào đó dùng Redis, Memcached hoặc database.
  • Hạn chế việc khởi tạo các kết nối tới DB trong master; mỗi worker nên tự tạo kết nối riêng.
  • Đối với WebSocket, cần sử dụng sticky sessions hoặc proxy (như Nginx) để duy trì kết nối trên cùng một worker.

Worker Threads

Khi nào nên dùng

Worker Threads thích hợp cho các tác vụ CPU‑intensive như xử lý ảnh, mã hoá, hoặc tính toán phức tạp mà không muốn chặn event loop. Khác với Cluster, các thread chia sẻ cùng một bộ nhớ (SharedArrayBuffer), giúp truyền dữ liệu nhanh hơn.

Ví dụ thực tế

Dưới đây là cách tạo một worker thread để tính toán một mảng lớn mà không làm treo server.

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

if (isMainThread) {
  const numbers = Array.from({ length: 1e7 }, (_, i) => i + 1);
  const worker = new Worker(__filename);
  worker.postMessage(numbers);
  worker.on('message', sum => {
    console.log('Sum:', sum);
  });
} else {
  parentPort.on('message', data => {
    const sum = data.reduce((a, b) => a + b, 0);
    parentPort.postMessage(sum);
  });
}

Trong ví dụ, tính tổng của 10 triệu số được thực hiện trong một thread riêng, cho phép event loop tiếp tục xử lý các request HTTP.

Kết hợp Cluster và Worker Threads

Một kiến trúc thực tế thường dùng Cluster để mở rộng số lượng tiến trình, đồng thời mỗi worker có thể khởi tạo một hoặc nhiều worker threads để xử lý các tác vụ nặng. Điều này tối ưu tối đa tài nguyên CPU và giảm thời gian chờ.

Ví dụ, một API nhận file ảnh, lưu vào S3 và thực hiện resize. Master tạo 4 worker, mỗi worker nhận một request, sau đó tạo một worker thread để thực hiện resize, cuối cùng trả về kết quả mà không làm nghẽn các request khác.

Kết luận

Việc áp dụng ClusterWorker Threads giúp ứng dụng Express mở rộng quy mô, giảm độ trễ và tăng độ ổn định khi đối mặt với lưu lượng cao. Khi kết hợp đúng cách, bạn sẽ có một hệ thống có khả năng xử lý hàng triệu yêu cầu đồng thời mà vẫn duy trì thời gian phản hồi dưới 100 ms.

Để nắm vững toàn bộ quy trình triển khai, Tham khảo khóa học "Lập trình Back-End với NodeJS Express" tại đây.