Websocket là một giao thức phổ biến rộng rãi cho phép giao tiếp song công (full-duplex connection)
hoàn toàn qua TCP. Có một số thư viện triển khai giao thức đó, một trong những thư viện mạnh mẽ và nổi tiếng nhất là Socket.IO ở phía Javascript, cho phép nhanh chóng tạo các mẫu giao tiếp theo thời gian thực.
Việc xây dựng một Websocket server đơn lẻ với một server duy nhất là một công việc đơn giản. Tuy nhiên, khi lượng user tăng vọt với số lượng request khổng lồ, dường như việc xây dựng một hạ tầng server Websocket có khả năng mở rộng là nhiệm vụ cấp thiết và không hề đơn giản.
Bài viết này sẽ và phân tích những thách thức trong việc mở rộng hạ tầng Websocket, những giải pháp mang lại một hệ thống cụm nhiều servers Websocket đáp ứng nhu cầu mở rộng.
1. Thách thức trong mở rộng WebSocket
1.1. Mở rộng trên HTTP
Để biết lý do tại sao việc mở rộng quy mô Websocket có vẻ khó khăn, hãy so sánh nó với HTTP, vì hầu hết mọi người đều hiểu rõ về nó.
Với HTTP, bạn có mẫu yêu cầu/trả lời một lần tức stateless
, bạn không mong đợi yêu cầu tiếp theo từ máy khách sẽ quay trở lại cùng một máy chủ. Điều đó có nghĩa là bạn gặp vấn đề về phiên dính (sticky session) và bạn không thể dễ dàng mở rộng quy mô để đạt được hiệu suất.
Với HTTP, bạn có thể chạy số lượng phiên bản máy chủ web gần như không giới hạn. Với bộ cân bằng tải chuyển yêu cầu đến một máy chủ web hoạt động tốt, và phản hồi sẽ được chuyển trở lại máy khách. Các kết nối HTTP thường tồn tại rất ngắn, chúng chỉ tồn tại cho đến khi nhận về phản hồi.
1.2. Mở rộng trên WebSocket
Mặt khác, Websocket khác với các yêu cầu HTTP ở tính chất liên tục. Máy khách mở một kết nối Websocket đến máy chủ và sử dụng lại kết nối đó stateful
.
Trên kết nối dài hạn này, cả máy chủ và máy khách đều có thể xuất bản và phản hồi các sự kiện. Khái niệm này được gọi là kết nối song công (duplex connection)
. Một kết nối có thể được mở thông qua bộ cân bằng tải, khi kết nối được mở, vẫn ở cùng một máy chủ cho đến khi bị đóng hoặc bị gián đoạn.
Điều này có nghĩa là sự tương tác có trạng thái; rằng cuối cùng bạn sẽ lưu trữ ít nhất một số dữ liệu trong bộ nhớ trên máy chủ WebSocket cho mỗi kết nối máy khách đang mở. Ví dụ: bạn có thể biết người dùng nào ở phía máy khách của socket và loại dữ liệu mà người dùng quan tâm.
Thực tế là các kết nối Websocket ổn định là điều khiến giao thức này trở nên mạnh mẽ đối với các ứng dụng thời gian thực, nhưng đó cũng là điều khiến việc mở rộng trên quy mô trở nên khó khăn hơn.
2. Phân tích cơ sở lý thuyết
2.1. Mục tiêu
Mục tiêu bài viết này nhằm phân tích cách xây dựng cụm Websocket có thể mở rộng để duy trì kết nối giữa nhiều máy khách và nhiều máy chủ. Đảm bảo mỗi client kết nối vào mạng lưới đêu có thể gửi và nhận thông điệp đến tất cả clients còn lại, dù cho kết nối trên các socket servers khác nhau.
2.2. Vấn đề
Quan sát hình vẽ trên, ta thấy một ngữ cảnh cụm scaling Websockets với 2 servers WS1 và WS2, đồng thời có 2 clients là User1 và User2, trong đó User1 kết nối WS1 và User2 kết nối WS2. Câu hỏi đặt ra làm sao để User1 gửi tin nhắn "Hi" đến cho User2?
Vì mỗi kết nối Websocket là stateful
, nên khi Bob muốn gửi tin nhắn đến Alice cần tiến hành xây dựng một mô hình kết nối trao đổi dữ liệu vòng ra sau giữa 2 server Websocket. Khi đó, Pub/sub là một mô hình trao đổi dữ liệu hữu hiệu với message broker là một lựa chọn tối ưu trong trường hợp này.
Vì thế, ta có thể thấy rõ 2 vấn đề chính cần giải quyết:
- Nhiều máy chủ Websocket có liên quan nên phối hợp với nhau. Khi máy chủ nhận được tin nhắn từ máy khách, nó phải đảm bảo rằng mọi máy khách được kết nối với bất kỳ máy chủ nào đều nhận được tin nhắn này.
- Khi máy khách bắt tay và thiết lập kết nối với máy chủ, tất cả các tin nhắn trong tương lai của nó sẽ chuyển qua cùng một máy chủ, nếu không máy chủ khác sẽ từ chối các tin nhắn tiếp theo khi nhận được chúng.
2.3. Giải pháp
Vấn đề đầu tiên có thể được giải quyết nguyên bản bằng cách sử dụng một Message Broker với mô hình pub/sub. Với Socket.io adapter vốn nằm trong bộ nhớ, cho phép truyền tin nhắn giữa các tiến trình (máy chủ) và phát các sự kiện tới tất cả máy khách thông qua mô hình pub/sub của Redis.
const redisAdapter = require('socket.io-redis');
const redisHost = process.env.REDIS_HOST || 'localhost';
io.adapter(redisAdapter({ host: redisHost, port: 6379 }));
Vấn đề thứ hai về việc giữ phiên khởi tạo bởi một máy khách có cùng máy chủ gốc có thể được giải quyết mà không gặp khó khăn gì. Bí quyết nằm ở việc tạo các kết nối cố định để khi máy khách kết nối với một máy chủ cụ thể, nó sẽ bắt đầu một phiên được liên kết hiệu quả với cùng một máy chủ.
Điều này không thể đạt được một cách trực tiếp nhưng chúng ta nên đặt thứ gì đó phía trước ứng dụng máy chủ NodeJS. Đây thường có thể là Proxy ngược (reverse porxy), như NGINX, HAProxy, Apache Httpd, Treafik,….
2.4. Tóm tắt
Để tóm tắt những gì bạn mong đợi với cấu hình này:
- Mỗi máy khách sẽ thiết lập kết nối với một phiên bản máy chủ ứng dụng Websocket cụ thể.
- Bất kỳ tin nhắn nào được gửi từ máy khách luôn đi qua cùng một máy chủ mà phiên được khởi tạo.
- Khi nhận được tin nhắn, máy chủ có thể phát nó. Bộ điều hợp chịu trách nhiệm quảng bá (broadcast) tất cả các máy chủ khác sẽ chuyển tiếp tin nhắn đến tất cả các máy khách đã thiết lập kết nối với chúng.
3. Mở rộng WebSocket với Socket.IO
3.1. Socket.IO là gì?
Ở bài viết trước, chúng ta đã lần lượt tìm hiểu các khái niệm cố lõi về giao thức WebSocket cũng như bộ công cụ hỗ trợ mạnh mẽ được thư viện Socket.IO hỗ trợ. Hãy cùng xem lại các bài viết để có kiến thức nền tảng tiếp tục tìm hiểu cách mở rộng WebSocket server dựa trên thư viện Socket.IO.
3.2. Mở rộng WebSocket với Socket.IO
Dưới đây là bài viết hướng dẫn chi tiết thiết lập cụm server WebSocket với thư viện Socket.IO
Khi triển khai nhiều máy chủ Socket.IO, có hai điều cần quan tâm:
- Bật phiên cố định (sticky-session) nếu tính năng long HTTP được bật (default)
- Sử dụng bộ chuyển đổi tương thích (adapter)
3.2.1. Sticky-session trên multi-servers
Nếu bạn dự định phân phối tải kết nối giữa các quy trình hoặc máy khác nhau, bạn phải đảm bảo rằng tất cả các yêu cầu được liên kết với một ID phiên cụ thể đều tiếp cận quy trình đã tạo ra chúng.
Điều này là do phương thức vận chuyển long polling HTTP gửi nhiều yêu cầu HTTP trong suốt thời gian tồn tại của phiên Socket.IO.
Trên thực tế, về mặt kỹ thuật, Socket.IO có thể hoạt động mà không cần các phiên cố định, với sự đồng bộ hóa sau (theo đường đứt nét)
Mặc dù rõ ràng là có thể triển khai nhưng chúng tôi cho rằng quá trình đồng bộ hóa này giữa các máy chủ Socket.IO sẽ mang lại hiệu quả lớn cho ứng dụng của bạn.
Nhìn chung:
- Nếu không bật phiên cố định, bạn sẽ gặp lỗi HTTP
400
"Session ID unknown" - Việc truyền tải WebSocket không có giới hạn này vì nó dựa trên một kết nối TCP duy nhất cho toàn bộ phiên. Điều đó có nghĩa là nếu bạn vô hiệu hóa phương thức vận chuyển long polling HTTP (là một lựa chọn hoàn toàn hợp lệ vào năm 2021), thì bạn sẽ không cần các phiên cố định
3.2.2. Redis-adapter
Bộ điều hợp là thành phần phía máy chủ chịu trách nhiệm truyền phát các sự kiện tới tất cả hoặc một tập hợp con máy khách.
Khi mở rộng quy mô sang nhiều máy chủ Socket.IO, bạn sẽ cần thay thế bộ điều hợp trong bộ nhớ mặc định bằng cách triển khai khác để các sự kiện được định tuyến chính xác đến tất cả máy khách.
Ngoài bộ điều hợp trong bộ nhớ, còn có 5 cách triển khai chính thức:
Ngoài ra còn có một số tùy chọn khác được duy trì bởi cộng đồng:
Xin lưu ý rằng vẫn cần bật phiên cố định khi sử dụng nhiều máy chủ Socket.IO với dự phòng long polling HTTP.
Installation:
npm install @socket.io/redis-adapter redis
Usage:
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const io = new Server();
const pubClient = createClient({ url: "redis://localhost:6379" });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
io.listen(3000);
});
4. Tổng kết
Bài toán mở rộng server trên hạ tầng Websocket là một bài toán xuất phát từ nhu cầu thực tế nhưng tương đối phức tạp và không dễ dàng để xử lý. So với các HTTP server, mỗi request là staless nên chỉ cần đặt thêm một hay nhiều máy chủ server tương tự mà không cần quá quan tâm nhiều các vấn đề còn lại.
Bài viết đã phân tích hai thách thức lớn nhất mà một cụm Websocket mở rộng gặp phải cũng như cách giải quyết hai vấn đề dựa trên Pub/sub và Reverse Proxy. Hi vọng các bạn có được kiến thức cần thiết khi đối mặt với bài toán mở rộng Websocket và có được hướng giải quyết phù hợp.
Đồng thời, trong bài viết này cũng cung cấp một quy trình thiết lập cụ thể một cụm Websocket server có thể mở rộng bằng cách áp dụng Socket.io với Redis adpater và Treafik reverse proxy được gom cụm và xây dựng trong Docker.
Tài liệu tham khảo:
- sw360cab/websockets-scaling: A tutorial to scale Websockets both via Docker Swarm and Kubernetes
- https://socket.io/docs/v4/redis-adapter/
- https://socket.io/docs/v4/using-multiple-nodes/
Trong thực tế, các bạn có thể lựa chọn các Adpater mô hình Pub/sub khác, cũng như một số reverse proxy phổ biến như nginx, haproxy,… hoặc lựa chọn môi trường triển khai phù hợp như Kubernetes K8s.
Bạn hãy thường xuyên theo dõi các bài viết hay về Lập Trình & Dữ Liệu trên 200Lab Blog nhé. Cũng đừng bỏ qua những khoá học Lập Trình tuyệt vời trên 200Lab nè.
Một vài bài viết mới bạn sẽ thích:
Git là gì? Tổng quan về Git cơ bản cho lập trình viên
Git là gì? Tổng quan về Git cơ bản cho lập trình viên
Bitbucket là gì? GitHub là gì? So sánh Bitbucket và GitHub
Cách giải quyết lỗi password authentication Github
Mobile & Web UI Kit For Flutter (100+ screens)
Bài viết liên quan
Giới thiệu Kiến trúc Backend for Frontend (BFF)
Nov 16, 2024 • 10 min read
Flask là gì? Hướng dẫn tạo Ứng dụng Web với Flask
Nov 15, 2024 • 7 min read
Webhook là gì? So sánh Webhook và API
Nov 15, 2024 • 8 min read
Spring Boot là gì? Hướng dẫn Khởi tạo Project Spring Boot với Docker
Nov 14, 2024 • 6 min read
Two-Factor Authentication (2FA) là gì? Vì sao chỉ Mật khẩu thôi là chưa đủ?
Nov 13, 2024 • 7 min read
Test-Driven Development (TDD) là gì? Hướng dẫn thực hành TDD
Nov 13, 2024 • 6 min read