Giao tiếp giữa service: REST, gRPC hay message queue?
1. Tổng quan
Khi tách một hệ thống monolith thành nhiều service nhỏ, câu hỏi đầu tiên luôn là: các service nói chuyện với nhau bằng cách nào? Bài này so sánh ba lựa chọn phổ biến nhất — REST, gRPC và message queue (Kafka/RabbitMQ) — qua ví dụ code cụ thể. Đọc xong, bạn sẽ biết khi nào nên chọn cái nào, và tránh được vài sai lầm kinh điển khi thiết kế giao tiếp giữa service.
2. Bài toán đặt ra
Giả sử bạn có hệ thống e-commerce với 3 service: OrderService, PaymentService, InventoryService. Khi OrderService nhận đơn hàng, nó cần:
- Gọi
PaymentServiceđể trừ tiền (cần kết quả ngay để báo cho người dùng). - Gọi
InventoryServiceđể trừ kho (có thể xử lý bất đồng bộ).
Hai nhu cầu khác nhau này dẫn đến hai kiểu giao tiếp khác nhau: đồng bộ (REST, gRPC) và bất đồng bộ (message queue).
3. REST — đơn giản, phổ biến, dễ debug
REST qua HTTP/JSON là lựa chọn mặc định vì gần như ai cũng biết dùng, công cụ debug (Postman, curl) có sẵn khắp nơi.
3.1. Ví dụ gọi REST với RestClient
public record PaymentRequest(String orderId, BigDecimal amount) {}
public record PaymentResponse(String transactionId, String status) {}
@Service
public class PaymentClient {
private final RestClient restClient;
public PaymentClient(RestClient.Builder builder) {
this.restClient = builder.baseUrl("http://payment-service:8081").build();
}
public PaymentResponse charge(PaymentRequest request) {
return restClient.post()
.uri("/api/payments")
.body(request)
.retrieve()
.body(PaymentResponse.class);
}
}
RestClient (Spring 6.1+) gửi JSON tới payment-service, nhận lại PaymentResponse đã deserialize sẵn. Nếu payment-service trả 4xx/5xx, retrieve() sẽ ném RestClientException — bạn nên bắt lỗi này ở lớp gọi.
3.2. Ưu điểm và nhược điểm
- Ưu: dễ học, dễ test bằng curl/Postman, hỗ trợ sẵn ở mọi ngôn ngữ, tận dụng được HTTP caching, load balancer, API gateway.
- Nhược: payload JSON nặng hơn binary, không có contract chặt (dễ lệch field giữa client/server), latency cao hơn gRPC vì parse text và HTTP/1.1 overhead.
Lưu ý: REST hợp khi số lượng request không quá lớn và bạn cần khả năng debug nhanh bằng mắt người. Với hệ thống nội bộ gọi hàng chục nghìn request/giây, REST JSON có thể là điểm nghẽn.
4. gRPC — hiệu năng cao, contract chặt
gRPC dùng Protocol Buffers (binary) và HTTP/2, phù hợp cho giao tiếp nội bộ giữa service cần tốc độ và contract rõ ràng.
4.1. Khai báo proto
syntax = "proto3";
package payment;
option java_package = "dev.nonla.payment.grpc";
option java_multiple_files = true;
service PaymentGrpcService {
rpc Charge (ChargeRequest) returns (ChargeResponse);
}
message ChargeRequest {
string order_id = 1;
double amount = 2;
}
message ChargeResponse {
string transaction_id = 1;
string status = 2;
}
File .proto này sinh ra stub Java cho cả server và client — đây chính là “contract” chặt mà REST không có sẵn.
4.2. Dependency Maven
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-server-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-client-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
Thư viện grpc-spring-boot-starter giúp tích hợp gRPC server/client vào Spring Boot mà không cần tự quản lý Server thủ công.
4.3. Server implementation
@GrpcService
public class PaymentGrpcServer extends PaymentGrpcServiceGrpc.PaymentGrpcServiceImplBase {
@Override
public void charge(ChargeRequest request, StreamObserver<ChargeResponse> responseObserver) {
ChargeResponse response = ChargeResponse.newBuilder()
.setTransactionId(UUID.randomUUID().toString())
.setStatus("SUCCESS")
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}
@GrpcService tự động đăng ký server này vào gRPC runtime. onNext gửi response, onCompleted đóng stream — bắt buộc phải gọi, nếu quên client sẽ bị treo chờ mãi.
4.4. Client gọi gRPC
@Service
public class PaymentGrpcClient {
@GrpcClient("payment-service")
private PaymentGrpcServiceGrpc.PaymentGrpcServiceBlockingStub stub;
public ChargeResponse charge(String orderId, double amount) {
ChargeRequest request = ChargeRequest.newBuilder()
.setOrderId(orderId)
.setAmount(amount)
.build();
return stub.charge(request);
}
}
BlockingStub gọi đồng bộ, trả kết quả trực tiếp — code gọn hơn REST vì không cần tự deserialize JSON.
4.5. Ưu điểm và nhược điểm
- Ưu: binary nhỏ, nhanh hơn JSON 2–5 lần; HTTP/2 multiplexing; contract
.protobuộc client/server đồng bộ schema; hỗ trợ streaming hai chiều. - Nhược: khó debug bằng tay (không đọc được payload trực tiếp), cần công cụ riêng (grpcurl, BloomRPC), không phù hợp gọi từ browser trực tiếp (cần grpc-web), team phải học thêm proto.
Lưu ý: đừng dùng gRPC cho API public hướng tới frontend web nếu chưa có hạ tầng grpc-web. gRPC hợp nhất cho giao tiếp nội bộ giữa service.
5. Message queue — bất đồng bộ, chịu lỗi tốt
Khi OrderService không cần đợi InventoryService trả lời ngay, message queue (Kafka, RabbitMQ) là lựa chọn hợp lý: gửi message rồi tiếp tục xử lý, consumer xử lý khi nào sẵn sàng.
5.1. Dependency Maven
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
5.2. Producer — OrderService phát event
public record StockReservedEvent(String orderId, String sku, int quantity) {}
@Service
public class InventoryEventPublisher {
private final KafkaTemplate<String, StockReservedEvent> kafkaTemplate;
public InventoryEventPublisher(KafkaTemplate<String, StockReservedEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishStockReserved(StockReservedEvent event) {
kafkaTemplate.send("stock-reserved-topic", event.orderId(), event);
}
}
kafkaTemplate.send đẩy event lên topic stock-reserved-topic, dùng orderId làm key để đảm bảo các event cùng order nằm trên cùng partition (giữ thứ tự xử lý).
5.3. Consumer — InventoryService lắng nghe
@Component
public class StockReservedListener {
@KafkaListener(topics = "stock-reserved-topic", groupId = "inventory-service")
public void onStockReserved(StockReservedEvent event) {
log.info("Reserving {} units of {} for order {}",
event.quantity(), event.sku(), event.orderId());
// trừ kho, xử lý idempotent theo orderId
}
}
@KafkaListener tự động poll message mới từ topic. Nếu InventoryService tạm thời sập, Kafka giữ message lại — không mất dữ liệu, consumer xử lý tiếp khi hồi phục.
5.4. Ưu điểm và nhược điểm
- Ưu: decoupling mạnh (producer không cần biết consumer còn sống hay không), chịu lỗi tốt (message được lưu lại), dễ scale theo partition, hỗ trợ event-driven architecture.
- Nhược: không có response ngay lập tức (phải thiết kế thêm callback/polling nếu cần kết quả), debug luồng dữ liệu phức tạp hơn (phải trace qua nhiều topic), cần hạ tầng broker riêng (Kafka/RabbitMQ) để vận hành và giám sát.
Lưu ý: đừng dùng message queue cho trường hợp cần phản hồi ngay cho người dùng (ví dụ xác nhận thanh toán thành công/thất bại) — độ trễ bất đồng bộ sẽ làm UX tệ đi.
6. So sánh nhanh ba phương án
| Tiêu chí | REST | gRPC | Message Queue |
|---|---|---|---|
| Kiểu giao tiếp | Đồng bộ | Đồng bộ | Bất đồng bộ |
| Định dạng | JSON (text) | Protobuf (binary) | Thường JSON/Avro |
| Tốc độ | Trung bình | Cao | Không áp dụng trực tiếp (throughput cao) |
| Contract | Lỏng (OpenAPI tùy chọn) | Chặt (.proto) |
Lỏng (schema registry nếu có) |
| Debug | Dễ (curl, Postman) | Khó hơn | Khó nhất (cần trace qua nhiều consumer) |
| Chịu lỗi khi service down | Fail ngay | Fail ngay | Message chờ, không mất |
| Phù hợp | API public, CRUD đơn giản | Giao tiếp nội bộ, hiệu năng cao | Event-driven, xử lý nền |
7. Chọn giao tiếp nào cho tình huống nào
Quay lại ví dụ OrderService:
@Service
public class OrderProcessingService {
private final PaymentGrpcClient paymentClient; // cần kết quả ngay, nội bộ, hiệu năng cao
private final InventoryEventPublisher inventoryPublisher; // không cần đợi, xử lý nền
public OrderProcessingService(PaymentGrpcClient paymentClient,
InventoryEventPublisher inventoryPublisher) {
this.paymentClient = paymentClient;
this.inventoryPublisher = inventoryPublisher;
}
public OrderResult processOrder(Order order) {
ChargeResponse charge = paymentClient.charge(order.id(), order.amount());
if (!"SUCCESS".equals(charge.getStatus())) {
return OrderResult.failed(order.id());
}
inventoryPublisher.publishStockReserved(
new StockReservedEvent(order.id(), order.sku(), order.quantity()));
return OrderResult.accepted(order.id(), charge.getTransactionId());
}
}
processOrder gọi gRPC để biết ngay thanh toán có thành công hay không (bắt buộc đồng bộ), sau đó chỉ phát event để trừ kho — vì người dùng không cần đợi bước này, và nếu InventoryService tạm sập, Kafka vẫn giữ message an toàn.
Quy tắc chung để chọn:
- Cần kết quả ngay, giao tiếp nội bộ, tần suất cao → gRPC.
- Cần kết quả ngay, giao tiếp với bên ngoài hoặc team khác chưa quen gRPC → REST.
- Không cần kết quả ngay, cần chịu lỗi và decoupling → message queue.
8. Viết test cho logic chọn luồng xử lý
@ExtendWith(MockitoExtension.class)
class OrderProcessingServiceTest {
@Mock PaymentGrpcClient paymentClient;
@Mock InventoryEventPublisher inventoryPublisher;
@InjectMocks OrderProcessingService orderProcessingService;
@Test
void shouldPublishStockEvent_whenPaymentSucceeds() {
Order order = new Order("order-1", "SKU-01", 2, new BigDecimal("100.00"));
ChargeResponse success = ChargeResponse.newBuilder()
.setTransactionId("tx-1")
.setStatus("SUCCESS")
.build();
given(paymentClient.charge(order.id(), order.amount())).willReturn(success);
OrderResult result = orderProcessingService.processOrder(order);
assertThat(result.status()).isEqualTo("ACCEPTED");
verify(inventoryPublisher).publishStockReserved(
new StockReservedEvent("order-1", "SKU-01", 2));
}
@Test
void shouldNotPublishStockEvent_whenPaymentFails() {
Order order = new Order("order-2", "SKU-02", 1, new BigDecimal("50.00"));
ChargeResponse failed = ChargeResponse.newBuilder().setStatus("FAILED").build();
given(paymentClient.charge(order.id(), order.amount())).willReturn(failed);
OrderResult result = orderProcessingService.processOrder(order);
assertThat(result.status()).isEqualTo("FAILED");
verifyNoInteractions(inventoryPublisher);
}
}
Test đầu kiểm tra khi thanh toán thành công, InventoryEventPublisher.publishStockReserved phải được gọi đúng tham số. Test sau kiểm tra khi thanh toán thất bại, hệ thống không phát event trừ kho — verifyNoInteractions đảm bảo không có side-effect ngoài ý muốn.
9. Lỗi thường gặp khi kết hợp cả ba
- Lạm dụng REST đồng bộ cho mọi thứ: dẫn đến “distributed monolith” — service A gọi B, B gọi C, một service chậm làm cả chuỗi timeout.
- Dùng gRPC nhưng không version proto: đổi field trong
.protomà không giữ tương thích ngược (xóa field cũ thay vì deprecate) làm client cũ crash. - Phát message nhưng không xử lý idempotent: Kafka có thể gửi lại message khi consumer retry — nếu
InventoryServicetrừ kho hai lần cho cùngorderIdlà có bug. - Trộn lẫn đồng bộ/bất đồng bộ không rõ ràng: để code dễ maintain, tên method/class nên thể hiện rõ kiểu giao tiếp, ví dụ
publishXxxEvent(bất đồng bộ) khác vớifetchXxx/chargeXxx(đồng bộ).
10. Kết luận
Không có phương án “tốt nhất” cho mọi trường hợp — REST hợp khi cần đơn giản và dễ debug, gRPC hợp khi cần tốc độ và contract chặt cho giao tiếp nội bộ, message queue hợp khi cần decoupling và chịu lỗi cho các luồng không cần phản hồi ngay. Trong thực tế, một hệ thống microservices trưởng thành thường dùng cả ba cùng lúc, mỗi phương án cho đúng loại giao tiếp mà nó phù hợp nhất — như ví dụ OrderService ở trên đã minh họa.