Một chuỗi hỏi đáp nhiều chặng giống việc giao qua nhiều cấp: A nhờ B, B nhờ C. Hai chỗ hay hỏng, và cả hai đều nằm ở khâu chuyển tiếp. Khi C từ chối, B có thuật lại đúng lý do — "khách đã huỷ đơn" — hay làm mờ thành "có trục trặc gì đó"? Và khi A hết kiên nhẫn bỏ đi, C không hề dừng lại — nó vẫn làm xong rồi nộp cho một cái ghế trống. 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; bài này đo chuyện gì xảy ra dọc 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.

Chỗ dễ làm tắt nhất là khâu chuyển tiếp lỗi, và tìm nó chỉ mất một lệnh:

# 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.

Mẫu số chung

Một timeout nghĩa là "tôi thôi chờ", không bao giờ là "hãy dừng lại" — và trên một chuỗi không có huỷ truyền xuống, khoảng cách đó rất đắt: bên gọi bỏ cuộc, bên bị gọi vẫn chạy tới cùng cho một cái ghế đã trống, và khi quá tải thì mọi request đều timeout trong khi hệ vẫn làm tối đa công việc để cho ra không kết quả. Tải không giảm — nó hoá vô ích, và đó chính là cách một hệ quá tải cứ mãi quá tải. Muốn giảm tải thì phải chặn ngay đầu vào: một circuit breaker, một cửa nhận có hạn mức, hoặc một tín hiệu huỷ thật sự truyền xuống và được bên dưới tôn trọng. Cùng sự nhầm-động-từ ấy ở khắp nơi: một fetch abort không bao giờ tới được máy chủ, một context của Go mà bên bị gọi phải chịu kiểm tra mới có tác dụng, một client timeout để lại truy vấn vẫn chạy. "Bỏ cuộc" và "dừng lại" là hai hành động khác nhau, và chỉ một trong hai làm giảm việc thật.

Điều thứ hai: ngữ cảnh không tự đi theo qua một ranh giới — bạn phải cố ý truyền nó, hoặc mất nó. Một lỗi nghiệp vụ 409 chỉ sống sót qua chuỗi nhiều chặng nếu mỗi chặng giữa chuyển tiếp đúng mã và thông điệp; một dòng m.fail(500) làm biếng là đủ làm phẳng nó, và chặng đầu báo "máy chủ hỏng" cho cái thật ra là "bạn gửi sai dữ liệu". Cùng cái truyền-có-chủ-đích ấy mang một trace id xuyên nhiều dịch vụ, một mã trạng thái HTTP qua một gateway, chuỗi cause của một exception, một status của gRPC qua ranh giới — rơi ở bất kỳ chặng nào là sự thật của gốc biến mất. Và người anh em của nó về thời gian: ngân sách phải co lại khi đi vào trong (mỗi chặng lấy một phần của phần còn lại), không phải mỗi đội đặt riêng một cách biệt lập — một A-1-giây bọc ngoài một B-2-giây là ngược đời, và phổ biến đúng vì không ai sở hữu cả chuỗ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.