Giao diện
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ách | Là gì | Hợp khi |
|---|---|---|
Module cluster | Một process primary tạo nhiều worker trên cùng máy, chia connection cho chúng | Chạ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 |
| PM2 | Trình quản lý process: chạy chế độ cluster, tự khởi động lại khi chết, reload từng worker | VM, server truyền thống |
| Nhiều container sau load balancer | Mỗi container một process, Kubernetes hay ECS chạy nhiều bản, load balancer chia request | Hạ 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:
| Worker | Request mỗi giây | So với 1 worker |
|---|---|---|
| 1 | khoảng 500 | 1 lần |
| 2 | khoảng 1.000 | 1,9–2 lần |
| 4 | 1.880–1.970 | 3,7–3,9 lần |
| 8 | 3.180–3.530 | 6,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
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 |
|---|---|
| Session | Redis, 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 chung | Redis. 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 upload | Object storage (S3, GCS), không lưu đĩa cục bộ của container |
| Rate limit, bộ đếm | Redis (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 process | Khô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:
| Probe | Câu hỏi | Trả lỗi thì | Nên kiểm tra |
|---|---|---|---|
| Liveness | Process còn sống và còn phục vụ được không? | Khởi động lại container | Event 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 |
| Readiness | Instance 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ớ
- 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. clusterchia connection theo round-robin (trừ Windows); throughput tăng gần tuyến tính tới số core thật.- Ứ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ỗ.
- 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.
- 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ồn | Dùng cho phần |
|---|---|
| Node.js docs, Cluster: How it works | Mặ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 Probes | Khở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:
| Worker | Request/s | p99 | Chia cho worker |
|---|---|---|---|
| 1 | 501–510 | 995–2.357 ms | |
| 2 | 959–1.003 | 130–276 ms | 100% |
| 4 | 1.881–1.965 | 65–77 ms | 99–100% |
| 8 | 3.175–3.527 | 39–74 ms | 95–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()
}