Gần như mọi dịch vụ đều gọi ra ngoài. Bài này đo hai thứ quyết định chuyện đó chạy hay sập: kích thước pool, và phép chờ.
Pool quyết định thông lượng
Dịch vụ gọi sang một upstream trả lời sau 20 ms, đo ở 300 kết nối đồng thời:
maxPoolSize |
Thông lượng | p50 |
|---|---|---|
| 1 | 186 req/s | 1 581,6 ms |
| 5 | 936 req/s | 315,6 ms |
| 20 | 3 711 req/s | 83,1 ms |
| 100 | 13 478 req/s | 21,8 ms |
Mỗi con số là một phép chia đơn giản: số kết nối đồng thời chia cho độ trễ của upstream. Pool 20 cho 80 kết nối thật (giải thích ngay dưới) chia 0,02 giây bằng 4 000 — đo được 3 711.
Và p50 ở pool 1 là 1,58 giây cho một upstream chỉ mất 20 ms. Toàn bộ phần dư là thời gian xếp hàng chờ tới lượt mượn kết nối.
maxPoolSize bị nhân lên mà không ai để ý
Con số trong bảng trên là mỗi WebClient, và mỗi bản sao verticle tạo một WebClient riêng. Dịch vụ trong phép đo có 4 bản sao, nên maxPoolSize=20 thật ra là 80 kết nối tới upstream.
maxPoolSize=50 nghĩ rằng mình lịch sự, chạy 16 bản sao verticle trên 4 pod — và upstream nhận 3 200 kết nối từ một dịch vụ. Con số phải nhân là maxPoolSize × số bản sao × số pod.
Muốn một pool dùng chung thì tạo WebClient một lần và chia sẻ, hoặc dùng WebClient.create(vertx, opts) với setShared(true) và đặt tên pool.
Bẫy lớn: quên phép chờ
Upstream còn sống nhưng không bao giờ trả lời — trường hợp thật khi một dịch vụ treo, không phải sập. Bắn 40 request vào endpoint đó rồi đo lưu lượng bình thường:
| Trước khi có request treo | Trong khi có 40 request treo | |
|---|---|---|
| Không đặt timeout | 918 req/s | 0 req/s — không một request nào thành công |
| Có timeout 2 giây | 919 req/s | 32 req/s, 276 lỗi |
Không phải chậm đi. Là về không, và ở lại đó vĩnh viễn. Bốn mươi request đủ để chiếm sạch pool 20 kết nối, và vì không có phép chờ nào, chúng không bao giờ trả kết nối lại.
Đây là cách một dịch vụ hạ nguồn ốm kéo sập dịch vụ khoẻ: không cần nó trả lỗi, chỉ cần nó im lặng.
Timeout là điều kiện cần, không phải điều kiện đủ
Nhìn lại cột phải: có timeout thì còn 32 req/s — sống, nhưng vẫn tệ hơn 918 rất nhiều. Bốn mươi request treo vẫn liên tục mượn lại kết nối mỗi hai giây, và chúng vẫn chiếm phần lớn pool.
Phép chờ giữ cho hệ thống không chết cứng. Để nó khoẻ thì cần thêm hai thứ mà sê-ri sẽ đo ở chặng microservice: circuit breaker ngắt hẳn lời gọi khi hạ nguồn đang hỏng (bài 25), và bulkhead tách pool riêng cho từng phụ thuộc để một cái ốm không ăn hết tài nguyên của cái khác (bài 31).
Nên đặt gì
var opts = new WebClientOptions()
.setMaxPoolSize(20) // nhớ nhân với số bản sao verticle
.setConnectTimeout(2000) // bắt tay TCP
.setIdleTimeout(10); // giây — đóng kết nối ngồi không
wc.get("/api").timeout(3000) // TỔNG thời gian cho một request
.send();
Bốn dòng, và dòng cuối là dòng quan trọng nhất: connectTimeout chỉ lo lúc mở kết nối, còn một upstream đã bắt tay xong rồi treo thì chỉ .timeout(...) trên từng request mới cứu được. Bảng đầu bài là cái giá của việc thiếu đúng dòng đó.
Bài sau: streaming và backpressure — một client đọc chậm làm máy chủ giữ bao nhiêu bộ nhớ.
Thử ba mươi giây
# ung dung cua ban dang mo bao nhieu ket noi ra ngoai
lsof -nP -p <pid> -iTCP -sTCP:ESTABLISHED | wc -l
So con số đó với maxPoolSize bạn nghĩ mình đã đặt. Chênh nhau đúng bằng số bản sao verticle — và đó là con số upstream đang nhìn thấy.