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

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 nên ngắn (2 giây là nhiều — nối được thì nối ngay), readTimeout 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ầ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, 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. 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.

Thử ba mươi giây

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.

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