Hình dung một văn phòng có 20 nhân viên đóng dấu hồ sơ, nhưng có nội quy: mọi tờ của cùng một người nộp phải được đóng dấu tuần tự, đúng thứ tự. Thế là cả xấp của một người dồn qua tay một nhân viên, còn 19 người kia ngồi chơi. Đó chính xác là điều executeBlocking mặc định làm với pool worker của bạn — và nó làm chậm hai mươi lần mà không để lại một dòng lỗi. Phần trước liệt kê những thứ tính là "chặn" — JDBC, đọc tệp, băm mật khẩu — và chúng không biến mất chỉ vì bạn dùng Vert.x. Bài này đo bốn cách chạy chúng cho đúng.
Bốn cách
// 1. executeBlocking, mặc định — CÓ MỘT CÁI BẪY Ở ĐÂY
vertx.executeBlocking(() -> { Thread.sleep(50); return "ok"; })
// 2. executeBlocking, không xếp thứ tự
vertx.executeBlocking(() -> { Thread.sleep(50); return "ok"; }, false)
// 3. Worker verticle — nhận việc qua event bus
new DeploymentOptions().setThreadingModel(ThreadingModel.WORKER)
// 4. Virtual thread verticle (Vert.x 4.5 trở lên)
new DeploymentOptions().setThreadingModel(ThreadingModel.VIRTUAL_THREAD)
Cả bốn đều cứu được event loop
Đây là điều quan trọng nhất, nên đo trước. Cho mỗi cách chịu tải nặng, rồi đo /nhanh — endpoint không liên quan gì, chỉ trả "ok":
| Trong lúc tải endpoint | /nhanh đạt |
p50 |
|---|---|---|
| chạy thẳng trên event loop | 573 req/s | 528,7 ms |
executeBlocking ordered |
95 007 req/s | 2,8 ms |
executeBlocking unordered |
96 177 req/s | 2,9 ms |
| worker verticle | 97 805 req/s | 2,9 ms |
| virtual thread verticle | 99 482 req/s | 2,9 ms |
Bốn cơ chế đúng đều giữ /nhanh ở khoảng 96–99 nghìn req/s; chạy thẳng trên event loop làm nó sập 166 lần. Chọn cách nào cũng được — miễn là chọn một cách.
Nhưng thông lượng của chính việc chặn thì khác hẳn
Đo thông lượng của chính endpoint chậm, 200 kết nối, mỗi request 50 ms:
| Cách | Thông lượng | Lý thuyết |
|---|---|---|
| chạy thẳng trên event loop | 297 req/s | 16 loop ÷ 0,05 s = 320 |
executeBlocking ordered (16 context) |
298 req/s | 16 context ÷ 0,05 s = 320 |
executeBlocking unordered |
375 req/s | 20 luồng ÷ 0,05 s = 400 |
| worker verticle (20 bản sao) | 375 req/s | 20 ÷ 0,05 = 400 |
| virtual thread verticle | 372 req/s | 20 ÷ 0,05 = 400 |
Mọi con số đo được đều khớp phép chia đơn giản ở cột phải. Và dòng thứ hai đang che giấu một cái bẫy.
Bẫy ordered=true
executeBlocking mặc định xếp thứ tự: mọi tác vụ gửi từ cùng một context chạy lần lượt, không bao giờ song song. Ở bảng trên nó vô hại vì máy chủ có 16 bản sao verticle, tức 16 context độc lập.
Chạy lại với một bản sao:
| Cách | Thông lượng | p50 |
|---|---|---|
executeBlocking ordered |
18 req/s | 8 390 ms |
executeBlocking unordered |
372 req/s | 532,8 ms |
Mặc định này có lý do chính đáng — nhiều mã dựa vào việc các tác vụ của cùng một request chạy theo thứ tự. Nhưng nếu bạn chỉ đang gọi CSDL thì thứ tự đó không có nghĩa gì, và bạn đang trả giá bằng toàn bộ tính song song.
Luật thực dụng: cứ truyền false trừ khi bạn biết chính xác vì sao cần thứ tự.
Pool hai mươi luồng là trần cứng
Ba cách còn lại đều dừng ở khoảng 375 req/s, vì workerPoolSize mặc định là 20. Với công việc 50 ms thì trần là 400 req/s và không có cách nào vượt qua ngoài việc tăng pool:
new VertxOptions().setWorkerPoolSize(100);
Nhưng tăng pool là quay lại đúng mô hình một luồng mỗi request mà phần 1 đo được là dừng ở 8 900 req/s. Pool càng lớn, Vert.x càng giống Tomcat.
Virtual thread verticle
Cách thứ tư đáng chú ý nhất về lâu dài: bên trong verticle kiểu này, mã chặn không giữ luồng nền vì luồng ảo bị park chứ không chiếm carrier. Trong phép đo trên nó cho 372 req/s — ngang worker verticle, vì tôi giới hạn ở 20 bản sao. Với số bản sao lớn hơn, trần đó biến mất theo cách mà worker pool không làm được.
Đây là tính năng còn mới trong 4.5; tôi chưa đo nó ở quy mô lớn nên không kết luận gì thêm ngoài con số ở trên.
Chọn cái nào
| Tình huống | Cách |
|---|---|
| Một đoạn chặn ngắn, lẻ tẻ | executeBlocking(..., **false**) |
| Cả một verticle toàn việc chặn (JDBC, xử lý tệp) | worker verticle |
| Cần giữ thứ tự trong một luồng nghiệp vụ | executeBlocking ordered — cố ý, không phải vì quên |
| Mã chặn nhiều và bạn dùng Java 21 | virtual thread verticle, và đo lại |
Và cách tốt nhất vẫn là không cần cách nào — dùng client không chặn cho CSDL, HTTP, Redis. Chặng dữ liệu của sê-ri sẽ đo chúng.
Muốn biết pool worker có đang kẹt không, đếm nhanh — đặc biệt để bắt cái bẫy ordered:
# pool worker cua ban co dang can khong
jcmd <pid> Thread.print | grep -c "vert.x-worker-thread"
jcmd <pid> Thread.print | grep -A1 "vert.x-worker-thread" | grep -c "TIMED_WAITING\|WAITING"
Số luồng worker bằng 20 và gần như tất cả đang chờ nghĩa là bạn đã chạm trần pool. Chỉ một luồng bận trong khi mười chín cái rảnh nghĩa là bạn vừa tìm thấy ordered=true.
Mẫu số chung
Cái bẫy ordered dạy một điều dễ quên: thứ tự và song song là hai thứ đối nghịch — đảm bảo cái này là cấm cái kia. Mặc định của executeBlocking chọn giữ thứ tự, và cái giá âm thầm của lựa chọn an toàn đó là toàn bộ tính song song: 20 luồng mà chỉ 1 chạy. Đây không phải lỗi, nó là một đánh đổi đúng đắn bị áp nhầm chỗ — thứ tự chỉ đáng giữ khi nó mang ý nghĩa (các bước của một giao dịch, các sự kiện của một thực thể), còn với hai truy vấn CSDL độc lập thì ép thứ tự là vứt đi năng lực bạn đã trả tiền. Cùng sự đánh đổi ấy ở một hàng đợi một-consumer, một synchronized đặt "cho chắc" trên đường nóng, mức cô lập SERIALIZABLE, hộp thư tuần tự của một actor: mỗi cái mua tính đúng-đắn-theo-thứ-tự bằng thông lượng. Nguyên tắc: chỉ trả cái giá tuần tự ở đúng chỗ thứ tự thật sự có nghĩa — và ở mọi chỗ khác, cho phép song song là mặc định đúng.
Điều thứ hai, về thứ tự ra quyết định: "làm cho đúng" và "làm cho tối ưu" là hai câu hỏi tách rời — giải cái đầu trước. Cả bốn cách đều cứu event loop (cú thắng đúng-đắn, 166 lần) rồi sau đó mới chênh nhau 20 lần về thông lượng (chuyện tinh chỉnh). Đừng lẫn "tôi đã đẩy việc chặn ra khỏi event loop chưa?" với "tôi đẩy nó bằng cách tốt nhất chưa?" — sai cái đầu thì cả máy chủ sập, sai cái sau thì chỉ chậm. Và cái trần thì có một cạm bẫy riêng: nới workerPoolSize để vượt trần chính là quay về đúng mô hình một-luồng- một-request mà bạn dùng Vert.x để tránh — pool càng to, Vert.x càng giống Tomcat. Thoát một giới hạn bằng cách phình tài nguyên thường chỉ đưa bạn về đúng thứ mình vừa bỏ đi; lối ra thật là không chặn nữa, chứ không phải chặn trên nhiều luồng hơn.