Có hai cách dạy một trăm học trò. Cách một: mỗi trò một gia sư kèm riêng — đắt, nhưng một trò hỏi lê thê thì chỉ cặp đó kẹt, 199 cặp kia vẫn chạy. Cách hai: vài thầy, mỗi thầy xoay vòng cả một lớp lớn thật nhanh — ít thầy phục vụ được nghìn trò, nhưng nếu một trò giữ chân thầy bằng một câu hỏi dài, cả lớp thầy đó ngồi chờ. "Một luồng một request" là kiểu gia sư một-kèm-một; event loop của Vert.x là kiểu vài thầy xoay vòng. Bài này bỏ qua mấy chữ "không chặn", "hướng sự kiện", đi thẳng vào chỗ khác biệt hiện thành số: chuyện gì xảy ra khi số kết nối đồng thời tăng lên.

Bàn thí nghiệm

Hai máy chủ, cùng một máy 16 nhân, cùng một endpoint làm đúng một việc: chờ 20 mili giây rồi trả lời — tương đương một lời gọi CSDL hoặc một API hạ nguồn.

// Vert.x — chờ bằng timer, không giữ luồng nào
vertx.setTimer(20, id -> req.response().end("ok"));

// Spring MVC — chờ bằng cách chặn luồng đang phục vụ request
Thread.sleep(20);
return "ok";

Bộ tạo tải viết bằng luồng ảo Java 21, cả hai đo trên cùng HTTP/1.1, mỗi mức chạy 6 giây.

Bảng đo

Kết nối đồng thời Thông lượng p50 p95 p99
200 Vert.x 9 143 req/s 21,5 ms 23,1 ms 29,0 ms
Spring MVC 8 622 req/s 22,8 ms 25,3 ms 27,2 ms
1 000 Vert.x 44 740 req/s 21,7 ms 24,5 ms 41,0 ms
Spring MVC 8 749 req/s 114,1 ms 118,6 ms 121,2 ms
5 000 Vert.x 86 804 req/s 47,1 ms 117,6 ms 203,5 ms
Spring MVC 8 937 req/s 567,0 ms 574,3 ms 577,2 ms

Không có lỗi nào ở cả sáu lần chạy.

Hai điều bảng đó nói

Ở 200 kết nối, hai bên như nhau. Vert.x nhanh hơn 6% — nằm trong nhiễu. Nếu ứng dụng của bạn chưa bao giờ vượt vài trăm kết nối đồng thời, bảng này không phải lý do để đổi khung.

Spring MVC dừng lại ở khoảng 8 900 req/s và không nhúc nhích nữa. Con số đó không ngẫu nhiên:

200 luồng Tomcat ÷ 0,0224 giây mỗi request ≈ 8 900 req/s

Đúng bằng kích thước pool chia cho thời gian xử lý. Tăng số kết nối lên gấp 25 lần không làm thông lượng tăng thêm một chút nào — nó chỉ làm hàng đợi chờ dài ra, và độ trễ tăng đúng theo tỉ lệ đó: 22 ms → 114 ms → 567 ms.

Vert.x thì không có pool nào để cạn. Cùng 16 event loop phục vụ 200 hay 5 000 kết nối, nên thông lượng đi theo số việc thật sự có, và độ trễ trung vị tăng chậm hơn nhiều.

Cái giá: luồng và bộ nhớ

Đo ngay trong lúc cả hai chịu tải 5 000 kết nối:

Số luồng Heap đang dùng
Vert.x 49 97 MiB
Spring MVC 237 242 MiB

Bốn mươi chín luồng phục vụ năm nghìn kết nối. Đó là toàn bộ ý tưởng của mô hình này.

Một chỗ tôi không giải thích được: RSS của tiến trình Vert.x lại cao hơn (613 MiB so với 502 MiB), dù heap thấp hơn nhiều — nhiều khả năng là bộ đệm ngoài heap của Netty, nhưng tôi chưa đào sâu nên không kết luận gì thêm.

Hai lần tôi đo sai

Đáng kể hơn cả bảng số, vì bạn sẽ vấp đúng hai chỗ này nếu tự đo.

Lần một: Vert.x chậm hơn Spring 17 lần. Kết quả đầu tiên cho Vert.x 505 req/s. Hoá ra cổng 8081 trên máy tôi đang bị một container Docker khác chiếm, nên máy chủ Vert.x không bao giờ bind được và bộ đo bắn vào một dịch vụ hoàn toàn khác. lsof -nP -iTCP:8081 -sTCP:LISTEN mất năm giây và trả lời mọi thứ.

Lần hai: 1,4 triệu lỗi. Sau khi đổi cổng, Vert.x chạy nhưng lỗi nhiều hơn thành công. Thông báo là too many concurrent streams — HttpClient của Java mặc định thương lượng HTTP/2, Vert.x chấp nhận, còn Tomcat thì không. Hai máy chủ đang bị đo trên hai giao thức khác nhau, và HTTP/2 giới hạn 100 luồng trên mỗi kết nối. Ép HttpClient.Version.HTTP_1_1 cho cả hai thì lỗi về 0 và bảng ở trên mới xuất hiện.

Cả hai lần, phép đo cho một con số gây sốc, và cả hai lần con số đó là dấu hiệu phép đo hỏng chứ không phải phát hiện. Đi tìm lỗi trước khi đi viết kết luận.

Cái giá thật không nằm ở bảng nào

Bốn mươi chín luồng phục vụ năm nghìn kết nối chỉ đúng khi không handler nào chặn. Một lời gọi JDBC, một Thread.sleep, một thư viện đọc tệp đồng bộ — bất cứ thứ gì giữ event loop lại — sẽ làm mọi kết nối mà luồng đó đang phục vụ đứng chờ theo.

Với Spring MVC, một request chậm làm chậm đúng một luồng trong hai trăm luồng. Với Vert.x, nó làm chậm một phần mười sáu toàn bộ máy chủ.

Đó là đánh đổi thật của mô hình này, và cũng là lý do bài 3 và bài 4 của sê-ri dành trọn cho đúng một luật: đừng chặn event loop.

Muốn xem máy chủ của mình đang trả tiền cho bao nhiêu luồng ngồi chờ, đếm nhanh:

# may chu cua ban dang co bao nhieu luong, va bao nhieu trong so do dang cho
jcmd <pid> Thread.print | grep -c '^"'
jcmd <pid> Thread.print | grep -c "java.lang.Thread.State: WAITING"

Số luồng xấp xỉ kích thước pool và phần lớn đang WAITING nghĩa là bạn đang nuôi hai trăm luồng chỉ để chúng ngồi chờ mạng — đúng tình huống mà mô hình event loop sinh ra để giải quyết.

Mẫu số chung

Cái đẹp và cái nguy của event loop là cùng một tính chất: nó dồn nhiều thứ lên ít người phục vụ. 49 luồng ôm 5 000 kết nối là hiệu quả tuyệt vời — cho tới khi một handler chặn, và cú chặn đó không đông cứng một kết nối mà đông cứng 1/16 cả máy chủ cùng lúc. Mô hình một-luồng-một-request cô lập tốt hơn (một request tồi chỉ hại một trong hai trăm luồng) nhưng trả bằng cái trần pool/độ-trễ. Đây là đánh đổi mật độ đổi lấy cô lập có mặt ở mọi tầng: nhiều container chia chung một kernel (nhẹ hơn VM nhưng một lỗi kernel hạ tất cả), nhiều tenant chung một CSDL (rẻ hơn nhưng một truy vấn tham lam ảnh hưởng mọi người), goroutine trên một luồng OS bị chặn, fiber trên một reactor. Nguyên tắc: gom nhiều đơn vị logic lên ít vật mang vật lý thì luôn được mật độ và mất cô lập — biết mình đang tối ưu cái nào, và canh chừng đúng cái blast-radius mình vừa mua.

Điều thứ hai, về đo đạc: trước khi tin một phép so sánh, hãy chứng minh hai bên đúng là thứ bạn nghĩ, dưới cùng điều kiện. Hai lần đo sai ở đây đều không phải lỗi tính toán — lần một tôi đo nhầm dịch vụ (Vert.x còn chẳng bind được cổng), lần hai đo hai giao thức khác nhau (một bên HTTP/2, một bên HTTP/1.1). Con số ra gây sốc, và con số gây sốc gần như luôn là phép đo hỏng: một tiến trình đơn luồng "làm 72 triệu lệnh/giây", một framework "chậm hơn 17 lần". Cái bẫy không nằm ở phép chia cuối cùng mà ở setup — cổng, giao thức, một dependency thừa, một cái mock lẫn vào. Nên khi kết quả lệch xa dự đoán, đừng vội viết kết luận; hỏi trước: tôi có đang đo đúng cái tôi tưởng, và hai bên có thật sự chạy trên cùng một sân không.