Vì sao chúng tôi thôi tự vận hành máy chủ WebSocket
· Hibira
Máy chủ thời gian thực tự vận hành bị nghẽn khi tin nhắn tăng vọt, và bạn phải tự chạy theo bản vá cho những lỗ hổng như PHP2Shell. Những gì chúng tôi gặp, những gì tự vận hành cần, và những gì dịch vụ lưu trữ sẵn gánh thay bạn.

Trước khi làm Hibira, chúng tôi từng tự vận hành máy chủ WebSocket viết bằng PHP, triển khai chung với ứng dụng theo kiểu nguyên khối (monolith), trong các dự án trước. Phần lớn thời gian chúng chạy ổn. Nhưng khi lượng tin nhắn tăng vọt, chúng bị tụt lại phía sau: máy chủ bận xử lý những tin nhắn đang có, các yêu cầu mới xếp hàng phía sau, và độ trễ cứ thế tăng lên. Đến khi tin nhắn tới được người dùng thì nội dung đã cũ. Giá đã thay đổi, tỷ số đã cập nhật, và dòng "đang nhập…" hiện ra sau khi người kia đã gửi tin nhắn.
Bài viết này tổng kết những gì chúng tôi rút ra: vì sao máy chủ thời gian thực bị nghẽn vào giờ cao điểm, nếu tự vận hành thì cần bổ sung những gì, và khi nào giao cho dịch vụ lưu trữ sẵn thì đáng hơn.
Nghẽn xảy ra như thế nào
Việc của máy chủ thời gian thực trông đơn giản: nhận một sự kiện và gửi tới mọi kết nối đã đăng ký. Vấn đề là khối lượng công việc này bị nhân lên, và chỉ cần một khâu chậm lại là mọi thứ phía sau đều phải chờ.
Đến nhanh hơn xử lý, hàng đợi chỉ dài thêm
Đây là quy tắc cơ bản của hàng đợi: chừng nào tin nhắn đến nhanh hơn tốc độ máy chủ xử lý, hàng đợi sẽ còn dài ra, và mỗi tin nhắn mới phải chờ mọi tin nhắn trước nó được xử lý xong.
Thử tính bằng con số. Giả sử máy chủ xử lý được 5.000 tin nhắn mỗi giây, và lúc cao điểm có 6.000 tin nhắn mỗi giây đổ về trong 10 giây. 10 giây đó để lại 10.000 tin nhắn chưa xử lý. Kể cả khi cao điểm kết thúc và không còn tin nhắn mới nào, máy chủ vẫn cần thêm 2 giây để xử lý hết, nên tin nhắn cuối cùng trong hàng đợi bị gửi trễ 2 giây. Nếu sau cao điểm vẫn có lưu lượng bình thường đổ về, việc xử lý hết còn chậm hơn.
Với một trang web, trễ 2 giây chỉ làm trải nghiệm kém đi một chút. Với tính năng thời gian thực, giá, vị trí hay trạng thái trò chơi của 2 giây trước thường đơn giản là dữ liệu sai.
Một sự kiện, nhân với mọi người đăng ký
Phát một lần không có nghĩa là gửi một lần. Trên một kênh có 2.000 người đăng ký, mỗi sự kiện được phát là 2.000 lần ghi. Khối lượng thực tế là "số lần phát mỗi giây × số người đăng ký". Những tình huống một-nhiều như phòng chat trực tiếp, tỷ số trận đấu hay bảng điều khiển vận hành là nơi dễ vượt ngưỡng đột ngột nhất, đúng vào lúc lượng người xem tăng lên.
Một kết nối chậm giữ chân tất cả
Điện thoại trong thang máy hay trên tàu điện ngầm có thể chậm hẳn đi hoặc tạm thời không nhận được dữ liệu. Nếu máy chủ ghi cho từng kết nối lần lượt và chờ ghi xong mới chuyển sang kết nối tiếp theo, chỉ một kết nối bị kẹt cũng khiến mọi người dùng phía sau bị trễ theo. Đó chính là "nghẽn" mà chúng tôi gặp: vấn đề không nằm ở đa số người dùng, mà ở vài kết nối chậm kéo cả lượt phát sóng chậm theo.
Sự chờ đợi lan rộng
Khi máy chủ bận, các yêu cầu HTTP dùng để phát sự kiện cũng chậm theo. Backend của bạn chờ phản hồi, nên các yêu cầu của chính nó cũng dồn ứ. Nếu các yêu cầu quá thời gian được tự động thử lại, mỗi lần thử lại lại chất thêm việc lên một máy chủ vốn đã quá tải. Ban đầu chỉ là máy chủ thời gian thực chậm, cuối cùng cả hệ thống đều chậm.
Kiến trúc nguyên khối làm vấn đề lớn hơn
Phần lớn những gì chúng tôi gặp đến từ việc triển khai mọi thứ thành một khối: máy chủ WebSocket chạy và được triển khai cùng phần còn lại của ứng dụng. Khi lưu lượng thời gian thực tăng vọt, nó ngốn tài nguyên của cả ứng dụng, khiến cả các yêu cầu API bình thường cũng phải xếp hàng; ngược lại, khi một chức năng khác chậm đi, tin nhắn thời gian thực cũng bị kẹt theo. Muốn mở rộng phần thời gian thực thì chỉ có cách thêm máy cho cả ứng dụng. Và trong một khối nguyên, mỗi lần triển khai ứng dụng thường cũng làm mọi kết nối WebSocket bị ngắt rồi kết nối lại.
Lý do còn lại: cập nhật bảo mật cho môi trường chạy
Ngoài hiệu năng, lý do còn lại khiến chúng tôi thôi tự vận hành là bảo mật. Hồi đó chúng tôi cũng gặp các lỗ hổng bảo mật của PHP và phải tự chạy theo các bản vá. Khi tự vận hành máy chủ, mọi lỗ hổng trong hệ điều hành, môi trường chạy của ngôn ngữ và thư viện đều do bạn tự theo dõi, không được bỏ sót cái nào.
Ví dụ gần nhất là hai lỗ hổng nghiêm trọng được gọi là "PHP2Shell" mà dự án PHP vá vào ngày 21 tháng 10 năm 2026:
- CVE-2026-109618: hỏng bộ nhớ khi xử lý tình trạng hết bộ nhớ (OOM).
- CVE-2026-109619: use-after-free do trạng thái còn sót lại giữa các yêu cầu.
Cả hai đều được chấm 9.2 theo CVSS v4.0 và có thể cho phép kẻ tấn công chưa xác thực thực thi mã từ xa. Bị ảnh hưởng là PHP phục vụ yêu cầu web, như PHP-FPM, Apache mod_php và php-cgi. Các phiên bản đã vá là 8.2.35, 8.3.36, 8.4.27 và 8.5.12; 8.1 trở về trước không còn được cập nhật chính thức định kỳ.
Hai lỗ hổng này nằm ở PHP phục vụ yêu cầu web, nên không nhất thiết tấn công trực tiếp vào tiến trình WebSocket chạy thường trực. Nhưng bài học vẫn vậy: ngay khi lỗ hổng được công bố, bạn phải tự xác định máy chủ nào bị ảnh hưởng, lên lịch nâng cấp và triển khai lại, càng nhanh càng tốt.
Nếu tự vận hành, hãy bổ sung những điều này
Vấn đề nào cũng có cách giải, nhưng mỗi cách là công việc liên tục, không phải cài đặt một lần là xong:
- Triển khai tách khỏi ứng dụng. Tách máy chủ thời gian thực ra khỏi khối nguyên để nó mở rộng và triển khai độc lập, nhờ vậy cao điểm không kéo sập các chức năng khác.
- Hàng đợi có giới hạn cho từng kết nối. Các lần ghi không được chờ lẫn nhau. Mỗi kết nối có hàng đợi riêng; khi đầy, nghĩa là client đó không theo kịp và nên bị ngắt, thay vì để nó làm chậm người khác.
- Tách nhận và gửi. API phát sự kiện không nên chờ đến khi mọi người đăng ký nhận được mới trả lời, nếu không thì phát sóng chậm sẽ kéo backend của bạn chậm theo.
- Mở rộng theo chiều ngang. Một máy chủ luôn có giới hạn. Thêm máy nghĩa là mọi máy phải chia sẻ kênh và trạng thái hiện diện, với một bộ cân bằng tải phía trước.
- Kiểm tra dung lượng theo mức cao điểm. Kiểm thử tải ở lưu lượng trung bình chỉ cho bạn biết "không sao". Cần kiểm thử ở mức gấp nhiều lần cao điểm, và khi rất nhiều client kết nối lại cùng lúc.
- Theo dõi hàng đợi và độ trễ, không chỉ CPU. Khi CPU trông vẫn còn dư, hàng đợi có thể đã đang dài ra. Bạn cần thấy tin nhắn phải chờ bao lâu, bao nhiêu kết nối bị ngắt vì quá chậm, và nhận cảnh báo khi tình hình xấu đi.
- Có người trực. Cao điểm thường là thời khắc quan trọng nhất của bạn: mở bán vé, phút cuối trận đấu, lúc khuyến mãi bắt đầu. Khi máy chủ gặp sự cố phải có người xử lý.
- Theo kịp cập nhật bảo mật. Khi lỗ hổng trong hệ điều hành, môi trường chạy của ngôn ngữ hay thư viện được công bố, cần đánh giá ảnh hưởng, nâng cấp và triển khai lại.
Ghi lần lượt từng kết nối
Hàng đợi riêng cho từng kết nối
Dịch vụ lưu trữ sẵn gánh thay bạn những gì
Khi dùng dịch vụ thời gian thực lưu trữ sẵn, nhà cung cấp lo những việc trên. Lấy Hibira làm ví dụ:
- Client chậm không làm chậm người khác. Mỗi kết nối có hàng đợi có giới hạn riêng, các lần ghi không chờ nhau. Client có hàng đợi bị đầy hoặc ghi quá thời gian sẽ bị đóng với mã lỗi 4100 của giao thức Pusher, và pusher-js tự kết nối lại khi nhận mã này.
- Bạn thấy được chuyện gì đã xảy ra. Những lần ngắt kết nối này hiện trên thẻ lỗi của ứng dụng với nhãn "Đã ngắt client chậm", nên bạn không phải tự lục log máy chủ.
- Chúng tôi lo dung lượng. Các máy chủ chạy thành cụm, chia sẻ kênh và trạng thái hiện diện, và mở rộng theo chiều ngang. Giới hạn của gói là giới hạn cứng rõ ràng, nên không có bất ngờ trên hóa đơn.
- Dùng giao thức Pusher. pusher-js, Laravel Echo và các thư viện máy chủ bạn đang dùng chạy được ngay; đổi dịch vụ chỉ là đổi host và khóa.
- Chúng tôi lo việc vá lỗi. Cập nhật bảo mật cho máy chủ WebSocket và môi trường chạy bên dưới là việc của chúng tôi. Bạn vẫn phải cập nhật ứng dụng của mình, nhưng bớt được một thứ phải canh chừng.
Chi phí cũng cần nói rõ: vượt mức miễn phí thì trả phí hằng tháng, sự kiện của bạn đi qua bên thứ ba, và bạn phải chấp nhận giới hạn của gói.
Khi nào tự vận hành vẫn hợp lý
Dịch vụ lưu trữ sẵn không phải câu trả lời duy nhất. Tự vận hành có thể hợp hơn khi:
- Lưu lượng nhỏ và ổn định, không có cao điểm rõ rệt.
- Nội dung sự kiện không được rời khỏi hạ tầng của bạn.
- Bạn đã có đội vận hành, hệ thống giám sát và lịch trực, nên thêm một dịch vụ chạy thường trực không phải gánh nặng.
Nếu bạn dùng Laravel, bài tự chạy Reverb hay dùng dịch vụ lưu trữ sẵn so sánh cụ thể hơn. Cả hai đều dùng giao thức Pusher, nên sau này đổi ý cũng chỉ là đổi cấu hình.
Kết luận của chúng tôi
Vì vậy chúng tôi quyết định xây dựng lại: tách việc truyền tin thời gian thực ra khỏi khối nguyên, biến nó thành một dịch vụ độc lập có thể mở rộng theo chiều ngang, dùng nội bộ trước rồi mở cho bên ngoài sử dụng. Phía trước có bộ cân bằng tải, và dịch vụ tự động mở rộng theo chiều ngang theo tải, nên khi lưu lượng ập đến đột ngột, nhiều máy chủ hơn cùng gánh thay vì một máy chủ phải gồng mình. Đó là Hibira.
Cái khó khi tự vận hành máy chủ WebSocket không phải là làm cho nó chạy, mà là giữ cho nó đúng giờ trong vài phút bận rộn nhất. Đó cũng chính là lúc người dùng quan tâm nhất và dễ xảy ra sự cố nhất. Nếu đội của bạn muốn dành thời gian cho sản phẩm thay vì cho hàng đợi và lịch trực, dịch vụ lưu trữ sẵn là lựa chọn nhẹ nhàng hơn.
Gói miễn phí của Hibira không cần thẻ tín dụng và đủ cho phát triển và ứng dụng nhỏ. Nếu bạn đang dùng Pusher, hãy làm theo các bước trong Chuyển từ Pusher Channels để chuyển sang mà không phải sửa mã.