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 Future của chính Vert.x — 15,9 so với 36,5 ns.
  • Future.all gộp nhánh rẻ hơn cả hai — 10,8 ns, trong khi Uni.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, SingleUni 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.