Gọi điện gặp máy bận thì bực nhưng không sao — bạn gác máy, gọi lại. Đáng sợ hơn nhiều là gọi được, đầu dây bên kia nhấc lên, rồi im lặng: không nói, không cúp. Bạn kẹt trên đường dây đó, không làm gì khác được, và nếu tất cả các đường dây của bạn đều dính một cuộc im lặng như thế thì bạn không nhận nổi một cuộc gọi thật nào nữa. Một upstream treo — còn sống nhưng không bao giờ trả lời — làm đúng chuyện đó với pool kết nối của bạn. Bài này đo hai thứ quyết định chuyện gọi ra ngoài 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.

Đây là chỗ dễ làm sập dịch vụ hạ nguồn. Bạn đặt 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 đó.

Muốn biết ứng dụng thật sự mở bao nhiêu kết nối ra ngoài, đếm thẳng:

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

Mẫu số chung

Điều đáng khắc cốt: một phụ thuộc báo lỗi dễ sống sót hơn nhiều so với một phụ thuộc im lặng. Lỗi thì trả kết nối về ngay, làm chuông cảnh báo kêu, cho bạn cái để catch; còn treo im thì giữ khư khư tài nguyên của bạn mãi mãi mà không phát tín hiệu nào — 40 cuộc im lặng chiếm sạch pool và kéo thông lượng về 0 vĩnh viễn. Gốc của nó là một mặc định tệ: chờ vô hạn. Không có phép chờ nghĩa là ngầm chọn "tôi sẽ đợi đến hết đời", và đó là lựa chọn tồi nhất có thể. Nguyên tắc không ngoại lệ: mọi lời gọi ra ngoài đều phải có một hạn chót — một HTTP request, một truy vấn CSDL, một lần lấy khoá, một lời gọi RPC — vì cái duy nhất tệ hơn một hạ nguồn hỏng là một hạ nguồn không chịu nói bạn biết nó hỏng.

Điều thứ hai, và nó là cái bẫy an-toàn-giả: một phép chờ phải canh đúng cái pha thật sự hỏng. connectTimeout gác lúc bắt tay — nhưng cú treo ở bài này xảy ra sau khi đã bắt tay xong, nên nó không đụng tới, và bạn "đã đặt timeout" mà vẫn chết. Đặt một cái chốt ở nhầm bước còn nguy hơn không đặt, vì nó cho cảm giác đã lo xong. Cùng khoảng-mù ấy ở một connect timeout không bao gồm read timeout, một statement_timeout của CSDL không bao gồm thời gian chờ trong pool kết nối, một healthcheck bắt tay được cổng nhưng không kiểm truy vấn thật. Khi tin vào một timeout, hỏi thẳng: nó bấm giờ từ mốc nào tới mốc nào — và cái pha nó không phủ mới là chỗ hệ thống của bạn sẽ treo. Cái cứu bạn không phải "có một timeout", mà là một hạn chót tổng cho toàn bộ thao tác.

Bài sau: streaming và backpressure — một client đọc chậm làm máy chủ giữ bao nhiêu bộ nhớ.