Quay lại bài viết
24 thg 8, 2026
19 min read

WebSocket Scaling: Từ Giới Hạn Của Một Server Đến Kiến Trúc Nhiều Node

Tiếp nối bài trước về cách WebSocket thiết lập kết nối và bảo mật, giả sử tính năng chat realtime của bạn đã chạy ổn: auth chặt chẽ, wss:// đầy đủ, vài nghìn user online mỗi tối. Rồi một ngày sản phẩm lên trang chủ của một trang báo lớn. Lượng user online nhảy từ 5 nghìn lên 80 nghìn trong một giờ. Server bắt đầu từ chối kết nối mới, memory tăng dựng đứng, và khi bạn vội vàng bật thêm một server thứ hai đằng sau load balancer, một vấn đề kỳ lạ xuất hiện: user A gửi tin nhắn mà user B không nhận được — dù cả hai đều đang online.

Vấn đề nằm ở bản chất của WebSocket: mỗi connection là stateful (server phải nhớ trạng thái của từng kết nối — user nào, đang subscribe kênh nào) và long-lived (kết nối mở hàng phút, hàng giờ thay vì vài chục millisecond như một HTTP request). Với HTTP thông thường, load balancer có thể ném request nào vào server nào cũng được, vì mỗi request tự chứa đủ thông tin để xử lý. Với WebSocket, connection của user B sống trên đúng một server cụ thể — và nếu tin nhắn của user A đến một server khác, nó không có cách nào chạm tới user B.

Bài này sẽ đi qua hai trục của bài toán scale WebSocket: vertical scaling — một server đơn lẻ chịu được bao nhiêu connection và điều gì chặn nó lại, và horizontal scaling — làm sao để nhiều server cùng gánh, từ load balancing, sticky session, đến pub/sub backplane và autoscaling. Mỗi phần đều có phần map sang hạ tầng AWS để bạn hình dung cách triển khai thực tế.


1. Vì sao WebSocket khó scale hơn HTTP thường?

Trước khi đi vào giải pháp, cần hiểu rõ vì sao những kỹ thuật scale quen thuộc của HTTP không áp dụng thẳng được.

Một HTTP request là stateless: request mang theo đủ thông tin (URL, header, token, body) để bất kỳ server nào cũng xử lý được, xong việc là đóng lại hoặc trả connection về pool. Muốn scale, bạn chỉ cần thêm server và để load balancer chia đều — không server nào cần biết server khác đang làm gì.

WebSocket phá vỡ cả hai giả định đó:

  • Connection gắn chặt với một server. Sau handshake, TCP connection giữa client và server mở liên tục. Toàn bộ trạng thái của connection — danh tính user, các room đang tham gia, buffer message đang chờ gửi — sống trong memory của đúng server đó. Không server nào khác nhìn thấy.
  • Connection sống lâu. Một HTTP request chiếm tài nguyên server trong vài chục millisecond. Một WebSocket connection chiếm tài nguyên liên tục trong suốt thời gian user online — kể cả khi không có message nào được gửi. 80 nghìn user online nghĩa là 80 nghìn connection đang mở đồng thời, mỗi cái đều tốn file descriptor, memory và một phần sức của event loop.

Hai đặc điểm này sinh ra hai câu hỏi, tương ứng với hai phần còn lại của bài:

  1. Một server đơn lẻ chịu được đến đâu, và làm sao đẩy giới hạn đó lên? (vertical scaling)
  2. Khi buộc phải dùng nhiều server, làm sao để chúng phối hợp — đặc biệt là khi user trên server này cần nhận message từ user trên server khác? (horizontal scaling)

2. Vertical Scaling: một server chịu được bao nhiêu connection?

Vertical scaling là tăng sức chịu tải bằng cách dùng máy mạnh hơn hoặc tinh chỉnh để một máy phục vụ được nhiều hơn — thay vì thêm máy. Với WebSocket, câu hỏi cụ thể là: điều gì quyết định con số connection tối đa của một server?

2.1. Chi phí của một connection

Mỗi WebSocket connection đang mở tiêu tốn tài nguyên ở nhiều tầng:

  • File descriptor. Trên Linux, mỗi TCP socket là một file descriptor (số nguyên mà kernel dùng để định danh một tài nguyên I/O đang mở — file, socket, pipe đều tính). Mỗi connection chiếm đúng một fd, và mỗi process có giới hạn số fd được mở đồng thời.
  • Kernel buffer. Kernel cấp cho mỗi socket một cặp buffer gửi và nhận, mỗi buffer từ vài KB đến vài trăm KB tùy cấu hình. Đây thường là phần tốn memory nhất của một connection nhàn rỗi.
  • TLS session state. Nếu dùng wss:// (mà bạn nên dùng), mỗi connection giữ thêm khóa mã hóa và buffer cho TLS record.
  • Userland object. Phía Node.js, mỗi connection là một object trên V8 heap: instance của thư viện ws, các event listener, hàng đợi message, cộng thêm state ứng dụng bạn tự gắn vào (user id, danh sách subscription).

Cộng lại, một connection nhàn rỗi tốn khoảng vài chục KB. Nghe nhỏ, nhưng nhân với 100 nghìn connection là vài GB memory — chưa tính lúc có traffic thật.

2.2. Giới hạn hệ điều hành, và một hiểu nhầm phổ biến

Giới hạn đầu tiên bạn sẽ đụng là fd limit. Mặc định trên nhiều distro Linux, một process chỉ được mở 1024 fd — nghĩa là server của bạn chết ở khoảng 1000 connection dù máy còn thừa tài nguyên. Giới hạn này nới được bằng ulimit -n (giới hạn theo process) và fs.file-max (giới hạn toàn hệ thống), lên hàng trăm nghìn hoặc hơn.

Một hiểu nhầm phổ biến: “một máy chỉ có 65535 port, nên tối đa 65535 connection”. Điều này sai ở phía server. Giới hạn 65535 áp cho ephemeral port (dải port tạm mà một máy dùng làm đầu ra khi chủ động mở kết nối đi) — tức là giới hạn của phía client khi kết nối đến cùng một đích. Phía server, mọi connection đều đi vào cùng một port lắng nghe (ví dụ 443); kernel phân biệt các connection bằng bộ tứ gồm IP nguồn, port nguồn, IP đích, port đích. Vì mỗi client có IP và port nguồn khác nhau, một server có thể giữ hàng triệu connection trên một port duy nhất — miễn là đủ fd và memory.

Ngoài fd, còn vài tham số kernel đáng biết khi đi xa hơn: net.core.somaxconn (độ dài hàng đợi kết nối đang chờ accept) và kích thước mặc định của kernel buffer (net.ipv4.tcp_rmem, tcp_wmem) — giảm buffer size giúp chứa được nhiều connection nhàn rỗi hơn trên cùng lượng RAM, đổi lại throughput mỗi connection thấp hơn.

2.3. Giới hạn phía Node.js

Nới xong giới hạn hệ điều hành, trần tiếp theo nằm ở chính runtime. Node.js xử lý I/O theo mô hình event loop đơn luồng: một luồng duy nhất lần lượt xử lý các sự kiện (message đến, socket đóng, timer). Mô hình này cực kỳ hợp với bài toán nhiều connection nhàn rỗi — 100 nghìn socket im lặng gần như không tốn CPU. Nhưng nó có hai điểm nghẽn:

  • Broadcast là O(N). Khi cần gửi một message đến 50 nghìn connection (thông báo toàn hệ thống, tin nhắn trong room lớn), event loop phải lần lượt serialize và ghi vào từng socket. Trong lúc đó, mọi việc khác — kể cả accept connection mới và trả lời ping — đều phải xếp hàng chờ.
  • Heap và GC. Hàng trăm nghìn object connection sống lâu trên heap làm mỗi chu kỳ garbage collection (quá trình V8 quét và thu hồi memory không còn dùng) tốn thời gian hơn, gây ra những khoảng khựng ảnh hưởng đến độ trễ message.

Bạn có thể tự đo chi phí memory mỗi connection để biết trần của mình nằm đâu. Đoạn script sau mở dần các connection đến server và in ra memory tiêu thụ:

import WebSocket from 'ws' const sockets: WebSocket[] = [] async function openBatch(count: number) { for (let i = 0; i < count; i++) { const ws = new WebSocket('wss://localhost:8443', { rejectUnauthorized: false }) ws.on('error', () => {}) sockets.push(ws) } await new Promise((resolve) => setTimeout(resolve, 2_000)) } async function main() { for (let opened = 0; opened < 10_000; opened += 1_000) { await openBatch(1_000) const { heapUsed, rss } = process.memoryUsage() console.log(`${sockets.length} sockets | heap ${(heapUsed / 1e6).toFixed(1)}MB | rss ${(rss / 1e6).toFixed(1)}MB`) } } main()

Chạy script này ở phía client, còn ở phía server theo dõi process.memoryUsage() tương tự — chia chênh lệch memory cho số connection là ra chi phí trung bình mỗi connection của chính ứng dụng bạn, chính xác hơn mọi con số benchmark trên mạng.

2.4. Trần của vertical scaling

Với tuning tốt, một server Node.js hoàn toàn có thể giữ hàng trăm nghìn connection. Vậy sao không cứ thế mua máy to hơn? Ba lý do:

  • Chi phí phi tuyến. Máy 128GB RAM đắt hơn nhiều so với bốn máy 32GB, và càng lên cao khoảng cách càng lớn.
  • Trần vật lý. Đến một lúc, không còn máy to hơn để mua — hoặc một event loop đơn luồng không nuốt nổi lượng message dù RAM còn thừa.
  • Single point of failure. Đây là lý do quan trọng nhất. Một server giữ 500 nghìn connection nghĩa là khi server đó chết — deploy lỗi, kernel panic, hardware hỏng — 500 nghìn user rớt kết nối cùng lúc, rồi cùng lúc kết nối lại. Cơn bão reconnect đó có thể đánh sập cả hệ thống còn lại.

Vertical scaling vì thế là bước tối ưu đầu tiên chứ không phải đích đến: nới giới hạn để mỗi node gánh được nhiều, rồi vẫn phải chạy nhiều node.

Map sang AWS: vertical scaling nghĩa là chọn instance size lớn hơn (memory-optimized như dòng r thường hợp với WebSocket vì memory là tài nguyên nghẽn trước). Lưu ý mỗi instance type có giới hạn băng thông mạng và số kết nối theo dõi được của security group (conntrack) riêng — instance nhỏ có thể nghẽn ở network trước khi nghẽn ở RAM.


3. Horizontal Scaling: chạy nhiều node

Horizontal scaling là thêm nhiều server cùng gánh tải thay vì phóng to một server. Với HTTP stateless, phần này gần như miễn phí. Với WebSocket, nó sinh ra ba bài toán mới: đưa connection đến node nào (load balancing và sticky session), làm sao message đi xuyên node (fan-out), và thêm bớt node ra sao cho an toàn (autoscaling và graceful drain).

3.1. Load balancing cho WebSocket

Load balancer (LB) là thành phần đứng giữa client và fleet server, nhận mọi kết nối đến rồi phân phối về các node phía sau. Với WebSocket, LB có hai điểm khác so với khi phục vụ HTTP:

  • LB phải giữ connection mở lâu dài. Sau handshake, LB không còn là người chia request nữa mà trở thành một đường ống: mọi frame giữa client và node đều chảy qua nó, trong suốt vòng đời connection. LB vì thế cũng tốn fd và memory cho từng connection y như server — nó phải được scale cùng.
  • Idle timeout là cái bẫy kinh điển. Hầu hết LB tự đóng connection không có dữ liệu chạy qua trong một khoảng thời gian (thường mặc định 60 giây). Một user mở tab chat nhưng không gõ gì trong vài phút sẽ bị rớt kết nối mà không ai hiểu vì sao. Giải pháp là heartbeat: server gửi frame ping định kỳ (ví dụ mỗi 30 giây) và client trả pong — vừa giữ connection sống qua LB, vừa giúp server phát hiện connection đã chết ngầm.

Về tầng hoạt động, có hai lựa chọn. LB layer 4 làm việc ở tầng TCP: chỉ chuyển tiếp byte, không hiểu nội dung, nên nhanh và gần như trong suốt với WebSocket. LB layer 7 hiểu HTTP: nó đọc được handshake, route theo path hoặc header, terminate TLS hộ server — đổi lại phải hỗ trợ Upgrade một cách tường minh và thêm một chút độ trễ. Cả hai đều dùng được cho WebSocket; chọn layer 7 khi bạn cần routing thông minh và offload TLS, layer 4 khi cần throughput tối đa và giữ nguyên thông tin tầng TCP.

Map sang AWS: cả ALB (layer 7) và NLB (layer 4) đều hỗ trợ WebSocket. ALB hỗ trợ Upgrade tự nhiên, terminate TLS, idle timeout cấu hình được (nâng lên và vẫn kèm heartbeat); NLB chuyển tiếp thẳng TCP với độ trễ thấp hơn. Mình đã so sánh chi tiết hai loại trong bài ALB hay NLBALB under the hood.

3.2. Sticky session: cần đến đâu?

Sticky session (còn gọi là session affinity) là cơ chế LB đảm bảo các request của cùng một client luôn được đưa về cùng một node, thay vì chia ngẫu nhiên.

Nghe qua thì có vẻ WebSocket bắt buộc cần sticky — nhưng thực ra cần phân biệt hai tình huống:

  • WebSocket thuần không cần sticky cho bản thân connection. Một connection là một TCP stream duy nhất; LB chọn node đúng một lần lúc handshake, và mọi frame sau đó tự động đi theo connection đó. Không có “request thứ hai” nào để đi lạc node.
  • Sticky trở nên bắt buộc khi handshake gồm nhiều request. Trường hợp điển hình là Socket.IO ở chế độ mặc định: nó bắt đầu bằng vài request HTTP long-polling (client liên tục gửi HTTP request để hỏi dữ liệu mới, mô phỏng realtime trên HTTP thường) trước khi nâng cấp lên WebSocket. Các request polling đó phải cùng đến một node — node khác không biết session này là ai. Không có sticky, client sẽ nhận lỗi session không tồn tại một cách ngẫu nhiên.

Sticky session có một tác dụng phụ ít người để ý: phân bố tải lệch dần. Vì connection sống lâu và không bao giờ được chia lại, node chạy lâu sẽ tích tụ connection, còn node mới thêm vào gần như rảnh — LB chỉ đưa được connection mới cho nó. Sau một đợt deploy cuốn chiếu, bạn có thể thấy node đầu tiên restart xong gánh gấp đôi node cuối. Cách xử lý là chủ động cân bằng lại: server định kỳ yêu cầu một phần client của node quá tải reconnect (client tự động quay lại qua LB và được chia vào node rảnh hơn), hoặc chấp nhận lệch và scale theo node nặng nhất.

Map sang AWS: ALB hỗ trợ sticky session bằng cookie ở tầng target group — cần bật nếu dùng Socket.IO kèm polling fallback. Chi tiết về target group và sticky session có trong bài tổng quan Elastic Load Balancer.

3.3. Fan-out xuyên node: bài toán cốt lõi

Đây chính là vấn đề làm user A không nhắn được cho user B ở đầu bài. User A connect vào node 1, user B connect vào node 2. Node 1 nhận message của A, nhìn vào danh sách connection trong memory của mình — không có B. Với node 1, user B đơn giản là không tồn tại.

Giải pháp tiêu chuẩn là thêm một backplane (kênh liên lạc chung mà mọi node đều nối vào, dùng để chuyển message giữa các node) hoạt động theo mô hình pub/sub (publish/subscribe — bên gửi publish message vào một channel có tên, mọi bên đã subscribe channel đó đều nhận được, hai bên không cần biết nhau). Redis Pub/Sub là lựa chọn phổ biến nhất cho vai trò này vì độ trễ thấp và gần như mọi hệ thống đều đã có sẵn Redis.

Luồng đi của một message lúc này như sau:

  1. User A gửi message vào room 42 qua connection với node 1.
  2. Node 1 không tự tìm người nhận, mà publish message vào channel room:42 trên Redis.
  3. Redis đẩy message cho mọi node đang subscribe channel room:42 — trong đó có node 2, vì node 2 đang giữ connection của user B (một thành viên room 42).
  4. Node 2 nhận message từ Redis, tra danh sách connection local của mình, và ghi message vào socket của user B.

Điểm mấu chốt: mỗi node chỉ subscribe những channel mà user của nó quan tâm, và chỉ chịu trách nhiệm ghi message vào các socket local. Không node nào cần biết user nào đang ở node nào — Redis lo việc định tuyến.

Một bản triển khai tối giản với wsioredis (Redis client cho Node.js; cần hai kết nối Redis riêng vì một kết nối đã vào chế độ subscribe thì không dùng để publish được nữa):

import { WebSocketServer, WebSocket } from 'ws' import Redis from 'ioredis' const pub = new Redis() const sub = new Redis() const wss = new WebSocketServer({ port: 8080 }) const roomMembers = new Map<string, Set<WebSocket>>() sub.on('message', (channel, payload) => { const members = roomMembers.get(channel) if (!members) { return } for (const socket of members) { if (socket.readyState === WebSocket.OPEN) { socket.send(payload) } } }) wss.on('connection', (socket) => { socket.on('message', async (raw) => { const { type, room, body } = JSON.parse(raw.toString()) const channel = `room:${room}` if (type === 'join') { if (!roomMembers.has(channel)) { roomMembers.set(channel, new Set()) await sub.subscribe(channel) } roomMembers.get(channel).add(socket) } if (type === 'chat') { await pub.publish(channel, JSON.stringify({ room, body })) } }) })

Để ý rằng node gửi cũng nhận lại message của chính mình qua Redis — đây là tính năng chứ không phải bug: mọi node xử lý message theo cùng một con đường, kể cả khi người gửi và người nhận cùng node.

Hai giới hạn của Redis Pub/Sub cần biết trước khi dùng:

  • Fire-and-forget. Redis không lưu message: node nào đang subscribe thì nhận, node nào vừa restart đúng lúc đó thì mất message vĩnh viễn. Chat thông thường chấp nhận được (client thường fetch lại lịch sử từ database khi reconnect), nhưng nếu cần đảm bảo không mất message giữa các node, hãy nhìn sang message broker có lưu trữ như Kafka — mình đã viết chi tiết trong bài Kafka architecture deep dive. Đổi lại Kafka có độ trễ cao hơn và vận hành nặng hơn hẳn, nên đừng vội dùng nếu chưa cần.
  • Chính Redis trở thành điểm nghẽn tiếp theo. Mọi message giữa các node đều đi qua một Redis instance. Khi tổng lượng message vượt sức một instance, cần shard channel ra nhiều Redis (ví dụ hash tên room để chọn instance) — lúc này độ phức tạp tăng đáng kể.

Map sang AWS: ElastiCache for Redis là lựa chọn tự nhiên cho backplane. Đặt ElastiCache cùng region và cùng VPC với fleet WebSocket để độ trễ pub/sub ở mức dưới millisecond.

3.4. Autoscaling: thêm bớt node mà không làm rớt user

Có LB và backplane rồi, mảnh ghép cuối là autoscaling — tự động thêm node khi tải tăng và bớt node khi tải giảm. WebSocket có hai điểm khác biệt lớn so với autoscaling cho HTTP.

Thứ nhất, CPU là metric sai. Một node giữ 100 nghìn connection nhàn rỗi có CPU gần bằng 0 — autoscaler nhìn CPU sẽ kết luận node đang rảnh và tắt bớt, trong khi node thực ra đã đầy. Metric đúng cho WebSocket là số connection đang mởmemory: ứng dụng đếm connection của mình và đẩy con số đó lên hệ thống monitoring làm tín hiệu scale.

Thứ hai, scale-in nguy hiểm hơn scale-out. Thêm node thì dễ: node mới vào LB và bắt đầu nhận connection mới. Nhưng tắt một node nghĩa là cắt hàng chục nghìn connection đang sống. Tắt đột ngột, toàn bộ client đó rớt cùng lúc và đồng loạt reconnect — chính là cơn bão reconnect đã nói ở phần 2.4, giờ do chính bạn tự gây ra mỗi lần scale-in hoặc deploy. Quy trình đúng gọi là graceful drain (rút cạn từ từ):

  1. Gỡ node khỏi LB trước — node không nhận thêm connection mới, connection cũ vẫn sống.
  2. Node chủ động yêu cầu client của mình reconnect: gửi close frame hoặc một message ứng dụng, rải đều theo thời gian thay vì đồng loạt.
  3. Client reconnect qua LB và được đưa vào các node còn lại. Phía client nên cài sẵn reconnect với jittered backoff — thử lại sau một khoảng chờ tăng dần và cộng thêm độ trễ ngẫu nhiên, để ngay cả khi node chết đột ngột, các client cũng không dồn về cùng một thời điểm.
  4. Hết thời gian drain (ví dụ vài phút), node đóng nốt các connection còn sót và tắt.

Deploy phiên bản mới thực chất cũng là quy trình này lặp cho từng node — vì vậy đầu tư vào graceful drain một lần, bạn dùng nó hàng ngày chứ không chỉ lúc scale-in.

Map sang AWS: Auto Scaling Group quản lý fleet, scale theo custom metric (số connection do ứng dụng đẩy lên CloudWatch) thay vì CPU. Deregistration delay của target group cho node thời gian drain trước khi bị gỡ hẳn, và lifecycle hook cho phép chạy logic drain trước khi instance bị terminate — cả hai khái niệm này có trong bài tổng quan Auto Scaling Group.


4. Kết luận

Quay lại câu chuyện đầu bài: server từ chối kết nối vì đụng fd limit, và user A không nhắn được cho user B vì hai người nằm trên hai node không có kênh liên lạc chung. Cả hai đều là hệ quả của cùng một gốc: WebSocket connection là stateful và long-lived, nên không thể scale như HTTP stateless. Những điểm chính đáng nhớ:

  • Chi phí của một connection nằm ở file descriptor, kernel buffer và object trên heap — cộng lại vài chục KB mỗi connection nhàn rỗi. Giới hạn 65535 port là của phía client, không phải của server; server nghẽn ở fd limit và memory trước.
  • Vertical scaling có trần: tuning giúp một node Node.js giữ hàng trăm nghìn connection, nhưng máy to là single point of failure — một node chết kéo theo cơn bão reconnect khổng lồ.
  • Load balancer cho WebSocket phải giữ connection lâu dài; nhớ nâng idle timeout và cài heartbeat. Sticky session chỉ bắt buộc khi handshake gồm nhiều request (như polling fallback của Socket.IO), và có tác dụng phụ là phân bố tải lệch dần.
  • Pub/sub backplane là trái tim của horizontal scaling: node publish message vào Redis thay vì tự tìm người nhận, mỗi node chỉ lo ghi vào socket local của mình. Redis Pub/Sub là fire-and-forget — cần độ bền message thì nhìn sang Kafka.
  • Autoscale theo connection count chứ không phải CPU, và đầu tư vào graceful drain: gỡ khỏi LB, yêu cầu client reconnect rải ra, rồi mới tắt node. Deploy hàng ngày cũng chính là quy trình này.

Còn nếu bạn không muốn tự vận hành toàn bộ những thứ trên, các dịch vụ managed như API Gateway WebSocket của AWS gánh hộ phần connection để bạn chỉ viết logic — đổi lại chi phí theo message và ít quyền kiểm soát hơn, một trade-off xứng đáng cân nhắc khi team nhỏ.

Liên quan