Một toà nhà có đúng một cái thang máy chung. Hôm đội chuyển nhà chiếm nó hai mươi phút, thì mọi người ở mọi tầng — người xuống mua cà phê, người đi họp, chẳng liên quan gì tới đội chuyển nhà — đều kẹt cứng. Mà đứng ở sảnh nhìn lên thì thấy thang máy vẫn đang chạy bình thường, chỉ là lúc nào cũng đầy. Cạn pool kết nối trong một dịch vụ là đúng cái thang máy đó: một hạ nguồn chậm chiếm hết chỗ, và đường nhanh chẳng liên quan chết theo, trong khi mọi đồng hồ đều báo "ổn". Tôi dựng đúng tình huống đó rồi đo, sau đó vá bằng hai kiểu vách ngăn khác nhau.
Dịch vụ của bạn gọi hai hạ nguồn: một cái trả lời tức thì, một cái thỉnh thoảng chậm. Hôm cái chậm trở nên rất chậm, cái nhanh cũng chết theo — dù nó chẳng liên quan gì, dù máy chủ vẫn rảnh CPU, dù không có ngoại lệ nào trong log.
Sàn diễn
Hạ nguồn có hai đường: /nhanh trả lời ngay, /cham trả lời sau 3 giây. Dịch vụ ở giữa gọi cả hai bằng một WebClient với maxPoolSize = 20.
WebClient chung = WebClient.create(vertx, new WebClientOptions().setMaxPoolSize(20));
r.get("/chung/nhanh").handler(c -> chung.get(H,"localhost","/nhanh").send()...);
r.get("/chung/cham") .handler(c -> chung.get(H,"localhost","/cham") .send()...);
Đường nhanh khi không ai quấy rầy:
57 225 req/s | p50 0,8 ms | p95 1,5 ms | p99 2,4 ms | loi 0
Bây giờ cho 60 khách treo trên đường chậm, rồi đo lại đường nhanh:
5 req/s | p50 8 997,9 ms | p95 9 001,3 ms | p99 9 002,0 ms | loi 0
Thông lượng giảm hơn mười một nghìn lần, p50 từ 0,8 ms lên gần 9 giây. Và chi tiết quan trọng nhất nằm ở cuối dòng: loi 0. Không request nào hỏng, không ngoại lệ nào được ném ra, không có gì cho try/catch bắt, không có gì cho cảnh báo tỉ lệ lỗi nhận ra. Mọi request đều thành công — chỉ là sau chín giây.
Con số 9 giây cũng nói lên cơ chế: mỗi lượt chậm giữ một kết nối đúng 3 giây, pool có 20 chỗ và 60 khách đang xếp hàng, nên một request nhanh phải đợi ba lượt luân chuyển mới tới lượt mình. Nó không hề bị "xử lý chậm" — nó không được xử lý gì cả, nó xếp hàng.
Đây là kiểu hỏng khó chẩn đoán nhất, vì mọi thứ bạn quen nhìn đều bình thường: CPU thấp, bộ nhớ ổn, tỉ lệ lỗi bằng không, hạ nguồn nhanh vẫn khoẻ nếu bạn curl thẳng vào nó. Thứ duy nhất sai là một tài nguyên dùng chung mà không ai đo.
Vá cách một: mỗi phụ thuộc một pool
WebClient riengNhanh = WebClient.create(vertx, new WebClientOptions().setMaxPoolSize(20));
WebClient riengCham = WebClient.create(vertx, new WebClientOptions().setMaxPoolSize(20));
Chỉ vậy thôi. Cùng tải nền 60 khách trên đường chậm:
62 500 req/s | p50 0,8 ms | p95 1,0 ms | p99 1,7 ms | loi 0
Đường nhanh không hề hấn gì — bằng đúng lúc không có tải nền. Đường chậm cũng giữ nguyên năng lực của nó (120 lượt hoàn thành trong 10 giây, y như khi dùng chung pool). Hai đường độc lập thật sự.
Vá cách hai: giữ chung pool, thêm một cái trần
Đôi khi bạn không muốn nhân đôi số kết nối. Cách khác là giữ một pool nhưng giới hạn số lượt đang bay tới phụ thuộc chậm:
static class Vach {
final int tran; int dangBay;
boolean xin(Runnable viec){
if (dangBay < tran) { dangBay++; viec.run(); return true; }
return false; // day thi TU CHOI NGAY, khong xep hang
}
void tra(){ dangBay--; }
}
Không cần khoá — biến đếm nằm trong Verticle, và Verticle bị giam vào một event loop. Đặt trần bằng nửa pool:
58 420 req/s | p50 0,8 ms | p95 1,1 ms | p99 1,8 ms | loi 0
Đường nhanh cũng được cứu. Nhưng cái giá lộ ra khi nhìn vào chính đường chậm, chạy 60 khách trong 10 giây:
| Cấu hình | Đường chậm hoàn thành | Bị từ chối |
|---|---|---|
| Chung pool | 120 | 0 |
| Pool riêng | 120 | 0 |
| Chung pool + vách ngăn (trần 10) | 40 | 74 651 lượt 429 |
Vách ngăn kiểu này cắt bớt chứ không chỉ cách ly: nó từ chối thẳng thay vì cho xếp hàng, nên đường chậm mất khoảng hai phần ba năng lực và bảy vạn lượt nhận 429. Đó là lựa chọn có chủ ý — "thà từ chối ngay còn hơn để khách chờ chín giây rồi vẫn hỏng" — nhưng phải là lựa chọn bạn biết mình đang chọn.
Nói gọn: pool riêng thì cách ly mà không cắt; trần đếm thì cách ly bằng cách cắt. Pool riêng tốn thêm kết nối và bộ nhớ, trần đếm tốn thiện chí của khách trên đường chậm.
Cùng cái bẫy, chỗ khác: pool luồng chặn
Vert.x có một tài nguyên dùng chung nữa mà rất ít người nghĩ tới — pool luồng của executeBlocking. Đọc thẳng từ thư viện thay vì tra tài liệu:
workerPoolSize mac dinh = 20
eventLoopPoolSize mac dinh = 32
maxWorkerExecuteTime mac dinh = 60 giay
WebClient maxPoolSize mac dinh = 5
Hai mươi luồng, dùng chung cho mọi lời gọi vertx.executeBlocking trong ứng dụng. Tôi dựng hai loại việc chặn — một cái 5 ms, một cái 2 giây — rồi cho 60 khách quay loại chậm:
| Thông lượng việc nhanh | p50 | |
|---|---|---|
| Không tải nền | 3 308 req/s | 15,1 ms |
| 60 khách ở việc chậm, pool mặc định | 8 req/s | 6 009,4 ms |
| 60 khách ở việc chậm, pool riêng | 3 303 req/s | 15,1 ms |
Đúng cùng một hình dạng, đúng cùng một lời giải:
WorkerExecutor rieng = vertx.createSharedWorkerExecutor("bao-cao", 4);
rieng.executeBlocking(() -> { /* viec cham */ });
Chỗ này còn dễ dính hơn cả pool HTTP, vì vertx.executeBlocking trông như một tiện ích vô hại và người ta rắc nó khắp nơi: xuất báo cáo PDF, gọi một thư viện JDBC cũ, đọc file cấu hình, mã hoá mật khẩu. Mỗi chỗ một tác giả khác nhau, không ai biết mình đang chia sẻ hai mươi luồng với những ai. Và maxWorkerExecuteTime mặc định 60 giây nghĩa là Vert.x sẽ chỉ ghi cảnh báo sau một phút — quá muộn để có tác dụng chẩn đoán.
Một chi tiết đáng chú ý trong bảng mặc định: WebClient.maxPoolSize chỉ là 5. Nếu bạn chưa từng chỉnh nó thì vách ngăn của bạn hẹp hơn nhiều so với con số 20 tôi dùng để đo ở trên, và hiện tượng cạn pool đến sớm hơn nhiều — đúng cái bẫy đã nhắc trong bài về API Gateway.
Cách tìm ra vách ngăn còn thiếu
Không cần công cụ gì: liệt kê mọi tài nguyên có số lượng hữu hạn mà nhiều đường mã cùng dùng. Trong một ứng dụng Vert.x điển hình có bốn cái:
| Tài nguyên | Mặc định | Ai chia sẻ |
|---|---|---|
WebClient pool |
5 mỗi host | mọi lời gọi qua cùng một client |
| Pool luồng chặn | 20 | mọi executeBlocking |
| Pool kết nối CSDL | tuỳ driver | mọi truy vấn |
| Event loop | 32 | mọi thứ chạy trên nó |
Với mỗi cái, hỏi: nếu một loại người dùng tài nguyên này chậm đi 100 lần, những ai chết theo? Danh sách trả lời chính là danh sách vách ngăn bạn cần.
Vách ngăn cũng là thứ khiến các cơ chế khác thật sự có tác dụng. Circuit breaker ở phần 25 chỉ cắt được lời gọi tới hạ nguồn hỏng — nó không giúp gì nếu pool đã cạn trước khi breaker kịp mở. Còn thử lại ở phần 30 thì làm mọi thứ tệ hơn khi không có vách ngăn: mỗi lượt thử lại giữ thêm một chỗ trong đúng cái pool đang cạn.
Muốn thấy hiện tượng cạn pool tận mắt, không cần sửa mã: dìm một phụ thuộc chậm xuống bằng một cửa sổ, rồi đo một đường hoàn toàn không liên quan ở cửa sổ khác:
# cua so 1: dim mot phu thuoc cham xuong
for i in $(seq 1 60); do
while true; do curl -s -o /dev/null localhost:8080/duong-goi-ha-nguon-cham; done &
done
# cua so 2: do mot duong HOAN TOAN KHONG LIEN QUAN
curl -o /dev/null -s -w "%{time_total}\n" localhost:8080/duong-nhanh-khong-lien-quan
Con số ở cửa sổ 2 mà nhảy từ mili giây lên giây trong khi log không có lỗi nào, thì hai đường đó đang dùng chung một tài nguyên hữu hạn — và bạn vừa tìm ra chỗ cần đặt vách ngăn.
Mẫu số chung
Bài học lớn nhất: một tài nguyên hữu hạn dùng chung là một sợi dây trói vô hình giữa những thứ chẳng liên quan gì tới nhau. Đường nhanh và đường chậm không chia sẻ code, không chia sẻ dữ liệu — chỉ chung một pool 20 chỗ, thế là đủ để cái này bóp chết cái kia. Và nó tàng hình với mọi thứ bạn quen canh: loi 0, CPU thấp, hạ nguồn khoẻ — vì "xếp hàng chờ" không phải "hỏng", mà dashboard của bạn chỉ rình cái "hỏng". Nên câu hỏi phát hiện là "nếu một nhóm dùng tài nguyên này chậm đi 100 lần, ai chết theo?" — trả lời xong là ra danh sách vách ngăn còn thiếu. Và thứ đáng theo dõi không phải tỉ lệ lỗi mà là độ bão hoà của pool: cạn pool hiện ra dưới dạng độ trễ với không lỗi nào, đúng cái điểm mù của giám sát dựa-trên-lỗi.
Điều thứ hai, về triết lý cách ly: "hỏng nhanh" gần như luôn tốt hơn "xong chậm". Một hàng đợi không trần biến một cơn quá tải thành một request thành công sau chín giây mà không chuông nào kêu — tệ hơn hẳn một cú 429 tức thì, vì cái chậm-mà-vẫn-xong thì không hệ thống cảnh báo nào bắt, còn khách thì đã bỏ đi từ lâu. Hai bản vá là hai lựa chọn có thật: pool riêng cách ly mà không cắt (trả bằng bộ nhớ, kết nối), trần đếm cách ly bằng cách cắt (trả bằng thiện chí của đường chậm, đổi lấy việc lỗi trở nên trung thực và tức thì). Cùng nguyên tắc "chọn đổ tải sớm" đứng sau mọi bulkhead, mọi hạn mức đồng thời, mọi hàng đợi có trần. Và nhớ: vách ngăn là nền cho các mẫu khác — circuit breaker hay retry đều vô nghĩa nếu cái pool đã cạn trước khi chúng kịp ra tay; tệ hơn, retry không có vách ngăn còn tự tay giữ thêm chỗ trong đúng cái pool đang chết.
Phần sau bàn về việc mang trace id đi xuyên các chặng bất đồng bộ, và những chỗ ngữ cảnh hay rơi mất.