Scale-out WebSocket với Redis Pub/Sub Adapter sau Load Balancer
Xây dựng kiến trúc WebSocket nhiều node với Socket.IO Redis Adapter: truyền message xuyên node, cấu hình Load Balancer, sticky session, Redis Cluster, kiểm thử và xử lý sự cố production.
ShanverseCập nhật 11 phút đọc— lượt xemĐang tải lượt xem
Redis
WebSocket
Load Balancing
Bài toán: kết nối nằm trong RAM của từng node
Giả sử hệ thống có ba Socket.IO server đứng sau Load Balancer:
Mỗi kết nối WebSocket là một TCP connection sống lâu và được một node cụ thể giữ. Danh sách socket, room và trạng thái kết nối mặc định nằm trong RAM của node đó.
Nếu Client A gửi tin nhắn cho một room, Node 1 chỉ nhìn thấy các socket cục bộ. Client B trên Node 2 sẽ không nhận được message nếu các node không có một backplane chung.
Điều cần giải quyết không phải là “chuyển” WebSocket đang mở giữa các server. Kết nối vẫn gắn với node đã accept nó. Mục tiêu là chuyển event đến node đang giữ socket đích.
Kiến trúc scale-out
Luồng gửi message:
Client gửi event đến Node A.
Bình luận
Node A xử lý xác thực và nghiệp vụ.
Adapter phát packet lên Redis channel.
Adapter trên Node B và Node C nhận packet.
Mỗi node chỉ chuyển packet đến những socket cục bộ phù hợp với namespace, room hoặc socket ID.
Client đích nhận event dù đang kết nối với node khác.
Redis không thay thế Load Balancer và cũng không giữ TCP connection. Nó là message backplane giữa các Socket.IO server.
Redis Adapter thực sự làm gì?
Socket.IO Adapter chịu trách nhiệm quản lý room và route packet. Adapter mặc định chỉ hoạt động trong một process. @socket.io/redis-adapter mở rộng việc route bằng Redis Pub/Sub.
Các thao tác như sau tiếp tục giữ nguyên API:
Khác biệt nằm phía sau: packet cần đi sang node khác được publish qua Redis.
Cài đặt
Cần hai Redis connection riêng: một connection publish và một connection subscribe. Sau khi một Redis client vào chế độ subscribe, không nên dùng cùng connection đó cho lệnh thông thường.
Server Node.js hoàn chỉnh
Điểm quan trọng: chỉ emit sau khi nghiệp vụ đã được xác thực. Với chat cần lưu lịch sử, hãy ghi message vào database trước rồi mới phát event. Pub/Sub là đường truyền realtime, không phải nguồn dữ liệu chuẩn.
Chạy nhiều node cục bộ
Một client vào qua Node A và một client vào qua Node C vẫn có thể cùng join room:42. Khi một node gọi io.to("room:42").emit(...), Adapter chuyển packet đến các node còn lại.
Load Balancer với Nginx
least_conn thường hợp lý hơn round-robin thuần cho kết nối sống lâu, nhưng vẫn cần đo theo traffic thực tế. Một node có ít connection nhưng mỗi connection phát nhiều message vẫn có thể tốn CPU hơn node khác.
Có cần sticky session không?
Câu trả lời phụ thuộc transport:
Chỉ dùng WebSocket: sau khi HTTP Upgrade thành công, connection TCP đã gắn với một upstream. Các frame tiếp theo đi trên cùng connection, vì vậy không cần sticky session cho từng frame.
Cho phép HTTP long-polling của Socket.IO: nhiều HTTP request thuộc cùng session phải đến đúng node. Khi đó cần sticky session, nếu không có thể gặp lỗi Session ID unknown.
Reconnect: đây là connection mới và Load Balancer có thể chọn node khác. Ứng dụng phải khôi phục room, subscription và state cần thiết sau khi xác thực lại.
Redis Adapter giải quyết cross-node broadcast, không thay thế session affinity khi long-polling đang bật.
Ví dụ bật IP affinity trong Nginx:
Trong production, cookie-based affinity thường chính xác hơn IP nếu nhiều người dùng đi qua cùng NAT hoặc proxy.
Gửi đúng người và đúng room
Không nên publish mọi event đến mọi client. Hãy thiết kế room rõ ràng:
Room name không phải cơ chế phân quyền. Luôn kiểm tra quyền trước khi join và trước khi xử lý event nhạy cảm.
Failure semantics cần hiểu đúng
Redis Pub/Sub ưu tiên độ trễ thấp, nhưng không bền vững:
Subscriber mất kết nối đúng lúc publish có thể bỏ lỡ message.
Không có replay, consumer group hay acknowledgement mặc định.
Redis restart hoặc network partition có thể tạo khoảng trống event.
Redis Adapter không biến event thành transaction phân tán.
Vì vậy:
Client cũng nên có cơ chế resync: sau reconnect, gọi API lấy các message sau lastSeenMessageId thay vì tin rằng mọi event realtime đều đã đến.
Redis outage xảy ra thì sao?
Khi Redis mất kết nối, các socket hiện tại có thể vẫn nói chuyện với node cục bộ, nhưng cross-node broadcast không còn đáng tin cậy. Điều này dễ tạo trạng thái “hệ thống trông vẫn sống nhưng một số client không nhận event”.
Nên triển khai:
Readiness probe kiểm tra Redis khi cross-node delivery là chức năng bắt buộc.
Metric cho trạng thái pub/sub connection, reconnect count và publish error.
Alert khi node không còn subscription.
Exponential backoff có jitter khi kết nối lại.
Circuit breaker hoặc degraded mode rõ ràng.
Không retry vô hạn một event không idempotent.
Redis Cluster và Sharded Pub/Sub
Với Redis Cluster lớn, Pub/Sub thông thường có thể tạo traffic toàn cluster. Redis 7 giới thiệu Sharded Pub/Sub; Socket.IO hỗ trợ qua createShardedAdapter().
Không chuyển sang sharded adapter chỉ vì “nghe nhanh hơn”. Hãy đo channel count, publish rate, network traffic giữa Redis nodes và khả năng tương thích phiên bản trước khi đổi.
Presence không nên chỉ dựa vào Pub/Sub
Câu hỏi “user nào đang online?” cần state có thể truy vấn. Có thể lưu presence dạng TTL trong Redis keys:
Node refresh TTL định kỳ; disconnect bình thường thì xóa key. TTL giúp state tự hết hạn khi process chết đột ngột. Với nhiều thiết bị, dùng Set/Hash kèm connection ID và thời hạn phù hợp.
Không dùng Pub/Sub như một database presence vì subscriber mới không thể replay trạng thái trước đó.
Graceful shutdown và deploy không rớt hàng loạt
Khi rollout:
Đánh dấu node không ready để Load Balancer ngừng đưa connection mới vào.
Đã test node failure, Redis failure và network partition.
Redis có HA/TLS/ACL và capacity phù hợp.
Đã benchmark workload thực thay vì chỉ đếm connection.
Kết luận
Scale-out WebSocket có hai bài toán riêng:
Load Balancer phân phối và giữ các connection sống lâu tới những server node.
Redis Pub/Sub Adapter đưa event đến node đang giữ socket đích.
Kiến trúc này rất phù hợp cho notification, presence, dashboard và chat realtime có nguồn dữ liệu bền vững riêng. Nhưng Pub/Sub là at-most-once và không có replay. Khi event không được phép mất, hãy kết hợp database/outbox hoặc chuyển sang Redis Streams Adapter hay broker bền vững hơn.
1upstream websocket_nodes {2 least_conn;3 server node-a:3000 max_fails=3 fail_timeout=10s;4 server node-b:
Codejavascript
1upstream socketio_nodes {2 ip_hash;3 server node-a:3000;4 server node-b:3000;5 server node-c:3000;6}
Codejavascript
1// Mỗi user có một room trên tất cả thiết bị.2socket.join(`user:${userId}`);34// Thành viên của một cuộc hội thoại.5socket.join(`conversation:${conversationId}`);67