Skip to content

Scale Node.js ​

Một process Node.js chạy JavaScript của ứng dụng trên một thread, nên chỉ dùng được một core cho phần đó (Process, thread). Muốn dùng hết các core của một máy, hay chạy trên nhiều máy, phải chạy nhiều process. Trang này giải thích các cách chạy nhiều process, điều kiện để làm được việc đó (ứng dụng phải stateless), cách xử lý WebSocket khi có nhiều instance, và health check.

Chạy nhiều process ​

CáchLà gìHợp khi
Module clusterMột process primary tạo nhiều worker trên cùng máy, chia connection cho chúngChạy trên VM hay máy riêng, muốn dùng hết core mà không cần công cụ ngoài
PM2Trình quản lý process: chạy chế độ cluster, tự khởi động lại khi chết, reload từng workerVM, server truyền thống
Nhiều container sau load balancerMỗi container một process, Kubernetes hay ECS chạy nhiều bản, load balancer chia requestHạ tầng container: đây là cách phổ biến nhất hiện nay

Với Kubernetes, thường chạy một process mỗi container và để nền tảng lo việc nhân bản, khởi động lại, rolling deploy. Chạy cluster bên trong container nữa chỉ làm việc giới hạn CPU và theo dõi bộ nhớ khó hơn, trừ khi có lý do cụ thể như container lớn nhiều core.

cluster chia connection thế nào ​

Theo tài liệu Node, mặc định trên mọi hệ điều hành trừ Windows, process primary lắng nghe cổng, nhận connection mới, rồi chia lần lượt cho các worker (round-robin). Cách thứ hai (worker tự nhận connection từ một socket dùng chung) về lý thuyết nhanh hơn, nhưng thực tế bị lệch rất nhiều do scheduler của hệ điều hành, có lúc hơn 70% connection dồn vào hai process.

Đơn vị được chia là connection, không phải request. Mọi request trên cùng một connection keep-alive đều tới cùng một worker. Trên lab, với một endpoint tốn 2 ms CPU mỗi request, throughput tăng gần tuyến tính theo số worker cho tới số core hiệu năng cao:

WorkerRequest mỗi giâySo với 1 worker
1khoảng 5001 lần
2khoảng 1.0001,9–2 lần
41.880–1.9703,7–3,9 lần
83.180–3.5306,3–6,9 lần

Từ 4 lên 8 worker không tăng gấp đôi, vì máy M2 chỉ có 4 core hiệu năng cao, 4 core còn lại chạy chậm hơn, và chính client đo cũng chiếm CPU. Các worker nhận lượng request gần như bằng nhau (95–100% so với worker nhận nhiều nhất).

Số worker hợp lý thường bằng số core mà process được phép dùng (os.availableParallelism()). Thêm worker vượt số core không tăng throughput mà chỉ làm các worker tranh nhau CPU. Mô phỏng hàng đợi dưới cho thấy cùng một tải thì thêm worker làm độ bận mỗi worker giảm và latency giảm theo:

Thử: độ bận và latency
Số worker dùng chung hàng đợi

Mô hình M/M/c: request đến ngẫu nhiên (Poisson), thời gian xử lý ngẫu nhiên (phân phối mũ), mọi worker lấy việc từ một hàng đợi chung. Số liệu là công thức, không phải số đo; số đo thật ở bảng bên dưới.

60request/giây
25,0 mstrung bình
115 msp99
Bắt đầu chậm
0501001502002500%20%40%60%80%100%latency (ms)
p99trung bình

Ở độ bận 60%, 60% request phải xếp hàng, p99 gấp 2,5 lần lúc rảnh. Thử tăng số worker: cùng một độ bận, nhiều worker dùng chung hàng đợi thì đường cong dựng đứng muộn hơn.

Ứng dụng phải stateless ​

Khi có nhiều process, request sau của một người dùng có thể rơi vào process khác request trước. Mọi trạng thái giữ trong bộ nhớ của một process vì vậy chỉ process đó thấy. Trên lab, cluster 4 worker lưu session trong một Map của mỗi worker. Đăng nhập rồi gọi /me ngay sau đó, mỗi request một connection mới, thì 200 trên 200 lần bị 401: round-robin đưa hai request liên tiếp vào hai worker khác nhau. Giữ cùng một connection keep-alive thì không lần nào lỗi, nhưng đó chỉ là may mắn của cách chia, không phải thiết kế.

Những thứ không được nằm trong bộ nhớ của một instance:

Trạng tháiĐặt ở đâu
SessionRedis, database, hoặc token tự chứa thông tin (JWT) để không cần lưu ở server (Kiến trúc › Bảo mật)
Cache dùng chungRedis. Cache trong process chỉ dùng cho dữ liệu mà mỗi instance có bản riêng cũng được, có TTL ngắn (Cache)
File uploadObject storage (S3, GCS), không lưu đĩa cục bộ của container
Rate limit, bộ đếmRedis (Rate limiter)
Job định kỳ (cron)Chạy ở một chỗ duy nhất (một CronJob riêng, hay một hàng đợi với job có khoá), không chạy trong mỗi instance, nếu không job chạy N lần
Lock trong processKhông đủ khi có nhiều instance (Race condition trong Node.js)

Sticky session (load balancer luôn đưa một người dùng tới cùng instance) che được vấn đề session, nhưng tải dồn không đều, và instance chết thì người dùng mất trạng thái. Nên coi đó là giải pháp tạm, không phải thiết kế.

WebSocket khi có nhiều instance ​

WebSocket là connection sống lâu, gắn với đúng một instance. Hai vấn đề phát sinh:

  • Phát tin tới người đang nối ở instance khác. Người A nối vào instance 1, người B nối vào instance 2, A nhắn cho B. Instance 1 không giữ connection của B. Cách phổ biến là pub/sub: mỗi instance subscribe một kênh Redis (hay NATS, Kafka), instance nhận tin thì publish, instance nào đang giữ connection của người nhận thì gửi đi. Socket.IO có sẵn adapter Redis cho việc này.
  • Bắt tay ban đầu. Socket.IO khi chạy bằng long-polling gửi nhiều request HTTP trước khi nâng cấp lên WebSocket; các request đó phải tới cùng một instance, nên cần sticky session ở load balancer, hoặc chỉ dùng transport WebSocket.

Deploy làm mọi connection WebSocket của instance cũ bị đóng, nên client phải tự nối lại, có backoff để không cùng nối lại một lúc (Xử lý lỗi & graceful shutdown).

Health check: liveness và readiness ​

Nền tảng chạy container cần biết hai điều khác nhau về mỗi instance:

ProbeCâu hỏiTrả lỗi thìNên kiểm tra
LivenessProcess còn sống và còn phục vụ được không?Khởi động lại containerEvent loop còn chạy (endpoint trả lời được). Không kiểm tra database: database chậm mà khởi động lại mọi instance thì chỉ làm tệ hơn
ReadinessInstance có nên nhận request lúc này không?Ngừng gửi request tới, không khởi động lạiĐã khởi động xong, đã có connection tới database; trả lỗi ngay khi bắt đầu shutdown

Tài liệu Kubernetes mô tả đúng hai hệ quả này: dựa vào kết quả probe, nó khởi động lại container không khoẻ, hoặc ngừng gửi traffic tới container chưa sẵn sàng. Một lỗi hay gặp là để liveness probe gọi database: khi database chậm vài giây, mọi instance cùng bị khởi động lại, connection pool cùng nối lại một lúc, và database càng quá tải.

Event loop bị chặn lâu (một request tốn CPU) cũng làm health check không trả lời được. Ngưỡng timeout của liveness probe nên rộng hơn đoạn chặn dài nhất có thể chấp nhận, để không khởi động lại một process chỉ đang bận (Chặn event loop).

Ghi nhớ

  1. Một process Node chỉ dùng một core cho JavaScript: chạy nhiều process bằng cluster, PM2, hay nhiều container; với Kubernetes thường là một process mỗi container.
  2. cluster chia connection theo round-robin (trừ Windows); throughput tăng gần tuyến tính tới số core thật.
  3. Ứng dụng phải stateless: session, cache dùng chung, upload, rate limit ra Redis, database, object storage; cron chỉ chạy một chỗ.
  4. WebSocket nhiều instance cần pub/sub để phát tin giữa các instance; client phải tự nối lại sau deploy.
  5. Liveness trả lời "process còn sống không" và không kiểm tra database; readiness trả lời "có nên nhận request không" và trả lỗi khi shutdown.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-26):

NguồnDùng cho phần
Node.js docs, Cluster: How it worksMặc định round-robin trên mọi nền tảng trừ Windows; cách worker tự nhận connection bị lệch, có lúc hơn 70% connection dồn vào hai process
Kubernetes docs, Liveness, Readiness, and Startup ProbesKhởi động lại container không khoẻ, ngừng gửi traffic tới container chưa sẵn sàng

Kiểm bằng lab: throughput theo số worker, cách chia request cho worker, session trong bộ nhớ khi có 4 worker. Từ kiến thức chung, chưa đối chiếu lần này: adapter Redis và long-polling của Socket.IO; khuyến nghị một process mỗi container.

Số đo chi tiết ​

Kết quả chạy lab/scale/cluster.mjs (MacBook M2 8 core, Node 24.14, ba lượt)

Endpoint tốn 2 ms CPU mỗi request, 64 connection keep-alive, closed-loop, 3 giây mỗi cấu hình:

WorkerRequest/sp99Chia cho worker
1501–510995–2.357 ms
2959–1.003130–276 ms100%
41.881–1.96565–77 ms99–100%
83.175–3.52739–74 ms95–100% so với worker nhận nhiều nhất

p99 với 1 worker dao động rất mạnh giữa các lượt: 64 connection cùng chờ một worker, nên một số connection phải chờ nhiều vòng liên tiếp.

Session trong Map của từng worker, cluster 4 worker, 200 lần đăng nhập rồi gọi /me: mỗi request một connection mới thì 200/200 lần 401; dùng chung một connection keep-alive thì 0 lần.

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- scale, chỉ cần Node.js, khoảng 20 giây. Primary của cluster tự dừng các worker khi process cha mất. Xem Lab.

lab/scale/cluster.mjs
js
// Trang nodejs/scale. Chỉ cần Node.js, khoảng 30 giây.
//   1. Throughput của một endpoint tốn 2 ms CPU mỗi request với 1, 2, 4, 8 worker của cluster
//   2. Request được chia cho các worker thế nào
//   3. Session lưu trong bộ nhớ của từng worker: đăng nhập ở worker này, request sau rơi vào worker khác
// Khi chạy với đối số "server N", script là primary của cluster N worker.
import cluster from 'node:cluster'
import http from 'node:http'
import { availableParallelism } from 'node:os'
import { fork } from 'node:child_process'

if (process.argv[2] === 'server') {
  const n = Number(process.argv[3])
  if (cluster.isPrimary) {
    for (let i = 0; i < n; i++) cluster.fork()
    let ready = 0
    cluster.on('listening', () => { if (++ready === n) process.send({ port: 0 }) })
    const counts = {}
    for (const id in cluster.workers) cluster.workers[id].on('message', (m) => { counts[m] = (counts[m] || 0) + 1 })
    process.on('message', (m) => { if (m === 'counts') process.send({ counts }) })
    // Process cha chết (kể cả bị ngắt giữa chừng) thì kênh IPC đóng: dừng mọi worker rồi thoát, không để sót cluster chạy ngầm
    process.on('disconnect', () => { for (const id in cluster.workers) cluster.workers[id].kill(); process.exit(0) })
  } else {
    const sessions = new Map() // session trong bộ nhớ của worker: chỉ worker này thấy
    http.createServer((req, res) => {
      const url = new URL(req.url, 'http://x')
      if (url.pathname === '/cpu') {
        const t = performance.now()
        while (performance.now() - t < 2) {} // 2 ms CPU, như render hay xử lý JSON
        process.send(cluster.worker.id)
        return res.end(String(cluster.worker.id))
      }
      if (url.pathname === '/login') { const sid = Math.random().toString(36).slice(2); sessions.set(sid, 'an'); return res.end(sid) }
      if (url.pathname === '/me') return res.end(sessions.has(url.searchParams.get('sid')) ? 'ok' : '401')
      res.end('ok')
    }).listen(Number(process.env.PORT), '127.0.0.1')
  }
} else {
  const PORT = 39_000 + Math.floor(Math.random() * 1000)
  const start = async (n) => {
    const child = fork(new URL(import.meta.url), ['server', String(n)], { env: { ...process.env, PORT: String(PORT) } })
    await new Promise((r) => child.once('message', r))
    return child
  }
  const get = (agent, path) => new Promise((resolve, reject) => http.get({ host: '127.0.0.1', port: PORT, path, agent }, (r) => { let b = ''; r.on('data', (c) => (b += c)); r.on('end', () => resolve(b)) }).on('error', reject))
  // Tải closed-loop: C client, mỗi client gửi request tiếp ngay khi request trước xong, trong S giây
  async function load(conns, seconds, path) {
    const agent = new http.Agent({ keepAlive: true, maxSockets: conns })
    let done = 0
    const lat = []
    const end = performance.now() + seconds * 1000
    await Promise.all(Array.from({ length: conns }, async () => { while (performance.now() < end) { const t = performance.now(); await get(agent, path); lat.push(performance.now() - t); done++ } }))
    agent.destroy()
    lat.sort((a, b) => a - b)
    return { rps: done / seconds, p99: lat[Math.floor(lat.length * 0.99)] }
  }
  console.log(`\n=== Endpoint tốn 2 ms CPU mỗi request, 64 connection, 3 giây mỗi cấu hình (máy có ${availableParallelism()} core) ===`)
  let base
  for (const n of [1, 2, 4, 8]) {
    const child = await start(n)
    await load(16, 0.5, '/cpu') // khởi động
    const r = await load(64, 3, '/cpu')
    base ??= r.rps
    child.send('counts')
    const { counts } = await new Promise((res) => child.once('message', res))
    const per = Object.values(counts).sort((a, b) => b - a)
    console.log(`${String(n).padStart(2)} worker: ${Math.round(r.rps).toLocaleString('vi-VN').padStart(6)} request/s (gấp ${(r.rps / base).toFixed(1).replace('.', ',')} lần), p99 ${r.p99.toFixed(1).replace('.', ',')} ms; mỗi worker nhận ${n > 1 ? `${Math.round((per.at(-1) / per[0]) * 100)}–100% so với worker nhận nhiều nhất` : 'tất cả'}`)
    child.kill()
    await new Promise((r) => setTimeout(r, 300))
  }

  console.log('\n=== Session trong bộ nhớ của worker, cluster 4 worker ===')
  const child = await start(4)
  let fail = 0
  const N = 200
  for (let i = 0; i < N; i++) {
    const a = new http.Agent({ keepAlive: false }) // mỗi request một connection mới, như đi qua load balancer
    const sid = await get(a, '/login')
    if ((await get(a, `/me?sid=${sid}`)) === '401') fail++
    a.destroy()
  }
  console.log(`Đăng nhập rồi gọi /me ngay sau đó, ${N} lần: ${fail} lần bị 401 (${Math.round((fail / N) * 100)}%) vì request thứ hai rơi vào worker khác`)
  const keep = new http.Agent({ keepAlive: true, maxSockets: 1 })
  let fail2 = 0
  for (let i = 0; i < N; i++) { const sid = await get(keep, '/login'); if ((await get(keep, `/me?sid=${sid}`)) === '401') fail2++ }
  keep.destroy()
  console.log(`Cùng thử nghiệm nhưng giữ một connection keep-alive: ${fail2} lần 401, vì mọi request trên một connection luôn tới cùng một worker`)
  child.kill()
}