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

Ảnh chụp đoạn mã nền tối bốn cách đẩy sự kiện tới trình duyệt cùng một bài toán với Go 1.23 server phát sự kiện ngẫu nhiên trung bình 1 trên 2 giây client đo từ lúc sinh ra tới lúc nhận được, bài toán gồm server có sự kiện mới lúc t0 ghi vào thông điệp client nhận lúc t1 độ trễ bằng t1 trừ t0, đồng thời đếm số request HTTP số byte header cộng body mỗi phút số kết nối TCP mở, chạy 60 giây mỗi cách cùng chuỗi sự kiện seed cố định cùng mạng Docker, bốn cách gồm polling GET poll since N mỗi T giây trễ trung bình T chia 2 request đều dù không có gì, long polling GET treo tới khi có sự kiện hoặc 30 giây trễ xấp xỉ 1 RTT mỗi sự kiện tốn một request mới, SSE GET text event-stream ghi data mãi một kết nối một chiều tự reconnect qua HTTP, WebSocket Upgrade khung hai chiều trên một TCP một kết nối hai chiều header 2 tới 6 byte mỗi khung, đo gì gồm A độ trễ p50 p99 max từ sự kiện tới client 60 sự kiện mỗi cách, B request và byte mỗi phút polling 1 giây polling 5 giây long polling SSE WebSocket, C khi mạng thêm 50 ms RTT netem cách nào chịu thiệt nhất, D CPU và số kết nối ở server khi 200 client cùng lúc

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

Ảnh chụp output thật nền tối polling 486 ms và 8,4 KB mỗi phút WebSocket 0,3 ms và 1,3 KB với Go 1.23 server và client trong go-lab sự kiện ngẫu nhiên trung bình 1 trên 2 giây byte đếm trên TCP sau khi đồng bộ, A B một client 40 giây mỗi cách gồm polling 1 giây trễ p50 485,9 ms p99 900,5 max 905,7 60 request mỗi phút 8373 byte mỗi phút 1 kết nối keep-alive, polling 5 giây p50 2336 ms p99 4180 max 4446 12 request 2295 byte 1 kết nối, long polling p50 0,5 ms p99 0,6 max 0,6 39 request xấp xỉ 1 mỗi sự kiện cộng hết hạn 5812 byte 1 kết nối, SSE p50 0,3 ms p99 0,4 max 0,4 1 request 1221 byte 1 kết nối, WebSocket p50 0,3 ms p99 0,4 max 0,5 1 request 1276 byte 1 kết nối, polling trễ xấp xỉ T chia 2 trung bình T tệ nhất 8 KB mỗi phút cho khoảng 23 sự kiện là header của khoảng 37 lần hỏi có gì không và nhận mảng rỗng, C server đặt sau netem 50 ms chiều server sang client client bên kia gồm polling 1 giây p50 472,8 ms p99 1067 T chia 2 che mất 50 ms p99 lên vì cộng RTT mỗi lượt, long polling p50 60,6 ms p99 60,8 max 111,5 là 50 ms cộng khoảng 10 ms max 111 sự kiện tới đúng lúc request cũ vừa trả phải chờ request mới, SSE p50 55,5 ms p99 60,3, WebSocket p50 58,2 ms p99 60,6, trễ mạng cộng thẳng vào push long polling thêm một khoảng trống mỗi lần phải mở request kế, D 200 client cùng lúc 30 giây phía server gồm polling 1 giây trễ p50 450 ms p99 967 60 request mỗi phút mỗi client 12000 request mỗi phút tổng CPU server 30 giây 0,33 giây goroutine 202 heap 4,2 MB, long polling 2,9 ms p99 7,4 34 request 0,23 giây 404 goroutine 5,8 MB, SSE 1,6 ms p99 3,6 2 request 0,11 giây 404 goroutine 5,4 MB, WebSocket 1,5 ms p99 2,6 2 request 0,15 giây 602 goroutine 7,1 MB, 200 kết nối mở bằng 200 tới 600 goroutine 5 tới 7 MB rẻ polling tốn CPU gấp 2 tới 3 lần mà trễ gấp 300 lần, nói thẳng 0,3 ms là hai tiến trình cùng máy qua Internet mọi cách cộng thêm RTT như ở C và khoảng cách giữa polling với ba cách kia vẫn là T chia 2 long polling 39 request mỗi phút cho 25 sự kiện nhiều hơn số sự kiện vì request cũng hết hạn 30 giây và mở lại chi phí ẩn của nó

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ề

  1. 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.
  2. 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.
  3. 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

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.