Phần 20 đo ba loại thất bại của một lượt hỏi đáp. Ứng dụng thật hiếm khi chỉ có một chặng: A gọi B, B gọi C. Bài này đo chuyện gì xảy ra dọc chuỗi đó.

Lỗi có về tới chặng đầu không — tuỳ bạn viết

Dịch vụ C từ chối bằng m.fail(409, "C từ chối: đơn đã huỷ"). Hai cách B chuyển tiếp:

// B — chuyen tiep dung
.onFailure(t -> {
    if (t instanceof ReplyException r) m.fail(r.failureCode(), "B: " + r.getMessage());
    else m.fail(500, "B: " + t.getMessage());
});

// B2 — nuot mat thong tin
.onFailure(t -> m.fail(500, "co loi gi do"));
A -> B  -> C : RECIPIENT_FAILURE mã=409 "B: C từ chối: đơn đã huỷ"
A -> B2 -> C : RECIPIENT_FAILURE mã=500 "có lỗi gì đó"

Cùng một sự việc, hai câu trả lời khác hẳn. Với B, chặng đầu biết chính xác cái gì hỏng và có thể trả 409 cho người dùng. Với B2, nó chỉ biết "có lỗi" — và một lỗi nghiệp vụ hoàn toàn bình thường vừa được báo cáo thành sự cố máy chủ.

ReplyException mang sẵn failureCode() và thông điệp. Bốn dòng chuyển tiếp ở trên giữ nguyên chúng qua bao nhiêu chặng cũng được. Đây là thứ nên viết thành một hàm tiện ích dùng chung, vì viết tay ở mỗi consumer là chỗ người ta hay làm tắt.

Timeout lồng nhau: bên trong không dừng lại

A đặt phép chờ 1 giây, B đặt 2 giây, C mất 1,5 giây:

A nhận: TIMEOUT sau 1 002 ms
sau khi A đã bỏ cuộc, C vẫn chạy xong? CÓ

A bỏ cuộc đúng hạn. Nhưng C vẫn chạy tới cùng và vẫn trả lời cho B — chỉ là không còn ai nghe.

Đây chính là điều phần 7 đo được với Future.all: Vert.x không có cơ chế huỷ. Timeout chỉ nghĩa là "tôi thôi không chờ nữa", không phải "dừng việc đó lại".

Hệ quả thực tế, và nó nghiêm trọng hơn vẻ ngoài: khi hệ thống quá tải, mọi request đều timeout ở chặng đầu trong khi các chặng sau vẫn làm đủ việc. Bạn mất toàn bộ kết quả nhưng trả đủ chi phí, và tải không hề giảm đi — nó chỉ trở nên vô ích. Đó là lý do circuit breaker (bài 25) tồn tại: chỉ có nó mới thật sự ngừng gửi việc đi.

Và nhớ đặt phép chờ giảm dần dọc chuỗi: nếu A chờ 1 giây thì B nên chờ ít hơn, không phải nhiều hơn. Cấu hình ở trên — A một giây, B hai giây — là ngược, và nó khá phổ biến vì mỗi đội đặt phép chờ cho riêng mình.

Mỗi chặng thêm vào tốn bao nhiêu

Số chặng p50 p99
1 12,8 µs 61,8 µs
2 13,4 µs 27,9 µs
3 15,3 µs 33,9 µs

Khoảng 1,2 µs mỗi chặng thêm vào. Nghĩa là trong cùng một tiến trình, chia nhỏ thành nhiều verticle gần như không tốn gì — bạn được tự do thiết kế theo ranh giới nghiệp vụ thay vì theo chi phí gọi.

Nhưng con số này chỉ đúng trong cùng JVM. Phần 21 đo được một chặng qua cụm tốn 66 µs, tức chuỗi ba chặng rải trên ba node là khoảng 200 µs — vẫn nhỏ, nhưng đã gấp mười ba lần.

Ba luật cho chuỗi hỏi đáp

Chuyển tiếp failureCode và thông điệp. Viết một hàm dùng chung, đừng để mỗi consumer tự chế.

Phép chờ phải giảm dần từ ngoài vào trong. Chặng ngoài cùng đặt ngân sách; mỗi chặng trong chỉ được lấy một phần của phần còn lại.

Đừng dựa vào timeout để giảm tải. Nó không dừng việc đang chạy. Muốn giảm tải thì phải chặn ở đầu vào — và đó là chủ đề của bốn bài tới.

Bài sau: Service Discovery — cách các dịch vụ tìm thấy nhau, và cái giá của việc tìm.

Thử ba mươi giây

# tim consumer nuot mat ma loi
grep -rn "m.fail(500" src/ | head

Mỗi dòng m.fail(500, ...) nằm trong một onFailure là một chỗ mã lỗi gốc có thể đang bị vứt đi — và chặng đầu chuỗi sẽ báo cho người dùng rằng máy chủ hỏng, trong khi thật ra họ gửi sai dữ liệu.