Vert.x có ba lớp bọc bất đồng bộ chính thức: Future của chính nó, RxJava 3, và Mutiny. Cộng thêm callback thuần thì thành bốn cách viết cùng một thứ. Tranh luận về chúng thường xoay quanh gu cá nhân, nên tôi viết đúng cùng một nghiệp vụ bằng cả bốn rồi đo.
Nghiệp vụ: gọi dịch vụ người dùng, rồi song song gọi dịch vụ đơn hàng và dịch vụ ưu đãi, gộp ba kết quả lại. Cả bốn đường trả về JSON giống hệt nhau — tôi kiểm tra trước khi đo.
Bốn cách viết
Callback thuần:
wc.get(H,"localhost","/nguoi-dung/"+id).send(ar1 -> {
if (ar1.failed()) { c.response().setStatusCode(502).end(); return; }
JsonObject nd = ar1.result().bodyAsJsonObject();
JsonObject[] kq = new JsonObject[2];
AtomicInteger con = new AtomicInteger(2);
boolean[] hong = {false};
Handler<Void> xong = v -> {
if (con.decrementAndGet() == 0 && !hong[0]) c.response().end(...);
};
wc.get(H,"localhost","/don-hang/"+id).send(ar2 -> {
if (ar2.failed()) { if(!hong[0]){hong[0]=true; c.response().setStatusCode(502).end();} return; }
kq[0] = ar2.result().bodyAsJsonObject(); xong.handle(null);
});
wc.get(H,"localhost","/uu-dai/"+id).send(ar3 -> { /* y het */ });
});
Future của Vert.x:
wc.get(H,"localhost","/nguoi-dung/"+id).send()
.compose(nd -> {
var fDon = wc.get(H,"localhost","/don-hang/"+id).send();
var fUu = wc.get(H,"localhost","/uu-dai/"+id).send();
return Future.all(fDon, fUu).map(x -> new JsonObject()
.put("nguoiDung", nd.bodyAsJsonObject())
.put("donHang", fDon.result().bodyAsJsonObject())
.put("uuDai", fUu.result().bodyAsJsonObject()));
})
.onSuccess(j -> c.response().end(j.encode()))
.onFailure(e -> c.response().setStatusCode(502).end());
RxJava 3:
rx.get(H,"localhost","/nguoi-dung/"+id).rxSend()
.flatMap(nd -> Single.zip(
rx.get(H,"localhost","/don-hang/"+id).rxSend(),
rx.get(H,"localhost","/uu-dai/"+id).rxSend(),
(don, uu) -> new JsonObject().put("nguoiDung", nd.bodyAsJsonObject())
.put("donHang", don.bodyAsJsonObject())
.put("uuDai", uu.bodyAsJsonObject())))
.subscribe(j -> c.response().end(j.encode()),
e -> c.response().setStatusCode(502).end());
Mutiny:
mu.get(H,"localhost","/nguoi-dung/"+id).send()
.flatMap(nd -> Uni.combine().all().unis(
mu.get(H,"localhost","/don-hang/"+id).send(),
mu.get(H,"localhost","/uu-dai/"+id).send())
.asTuple()
.map(t -> new JsonObject().put("nguoiDung", nd.bodyAsJsonObject())
.put("donHang", t.getItem1().bodyAsJsonObject())
.put("uuDai", t.getItem2().bodyAsJsonObject())))
.subscribe().with(j -> c.response().end(j.encode()),
e -> c.response().setStatusCode(502).end());
| Số dòng | Dấu ngoặc mở | |
|---|---|---|
| Callback | 25 | 11 |
Future |
15 | 2 |
| RxJava | 14 | 1 |
| Mutiny | 15 | 1 |
Thông lượng: không phân biệt được
200 kết nối, 10 giây, chạy hai lần:
| Lần 1 | Lần 2 | p99 | |
|---|---|---|---|
| Callback | 36 497 req/s | 40 110 | 10,2 ms |
Future |
40 116 req/s | 39 738 | 10,5 ms |
| RxJava | 39 381 req/s | 40 604 | 9,6 ms |
| Mutiny | 39 508 req/s | 38 680 | 13,1 ms |
Chênh lệch giữa hai lần chạy của cùng một cách còn lớn hơn chênh lệch giữa các cách. Ở tầng này, chọn lớp bọc nào không phải là quyết định về hiệu năng.
Chi phí thật của lớp bọc
Để thấy được chi phí, phải bỏ hết I/O đi — nếu không thì 25 µs của một lời gọi HTTP che mất tất cả. Đây là 500 000 lượt của một chuỗi ba bước không có I/O gì, trung vị của năm lần đo:
| Chuỗi 3 bước | Gộp 2 nhánh | Đường lỗi | |
|---|---|---|---|
| Gọi hàm Java thẳng | 4,0 ns | — | — |
Future của Vert.x |
36,5 ns | 10,8 ns | 248,0 ns |
RxJava Single |
15,9 ns | 28,6 ns | 242,7 ns |
Mutiny Uni |
23,0 ns | 136,2 ns | 268,7 ns |
Không có bên nào thắng đều, và đó là kết quả tôi không đoán trước:
- RxJava xâu chuỗi rẻ hơn
Futurecủa chính Vert.x — 15,9 so với 36,5 ns. Future.allgộp nhánh rẻ hơn cả hai — 10,8 ns, trong khiUni.combine().all()tốn 136,2 ns, tức gấp 12,6 lần.- Đường lỗi thì cả ba như nhau, quanh 240–270 ns. Ở đó chi phí không thuộc về thư viện mà thuộc về việc tạo ra
Exception— Java phải dựng lại toàn bộ vết gọi.
Nhưng hãy đặt những con số này cạnh thực tế: một lời gọi HTTP nội bộ trong phép đo ở trên tốn khoảng 25 µs. 36,5 ns là 0,15% của con số đó. Ngay cả 136,2 ns của Mutiny cũng chỉ là 0,54%. Chừng nào mỗi bước trong chuỗi của bạn còn chạm tới mạng hay đĩa, chi phí lớp bọc là không nhìn thấy được — đúng như bộ đếm tần suất ở phần 29 và trace id ở phần 32.
Chỉ có một trường hợp những con số này quan trọng: khi bạn xâu chuỗi hàng triệu lượt trong bộ nhớ, không I/O. Lúc đó hãy hỏi vì sao mình lại dùng bất đồng bộ cho việc đó.
Chi phí khởi động
Đây là chỗ khác biệt lớn nhất mà tôi đo được:
| Lần chạy đầu tiên | Tổng số lớp đã nạp | |
|---|---|---|
Future |
10,5 ms | 857 |
| Mutiny | 35,4 ms | 954 |
| RxJava | 44,1 ms | 972 |
RxJava đắt hơn Future 4,2 lần về thời gian nạp, cộng thêm 115 lớp. Với một máy chủ chạy nhiều tháng thì 34 ms là không đáng kể. Với một hàm serverless khởi động lạnh hàng nghìn lần mỗi ngày, hoặc một công cụ dòng lệnh, thì đó là con số duy nhất trong cả bài này đáng để cân nhắc.
Vậy chọn thế nào
Số đo đã loại bỏ hiệu năng khỏi bàn cân. Cái còn lại:
Callback thuần thì đừng. 25 dòng so với 14, và quan trọng hơn con số: hãy nhìn lại đoạn mã đầu bài. Tôi phải tự viết bộ đếm AtomicInteger, tự viết cờ hong[0] để không trả lời hai lần, tự nhớ rằng cả hai nhánh song song đều phải kiểm tra cờ đó. Ba thứ đó là ba chỗ để lọt lỗi, và chúng chỉ lộ ra khi hạ nguồn hỏng — tức là đúng lúc bạn cần chúng đúng nhất. Ba cách còn lại cho bạn thứ đó miễn phí.
Future là mặc định hợp lý. Nó có sẵn, không thêm dependency, nạp nhanh nhất, và gộp nhánh rẻ nhất. Nhược điểm là bộ toán tử mỏng: không có retryWhen, debounce, buffer, window — những thứ RxJava có hàng trăm cái.
RxJava khi bạn thật sự cần luồng dữ liệu, chứ không phải một kết quả. Xử lý dòng sự kiện, gộp theo cửa sổ thời gian, chống dội phím — đó là chỗ Future bó tay còn RxJava đã có sẵn toán tử.
Mutiny nếu bạn cũng đang dùng Quarkus, vì tính nhất quán giữa hai nơi đáng giá hơn 136 ns. Thông báo lỗi của Mutiny cũng là thứ dễ đọc nhất trong ba thư viện — nó nói thẳng bạn quên subscribe() thay vì im lặng không làm gì.
Và đừng trộn. Một mã nguồn có cả Future, Single và Uni buộc mọi người đọc phải chuyển đổi liên tục, mà mỗi lần chuyển là một chỗ để lọt lỗi — nhiều hơn hẳn bất cứ thứ gì trong bảng số ở trên.
Thử ba mươi giây
Trước khi đổi cả mã nguồn sang một thư viện reactive vì lý do hiệu năng, hãy đo đúng chuỗi của bạn mà không có I/O:
long t0 = System.nanoTime();
for (int i = 0; i < 500_000; i++)
Future.succeededFuture(1L).compose(v -> Future.succeededFuture(v+1))
.onComplete(ar -> hutDen += ar.result());
System.out.printf("%.1f ns/luot%n", (System.nanoTime()-t0)/500_000.0);
Rồi chia con số đó cho thời gian một lời gọi mạng của bạn. Nếu tỉ lệ nhỏ hơn một phần trăm — gần như chắc chắn là vậy — thì lý do đổi phải là khả năng đọc hoặc bộ toán tử, không phải tốc độ.
Phần sau dựng cụm Vert.x ba node bằng Hazelcast và đo chuyện gì xảy ra khi mất một node.