Hình dung eb.request như nhấc máy gọi một số nội bộ trong công ty: cùng một thao tác, nhưng người nghe có thể ngồi ngay bàn bên cạnh (qua loa nội bộ, tức thì) hoặc ở chi nhánh khác (qua tổng đài thành phố, chậm hơn và có thể rớt). Nhìn vào lời gọi bạn không thể biết mình đang gọi bên nào — mà một bên tốn 11 micro giây, bên kia 66. Phần 19 đo event bus trong cùng tiến trình và kết bằng một lời hứa: con số 11 micro giây sẽ đổi thế nào khi thông điệp phải đi qua mạng. Bài này trả lời, và câu trả lời đủ để đổi cách bạn thiết kế.

Dựng cụm

ClusterManager cm = new HazelcastClusterManager();
Vertx vertx = Vertx.builder().withClusterManager(cm).buildClustered()
        .toCompletionStage().toCompletableFuture().get();

Không có gì khác trong mã ứng dụng. Cùng eb.request(...), cùng địa chỉ — chỉ khác là consumer có thể đang ở tiến trình khác.

[tho] gia nhập cụm sau 2,9 s | số node = 1
[do]  gia nhập cụm sau 0,8 s | số node = 2

Node đầu tiên mất gần ba giây để dựng cụm; node thứ hai vào cụm có sẵn chỉ mất 0,8 giây. Đây là thời gian cộng thêm vào khởi động ứng dụng — phần 2 đo Vert.x thường khởi động trong 0,18 giây, nên bật cụm làm nó chậm đi khoảng mười lần.

Đo lại

Cùng JVM Qua cụm HTTP loopback
Độ trễ p50 11,3 µs 66,3 µs 56,5 µs
p95 25,5 µs 134,7 µs 121,4 µs
p99 44,3 µs 202,8 µs 176,3 µs
Thông lượng 1 096 025/s 199 259/s 95 838/s
Nhìn hai cột phải. Event bus qua cụm chậm hơn HTTP trên loopback về độ trễ — 66,3 so với 56,5 µs. Lợi thế tốc độ mà phần 19 đo được đến hoàn toàn từ việc không đi qua mạng; một khi đã qua mạng thì nó biến mất.

Thông lượng vẫn gấp đôi HTTP (199 nghìn so với 96 nghìn), vì event bus dùng một kết nối TCP dùng chung và khung dữ liệu gọn hơn. Nhưng đây không còn là khác biệt hạng nặng — đây là cùng một hạng.

Kết luận thực dụng vẫn như phần 19, chỉ chắc chắn hơn: chọn event bus vì kiến trúc, đừng chọn vì tốc độ.

send không ưu tiên node của bạn

Đây là phần tôi nghĩ nhiều người đoán sai. Đăng ký consumer cho cùng một địa chỉ ở cả hai node, rồi gửi 2 000 request từ node thứ hai:

2 000 request -> consumer CÙNG node: 1000 | consumer node KHÁC: 1000

Chia đôi tuyệt đối. Vert.x không ưu tiên consumer cục bộ — nó chia vòng tròn trên toàn cụm như thể mọi consumer đều ở cùng khoảng cách.

Nghĩa là trong một cụm ba node, hai phần ba lời gọi của bạn đi qua mạng dù ngay trong tiến trình đã có sẵn người xử lý. Với bảng ở trên, đó là chênh lệch giữa 11 µs và 66 µs cho hai phần ba lưu lượng.

Nếu bạn muốn ưu tiên cục bộ, phải nói rõ:

eb.request("dv", than, new DeliveryOptions().setLocalOnly(true));

Nhưng cân nhắc kỹ: setLocalOnly cũng có nghĩa là không có cân bằng tải toàn cụm nữa, và nếu node đó không có consumer thì bạn nhận NO_HANDLERS như phần 20 đo được, thay vì được phục vụ bởi node khác.

Cụm cho bạn gì để đổi

Cân bằng tải không cần cấu hình. Thêm một node là nó tự nhận phần việc của mình, không cần đăng ký dịch vụ, không cần bộ cân bằng tải.

Cùng một API. Mã không đổi một dòng giữa chạy một node và chạy mười node.

Và một phụ thuộc mới. Hazelcast là một hệ thống phân tán đầy đủ chạy bên trong ứng dụng của bạn: nó có bầu cử, có phân vùng mạng, có trạng thái riêng. Bài 42 của sê-ri sẽ đo cụm ba node và chuyện gì xảy ra khi mất một node — và đó là chỗ cái giá thật của việc bật cụm hiện ra.

Khi nào nên bật

Khi bạn thật sự cần nhiều node nói chuyện với nhau qua event bus. Nếu các bản sao của bạn độc lập — mỗi cái phục vụ request riêng, chia sẻ trạng thái qua CSDL — thì cụm chỉ thêm phụ thuộc và thêm ba giây khởi động.

Đừng bật cụm chỉ để "sẵn sàng mở rộng". Bảng ở trên là cái giá bạn trả ngay, còn lợi ích chỉ đến khi thật sự có nhiều node cần trao đổi.

Muốn biết mình đang trả cái giá nào, hỏi cụm có mấy node và thử một lời gọi chỉ-cục-bộ:

System.out.println("so node trong cum: " + clusterManager.getNodes().size());
vertx.eventBus().request("dv", "x", new DeliveryOptions().setLocalOnly(true))
     .onFailure(t -> System.out.println("khong co consumer cuc bo: " + t.getMessage()));

Nếu lời gọi setLocalOnly thất bại trong khi lời gọi thường vẫn chạy, mọi request của bạn đang đi qua mạng — và bảng trên cho biết cái giá.

Mẫu số chung

Lớp trừu tượng nguy hiểm nhất là lớp làm một lời gọi qua mạng trông y hệt một lời gọi tại chỗ: cùng eb.request, nhưng người nhận ở bên kia dây tốn gấp sáu lần (66 so với 11 µs) và hỏng theo những kiểu mà lời gọi cục bộ không bao giờ có. Đó chính là cốt lõi của "những ngộ nhận về hệ phân tán" (fallacies of distributed computing): mạng không đáng tin, độ trễ không bằng không, băng thông không vô hạn — và một lớp bọc giấu đi cái ranh giới mạng cũng giấu luôn những sự thật đó. Mỗi thế hệ học lại bài này: RMI/CORBA ngày xưa, một lazy-load của ORM âm thầm bắn một truy vấn cho mỗi lần chạm trường (N+1), một cache "trong suốt". Hãy luôn biết một lời gọi rơi vào phía nào của sợi dây, vì mã không nói cho bạn — mà ở đây, mặc định còn đẩy hai phần ba số lời gọi sang phía đắt.

Điều thứ hai: bật một tính năng phân tán là cái giá trả ngay để đổi một lợi ích có thể không bao giờ thu. Một cụm kéo cả một hệ phân tán vào trong tiến trình của bạn (bầu cử, phân vùng, cộng ba giây khởi động) và chỉ đáng đồng tiền khi các node thật sự phải nói chuyện với nhau; nếu các bản sao vốn độc lập (trạng thái nằm ở CSDL) thì nó là chi phí thuần. Đây là YAGNI có răng: "bật sẵn cho dễ mở rộng" mua ngay hoá đơn hôm nay và giá trị thì để ngỏ. Và cái mặc định của nó được chỉnh cho mục tiêu của framework (chia đều toàn cụm), không cho độ trễ của bạn (bỏ qua consumer ngay bên cạnh) — nhắc lại rằng một mặc định tối ưu cho đích của người làm ra nó, không cho đích của bạn, trừ khi bạn cố ý ghi đè.

Bài sau: mở event bus ra trình duyệt qua SockJS, và một dòng cấu hình biến nó thành cửa sau.