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.

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.

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.

Thử ba mươi giây

Tìm hiện tượng cạn pool bằng hai lệnh, không cần sửa mã:

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

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.