Một cây cầu sập, cả thị trấn quen đi vòng đường xa để né nó — rồi ngày cầu sửa xong mở lại, nhiều người vẫn cứ đi đường vòng theo thói quen. Mô hình bất đồng bộ trong Java đúng là con đường vòng đó: nó dựng ra để né "cây cầu sập" mang tên một-luồng-mỗi-request. Java 21 vừa mở lại cầu bằng luồng ảo, và bài này đo xem con đường vòng còn đáng đi tới đâu — bằng cách cho Vert.x Web, Spring MVC, và Spring MVC bật luồng ảo chạy cùng một endpoint trên cùng một máy.

Phần 1 so Vert.x trần với Spring MVC và cho một bảng rất chênh lệch. Bài này làm lại đầy đủ hơn: dùng Vert.x Web cho công bằng, thêm endpoint không có I/O để đo chi phí khung, và thêm một đối thủ thứ ba mà Java 21 vừa mang tới — Spring MVC bật luồng ảo.

Ba khung, hai endpoint

/nhanh trả JSON ngay lập tức. /io chờ 20 ms rồi trả cùng JSON.

Endpoint /nhanh — chi phí thuần của khung:

Kết nối Vert.x Web Spring MVC Spring MVC + luồng ảo
200 85 415 65 008 81 233
1 000 97 182 90 645 77 383
5 000 100 063 79 714 67 543

Ba khung nằm cùng một khoảng, 65–100 nghìn req/s. Chi phí của khung không phải chỗ tạo ra khác biệt — nếu ứng dụng của bạn không chờ gì cả, chọn khung nào cũng được.

Endpoint /io — có chờ 20 ms:

Kết nối Vert.x Web Spring MVC Spring MVC + luồng ảo
200 9 211 8 628 7 961
1 000 44 700 8 814 42 589
5 000 99 410 8 986 58 397

Điều đáng nói nhất

Nhìn dòng 1 000 kết nối: 42 589 so với 44 700 — chênh 5%. Spring MVC bật luồng ảo bám sát Vert.x, trong khi Spring MVC thường đứng yên ở 8 814.

Đây là thay đổi lớn nhất với hệ sinh thái Java trong nhiều năm. Cột giữa — mô hình một luồng mỗi request — là thứ Vert.x sinh ra để thay thế, và nó dừng ở khoảng 8 900 req/s vì pool 200 luồng chia cho 22 ms. Bật một dòng cấu hình luồng ảo là trần đó biến mất.

Nhưng đọc tiếp hai dòng còn lại. Ở 5 000 kết nối, Vert.x cho 99 410 còn luồng ảo cho 58 397 — 1,7 lần. Và bảng tài nguyên giải thích một phần:

Số luồng RSS
Vert.x Web 49 709 MiB
Spring MVC 237 613 MiB
Spring MVC + luồng ảo 54 1 210 MiB

Luồng ảo giữ số luồng nền thấp như Vert.x (54 so với 49), nhưng tốn gần gấp đôi bộ nhớ — mỗi luồng ảo vẫn là một ngăn xếp trên heap, và ở 5 000 request đồng thời thì đó là 5 000 ngăn xếp.

Chỗ phép so sánh này không công bằng

Thread.sleep không phải I/O thật. Với luồng ảo, Thread.sleep park luồng đúng như một lời gọi mạng, nên phép đo hợp lệ. Nhưng một lời gọi JDBC thật sẽ pin luồng ảo vào luồng nền trong nhiều trường hợp, và khi đó cột phải sẽ tụt về gần cột giữa. Muốn biết chắc, phải đo bằng chính driver bạn dùng.

Vert.x chạy 16 bản sao verticle, Spring chạy cấu hình mặc định. Tôi không chỉnh pool của Tomcat. Tăng server.tomcat.threads.max lên 2 000 sẽ đẩy cột giữa lên đáng kể — cùng với bộ nhớ.

Cùng một JVM, cùng một máy, không có mạng thật. Độ trễ mạng thật sẽ làm mọi cột gần nhau hơn.

Endpoint quá đơn giản. Không có CSDL, không có tuần tự hoá phức tạp, không có bảo mật. Trong ứng dụng thật, phần lớn thời gian nằm ở những thứ đó chứ không ở khung.

Vậy chọn gì

Tình huống Chọn
Đội đã quen Spring, tải vừa phải Spring MVC + luồng ảo — một dòng cấu hình, không viết lại gì
Hàng chục nghìn kết nối giữ mở Vert.x — phần 13 đo 4 KB mỗi kết nối
Cần kiểm soát bộ nhớ chặt chẽ Vert.x — bảng trên cho thấy nó dùng ít nhất
Dịch vụ chủ yếu gọi ra ngoài, độ trễ đuôi quan trọng Vert.x — cột /io ở 5 000 kết nối
Ứng dụng CRUD bình thường Khung nào cũng được; bảng /nhanh nói vậy

Kết luận thật của bài này không phải "Vert.x thắng". Nó là: luồng ảo đã xoá phần lớn lý do hiệu năng để chọn mô hình bất đồng bộ, và những lý do còn lại — bộ nhớ, kết nối giữ mở, độ trễ đuôi ở quy mô rất lớn — vẫn thật nhưng hẹp hơn nhiều so với hai năm trước.

Và cái giá của Vert.x thì không đổi: mọi thứ phần 3 và phần 8 đo được — một Thread.sleep lạc chỗ làm sập thông lượng 59 lần, ThreadLocal trả về dữ liệu của người khác. Với luồng ảo, mã chặn vẫn cứ là mã chặn bình thường.

Nếu muốn tự kiểm, bật một dòng rồi chạy lại phép đo tải của chính mình ở mức đồng thời cao:

# trong application.properties cua ban
spring.threads.virtual.enabled=true

Thông lượng nhảy vọt như cột phải trong bảng /io nghĩa là nút thắt của bạn là pool luồng chứ không phải khung.

Mẫu số chung

Bài học lớn nhất không phải khung nào thắng, mà là: một ràng buộc từng biện minh cho cả một kiến trúc có thể bị một tiến bộ ở tầng dưới xoá sạch. Người ta chọn mô hình bất đồng bộ để né cái trần "một luồng mỗi request" — pool 200 chia cho độ trễ, dừng ở 8 900 req/s — và luồng ảo, một dòng cấu hình ở tầng runtime, làm trần đó bốc hơi. Đây là chuyện lặp lại suốt lịch sử nghề: NoSQL từng được chọn cho quy mô mà Postgres hiện đại kham được, callback lồng nhau từng cần thiết trước khi có async/await, quản lý bộ nhớ tay từng bắt buộc trước khi có GC. Nên với mọi mẩu phức tạp bạn đang gánh "vì hiệu năng", hãy định kỳ hỏi lại: cái lý do khiến ta chọn nó năm xưa còn đúng trên nền tảng hôm nay không — vì cái đường vòng dựng ra để né cây cầu sập vẫn tốn công đi, kể cả sau ngày cầu đã mở lại.

Điều thứ hai, để khỏi mừng vội: một bản vá thường dời chi phí chứ không xoá nó. Luồng ảo gỡ trần pool luồng — nhưng đổi lại mỗi luồng ảo là một ngăn xếp trên heap, nên ở 5 000 request đồng thời nó ngốn gần gấp đôi bộ nhớ; cái tường "hết luồng" biến thành cái tường "hết RAM", chỉ dời chỗ. Và Thread.sleep trong đo đạc park đẹp đẽ, nhưng JDBC thật pin luồng ảo lại, kéo lợi thế về gần con số cũ. Không có bữa trưa miễn phí: khi một lớp mới hứa "giải quyết xong", hãy hỏi chi phí đã đi đâu — sang bộ nhớ, sang một điều kiện tiên quyết (đừng synchronized, đừng chặn native), sang một ca biên. Đo trên đúng tải và đúng driver của mình, vì lời hứa chung chung đúng ở phòng thí nghiệm và dời chi phí vào đúng chỗ bạn chưa nhìn.

Bài sau: event bus — thứ Spring không có gì tương đương, và là lý do kiến trúc để chọn Vert.x.