Một bảo vệ ngồi trông cả trăm màn hình camera thì nhàn tênh — mọi thứ đứng yên, giữ thì rẻ. Nhưng bảo anh ta chạy tới từng phòng đưa tận tay một tờ thông báo thì đó là trăm lượt chạy, và số phòng càng nhiều càng lâu. Kết nối WebSocket giữ mở trong Vert.x đúng như vậy: giữ mười nghìn cái gần như không tốn gì, nhưng phát tin cho tất cả lại là một vòng quét O(n). Bài này đo cả hai mặt đó — cùng cái bẫy trần 50 kết nối phía client làm chương trình treo mà không báo lỗi.
Kết nối giữ mở là chỗ mô hình event loop tỏa sáng nhất: mười nghìn client đang chờ thì với mô hình một luồng mỗi kết nối là mười nghìn luồng, còn ở đây là con số bạn sắp thấy.
Máy chủ
vertx.createHttpServer()
.webSocketHandler(ws -> {
if (!ws.path().equals("/ws")) { ws.reject(); return; }
WS.add(ws);
ws.closeHandler(v -> WS.remove(ws));
ws.textMessageHandler(m -> ws.writeTextMessage("echo:" + m));
})
.requestHandler(router) // HTTP thường vẫn dùng chung cổng
.listen(8080);
Hai dòng đáng chú ý: reject() cho đường dẫn lạ — không có nó thì mọi đường dẫn đều nâng cấp được thành WebSocket; và closeHandler gỡ khỏi tập — thiếu nó là rò rỉ bộ nhớ chắc chắn, vì tập giữ tham chiếu tới kết nối đã chết.
Đo
| Số kết nối | Heap tăng thêm | Mỗi kết nối | RSS | Số luồng | Phát tin cho tất cả |
|---|---|---|---|---|---|
| 1 000 | 4 MiB | 4 263 B | 120 MiB | 37 | 7,5 ms |
| 5 000 | 20 MiB | 4 199 B | 150 MiB | 39 | 17,9 ms |
| 9 767 | 39 MiB | 4 037 B | 177 MiB | 39 | 21,3 ms |
Ba điều đọc ra:
Số luồng không đổi. Ba mươi bảy luồng phục vụ một nghìn kết nối, ba mươi chín luồng phục vụ gần mười nghìn. Đây là toàn bộ lý do dùng mô hình này cho kết nối giữ mở — phần 1 đo được Spring MVC dùng 237 luồng chỉ để phục vụ request ngắn.
Khoảng 4 KB heap mỗi kết nối, và con số này ổn định khi số kết nối tăng — không có chi phí ẩn nào bùng lên ở quy mô lớn. Mười nghìn kết nối là 39 MiB; một trăm nghìn sẽ là khoảng 400 MiB, vẫn nằm trong tầm một máy chủ bình thường.
RSS tăng chậm hơn nhiều so với heap — 120 lên 177 MiB, phần lớn là bộ đệm của Netty được tái dùng.
Cái bẫy nằm ở phía client
Lần đo đầu tiên của tôi cho ra đúng 50 kết nối dù yêu cầu 10 000, và không có lỗi nào:
maxWebSockets = 50 ← mặc định của HttpClientOptions
maxPoolSize = 5
HttpClient của Vert.x giới hạn 50 WebSocket cho mỗi endpoint. Yêu cầu thứ 51 không bị từ chối — nó xếp hàng đợi cho tới khi có chỗ, nên chương trình chỉ đơn giản là treo ở đó.
Nếu bạn viết một dịch vụ mở nhiều WebSocket ra ngoài — cầu nối, bot, bộ thu thập dữ liệu — thì đây là trần bạn sẽ đâm vào, và triệu chứng là "chậm không rõ lý do" chứ không phải một lỗi.
new HttpClientOptions().setMaxWebSockets(5000);
Phát tin cho tất cả tốn tuyến tính
Bảy phẩy năm mili giây cho một nghìn, 21,3 ms cho gần mười nghìn — khoảng 2,2 micro giây mỗi kết nối. Tuyến tính, không có bất ngờ.
Nhưng nhớ rằng đó là thời gian ngồi trên event loop. Phần 3 đo được chặn 50 ms làm thông lượng sập 59 lần; một vòng lặp phát tin cho 100 000 kết nối mất khoảng 220 ms và sẽ làm đúng như vậy. Ở quy mô đó, phải chia nhỏ vòng lặp qua nhiều lượt event loop, hoặc phân tán kết nối qua nhiều bản sao verticle và để mỗi bản tự phát cho phần của mình.
Ba việc phải nhớ
Luôn closeHandler để gỡ khỏi tập. Không có nó, tập kết nối chỉ có lớn lên.
Luôn reject() cho đường dẫn không mong đợi. Mặc định là chấp nhận mọi đường dẫn.
Kiểm tra writeQueueFull() khi gửi nhiều. Đúng bài học của phần 12: writeTextMessage không chặn, nên một client đọc chậm sẽ làm hàng chờ ghi phình lên y hệt.
Muốn biết mình đang giữ đúng bao nhiêu kết nối và tốn bao nhiêu mỗi cái, đếm nhanh:
# dem ket noi websocket dang mo va bo nho tuong ung
lsof -nP -p <pid> -iTCP -sTCP:ESTABLISHED | wc -l
jcmd <pid> GC.heap_info | head -2
Chia số MiB cho số kết nối. Ra hơn 10 KB mỗi kết nối nghĩa là bạn đang giữ thêm thứ gì đó cho mỗi client — thường là một buffer hoặc một danh sách chưa bao giờ được dọn.
Mẫu số chung
Hai ngân sách rất khác nhau mà người ta hay gộp làm một: giữ một thứ và tác động lên mọi thứ đang giữ. Event loop khiến giữ mười nghìn kết nối gần như miễn phí — 39 luồng, 4 KB mỗi cái, phẳng — nhưng đó là chi phí lúc nằm yên. Khoảnh khắc bạn phát tin cho tất cả, nó thành một vòng quét O(n) trên một event loop, và ở 100 000 kết nối là 220 ms chặn cứng cả luồng đó. Cùng cái bẫy "rẻ khi giữ, đắt khi quét" ở một HashMap triệu khoá tra cứu tức thì nhưng lặp hết thì lâu, một bảng CSDL index tốt cho tra một dòng mà full scan thì chết, một danh sách theo dõi nhẹ cho tới lần gửi thông báo hàng loạt. Bài học: "rẻ để nắm giữ" không suy ra "rẻ để duyệt qua" — đo riêng cái thao tác chạm-tới-tất-cả, vì đó là cái O(n) mà footprint tĩnh không hề báo trước, và ở quy mô lớn nó phải được chia nhỏ hoặc rải ra.
Điều thứ hai, một cái bẫy chẩn đoán: một cái trần chặn kiểu xếp-hàng hiện ra thành "chậm bí ẩn", chứ không phải một lỗi. maxWebSockets=50 không ném lỗi ở kết nối thứ 51 — nó cho cái đó đứng chờ, nên "đụng trần" trông y hệt "tự nhiên treo". Đây là lý do một tài nguyên bão hoà thường lộ ra dưới dạng độ trễ chứ không phải lỗi: một pool kết nối cạn, một semaphore hết vé, một rate-limiter đang ghìm — tất cả đều im lặng bắt bạn chờ. Nên khi thứ gì đó chậm không giải thích được mà log thì sạch, hãy nghi một cái hàng đợi hoặc một cái trần mặc định bạn không biết trước khi nghi mã của mình — và biết rõ mặc định của thư viện hành xử ra sao tại giới hạn: từ chối lớn tiếng, hay lặng lẽ xếp hàng.
Bài sau: Server-Sent Events — làm được phần lớn những gì WebSocket làm, và đo xem nó rẻ hơn hay không.