HTTP thông thường là hỏi-đáp: mở kết nối, gửi request, nhận response, xong. Muốn cập nhật liên tục — tin nhắn, giá cổ phiếu, thông báo đẩy — client phải hỏi đi hỏi lại (polling), mỗi lần một request đầy đủ với header đồ sộ. WebSocket lật ngược: bắt tay một lần rồi giữ kết nối mở mãi, hai bên gửi khung dữ liệu tí xíu bất cứ lúc nào. Cái giá của mô hình này không ở mỗi lần gửi, mà ở chỗ giữ một kết nối mở tốn tài nguyên gì. Bài này đo cả hai: khung WebSocket nhỏ cỡ nào, và một kết nối mở ngốn bao nhiêu bộ nhớ — rồi vấp đúng cái bẫy khi trả lời câu thứ hai.

Giữ một kết nối mở

WebSocket giữ kết nối thế nào

Một kết nối WebSocket bắt đầu như một request HTTP bình thường, nhưng mang thêm header Upgrade: websocket. Máy chủ trả lời 101 Switching Protocols, và từ giây phút đó kết nối TCP không còn nói tiếng HTTP nữa — nó chuyển sang giao thức khung (frame) của WebSocket. Không đóng lại. Cả client lẫn server đều có thể gửi một khung bất cứ lúc nào, không cần bên kia hỏi trước.

Đây là khác biệt nền tảng với HTTP hỏi-đáp. Với polling, muốn biết có tin mới client phải gửi một request đầy đủ mỗi vài giây, kể cả khi chẳng có gì mới. Với WebSocket, kết nối nằm sẵn đó im lặng, và ngay khi có dữ liệu server đẩy một khung qua. Đổi lại sự tức thời đó, hệ thống phải gánh một kết nối luôn mở cho mỗi client — và khi có hàng chục nghìn client, cái gánh đó là câu hỏi thiết kế trung tâm.

Đo: khung tí xíu, nhưng kết nối thì phải giữ

Tôi dựng một máy chủ WebSocket thật (thư viện websockets của Python) trong container, đo hai thứ.

Thứ nhất, kích thước một khung. Tôi gửi tin "hi" (2 byte dữ liệu) và bắt gói bằng tcpdump để đọc đúng số byte trên dây:

client -> server: gói TCP mang 8 byte  (2 header + 4 mask + 2 payload)
server -> client: gói TCP mang 4 byte  (2 header + 2 payload)

Một khung WebSocket cực gọn: 2 byte header (cờ và độ dài), cộng 4 byte mask nếu đi từ client (giao thức bắt buộc client che dữ liệu), cộng payload. Gửi 2 byte "hi" từ client tốn đúng 8 byte trên dây; server đẩy về tốn 4 byte. So sánh: một request HTTP thực tế với User-Agent, Cookie, Accept dễ tới vài trăm tới cả nghìn byte — và mỗi lần polling phải trả lại toàn bộ. Với cập nhật nhỏ và thường xuyên, WebSocket rẻ hơn polling cả một hai bậc độ lớn về byte — lý do nó thắng cho chat, bảng giá, game.

Thứ hai — và đây là trọng tâm — chi phí bộ nhớ để giữ một kết nối mở. Tôi mở 3000 kết nối cùng lúc từ client tới máy chủ, giữ nguyên chúng, rồi đọc VmRSS của tiến trình máy chủ trước và sau:

Cách giữ kết nối RSS lúc trống RSS + 3000 kết nối Mỗi kết nối
Máy chủ websockets (Python) 27,2 MB 164,5 MB ~45,8 KB
Socket TCP trần (giữ trong list) 9,9 MB 10,2 MB ~0,1 KB

Máy chủ WebSocket Python tốn 45,8 KB cho mỗi kết nối mở. Con số đó nghe nhỏ, nhưng nhân lên thì đáng sợ: 100.000 kết nối × 45,8 KB ≈ 4,58 GB RAM chỉ để giữ chúng mở, chưa gửi byte nào. Đây là lý do một máy chủ realtime hay đo sức chịu tải bằng "số kết nối đồng thời" chứ không chỉ bằng "request mỗi giây".

Một lần tôi đo hớ: cái đắt không phải kết nối

Đo ra 45,8 KB mỗi kết nối, phản xạ đầu tiên của tôi là gán tội cho nhân hệ điều hành: "mỗi kết nối TCP mở giữ một bộ đệm gửi và một bộ đệm nhận trong nhân (SO_SNDBUF/SO_RCVBUF, đúng cái đã đo ở bài đệm gửi và nhận), mỗi cái cỡ vài chục KB, cộng lại ra 45,8 KB là phải rồi." Tôi định viết cả một mục về việc chỉnh bộ đệm socket để giảm chi phí kết nối.

May là tôi đo đối chứng trước khi kết luận. Tôi viết một máy chủ không dùng thư viện nào — chỉ socket trần, accept() mỗi kết nối rồi nhét vào một danh sách — rồi mở đúng 3000 kết nối tới nó. Nếu 45,8 KB thật sự là bộ đệm nhân, con số ở đây phải tương đương.

Nó chỉ tốn 0,1 KB mỗi kết nối (RSS nhích từ 9,9 lên 10,2 MB cho cả 3000). Kém 400 lần. Cùng một loại kết nối TCP mở, cùng nhân Linux, cùng số lượng — khác biệt duy nhất là cái bọc quanh kết nối. Vậy 45,8 KB kia gần như toàn bộ không phải chi phí của kết nối, mà là chi phí runtime mà thư viện websockets giữ cho mỗi kết nối: một tác vụ asyncio riêng, một đối tượng protocol, các bộ đệm đọc/ghi ở tầng ứng dụng, hàng đợi tin. Bản thân kết nối TCP mở rất rẻ; cái đắt là bộ máy Python tôi dựng quanh nó.

Có một chi tiết thứ hai tôi suýt hiểu sai và phải nói cho sòng phẳng: VmRSS chỉ đếm bộ nhớ vùng người dùng. Bộ đệm socket trong nhân (struct socket, các skbuff) là bộ nhớ nhân, không hiện ra trong RSS của tiến trình. Nên ngay cả con số 0,1 KB của socket trần cũng chưa phải toàn bộ chi phí hệ thống — phần nhân vẫn có, chỉ là nó không nằm trong thước đo tôi dùng. Bài học: "chi phí giữ một kết nối" là một câu hỏi chưa đủ nghĩa cho tới khi nói rõ đếm bộ nhớ nào (người dùng hay nhân) và ở runtime nào. Tôi suýt dán một con số của thư viện Python lên thành "cái giá của một kết nối TCP" — hai thứ khác nhau một trời một vực.

Điều này giải thích luôn bài toán kinh điển "C10K" rồi "C10M" (mười nghìn, rồi mười triệu kết nối trên một máy). Nhân Linux từ lâu đã kham được hàng triệu kết nối mở; thứ vỡ trước là chi phí mỗi kết nối ở tầng ứng dụng. Máy chủ Go hay Rust giữ mỗi kết nối trong vài KB nên ôm được hàng triệu; máy chủ Python asyncio tốn 45,8 KB mỗi kết nối sẽ chạm trần RAM ở khoảng trăm nghìn — cùng một nhân, khác nhau ở lớp runtime.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là chọn WebSocket khi cập nhật vừa nhỏ vừa thường xuyên, không phải cho mọi thứ. Nếu client cần biết tin mới ngay lập tức và tin đó nhỏ (một dòng chat, một tick giá, một sự kiện), WebSocket thắng đậm: khung 8 byte thay vì request vài trăm byte, và không có độ trễ của việc hỏi-rồi-chờ. Nhưng nếu dữ liệu cập nhật vài phút một lần, hoặc client chỉ cần lấy về rồi thôi, thì một kết nối mở suốt ngày chỉ để thỉnh thoảng nhận một tin là lãng phí — polling thưa hoặc SSE nhẹ gánh hơn. Chi phí thật của WebSocket không phải mỗi tin, mà là cái kết nối luôn mở bạn phải nuôi cho từng client, kể cả khi nó im lặng.

Hệ quả thứ hai là quy hoạch dung lượng theo số kết nối đồng thời, đo trên đúng runtime của mình. Trước khi hứa "chịu được 500.000 người online", hãy đo bộ nhớ mỗi kết nối trên chính stack bạn dùng rồi nhân lên — đừng đoán. 45,8 KB × 500.000 = 23 GB, con số quyết định bạn cần bao nhiêu máy. Và vì phần lớn chi phí nằm ở tầng ứng dụng chứ không ở nhân, chọn ngôn ngữ/thư viện là đòn bẩy lớn nhất: cùng một tải kết nối, một stack gọn có thể tốn ít RAM hơn cả chục lần.

Hệ quả thứ ba là một nếp đo lường chung, đúng cho cả sê-ri: luôn đo đối chứng trước khi gán nguyên nhân. Tôi có sẵn một lời giải thích nghe hợp lý (bộ đệm nhân) cho 45,8 KB, và nếu lười tôi đã viết nó thành sự thật; chỉ một phép đo đối chứng — bỏ thư viện đi — đã lật ngược kết luận. Con số mang theo: giữ một kết nối WebSocket mở tốn ~45,8 KB trên máy chủ Python nhưng chỉ ~0,1 KB nếu là socket TCP trần — cái đắt là runtime bọc quanh kết nối, không phải bản thân kết nối. Khi một con số làm bạn ngạc nhiên, đừng vội gán nó cho nghi phạm quen mặt; hãy tách biến, vì thứ đắt nhất thường không nằm ở nơi bạn tưởng.

Một chi tiết dễ quên: kết nối im vẫn có thể chết

Giữ một kết nối mở không có nghĩa nó tự sống. Một kết nối TCP không gửi gì thì cả hai đầu đều tưởng nó còn khỏe, nhưng một NAT hay tường lửa ở giữa có thể âm thầm quên bảng ánh xạ sau vài phút im lặng — lần sau gửi khung sẽ rơi vào hư không. Vì thế WebSocket có sẵn khung ping/pong: vừa giữ đường qua NAT khỏi bị dọn, vừa phát hiện sớm khi kết nối đã chết (như bài keepalive TCP đã đo cho tầng dưới). Một máy chủ realtime nghiêm túc phải bật ping định kỳ và đóng những kết nối không đáp pong — nếu không, "số kết nối đang mở" phình lên toàn kết nối ma, ngốn đúng 45,8 KB mỗi cái mà chẳng phục vụ ai. Trong phép đo trên tôi cố ý tắt ping (ping_interval=None) để đo đúng chi phí tĩnh của một kết nối; sản xuất thì ngược lại, ping giữ cho con số kết nối phản ánh sự thật.

Thử ba mươi giây

Mở trình duyệt tới một trang có realtime (chat, bảng điều khiển, trang giá), bật DevTools tab Network và lọc theo WS. Bạn sẽ thấy một kết nối 101 Switching Protocols mở ra và không đóng lại suốt thời gian bạn ở trang — nhấp vào nó, xem tab Messages để thấy từng khung nhỏ chảy qua theo thời gian thực, mỗi cái chỉ vài byte. Đó là một kết nối đang được giữ mở riêng cho bạn ở phía máy chủ, tốn vài chục KB bộ nhớ bên đó. Nhân con số ấy với số người đang cùng online, và bạn có ngay bài toán dung lượng mà mọi hệ thống realtime phải giải — cái giá không phải ở mỗi tin, mà ở chỗ giữ cho đường dây luôn mở.