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.
Đó 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
requestsnổi tiếng vì không có timeout mặc định — cùng cái footgun. - Go
http.ClientvớiTimeoutbằ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ì.