Hình dung mỗi lời gọi HTTP ra ngoài như một cú điện thoại sang phòng ban khác. Khi đầu kia nhấc máy nhanh thì không sao. Nhưng nếu họ treo máy mà bạn không đặt giờ cúp, bạn cầm ống nghe chờ mãi — và suốt thời gian đó, cái đường dây ấy (một luồng) bị chiếm. Ứng dụng nào cũng gọi ra ngoài. Bài này về cách làm việc đó trong Spring, và về con số làm tôi dừng lại.

Phép chờ mặc định

Tôi dựng một endpoint ngủ 5 giây, rồi gọi nó bằng bốn cách:

  RestTemplate mặc định          5027 ms   ok: xong
  RestTemplate timeout 1s        1003 ms   ResourceAccessException: Read timed out
  RestClient mặc định            5027 ms   ok: xong
  WebClient responseTimeout 1s   1020 ms   TimeoutException

Hai dòng "mặc định" chờ đủ 5 giây rồi trả về thành công. Không có phép chờ nào cả.

Endpoint của tôi ngủ 5 giây. Một dịch vụ thật bị treo sẽ giữ luồng của bạn tới khi tầng TCP bỏ cuộc — trên Linux mặc định là khoảng 15 phút.

Đây là cách một dịch vụ chậm kéo sập cả hệ thống. Mỗi request đang chờ giữ một luồng. Pool luồng của Tomcat mặc định 200. Dịch vụ phụ thuộc treo, 200 request tới, và ứng dụng của bạn ngừng phục vụ mọi thứ — kể cả những endpoint không liên quan.

Đó chính là cảnh một tổng đài 200 đường dây bị nghẽn sạch vì ai cũng đang cầm máy chờ một số đã chết. Không có phép chờ mặc định là quyết định thiết kế có lý do (thư viện không biết dịch vụ của bạn nhanh chậm ra sao), nhưng nó nghĩa là bạn phải đặt, mỗi lần, không có ngoại lệ.

Đặt phép chờ

@Bean
RestClient restClient(RestClient.Builder b) {
    var f = new SimpleClientHttpRequestFactory();
    f.setConnectTimeout(2000);      // bắt tay TCP
    f.setReadTimeout(5000);         // chờ dữ liệu về
    return b.requestFactory(f).baseUrl("https://api.doi-tac.com").build();
}

Hai phép chờ khác nhau và đều cần: connectTimeout là "chờ bao lâu để họ nhấc máy" — nên ngắn (2 giây là nhiều, nối được thì nối ngay); readTimeout là "chờ bao lâu để họ nói xong" — tuỳ theo dịch vụ.

Với RestTemplateBuilder thì gọn hơn:

@Bean RestTemplate rt(RestTemplateBuilder b) {
    return b.connectTimeout(Duration.ofSeconds(2))
            .readTimeout(Duration.ofSeconds(5)).build();
}

Luôn tiêm builder thay vì new. Builder mang theo mọi cấu hình chung — interceptor, bộ chuyển đổi, đo lường bằng Micrometer. new RestClient() là mất hết.

Chọn client nào

Client Trạng thái Dùng khi
RestTemplate bảo trì, không bỏ mã cũ, đừng viết mới
RestClient khuyên dùng từ Spring 6.1 mặc định cho mọi lời gọi đồng bộ
WebClient tích cực reactive, hoặc cần đối xử luồng dữ liệu
@HttpExchange khuyên dùng client theo kiểu khai báo

RestClient là câu trả lời cho phần lớn trường hợp: API kiểu fluent như WebClient nhưng đồng bộ, không kéo theo cả ngăn xếp reactive.

var don = client.get().uri("/don/{id}", id).retrieve().body(Don.class);

WebClient vẫn đúng khi bạn gọi hàng nghìn lời gọi song song, hoặc xử lý luồng sự kiện. Nhưng dùng nó rồi .block() ngay là mang phức tạp mà không được gì — đúng cái tôi làm trong phép đo trên, và đó là ví dụ về việc không nên làm trong mã thật.

Client khai báo

Cách tôi thích nhất, có từ Spring 6:

interface DichVuDon {
    @GetExchange("/don/{id}")
    Don lay(@PathVariable String id);

    @PostExchange("/don")
    Don tao(@RequestBody DonMoi d);
}

@Bean DichVuDon dichVuDon(RestClient.Builder b) {
    var client = b.baseUrl("https://api.doi-tac.com").build();
    var f = HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(client)).build();
    return f.createClient(DichVuDon.class);
}

Giao diện mô tả API, Spring sinh phần cài đặt. Nó cho bạn một kiểu để tiêm và để giả lập trong test — thay vì rải chuỗi URL khắp mã nghiệp vụ.

Nếu đã quen Feign của Spring Cloud thì đây là bản có sẵn trong Spring, không cần phụ thuộc thêm.

Lỗi từ phía kia

  RestClient gặp 500  ->  InternalServerError: 500 Internal Server Error: "{...}"

retrieve() ném ngoại lệ với mã 4xx và 5xx. Ba nhánh xử lý:

client.get().uri("/don/{id}", id)
      .retrieve()
      .onStatus(s -> s.value() == 404, (rq, rs) -> { throw new KhongTimThay(id); })
      .body(Don.class);

Với 404, ném ngoại lệ riêng thường đúng hơn là để NotFound của Spring lan ra.

Muốn tự xử lý hoàn toàn thì dùng .exchange() thay .retrieve().

Ba thứ nữa phải có

Thử lại, nhưng chỉ với thao tác lặp lại được. GET thì an toàn; POST tạo đơn hàng mà thử lại là tạo hai đơn. Spring Retry:

@Retryable(retryFor = ResourceAccessException.class,
           maxAttempts = 3, backoff = @Backoff(delay = 200, multiplier = 2))
Don lay(String id) { ... }

multiplier = 2 là lùi theo cấp số nhân — quan trọng, vì thử lại ngay lập tức chỉ làm dịch vụ đang quá tải thêm nặng. Cứ dập máy rồi quay số lại ngay thì chỉ dồn thêm cho một tổng đài đang ngộp.

Cầu dao (circuit breaker). Khi dịch vụ kia hỏng, thử lại 3 lần chỉ làm chậm gấp ba. Cầu dao mở ra sau N lần hỏng và trả lỗi ngay — thôi gọi cái số đã chết mấy lần liền, cho dịch vụ kia thời gian hồi phục:

@CircuitBreaker(name = "doiTac", fallbackMethod = "duPhong")
Don lay(String id) { ... }

Ghi log và đo. Mỗi lời gọi ra ngoài cần có mã tương quan đi kèm (bài 17) và một chỉ số độ trễ theo phân vị. Micrometer làm sẵn nếu bạn dùng builder Spring cấp.

Pool kết nối

SimpleClientHttpRequestFactory dùng HttpURLConnection — đơn giản nhưng không gộp kết nối. Với lưu lượng thật, mỗi request là một lượt bắt tay TCP và TLS mới.

Đổi sang Apache HttpClient hoặc JdkClientHttpRequestFactory:

var http = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(2))
        .build();
var f = new JdkClientHttpRequestFactory(http);
f.setReadTimeout(Duration.ofSeconds(5));

java.net.http.HttpClient của JDK 11+ gộp kết nối sẵn và hỗ trợ HTTP/2 — không cần phụ thuộc ngoài.

Và như bài 66 sê-ri Java đã nói về pool kết nối CSDL: kích thước pool là trần thông lượng — số đường dây điện thoại bạn có là trần số cuộc gọi cùng lúc. Pool 10 kết nối với dịch vụ trả lời trong 100 ms cho tối đa 100 lời gọi mỗi giây, dù bạn có bao nhiêu luồng.

Nếu chỉ soi một thứ trong dự án hôm nay, đếm số client không đặt giờ cúp:

grep -rn 'new RestTemplate()\|RestClient.create()' --include='*.java' src/main

Mỗi kết quả là một client không có phép chờ — nó sẽ chờ tới khi hệ điều hành bỏ cuộc. Bảng đầu bài cho thấy 5 giây chờ đủ 5 giây; một dịch vụ treo thật thì con số đó là mười lăm phút.

Mẫu số chung

Cái bẫy "không có phép chờ mặc định" không phải lỗi của Spring — nó là một trong những ngộ nhận về tính toán phân tán kinh điển: lập trình viên ngầm tin "mạng đáng tin" và "độ trễ bằng không", nên viết một lời gọi qua mạng như thể nó là một lời gọi hàm cục bộ. Nhưng lời gọi hàm thì không bao giờ treo 15 phút.

  • Python requests nổi tiếng vì không có timeout mặc định — cùng cái footgun.
  • Go http.Client với Timeout bằng 0 cũng là "chờ vô hạn", phải tự đặt.
  • Node từng không có, rồi phải thêm mặc định.

Và bộ công cụ chống chịu thì giống hệt nhau ở mọi ngôn ngữ, chỉ khác tên: phép chờ, thử lại có lùi, cầu dao, và bulkhead/pool. Java có Resilience4j (xưa là Hystrix), .NET có Polly, lưới dịch vụ như Istio/Envoy làm ngay ở tầng hạ tầng; còn "kích thước pool là trần thông lượng" chính là định luật Little mặc áo khác.

Hai điều đáng mang theo. Một: đặt phép chờ không phải việc tinh chỉnh tuỳ thích, nó là tính đúng đắn — thư viện để mặc định rộng vì nó không thể biết SLA của bạn, nên trách nhiệm đổ về bạn, mỗi lần. Hai: một lời gọi rời khỏi tiến trình của bạn là một lời gọi có thể treo mãi mãi — hãy bọc mỗi cái trong một hạn chót, và coi cái luồng (hay kết nối) nó đang giữ là một tài nguyên khan hiếm bạn đang cho mượn. Sợi chỉ chung: gọi xa không phải gọi gần mặc áo đẹp — nó hỏng theo những kiểu mà gọi hàm cục bộ không bao giờ, và cả bốn mẫu chống chịu kia sinh ra chỉ để đối phó với đúng sự thật đó.

Ngày mai: thiết kế REST API — và những chỗ mà "chuẩn REST" không giúp được gì.