HTTP sinh ra cho một việc: client hỏi, server trả lời. Khi server có chuyện muốn nói trước — một tin nhắn mới, giá vừa đổi, đơn hàng vừa chuyển trạng thái — mô hình đó không có chỗ cho nó, và người ta đã nghĩ ra bốn cách lách: hỏi liên tục (polling), hỏi rồi treo máy chờ (long polling), mở một phản hồi không bao giờ kết thúc (SSE), và đổi hẳn giao thức trên cùng kết nối TCP (WebSocket). Bốn cách này thường được so sánh bằng sơ đồ mũi tên. Phần này so sánh bằng đồng hồ và bộ đếm byte: cùng một luồng sự kiện ngẫu nhiên, cùng một client, đo độ trễ từ lúc sự kiện sinh ra tới lúc client cầm được nó, và đếm từng byte đi qua TCP.
Cùng một bài toán, bốn cách đẩy

Hình 1: Server ghi thời điểm sinh sự kiện vào thông điệp; client đo chênh lệch khi nhận và đếm byte trên chính kết nối TCP. Bốn cách, một thước.
Server Go sinh sự kiện theo phân phối mũ với khoảng cách trung bình 2 giây (seed cố định) và ghi t0 — thời điểm sinh — vào từng sự kiện. Nó phục vụ bốn đường: /poll?since=N trả ngay các sự kiện mới hơn N; /longpoll?since=N treo tới khi có sự kiện mới hoặc hết 30 giây; /sse giữ phản hồi text/event-stream mở và ghi data: … mỗi khi có sự kiện; /ws nâng cấp lên WebSocket và gửi mỗi sự kiện thành một khung. Client Go bọc net.Conn để đếm mọi byte đọc được — header, body, khung, tất cả — và với mỗi sự kiện nhận được, ghi now − t0. Server và client chạy trong cùng container nên cùng đồng hồ và độ trễ mạng gần như bằng không; đó là cố ý, để thứ đo được là cơ chế, không phải mạng. Phần C mới thêm mạng vào.
Đo thật: mili giây, byte, và CPU

Hình 2: Polling trả giá bằng T/2 độ trễ và header rỗng; long polling bằng một request mỗi sự kiện; SSE và WebSocket bằng một kết nối mở. Với 200 client, polling tốn CPU nhất và trễ nhất.
| cách | trễ p50 | p99 | request/phút | byte/phút | 200 client: CPU server 30 s |
|---|---|---|---|---|---|
| polling 1 s | 486 ms | 900 ms | 60 | 8.373 | 0,33 s |
| polling 5 s | 2.336 ms | 4.180 ms | 12 | 2.295 | — |
| long polling | 0,5 ms | 0,6 ms | 39 | 5.812 | 0,23 s |
| SSE | 0,3 ms | 0,4 ms | 1 | 1.221 | 0,11 s |
| WebSocket | 0,3 ms | 0,4 ms | 1 | 1.276 | 0,15 s |
| sau netem 50 ms: polling / long polling / SSE / WS | 473 / 60,6 / 55,5 / 58,2 ms | 1.067 / 60,8 (max 111) / 60,3 / 60,6 |
Polling: trễ bằng nửa chu kỳ, và byte toàn là câu hỏi rỗng. Hỏi mỗi giây: p50 486 ms, p99 900 ms — đúng như toán học của việc sự kiện rơi ngẫu nhiên vào một chu kỳ T: trung bình T/2, tệ nhất T. Hỏi mỗi 5 giây: p50 2,3 giây, p99 4,2 giây. Cái giá thứ hai nằm ở bộ đếm byte: 8.373 byte mỗi phút cho khoảng 23 sự kiện, trong khi SSE mang cùng số sự kiện bằng 1.221 byte. Phần chênh là header HTTP của ~37 lần hỏi "có gì mới không" và nhận về []. Với một client, đó là chuyện nhỏ; với một triệu client di động, đó là 8 GB mỗi phút để nói "không có gì".
Long polling: trễ gần như không, nhưng mỗi sự kiện là một request mới. p50 0,5 ms — request đã treo sẵn nên sự kiện đi thẳng ra. Nhưng 39 request mỗi phút cho 25 sự kiện: mỗi sự kiện đóng một request và client phải mở request kế, cộng những request hết hạn 30 giây mà không có gì. 5.812 byte mỗi phút — bằng gần năm lần SSE — toàn là header của những lần mở lại. Và có một lỗ hổng nhìn thấy được ở phần C: sự kiện rơi đúng khoảng giữa lúc request cũ vừa trả và request mới chưa tới nơi thì phải chờ — max 111 ms khi mạng 50 ms, gần gấp đôi p50.
SSE và WebSocket: cùng 0,3 ms, cùng 1,2–1,3 KB, khác nhau ở chiều. Một kết nối mở suốt, sự kiện đi ra ngay khi có, byte chỉ là payload cộng vài byte đóng khung (id:/data: của SSE, 2–6 byte header của khung WebSocket). Ở quy mô một client chúng không phân biệt được bằng số; khác biệt là SSE một chiều (server → client) và đi qua HTTP thuần nên mọi proxy, CDN, HTTP/2 đều hiểu, còn WebSocket hai chiều trên một giao thức riêng sau bắt tay — phần 10 sẽ đo xem khác biệt ấy mua được gì.
Thêm 50 ms mạng: mọi cách đẩy cộng đúng 50 ms, polling che mất nó. Đặt server sau netem delay 50ms ở chiều server → client: SSE 55,5 ms, WebSocket 58,2, long polling 60,6 — độ trễ mạng cộng thẳng vào, không hơn. Polling p50 473 ms không đổi vì T/2 lớn hơn RTT gấp mười, chỉ p99 nhích lên 1.067 ms. Nghĩa là trên mạng thật, khoảng cách giữa polling và ba cách kia vẫn nguyên là T/2, và ba cách kia bị giới hạn bởi chính RTT — thứ không cách nào ở tầng ứng dụng rút ngắn được.
200 client: polling tốn CPU gấp ba lần SSE mà trễ gấp 300 lần. Server chịu 200 client polling mỗi giây là 12.000 request mỗi phút, 0,33 giây CPU trong 30 giây, p50 vẫn 450 ms. 200 client SSE: 2 request mỗi phút mỗi client (mở lần đầu và đồng bộ), 0,11 giây CPU, p50 1,6 ms, với 404 goroutine và 5,4 MB heap. WebSocket 0,15 giây, 602 goroutine (ba mỗi kết nối: đọc, ghi, handler), 7,1 MB. Long polling ở giữa: 0,23 giây và p50 2,9 ms — mỗi sự kiện đánh thức 200 handler đang chờ rồi 200 request mới ập vào. Con số 5–7 MB cho 200 kết nối mở là lời đáp cho nỗi lo "giữ kết nối tốn tài nguyên": ở quy mô này, kết nối mở rẻ hơn câu hỏi rỗng nhiều lần — phần 6 đo ở 10.000.
Đánh đổi cần cân nhắc
Polling không chết vì vô dụng; nó sống vì không cần gì. Không cần proxy hiểu kết nối dài, không cần xử lý reconnect, không cần giữ trạng thái ở server, và với sự kiện hiếm (vài lần một giờ) mà chu kỳ 30 giây là chấp nhận được, nó là lựa chọn rẻ nhất về mã. Giá của nó tỉ lệ với số client nhân tần suất hỏi, không với số sự kiện — nên nó thua ngay khi client nhiều hoặc cần trễ dưới một giây.
Kết nối dài đổi header lấy trạng thái. SSE và WebSocket không tốn byte cho câu hỏi rỗng nhưng server phải nhớ từng client: goroutine, bộ đệm, và chuyện gì xảy ra khi client đọc chậm (phần 5), khi mạng đứt mà không ai báo (phần 4), khi cần hai máy chủ (phần 9). Đo ở đây là chi phí lúc mọi thứ êm; phần còn lại của sê-ri là lúc không êm.
Phạm vi đo. Cùng một máy, một client hoặc 200, sự kiện nhỏ (40 byte) và thưa (trung bình 2 giây): byte/phút phản ánh overhead chứ không phản ánh payload; với thông điệp 10 KB, bốn cách gần nhau hơn nhiều về byte. Độ trễ 0,3 ms là của hai tiến trình chung kernel; qua Internet cộng RTT như phần C. Chưa đo: HTTP/2 cho SSE, nén, và trình duyệt thật thay vì client Go.
Ba ý mang về
- Polling trả giá bằng T/2 độ trễ và header rỗng: hỏi mỗi giây là 486 ms trung bình, 900 ms tệ nhất, và 8,4 KB mỗi phút để nhận ~23 sự kiện; giá tỉ lệ với số client nhân tần suất, không với số sự kiện.
- Long polling gần như không trễ nhưng mỗi sự kiện là một request mới (39 request cho 25 sự kiện) và có lỗ hổng lúc mở lại (max 111 ms khi RTT 50 ms); SSE và WebSocket cùng 0,3 ms trên một kết nối với 1,2–1,3 KB mỗi phút.
- Kết nối mở rẻ hơn bạn nghĩ: 200 kết nối là 5–7 MB và 0,11–0,15 giây CPU mỗi 30 giây, trong khi 200 client polling tốn gấp ba CPU cho 12.000 request mỗi phút và vẫn trễ 450 ms. Trễ mạng cộng thẳng vào mọi cách đẩy, nhưng không đổi khoảng cách giữa chúng.
Nguồn
- RFC 6455 — The WebSocket Protocol: https://www.rfc-editor.org/rfc/rfc6455
- WHATWG — Server-sent events (HTML Living Standard): https://html.spec.whatwg.org/multipage/server-sent-events.html
- RFC 6202 — Known Issues and Best Practices for the Use of Long Polling and Streaming in Bidirectional HTTP: https://www.rfc-editor.org/rfc/rfc6202
- gorilla/websocket — tài liệu gói Go: https://pkg.go.dev/github.com/gorilla/websocket
- Go —
net/httpvàhttp.Flushercho SSE: https://pkg.go.dev/net/http#Flusher
Phần sau mở từng byte của bắt tay WebSocket: Upgrade, Sec-WebSocket-Key và Sec-WebSocket-Accept tính bằng tay, bắt gói bằng tcpdump, và đo bắt tay tốn bao nhiêu so với một request HTTP keep-alive.