Có hai câu hỏi mà một ứng dụng phải trả lời cho hệ thống điều phối, và chúng khác nhau hoàn toàn:
- Liveness — "tiến trình này còn cứu được không, hay phải giết đi khởi động lại?"
- Readiness — "có nên gửi luồng khách vào đây lúc này không?"
Hầu hết bài hướng dẫn khai một endpoint /health rồi trỏ cả hai probe vào đó. Bài này dựng một Verticle có đủ bốn kiểu health check, tắt cơ sở dữ liệu, và đo xem cách khai sai gây ra chuyện gì.
Bốn endpoint, bốn cách khai
vertx-health-check cho phép gom nhiều thủ tục kiểm tra vào một HealthChecks, rồi gắn nó vào router bằng HealthCheckHandler. Handler tự trả 200 khi mọi thủ tục UP, 503 khi có cái DOWN.
// liveness: chi hoi ve chinh tien trinh, KHONG hoi ai ben ngoai
HealthChecks song = HealthChecks.create(vertx);
song.register("tien-trinh", pr -> pr.complete(Status.OK()));
// readiness: CO hoi phu thuoc ben ngoai
HealthChecks sanSang = HealthChecks.create(vertx);
sanSang.register("csdl", pr -> pr.complete(csdlSong ? Status.OK() : Status.KO()));
sanSang.register("da-nap-xong", pr -> pr.complete(dangKhoiDong ? Status.KO() : Status.OK()));
// kieu SAI: nhet phu thuoc ben ngoai vao liveness
HealthChecks gopSai = HealthChecks.create(vertx);
gopSai.register("csdl", pr -> pr.complete(csdlSong ? Status.OK() : Status.KO()));
Router r = Router.router(vertx);
r.get("/song") .handler(HealthCheckHandler.createWithHealthChecks(song));
r.get("/san-sang") .handler(HealthCheckHandler.createWithHealthChecks(sanSang));
r.get("/gop-sai") .handler(HealthCheckHandler.createWithHealthChecks(gopSai));
Một endpoint /dat?csdl=false cho phép tôi "giết" cơ sở dữ liệu giả lập lúc đang chạy.
Ba trạng thái, và cái khác biệt lộ ra ở trạng thái thứ ba
| Trạng thái | /song (liveness) |
/san-sang (readiness) |
/gop-sai |
|---|---|---|---|
| Đang khởi động, chưa nạp xong | 200 | 503 | 200 |
| Đã nạp xong, CSDL khoẻ | 200 | 200 | 200 |
| CSDL chết | 200 | 503 | 503 |
Thân phản hồi của /san-sang lúc đang khởi động nói rõ cái nào hỏng:
['da-nap-xong=DOWN', 'csdl=UP']
Hai hàng đầu vô hại. Hàng thứ ba là chỗ mọi thứ đổ vỡ, và để thấy vì sao thì phải hỏi một câu mà ít ai hỏi: lúc CSDL chết, ứng dụng có thực sự hỏng không?
Ứng dụng "hỏng" đó chạy 101 326 req/s
Vẫn để CSDL ở trạng thái chết, tôi bắn tải vào một route nghiệp vụ không đụng tới CSDL:
/viec khi CSDL chet: 101 326 req/s | p50 1,8 ms | p95 3,4 ms | p99 7,2 ms | loi 0
Không mất một request nào. Tiến trình hoàn toàn khoẻ. Nó vẫn phục vụ được trang tĩnh, vẫn trả được dữ liệu trong cache, vẫn trả được lỗi 503 tử tế cho đúng những route cần CSDL.
Vậy mà /gop-sai trả 503, và hệ thống điều phối đọc con số đó theo đúng nghĩa của liveness: giết tiến trình đi, khởi động lại. Khởi động lại xong CSDL vẫn chết, probe lại 503, lại giết. Vòng lặp đó chạy trên mọi bản sao cùng lúc, vì tất cả cùng nhìn vào một CSDL.
Kết cục là một sự cố cơ sở dữ liệu — vốn chỉ làm hỏng những route cần CSDL — biến thành sự cố mất sạch toàn bộ dịch vụ, kể cả những phần không liên quan gì đến CSDL. Và khi CSDL sống lại, đám bản sao đang trong vòng khởi động lại phải nối lại cùng lúc, đúng lúc CSDL yếu nhất.
Khai đúng thì hàng thứ ba trong bảng đọc là: "tiến trình khoẻ, đừng giết; nhưng đừng gửi khách vào lúc này". Đó là hai câu trả lời khác nhau, và chỉ có hai endpoint mới nói được cả hai.
Cái giá của một lần khởi động lại
Tôi đo khoảng từ lúc gọi java đến lúc tiến trình in mốc "đã nhận được luồng khách", ba lần:
lan 1: 165 ms lan 2: 168 ms lan 3: 171 ms
165 ms nghe rẻ — nhưng đây là một Verticle rỗng, không pool kết nối, không nạp cache, không Spring context. Ứng dụng thật cộng thêm vài giây nữa. Đáng chú ý là phần thường bị đem ra doạ lại không đắt bằng người ta tưởng:
5 giay dau (JIT nguoi) : 98 236 req/s | p99 3,5 ms
sau 30 giay (da nong) : 107 494 req/s | p99 1,5 ms
JIT nguội chỉ lấy đi 8,6% thông lượng. Cái đắt là 165 ms hoàn toàn không phục vụ ai, nhân với số lần vòng lặp quay, nhân với số bản sao.
Health check chặn event loop: 102 118 → 156 req/s
Đây là cái bẫy riêng của Vert.x. Một thủ tục kiểm tra viết kiểu đồng bộ — connection.isValid(), ping() của một driver JDBC — sẽ chạy trên chính event loop:
chan.register("csdl-chan", pr -> {
Thread.sleep(300); // ping CSDL kieu dong bo
pr.complete(Status.OK());
});
Endpoint đó tự nó trả về sau 306,4 ms, so với 0,5–0,7 ms của hai endpoint kia. Nhưng con số đáng sợ nằm ở chỗ khác: tôi cho ba luồng probe gọi /chan liên tục rồi đo route nghiệp vụ.
| Số event loop | Không probe | Có probe | |
|---|---|---|---|
| 1 | 102 118 req/s, p50 0,9 ms | 156 req/s, p50 611,9 ms | giảm 654 lần |
| 4 | 100 916 req/s | 90 089 req/s | giảm 10,7% |
Với một event loop, ba probe thay nhau giữ nó gần như liên tục, và ứng dụng gần như chết hẳn — nhưng liveness vẫn trả 200, nên không có hệ thống điều phối nào biết. Không có cảnh báo, không có restart, chỉ có p50 nhảy từ 0,9 ms lên 611,9 ms.
Với bốn event loop thì con số hiền hẳn, và số học giải thích được: ba probe mỗi lần chặn 300 ms chỉ tạo ra khoảng một giây chặn mỗi giây, rải trên bốn loop, tức khoảng một phần tư của một loop — quãng 6% tổng năng lực, đúng cỡ mức 10,7% đo được.
Bài học không phải "hãy dùng nhiều event loop". Bài học là thiệt hại của một thao tác chặn tỉ lệ với phần event loop mà nó chiếm, nên cùng một đoạn code có thể vô hại trên máy 8 nhân của bạn và thảm hoạ trên container bị giới hạn 1 CPU. Health check là chỗ dễ lọt nhất, vì nó là đoạn code duy nhất trong ứng dụng mà không ai bao giờ đo hiệu năng.
Cách viết đúng là đừng chặn — dùng API bất đồng bộ của client, và bọc một timeout:
sanSang.register("csdl", 2000, pr -> // 2000 ms: tu coi la KO neu qua han
pool.query("SELECT 1").execute()
.onSuccess(rs -> pr.complete(Status.OK()))
.onFailure(e -> pr.complete(Status.KO(new JsonObject().put("loi", e.getMessage())))));
Vài luật rút ra được từ mấy phép đo trên
- Liveness không được hỏi bất kỳ thứ gì ngoài tiến trình. Không CSDL, không dịch vụ hạ nguồn, không đĩa mạng. Câu hỏi nó trả lời là "khởi động lại có cứu được không" — mà khởi động lại không bao giờ cứu được một CSDL đang chết.
- Readiness thì ngược lại — chính nó là chỗ khai phụ thuộc, và cũng là chỗ khai "tôi chưa nạp xong".
- Đừng để một phụ thuộc không thiết yếu vào readiness. Dịch vụ gợi ý sản phẩm chết mà làm cả trang bán hàng rơi khỏi bộ cân bằng tải thì cũng là một kiểu tự bắn vào chân, chỉ nhẹ hơn.
- Health check phải có timeout riêng, nếu không probe treo sẽ bị chấm là hỏng — hoặc tệ hơn, nằm treo mãi.
- Nếu bạn dùng circuit breaker như phần 25, hãy nghĩ kỹ trước khi nối trạng thái breaker vào readiness: breaker mở là đang bảo vệ hạ nguồn, không nhất thiết nghĩa là bản sao này nên rời khỏi bộ cân bằng tải.
Thử ba mươi giây
Dựng hai endpoint tách bạch rồi tự tay giết phụ thuộc mà xem:
HealthChecks song = HealthChecks.create(vertx);
song.register("tien-trinh", pr -> pr.complete(Status.OK()));
HealthChecks sanSang = HealthChecks.create(vertx);
sanSang.register("csdl", pr -> pr.complete(csdlSong ? Status.OK() : Status.KO()));
r.get("/song").handler(HealthCheckHandler.createWithHealthChecks(song));
r.get("/san-sang").handler(HealthCheckHandler.createWithHealthChecks(sanSang));
curl -o /dev/null -w "song=%{http_code}\n" localhost:8080/song
curl -o /dev/null -w "san-sang=%{http_code}\n" localhost:8080/san-sang
curl "localhost:8080/dat?csdl=false"
curl -o /dev/null -w "song=%{http_code}\n" localhost:8080/song # van 200 — dung
curl -o /dev/null -w "san-sang=%{http_code}\n" localhost:8080/san-sang # 503 — dung
Nếu /song của bạn cũng đổi sang 503 ở dòng cuối, bạn đang khai một quả bom hẹn giờ: nó không nổ cho tới ngày CSDL có sự cố, và hôm đó nó sẽ nhân đôi sự cố lên.
Phần sau dựng một API Gateway bằng Vert.x và đo xem thêm một chặng trung gian đắt bao nhiêu.