99 Ngày Spring — Ngày 75: Giao tiếp giữa service
Ba ngày qua ta có cấu hình tập trung, danh bạ và cửa vào. Giờ tới việc xảy ra nhiều nhất trong một hệ microservices: service gọi service. Câu hỏi không phải “dùng thư viện nào” mà là làm sao để lời gọi có timeout, tìm đúng instance, thử lại khi hợp lý và không thử lại khi nguy hiểm. Bài này so sánh ba cách viết client trên Spring Boot 3.5 (RestClient, HTTP interface, OpenFeign) rồi bàn về retry với Spring Cloud LoadBalancer. Java 21 và Spring Cloud 2025.0.

1. Đồng bộ hay bất đồng bộ trước đã
Gọi đồng bộ (HTTP) khi bạn cần câu trả lời ngay để tiếp tục: kiểm tra tồn kho trước khi tạo đơn, lấy giá hiện tại. Người gọi chờ, và chịu độ trễ cộng dồn của cả chuỗi.
Nhắn tin bất đồng bộ (Kafka, RabbitMQ; Ngày 78–79) khi việc có thể xảy ra sau: gửi email, cập nhật báo cáo, đồng bộ sang hệ thống khác. Người gọi không chờ, và service nhận có thể tạm chết mà không kéo theo người gửi.
Chuỗi gọi đồng bộ dài (A gọi B gọi C gọi D) nhân xác suất lỗi và cộng độ trễ; nếu thấy chuỗi như vậy, trước khi tối ưu client hãy hỏi có bước nào nên thành sự kiện không.
2. RestClient cân bằng tải: nền của mọi thứ
package vn.motdev.order;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.time.Duration;
@Configuration
class PaymentClientConfig {
@Bean
@LoadBalanced
RestClient.Builder loadBalancedBuilder() {
var factory = new JdkClientHttpRequestFactory(); // HttpClient của JDK 21 phía dưới
factory.setReadTimeout(Duration.ofSeconds(2)); // KHÔNG có timeout = chờ vô hạn khi đối tác treo
return RestClient.builder().requestFactory(factory);
}
@Bean
RestClient paymentClient(RestClient.Builder builder) {
return builder.baseUrl("http://payment-service").build(); // tên trong danh bạ (Ngày 73)
}
}RestClient(Spring 6.1) là API đồng bộ hiện đại thayRestTemplate, đã học ở Ngày 66.@LoadBalancedtrênBuilderthêm interceptor đổi tên service thành địa chỉ từ danh bạ (Ngày 73).Timeout là bắt buộc, không phải tùy chọn:
JdkClientHttpRequestFactorycho đặt read timeout; connect timeout đặt trênHttpClientcủa JDK truyền vào factory. Không có timeout, một đối tác treo giữ luồng của bạn mãi mãi, và với thread pool cố định, service của bạn chết theo.Một
RestClientbean cho mỗi đối tác vớibaseUrlriêng: timeout, header xác thực và interceptor khác nhau theo đối tác, và test dễ thay thế.
3. HTTP interface: hợp đồng dưới dạng interface
package vn.motdev.order;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
// HTTP interface (Spring Framework 6): hợp đồng gọi xa dưới dạng interface, không cần thư viện ngoài
@HttpExchange("/api/payments")
public interface PaymentApi {
@GetExchange("/{id}")
PaymentView get(@PathVariable String id);
@PostExchange
PaymentView create(@RequestBody CreatePayment body);
}
record PaymentView(String id, String status, long amount) {}
record CreatePayment(String orderId, long amount, String idempotencyKey) {}package vn.motdev.order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
class PaymentApiConfig {
@Bean
PaymentApi paymentApi(RestClient paymentClient) {
// Sinh implementation của interface, chạy trên RestClient đã cân bằng tải + có timeout
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(paymentClient))
.build()
.createClient(PaymentApi.class);
}
}
@org.springframework.stereotype.Service
class CheckoutService {
private final PaymentApi payments;
CheckoutService(PaymentApi payments) { this.payments = payments; }
PaymentView pay(String orderId, long amount) {
// Khóa idempotency: gọi lại (retry) không tạo giao dịch thứ hai
return payments.create(new CreatePayment(orderId, amount, "order-" + orderId));
}
}Interface với
@HttpExchangemô tả API xa;HttpServiceProxyFactorysinh implementation chạy trênRestClientbạn đưa vào, nên mọi cấu hình cân bằng tải, timeout, interceptor đều được kế thừa. Không thêm thư viện ngoài.Lợi ích lớn nhất là hợp đồng nằm trong code: đổi đường dẫn hay kiểu trả về là lỗi biên dịch ở người gọi, và service cung cấp có thể publish interface này trong một module
payment-apiđể người dùng tái sử dụng.Trong ví dụ,
CreatePaymentmangidempotencyKey: phía payment lưu khóa và trả lại kết quả cũ nếu thấy khóa trùng. Đây là điều kiện để mục 5 có thể retry POST an toàn.Spring Boot 4 thêm tự động cấu hình cho HTTP interface (
@ImportHttpServices); trên Boot 3.5 ta khai báo factory bằng tay như trên.
// Lựa chọn khác: OpenFeign — spring-cloud-starter-openfeign, @EnableFeignClients trên lớp @SpringBootApplication
@org.springframework.cloud.openfeign.FeignClient(name = "payment-service", path = "/api/payments")
interface PaymentFeign {
@org.springframework.web.bind.annotation.GetMapping("/{id}")
PaymentView get(@org.springframework.web.bind.annotation.PathVariable String id);
}
// Cùng ý tưởng "interface = hợp đồng", tích hợp sẵn LoadBalancer và CircuitBreaker.
// Spring Cloud OpenFeign ở trạng thái feature-complete: chỉ sửa lỗi, không thêm tính năng;
// Spring khuyến nghị HTTP interface + RestClient cho dự án mới.4. Chọn cách nào
RestClient trực tiếp: một hai lời gọi, cần kiểm soát từng chi tiết của request, hoặc API xa không ổn định để làm interface.
HTTP interface: mặc định cho dự án mới. Cùng mô hình interface như Feign, nhưng nằm trong Spring Framework, chạy trên client bạn tự cấu hình, hỗ trợ cả
RestClient,WebClientvàRestTemplate.OpenFeign: đội đã dùng quen, có sẵn bộ interceptor/decoder tùy chỉnh, hoặc dựa vào tích hợp Feign với CircuitBreaker. Nhớ rằng Spring Cloud OpenFeign chỉ nhận sửa lỗi; đừng chọn nó cho phần mới chỉ vì thói quen.
Dù chọn gì, ba thứ không đổi: timeout tường minh, gọi bằng tên qua danh bạ, và một client riêng cho mỗi đối tác.
5. Retry: thử lại đúng cách
# order-service/src/main/resources/application.yml
spring:
cloud:
loadbalancer:
retry:
enabled: true # cần spring-retry trên classpath
max-retries-on-same-service-instance: 0
max-retries-on-next-service-instance: 2 # đổi sang instance KHÁC, tối đa 2 lần
retry-on-all-operations: false # mặc định: chỉ GET — POST không tự retry
retryable-status-codes: 502,503,504
backoff:
enabled: true
min-backoff: 100ms
max-backoff: 1s
health-check:
interval: 10s # loại instance đang DOWN khỏi danh sách chọnSpring Cloud LoadBalancer có thể retry sang instance khác khi kết nối thất bại hoặc nhận mã 502/503/504: đúng loại lỗi mà retry có ích, vì instance bên cạnh có thể đang khỏe. Với
RestClientvàRestTemplateđược@LoadBalanced, cấu hình trên tự bậtRetryLoadBalancerInterceptor.Mặc định chỉ retry GET (
retry-on-all-operations: false). POST tạo thanh toán mà retry mù quáng là tạo hai giao dịch: request đầu có thể đã thành công nhưng câu trả lời bị mất trên đường về. Chỉ bật retry cho thao tác ghi khi phía nhận idempotent nhờ khóa như mục 3.Backoff có jitter để nhiều client không đồng loạt thử lại cùng lúc (retry storm). Giới hạn tổng số lần thử: 3 lần × 2 giây timeout là 6 giây người dùng phải chờ; retry không thay được timeout hợp lý.
Retry giải quyết lỗi thoáng qua. Lỗi kéo dài (service chết hẳn, quá tải) cần ngừng gọi một thời gian để nó hồi phục: đó là circuit breaker và bulkhead của Resilience4j, Ngày 76. Retry chồng lên circuit breaker chồng lên timeout là bộ ba tiêu chuẩn.
Lỗi 4xx (400, 404, 422) không bao giờ retry: request sai thì gửi lại vẫn sai. Ngày 69 đã chuẩn hóa
ProblemDetail; client nên đọc nó để phân biệt lỗi của mình với lỗi của đối tác.
6. Bài tập thực hành
Cấu hình
PaymentApiquaRestClientcân bằng tải, chạy hai instance payment và logserver.portphía nhận để thấy round-robin qua HTTP interface.Cho một instance payment ngủ 10 giây; với read timeout 2 giây, đo thời gian người gọi chờ khi có retry sang instance khác và khi không.
Bật
retry-on-all-operations: truechưa có idempotency key, gửi POST và làm rơi câu trả lời (tắt mạng giữa chừng bằng Toxiproxy hoặc ngủ trước khi trả về): đếm số giao dịch được tạo. Thêm khóa idempotency và lặp lại.Chuyển một client Feign hiện có sang HTTP interface; so sánh lượng cấu hình và số dependency.
Tóm lại: chọn đồng bộ hay bất đồng bộ trước khi chọn thư viện; @LoadBalanced RestClient với timeout tường minh là nền; HTTP interface đưa hợp đồng vào code và là mặc định cho dự án mới, OpenFeign cho đội đã dùng; retry chỉ sang instance khác, chỉ cho lỗi thoáng qua, và chỉ cho thao tác idempotent. Ngày 76 hoàn thiện bộ ba: circuit breaker, bulkhead và rate limiter với Resilience4j.
Tài liệu đối chiếu
RestClient: https://docs.spring.io/spring-framework/reference/integration/rest-clients.html ; HTTP interface: https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-http-interface ; Spring Cloud LoadBalancer retry: https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/loadbalancer.html ; Spring Cloud OpenFeign: https://docs.spring.io/spring-cloud-openfeign/reference/
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.

