Nghĩ về SSE như một buổi phát thanh: đài nói, bạn chỉ nghe, mất sóng thì máy tự dò lại và bắt tiếp từ chỗ dở. WebSocket thì như một cuộc gọi điện thoại: hai bên cùng nói được. Nhưng dù bạn đang bật radio hay đang gọi điện, thứ tốn tài nguyên vẫn là một đường truyền mở — và đó là chỗ mà niềm tin phổ biến "WebSocket nhẹ hơn HTTP" tan ra khi đặt lên bàn cân. Bài này cân thử.

Người ta hay chọn WebSocket vì nghĩ nó "nhẹ hơn HTTP". Phần trước đo WebSocket; bài này đo SSE trên cùng máy chủ, cùng số client, và so hai bảng.

SSE trong Vert.x là ba dòng

r.get("/sse").handler(c -> {
    var res = c.response();
    res.setChunked(true)
       .putHeader("Content-Type", "text/event-stream")
       .putHeader("Cache-Control", "no-cache");
    SSE.add(res);
    res.closeHandler(v -> SSE.remove(res));
});

// gui mot su kien
res.write("data: " + json + "\n\n");

Không có bắt tay nâng cấp, không có giao thức riêng. Đây là một response HTTP bình thường không bao giờ kết thúc, và trình duyệt có sẵn EventSource để đọc nó.

Đo cạnh nhau

Cùng máy chủ, cùng máy, mỗi lần chạy trên tiến trình sạch:

Số kết nối Heap mỗi kết nối RSS Số luồng Phát tin cho tất cả
1 000 WebSocket 4 263 B 120 MiB 37 7,5 ms
SSE 4 071 B 109 MiB 37 9,1 ms
5 000 WebSocket 4 199 B 150 MiB 39 17,9 ms
SSE 3 909 B 172 MiB 37 24,5 ms
10 000 WebSocket 4 037 B 177 MiB 39 21,3 ms
SSE 3 905 B 203 MiB 39 54,9 ms
Bộ nhớ mỗi kết nối gần như bằng nhau — khoảng 4 KB cho cả hai. Điều đó hợp lý khi nghĩ kỹ: dù là WebSocket hay SSE, thứ máy chủ giữ vẫn là một kết nối TCP cộng một chút trạng thái. Giao thức bên trên gần như không đổi chi phí đó.

Nên nếu bạn đang chọn WebSocket vì "nó nhẹ hơn", bảng trên nói rằng lý do đó không tồn tại.

Chỗ khác biệt thật

Phát tin ở quy mô lớn. Ở mười nghìn kết nối, WebSocket mất 21,3 ms còn SSE mất 54,9 ms — chậm hơn 2,5 lần. Lý do nằm ở khung dữ liệu: SSE gửi văn bản có cấu trúc (data: ...\n\n) qua chunked encoding, còn WebSocket gửi khung nhị phân gọn hơn. Ở một nghìn kết nối thì chênh lệch không đáng kể; ở trăm nghìn thì đáng.

Một chiều hay hai chiều. Đây là khác biệt quyết định. SSE chỉ đi từ máy chủ xuống client. Client muốn gửi gì thì phải mở một request HTTP riêng — vẫn được, nhưng là hai đường thay vì một.

Tự kết nối lại. EventSource của trình duyệt tự động kết nối lại khi đứt, và gửi kèm header Last-Event-ID để máy chủ biết gửi tiếp từ đâu. Với WebSocket bạn phải tự viết phần này, và tự viết phần này đúng thì khó hơn nó trông.

Đi qua hạ tầng cũ. SSE là HTTP thường, nên proxy, cân bằng tải, tường lửa doanh nghiệp đều hiểu. WebSocket cần nâng cấp giao thức, và không ít thiết bị trung gian vẫn cắt nó.

Chọn cái nào

Bài toán Nên dùng
Bảng điều khiển, thông báo, giá cổ phiếu, tiến độ tác vụ SSE
Chat, game, cộng tác thời gian thực, gõ phím trực tiếp WebSocket
Cần chạy qua proxy doanh nghiệp không kiểm soát được SSE
Trăm nghìn kết nối trở lên, mỗi giây một lần phát WebSocket

Luật rút gọn: cần hai chiều thật thì WebSocket, còn lại thì SSE — vì nó đơn giản hơn, tự kết nối lại, và đo ra không tốn hơn.

Một cái bẫy chung

Cả hai đều giữ tham chiếu tới kết nối trong một tập, và cả hai đều cần dọn:

res.closeHandler(v -> SSE.remove(res));      // SSE
ws.closeHandler(v -> WS.remove(ws));         // WebSocket

Thiếu dòng này thì tập chỉ lớn lên, và vòng lặp phát tin ngày càng chậm vì nó đang gửi cho những kết nối đã chết từ lâu. Với 4 KB mỗi kết nối, một dịch vụ chạy vài tuần sẽ tự giải thích vì sao nó hết bộ nhớ.

Muốn nhìn tận mắt một luồng SSE, mở nó bằng curl:

# xem mot luong SSE bang mat
curl -N -H "Accept: text/event-stream" http://localhost:8080/sse

Cờ -N tắt bộ đệm của curl. Nếu không thấy gì cho tới khi ngắt, máy chủ của bạn đang gom thân response thay vì gửi từng đoạn — thiếu setChunked(true) hoặc có một proxy ở giữa đang đệm lại.

Mẫu số chung

Niềm tin "giao thức này nhẹ hơn" gần như luôn nhắm nhầm chỗ: cái tốn tài nguyên không phải giao thức mà là một kết nối TCP đang mở — khoảng 4 KB, và WebSocket hay SSE cũng thế, vì lớp khung bên trên chỉ là phần lẻ. Chọn giữa hai thứ theo "cái nào nhẹ hơn" là chọn theo một huyền thoại dân gian mà một phép đo mười phút đủ để xoá. Cùng kiểu huyền thoại ấy ở "gRPC nhẹ hơn REST nên ít RAM hơn", "giao thức nhị phân luôn tiết kiệm hơn text" — đúng ở tầng byte nhưng vô nghĩa nếu nút thắt nằm ở số kết nối giữ mở hay số vòng khứ hồi. Khi so hai công cụ, hỏi tài nguyên khan hiếm thật sự là gì (kết nối, vòng mạng, bộ nhớ, hay số nhân) rồi mới đo — chứ đừng chọn theo tính từ "nhẹ". Và một khi tài nguyên đã hoà, quyết định dời sang năng lực: cần hai chiều thật không, có tự kết nối lại không, có qua được proxy không — đó mới là trục cắn.

Điều thứ hai, cái bẫy khiêm tốn mà đắt: mỗi lần thêm một thứ vào một tập sống lâu, phải có một chỗ gỡ nó ra khi vòng đời kết thúc. SSE.add(res) mà không có closeHandler gỡ ra là một rò rỉ chắc chắn — tập phình mãi, và vòng phát tin ngày một chậm vì gửi cho những kết nối đã chết. Đây là họ hàng của mọi rò rỉ "đăng ký mà không huỷ đăng ký": một event listener gắn vào mà quên tháo, một observer giữ tham chiếu ngược, một goroutine không có đường thoát, một cursor hay file handle mở mà không đóng. Quy tắc: mỗi add phải có một remove neo vào đúng sự kiện chấm dứt — và cái sự kiện đó (close, finally, defer, dispose) là chỗ dễ quên nhất, vì lúc viết mọi thứ vẫn chạy tốt; nó chỉ phát nổ sau vài tuần.

Bài sau: xác thực và phân quyền trong Vert.x Web, và chi phí kiểm JWT trên mỗi request.