gRPC trong Spring Boot
Tung Bui Thanh (TungDaDev)29 mins read

Làm microservices một thời gian thì sẽ tới lúc chuyện các service "nói chuyện" được với nhau là chưa đủ. Cần gọi nhau với độ trễ thật thấp (low latency) và thông lượng thật cao (high throughput), lúc đó REST/JSON bắt đầu đuối. gRPC sinh ra cho đúng những chỗ như vậy.
Bài này mình viết theo góc nhìn của một engineer: mổ xẻ gRPC từ tầng byte, tầng network, rồi tới mấy best practice để dựng một hệ thống gRPC chạy ổn, chịu tải tốt với Spring Boot.
# bản chất
gRPC (Google Remote Procedure Call) là framework mã nguồn mở cho phép bạn gọi một hàm nằm trên server khác, mà cảm giác y như đang gọi một hàm local trong cùng process.
Khi client gọi method, gRPC serialize request thành dạng nhị phân (Protobuf) rồi đẩy qua network bằng HTTP/2. Bên server nhận về, deserialize, xử lý, rồi trả kết quả ngược lại theo đúng quy trình đó.
Khác biệt lớn nhất so với REST:
- REST: hướng tài nguyên, resource-oriented (ví dụ: GET /users/123).
- gRPC: hướng hành động, action-oriented (ví dụ: userService.GetUser(id=123)). Nó đi thẳng vào logic nghiệp vụ và các operation.
# kiến trúc tổng quan

# protocol buffers (protobuf)
Nếu gRPC là cơ thể thì Protobuf là trái tim. Nó vừa là ngôn ngữ định nghĩa interface (IDL, Interface Definition Language), vừa là công cụ serialize dữ liệu.
# định nghĩa contract rõ ràng và chặt chẽ
Ở REST, OpenAPI/Swagger có hay không là tuỳ team. gRPC thì bắt bạn định nghĩa contract ngay từ đầu bằng file .proto, nên giữa các team không còn kiểu "ủa field này kiểu gì vậy".
syntax = "proto3";
package user;
option java_multiple_files = true;
option java_package = "com.tungdadev.grpc.user";
// 1. Service definition
service UserService {
// Unary RPC
rpc GetUser (GetUserRequest) returns (UserResponse);
// Server streaming
rpc ListUsers (ListUsersRequest) returns (stream UserResponse);
// Client streaming
rpc UploadUsers (stream CreateUserRequest) returns (UploadSummary);
// Bidirectional streaming
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
// 2. Messages definition
message GetUserRequest {
string user_id = 1;
}
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
int32 age = 4;
repeated string roles = 5; // Tương đương List trong Java
map<string, string> metadata = 6; // Tương đương Map
UserStatus status = 7;
optional string phone = 8; // Nullable field (Proto3)
}
enum UserStatus {
USER_STATUS_UNSPECIFIED = 0; // Bắt buộc giá trị mặc định là 0
USER_STATUS_ACTIVE = 1;
USER_STATUS_INACTIVE = 2;
USER_STATUS_BANNED = 3;
}# wire format (cách protobuf encode)
JSON gửi đi nguyên một cục text, có cả ngoặc nhọn, dấu phẩy lẫn tên trường. Protobuf thì mã hoá hết thành nhị phân theo cấu trúc: [field_number << 3 | wire_type] + [value]
| Wire Type | Meaning | Dùng cho |
|---|---|---|
| 0 | Varint | int32, int64, uint32, bool, enum |
| 1 | 64-bit | fixed64, double |
| 2 | Length-delimited | string, bytes, embedded messages, packed repeated |
| 5 | 32-bit | fixed32, float |
Ví dụ encode UserResponse{id: "abc", age: 25}:
Field 1 (id), wire type 2: 0x0A 0x03 "abc" (tag=0A, length=3, value="abc")
Field 4 (age), wire type 0: 0x20 0x19 (tag=20, varint=25)
Vì sao nhỏ hơn JSON:
- Payload không chứa tên field, chỉ có field number
- Số nhỏ dùng varint (25 tốn 1 byte, còn "25" dạng text tốn 2 byte; riêng số âm kiểu
int32luôn tốn 10 byte, nên số hay âm thì dùngsint32) - Không có ký tự phân cách (
{,},",:,,) - Giá trị mặc định không encode (zero value thì không gửi)
Varint khá khéo: mỗi byte dùng 7 bit cho dữ liệu, bit cao nhất báo "còn byte nữa không", nên số nguyên 25 không cần tới 4 bytes mà chỉ tốn đúng 1 byte. Cộng thêm chuyện không gửi tên trường (chỉ gửi field number) và bỏ qua giá trị mặc định (zero value), payload Protobuf thường nhỏ hơn JSON vài lần và serialize/deserialize cũng nhanh hơn đáng kể. Chênh bao nhiêu thì tuỳ hình dạng dữ liệu: message toàn số và tên field dài thì lợi nhiều, message chủ yếu là chuỗi dài thì lợi ít. Muốn có con số cho hệ thống của mình thì nên tự benchmark với payload thật.
# http/2 transport
Thiếu HTTP/2 thì gRPC cũng chẳng nhanh được bao nhiêu. Ghép hai thứ lại mới ra mấy khả năng mà HTTP/1.1 không có.
# tại sao http/2 quan trọng cho grpc
| Feature | HTTP/1.1 | HTTP/2 |
|---|---|---|
| Connections | Mỗi lúc 1 request/connection (pipelining gần như không ai dùng) | Multiplexing: nhiều request chạy song song trên đúng 1 TCP connection |
| Headers | Text, request nào cũng gửi lại | HPACK compression: header lặp lại chỉ gửi chỉ số trong bảng, không gửi lại nguyên chuỗi |
| Data format | Text | Binary framing: chia thành các frame nhị phân, parse dễ |
| Server push | Không | Có trong spec, nhưng gRPC không dùng (Chrome cũng đã bỏ) |
| Streaming | Chunked transfer (kiểu chữa cháy) | Native bidirectional streaming: stream hai chiều thật sự |
# multiplexing
Với HTTP/2, các stream chạy song song mà không phải xếp hàng chờ nhau ở tầng ứng dụng. Có điều bên dưới vẫn là một TCP connection: mất một gói tin thì TCP giữ lại mọi byte phía sau cho tới khi gửi lại xong, nên mọi stream cùng khựng (head-of-line blocking ở tầng TCP). Mạng nội bộ ít mất gói thì chuyện này không đáng kể.

HTTP/1.1 phải mở nhiều TCP connection (browser giới hạn 6 cái mỗi domain). HTTP/2 thì một connection là đủ cho tất cả.
# 4 communication patterns
# unary rpc (request-response)
Y như REST: gửi một request, nhận một response. Hợp với CRUD, truy vấn đơn giản.

Ví dụ với Java + Spring Boot:
// Server implementation
@GrpcService
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
UserResponse user = UserResponse.newBuilder()
.setId(request.getUserId())
.setName("John Doe")
.build();
responseObserver.onNext(user);
responseObserver.onCompleted(); // Đóng stream
}
}# server streaming
Client gửi 1 request, server trả về liên tục nhiều response. Hợp cho việc kéo tập dữ liệu lớn, export file, stream log.

// Server
@Override
public void listUsers(ListUsersRequest request, StreamObserver<UserResponse> responseObserver) {
List<User> users = userRepository.findAll(request.getFilter());
for (User user : users) {
responseObserver.onNext(toProto(user)); // Gửi từng chunk
}
responseObserver.onCompleted();
}
// Client
Iterator<UserResponse> users = userStub.listUsers(request);
while (users.hasNext()) {
UserResponse user = users.next();
// process each user
}# client streaming
Client bơm dữ liệu lên liên tục, server nhận đủ rồi mới trả về đúng 1 kết quả. Hợp với upload file lớn, gom dữ liệu sensor IoT, hoặc batch processing.

// Server
@Override
public StreamObserver<CreateUserRequest> uploadUsers(StreamObserver<UploadSummary> responseObserver) {
return new StreamObserver<>() {
int received = 0, created = 0, failed = 0;
@Override
public void onNext(CreateUserRequest request) {
received++;
try {
userRepository.save(toEntity(request));
created++;
} catch (Exception e) {
failed++;
}
}
@Override
public void onCompleted() {
responseObserver.onNext(UploadSummary.newBuilder()
.setTotalReceived(received)
.setTotalCreated(created)
.setTotalFailed(failed)
.build());
responseObserver.onCompleted();
}
@Override
public void onError(Throwable t) {
log.error("Client stream error", t);
}
};
}# bidirectional streaming
Client và server mỗi bên tự gửi stream của mình, không ai phải đợi ai. Dùng cho chat real-time, game nhiều người chơi, hay trả lời AI kiểu stream từng token.

// Server
@Override
public StreamObserver<ChatMessage> chat(StreamObserver<ChatMessage> responseObserver) {
return new StreamObserver<>() {
@Override
public void onNext(ChatMessage message) {
// Echo back or broadcast
ChatMessage reply = ChatMessage.newBuilder()
.setSenderId("server")
.setContent("Received: " + message.getContent())
.setTimestamp(System.currentTimeMillis())
.build();
responseObserver.onNext(reply);
}
@Override
public void onCompleted() {
responseObserver.onCompleted();
}
@Override
public void onError(Throwable t) {
log.error("Stream error", t);
}
};
}# error handling
# grpc status codes
| Code | Name | Ý nghĩa | Tương đương HTTP |
|---|---|---|---|
| 0 | OK | Thành công | 200 |
| 1 | CANCELLED | Client hủy request | 499 |
| 2 | UNKNOWN | Lỗi không xác định | 500 |
| 3 | INVALID_ARGUMENT | Input không hợp lệ | 400 |
| 4 | DEADLINE_EXCEEDED | Timeout | 504 |
| 5 | NOT_FOUND | Resource không tồn tại | 404 |
| 6 | ALREADY_EXISTS | Resource đã tồn tại | 409 |
| 7 | PERMISSION_DENIED | Không có quyền | 403 |
| 8 | RESOURCE_EXHAUSTED | Rate limit / quota | 429 |
| 9 | FAILED_PRECONDITION | Precondition failed | 400 |
| 10 | ABORTED | Conflict (optimistic lock) | 409 |
| 11 | OUT_OF_RANGE | Giá trị ngoài range | 400 |
| 12 | UNIMPLEMENTED | Method chưa implement | 501 |
| 13 | INTERNAL | Internal server error | 500 |
| 14 | UNAVAILABLE | Service tạm thời unavailable | 503 |
| 15 | DATA_LOSS | Mất hoặc hỏng dữ liệu | 500 |
| 16 | UNAUTHENTICATED | Chưa authenticate | 401 |
# trả error từ server
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
if (request.getUserId().isBlank()) {
responseObserver.onError(Status.INVALID_ARGUMENT
.withDescription("user_id must not be empty")
.asRuntimeException());
return;
}
User user = userRepository.findById(request.getUserId()).orElse(null);
if (user == null) {
responseObserver.onError(Status.NOT_FOUND
.withDescription("User not found: " + request.getUserId())
.asRuntimeException());
return;
}
responseObserver.onNext(toProto(user));
responseObserver.onCompleted();
}# rich error details (google error model)
import com.google.protobuf.Any;
import com.google.rpc.BadRequest;
import com.google.rpc.Code;
import io.grpc.protobuf.StatusProto;
// Trả error với metadata chi tiết
com.google.rpc.Status status = com.google.rpc.Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT.getNumber())
.setMessage("Validation failed")
.addDetails(Any.pack(BadRequest.newBuilder()
.addFieldViolations(BadRequest.FieldViolation.newBuilder()
.setField("email")
.setDescription("Invalid email format")
.build())
.addFieldViolations(BadRequest.FieldViolation.newBuilder()
.setField("age")
.setDescription("Must be between 1 and 150")
.build())
.build()))
.build();
responseObserver.onError(StatusProto.toStatusRuntimeException(status));# client-side error handling
try {
UserResponse response = userStub.getUser(request);
} catch (StatusRuntimeException e) {
switch (e.getStatus().getCode()) {
case NOT_FOUND:
log.warn("User not found: {}", e.getStatus().getDescription());
break;
case UNAVAILABLE:
log.error("Service unavailable, will retry");
// retry logic
break;
case DEADLINE_EXCEEDED:
log.error("Request timed out");
break;
default:
log.error("gRPC error: {} - {}", e.getStatus().getCode(), e.getStatus().getDescription());
}
}# interceptors (middleware)
Mấy thứ cross-cutting như logging, tracing, authentication thì phải tách khỏi business logic, cái này gần như luật rồi. REST có Filter/Interceptor, gRPC cũng có cơ chế tương tự và cũng gọi là Interceptor.
# server interceptor
public class TraceIdServerInterceptor implements ServerInterceptor {
private static final Metadata.Key<String> TRACE_ID_KEY =
Metadata.Key.of("x-trace-id", Metadata.ASCII_STRING_MARSHALLER);
public static final Context.Key<String> TRACE_ID_CONTEXT_KEY = Context.key("traceId");
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String traceId = headers.get(TRACE_ID_KEY);
if (traceId == null) {
traceId = UUID.randomUUID().toString();
}
// Put traceId in context for downstream use
Context ctx = Context.current().withValue(TRACE_ID_CONTEXT_KEY, traceId);
return Contexts.interceptCall(ctx, call, headers, next);
}
}# client interceptor
public class AuthClientInterceptor implements ClientInterceptor {
private static final Metadata.Key<String> AUTH_KEY =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private final TokenProvider tokenProvider;
public AuthClientInterceptor(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method,
CallOptions callOptions,
Channel next) {
return new ForwardingClientCall.SimpleForwardingClientCall<>(
next.newCall(method, callOptions)) {
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
headers.put(AUTH_KEY, "Bearer " + tokenProvider.getToken());
super.start(responseListener, headers);
}
};
}
}# logging interceptor
public class LoggingServerInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
long startTime = System.nanoTime();
String methodName = call.getMethodDescriptor().getFullMethodName();
log.info("gRPC request | method={}", methodName);
ServerCall<ReqT, RespT> wrappedCall = new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override
public void close(Status status, Metadata trailers) {
long duration = (System.nanoTime() - startTime) / 1_000_000;
log.info("gRPC response | method={} | status={} | duration={}ms",
methodName, status.getCode(), duration);
super.close(status, trailers);
}
};
return next.startCall(wrappedCall, headers);
}
}# interceptor chain order
Server server = ServerBuilder.forPort(9090)
.intercept(new TraceIdServerInterceptor()) // add đầu tiên → chạy sau cùng
.intercept(new AuthServerInterceptor()) // add thứ 2 → chạy thứ 2
.intercept(new LoggingServerInterceptor()) // add sau cùng → chạy đầu tiên
.addService(new UserServiceImpl())
.build();
// Lưu ý: interceptors chạy theo thứ tự NGƯỢC (last added = first executed)
// Thực tế: Logging → Auth → TraceId → Service
// Muốn TraceId chạy trước để log có trace id thì add nó SAU CÙNGĐây là cách đăng ký interceptor khi tự dựng server bằng grpc-java thuần. Trong Spring Boot thì không phải nhớ quy tắc "thêm sau chạy trước" này nữa: interceptor đăng ký qua bean và xếp theo @Order, số nhỏ chạy trước (xem mục Spring Boot bên dưới).
# deadlines & timeouts
// Client set deadline (timeout)
UserResponse response = userStub
.withDeadlineAfter(5, TimeUnit.SECONDS) // timeout 5s
.getUser(request);
// Server check deadline
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
if (Context.current().isCancelled()) {
responseObserver.onError(Status.CANCELLED
.withDescription("Client cancelled or deadline exceeded")
.asRuntimeException());
return;
}
// ... process
}Đoạn kiểm tra isCancelled() ở trên chủ yếu để server bỏ ngang việc nặng: lúc deadline đã qua thì client đã nhận DEADLINE_EXCEEDED rồi, response gửi về cũng không ai đọc.
Deadline propagation: service A gọi B, B gọi C thì deadline đi theo. Client đặt 5s, A tiêu mất 2s thì B chỉ còn 3s để làm xong (tính cả lúc gọi sang C). Ở grpc-java, deadline nằm trong Context của request, call đi ra lấy deadline nhỏ hơn giữa deadline của chính nó và của Context hiện tại. Nếu bạn chuyển việc sang thread pool khác mà không mang Context theo (Context.current().wrap(...) hoặc Context.currentContextExecutor(...)) thì deadline cũng rơi mất.
# metadata (headers)
// Client gửi metadata
Metadata metadata = new Metadata();
metadata.put(Metadata.Key.of("x-workspace-id", Metadata.ASCII_STRING_MARSHALLER), "ws-123");
metadata.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), UUID.randomUUID().toString());
UserResponse response = userStub
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
.getUser(request);
// Server đọc metadata
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
String workspaceId = WORKSPACE_ID_CTX_KEY.get(); // from interceptor context
// ...
}# spring boot integration
Trước đây muốn dùng gRPC trong Spring Boot thì phải nhờ thư viện cộng đồng, phổ biến nhất là grpc-spring-boot-starter của net.devh. Bản cuối của nó (3.1.0, tháng 4/2024) chỉ chạy với Spring Boot 3.x. Giờ Spring đã có dự án chính thức Spring gRPC, và từ Spring Boot 4.1 phần auto-configuration được đưa thẳng vào Spring Boot với các starter spring-boot-starter-grpc-server và spring-boot-starter-grpc-client. Phần dưới viết theo Spring Boot 4.1.1 (đi kèm Spring gRPC 1.1.1, grpc-java 1.83.1). Toàn bộ code trong mục này mình đã chạy thử, kể cả test.
Nếu đang dùng net.devh thì lúc chuyển sang cần để ý: annotation cùng tên @GrpcService nhưng nằm ở package khác (org.springframework.grpc.server.service), property đổi từ grpc.* sang spring.grpc.*, và @GrpcClient không còn nữa, thay bằng GrpcChannelFactory hoặc @ImportGrpcClients.
# dependencies
Tạo project trên start.spring.io với hai dependency "Spring gRPC Server" và "Spring gRPC Client" là có sẵn mọi thứ, kể cả plugin sinh code từ file .proto. Version của grpc-java và protobuf do Spring Boot quản lý, không cần khai báo tay.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-client</artifactId>
</dependency>
<!-- Test: in-process transport, @AutoConfigureTestGrpcTransport -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-server-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-client-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Sinh code Java từ src/main/proto/*.proto, version đã được Spring Boot quản lý -->
<plugin>
<groupId>io.github.ascopes</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>Đặt file .proto vào src/main/proto, chạy mvn compile là có class UserServiceGrpc, GetUserRequest... trong target/generated-sources.
# application config
# application.yml
spring:
grpc:
server:
port: 9090
keepalive:
permit:
time: 30s # cho phép client ping mỗi 30s (mặc định chỉ cho 5 phút)
without-calls: true
client:
channel:
user-service: # tên channel, dùng lại trong code
target: 'static://localhost:9090'
default:
deadline: 3s # mặc định KHÔNG có deadline
load-balancing-policy: round_robin
keepalive:
time: 30s
timeout: 5sHai chỗ đáng chú ý. Thứ nhất, Spring Boot không đặt deadline mặc định cho channel, quên cấu hình default.deadline mà cũng quên withDeadlineAfter() trong code là call có thể treo vô hạn. Thứ hai, keepalive.permit.time phía server phải nhỏ hơn hoặc bằng keepalive.time của client, lý do ở mục performance bên dưới.
# server implementation
import org.springframework.grpc.server.service.GrpcService;
@GrpcService // bean Spring, tự đăng ký với gRPC server
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
private final UserService userService;
public UserGrpcService(UserService userService) {
this.userService = userService;
}
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
if (request.getUserId().isBlank()) {
responseObserver.onError(Status.INVALID_ARGUMENT
.withDescription("user_id must not be empty")
.asRuntimeException());
return;
}
// NotFoundException hay lỗi bất ngờ cứ để ném ra, GrpcExceptionHandler lo việc map sang Status
User user = userService.findById(request.getUserId());
responseObserver.onNext(toProto(user));
responseObserver.onCompleted();
}
private UserResponse toProto(User user) {
return UserResponse.newBuilder()
.setId(user.id())
.setName(user.name())
.setEmail(user.email())
.build();
}
}Thay vì try/catch trong từng method, Spring gRPC cho khai báo GrpcExceptionHandler, hơi giống @ControllerAdvice bên REST. Handler trả null nghĩa là "không phải việc của tôi", để handler khác xử lý. Exception không có handler nào nhận thì client nhận UNKNOWN và không có mô tả, nên chi tiết lỗi nội bộ không bị lộ ra ngoài.
import org.springframework.grpc.server.exception.GrpcExceptionHandler;
@Configuration
public class GrpcErrorConfig {
@Bean
GrpcExceptionHandler notFoundHandler() {
return exception -> exception instanceof NotFoundException
? Status.NOT_FOUND.withDescription(exception.getMessage()).asException()
: null; // null = không xử lý, nhường handler khác
}
}# client usage
Đừng bao giờ tạo Channel mới cho mỗi request. GrpcChannelFactory tạo channel theo tên đã khai báo trong spring.grpc.client.channel.*; tạo stub một lần trong constructor rồi dùng lại, stub và channel đều thread-safe.
import org.springframework.grpc.client.GrpcChannelFactory;
@Service
@Slf4j
public class UserGrpcClient {
private final UserServiceGrpc.UserServiceBlockingStub userStub;
public UserGrpcClient(GrpcChannelFactory channels) {
// "user-service" khớp với spring.grpc.client.channel.user-service.* trong application.yml
this.userStub = UserServiceGrpc.newBlockingStub(channels.createChannel("user-service"));
}
public UserResponse getUser(String userId) {
try {
// Deadline riêng cho call này, ghi đè default.deadline của channel
return userStub
.withDeadlineAfter(3, TimeUnit.SECONDS)
.getUser(GetUserRequest.newBuilder()
.setUserId(userId)
.build());
} catch (StatusRuntimeException e) {
log.error("gRPC call failed | status={} | desc={}",
e.getStatus().getCode(), e.getStatus().getDescription());
throw new ServiceCallException("User service unavailable", e);
}
}
}Muốn gọn hơn nữa thì dùng @ImportGrpcClients(target = "user-service", types = UserServiceGrpc.UserServiceBlockingStub.class) đặt trên class cấu hình, Spring tạo sẵn bean stub để bạn inject thẳng.
# register interceptors
Interceptor dùng chung cho mọi service thì khai báo bean kèm @GlobalServerInterceptor (phía server) hoặc @GlobalClientInterceptor (phía client). Thứ tự chạy theo @Order: số nhỏ hơn bọc ngoài, chạy trước.
import org.springframework.grpc.client.GlobalClientInterceptor;
import org.springframework.grpc.server.GlobalServerInterceptor;
@Configuration
public class GrpcInterceptorConfig {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE) // bọc ngoài cùng, thấy được cả status do GrpcExceptionHandler trả ra
@GlobalServerInterceptor
ServerInterceptor loggingInterceptor() {
return new LoggingServerInterceptor();
}
@Bean
@Order(10)
@GlobalServerInterceptor
ServerInterceptor traceIdInterceptor() {
return new TraceIdServerInterceptor();
}
@Bean
@GlobalClientInterceptor
ClientInterceptor authClientInterceptor(TokenProvider tokenProvider) {
return new AuthClientInterceptor(tokenProvider);
}
}Chỗ Ordered.HIGHEST_PRECEDENCE cho logging interceptor là mình rút ra lúc chạy thử. Để @Order bình thường thì những request lỗi do exception (đi qua GrpcExceptionHandler) không hề hiện trong log của interceptor, chỉ request thành công và request gọi onError() thủ công mới có. Lý do là exception được bắt và chuyển thành status ở một lớp nằm ngoài interceptor của bạn. Đẩy logging ra ngoài cùng thì nó thấy đủ cả NOT_FOUND lẫn UNKNOWN.
Interceptor chỉ dành cho một service thì khai báo ngay trên service đó: @GrpcService(interceptors = AuditInterceptor.class). Interceptor global chạy trước, rồi mới tới interceptor riêng của service.
# load balancing
# vấn đề với grpc + http/2
HTTP/2 dồn nhiều request lên 1 TCP connection. Nếu dùng L4 load balancer (cân bằng ở tầng TCP), mọi request của 1 client sẽ rơi vào cùng 1 backend, vì nó chỉ có đúng 1 connection.
# giải pháp
| Approach | Cách hoạt động | Pros | Cons |
|---|---|---|---|
| Client-side LB | Client biết hết các backend, tự chọn | Không nghẽn cổ chai, latency thấp | Client phức tạp hơn |
| L7 Proxy (Envoy, Nginx) | Proxy hiểu HTTP/2, route theo từng request | Client không cần biết gì | Thêm một hop, dễ thành single point |
| Service Mesh (Istio) | Mỗi pod một sidecar proxy | Observability đầy đủ | Phức tạp, tốn tài nguyên |
| Look-aside LB | Một LB service bên ngoài trả danh sách backend | Policy linh hoạt | Thêm một dependency |
# Ví dụ cấu hình Client-side LB với Spring Boot
spring:
grpc:
client:
channel:
user-service:
target: 'dns:///user-service-grpc:9090' # DNS trả về nhiều IP, client tự chia tải
default:
load-balancing-policy: round_robinPolicies có sẵn:
pick_first: dùng backend đầu tiên còn sống (mặc định của grpc-java)round_robin: xoay vòng lần lượt qua các backend (Spring Boot đặt sẵn làm mặc định cho channel khai báo qua property)
# với kubernetes
# Headless service (trả về tất cả pod IPs)
apiVersion: v1
kind: Service
metadata:
name: user-service-grpc
spec:
clusterIP: None # headless
ports:
- port: 9090
protocol: TCP
selector:
app: user-serviceClient resolve DNS → nhận về IP của tất cả pod → round-robin qua từng cái.
Có một cái bẫy: DNS resolver của gRPC chỉ resolve lại khi có connection bị đứt, chứ không định kỳ hỏi lại. Scale từ 3 lên 6 pod thì client cũ vẫn chỉ gọi 3 pod cũ. Cách hay dùng là đặt maxConnectionAge ở server (vd vài phút) để server chủ động đóng connection, client buộc phải resolve lại và thấy pod mới. Với Spring Boot là spring.grpc.server.keepalive.connection.max-age: 5m, kèm keepalive.connection.grace-period (mặc định 30s) để request đang chạy kịp xong.
# security
# tls (transport layer security)
Spring Boot dùng chung cơ chế SSL bundle cho cả gRPC: khai báo certificate một lần dưới spring.ssl.bundle, rồi trỏ server hoặc channel tới tên bundle.
spring:
ssl:
bundle:
pem:
grpc-server:
keystore:
certificate: 'file:certs/server.crt'
private-key: 'file:certs/server.key'
grpc-client:
truststore:
certificate: 'file:certs/ca.crt'
grpc:
# Server
server:
ssl:
bundle: grpc-server
# Client
client:
channel:
user-service:
target: 'static://user-service:9090'
ssl:
bundle: grpc-client# mutual tls (mtls)
Client và server kiểm tra certificate của nhau: server có thêm truststore để kiểm tra client và bật client-auth: require, client có thêm keystore chứa certificate của mình.
spring:
ssl:
bundle:
pem:
grpc-server:
keystore:
certificate: 'file:certs/server.crt'
private-key: 'file:certs/server.key'
truststore:
certificate: 'file:certs/ca.crt'
grpc-client:
keystore:
certificate: 'file:certs/client.crt'
private-key: 'file:certs/client.key'
truststore:
certificate: 'file:certs/ca.crt'
grpc:
server:
ssl:
bundle: grpc-server
client-auth: require
client:
channel:
user-service:
ssl:
bundle: grpc-client# jwt authentication (via interceptor)
public class JwtAuthServerInterceptor implements ServerInterceptor {
private static final Metadata.Key<String> AUTH_KEY =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private final JwtValidator jwtValidator;
public JwtAuthServerInterceptor(JwtValidator jwtValidator) {
this.jwtValidator = jwtValidator;
}
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String authHeader = headers.get(AUTH_KEY);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
call.close(Status.UNAUTHENTICATED.withDescription("Missing token"), new Metadata());
return new ServerCall.Listener<>() {};
}
String token = authHeader.substring(7);
try {
Claims claims = jwtValidator.validate(token);
Context ctx = Context.current()
.withValue(USER_ID_CTX_KEY, claims.getSubject())
.withValue(ROLES_CTX_KEY, claims.get("roles", List.class));
return Contexts.interceptCall(ctx, call, headers, next);
} catch (JwtException e) {
call.close(Status.UNAUTHENTICATED.withDescription("Invalid token"), new Metadata());
return new ServerCall.Listener<>() {};
}
}
}# performance optimization
# connection management
// Reuse channel (thread-safe, multiplexed)
// KHÔNG tạo channel mới cho mỗi request
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090)
.usePlaintext()
.keepAliveTime(30, TimeUnit.SECONDS)
.keepAliveTimeout(5, TimeUnit.SECONDS)
.maxInboundMessageSize(10 * 1024 * 1024) // 10MB
.build();
// Tạo stub từ channel (lightweight, có thể tạo nhiều)
UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel);Cẩn thận với keepAliveTime phía client: server grpc-java mặc định chỉ cho phép ping cách nhau tối thiểu 5 phút (permitKeepAliveTime). Client ping mỗi 30s mà server để mặc định thì server sẽ đóng connection với lỗi GOAWAY ... too_many_pings. Nên phía server phải nới ra tương ứng, với Spring Boot là spring.grpc.server.keepalive.permit.time: 30s (và keepalive.permit.without-calls: true nếu client bật keepAliveWithoutCalls), như trong file cấu hình ở mục Spring Boot.
# message size
// Server
ServerBuilder.forPort(9090)
.maxInboundMessageSize(10 * 1024 * 1024) // 10MB max request
.addService(new UserServiceImpl())
.build();
// Client
ManagedChannelBuilder.forAddress("localhost", 9090)
.maxInboundMessageSize(10 * 1024 * 1024) // 10MB max response
.build();Mặc định là 4MB. Cần truyền file/data lớn thì tăng lên, nhưng data > 1MB thì nên chuyển sang streaming. Trong Spring Boot thì không cần tự gọi builder: phía server là spring.grpc.server.inbound.message.max-size: 10MB, phía client là spring.grpc.client.channel.<tên>.inbound.message.max-size: 10MB.
# compression
// Client enable compression
UserResponse response = userStub
.withCompression("gzip")
.getUser(request);
// Server tự giải nén request nếu client gửi compressed (registry mặc định đã có gzip).
// Response thì server KHÔNG tự nén, phải bật cho từng call:
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
((ServerCallStreamObserver<UserResponse>) responseObserver).setCompression("gzip");
// ... onNext / onCompleted như bình thường
}# flow control (streaming)
// Server-side flow control cho streaming
@Override
public void listUsers(ListUsersRequest request, StreamObserver<UserResponse> responseObserver) {
ServerCallStreamObserver<UserResponse> serverObserver =
(ServerCallStreamObserver<UserResponse>) responseObserver;
// Set callback khi client sẵn sàng nhận thêm
serverObserver.setOnReadyHandler(() -> {
while (serverObserver.isReady()) {
UserResponse next = getNextUser();
if (next == null) {
serverObserver.onCompleted();
return;
}
serverObserver.onNext(next);
}
// Client chưa ready → pause, sẽ được gọi lại khi ready
});
}# connection pooling
# Mỗi channel tự giữ connection tới từng backend bên dưới (subchannel),
# nên dùng lại một channel cho cả ứng dụng là đủ, không cần pool riêng.
# Nhiều backend cố định thì để DNS trả về nhiều IP (headless service),
# hoặc đặt load balancer L7 phía trước.
spring:
grpc:
client:
channel:
user-service:
target: 'dns:///user-service-grpc:9090'
default:
load-balancing-policy: round_robin
keepalive:
time: 30s
timeout: 5s
without-calls: true# grpc vs rest: khi nào dùng gì
| Tiêu chí | gRPC | REST |
|---|---|---|
| Performance | Thường nhanh hơn (binary, HTTP/2) | Đủ dùng cho đa số trường hợp |
| Payload size | Nhỏ hơn vài lần (Protobuf) | JSON dài dòng nhưng người đọc được |
| Streaming | Hai chiều, có sẵn | WebSocket/SSE (chữa cháy) |
| Contract | Chặt (.proto file, code gen) | Lỏng (OpenAPI có hay không tuỳ) |
| Browser support | Cần proxy grpc-web | Chạy thẳng |
| Debugging | Khó (binary) | Dễ (curl, Postman) |
| Tooling | Ít hơn | Rất nhiều |
| Learning curve | Cao hơn | Thấp |
| Backward compat | Tốt (nhờ field number) | Tuỳ cách làm versioning |
Dùng gRPC khi:
- Các service nội bộ gọi nhau
- Cần throughput cao, latency thấp
- Có stream dữ liệu (real-time, tập dữ liệu lớn)
- Hệ thống viết bằng nhiều ngôn ngữ
- Muốn contract thật chặt
Dùng REST khi:
- API public ra ngoài (browser, mobile, bên thứ ba)
- CRUD đơn giản
- Team chưa quen gRPC
- Cần debug cho dễ
- Cần cache (HTTP caching headers)
Kiểu kết hợp cả hai (phổ biến nhất):

# protobuf best practices
# schema evolution (backward/forward compatibility)
// Version 1
message UserResponse {
string id = 1;
string name = 2;
}
// Version 2 (backward compatible)
message UserResponse {
string id = 1;
string name = 2;
string email = 3; // NEW: old clients ignore unknown fields
// int32 age = 4; // REMOVED: just stop using, don't reuse number
reserved 4; // Prevent reuse of field number 4
reserved "age"; // Prevent reuse of field name "age"
}Rules:
- KHÔNG đổi field number của field đang có
- KHÔNG đổi type của field đang có
- Field đã xoá thì đánh dấu
reserved - Thêm field mới với number mới → backward compatible
- Code cũ gặp field lạ thì bỏ qua (Java giữ lại dưới dạng unknown fields khi serialize lại) → forward compatible
- Đừng đổi tên enum value hay xoá enum value đang có dữ liệu; JSON mapping dùng tên field/enum, nên đổi tên vẫn phá client dùng JSON
# naming conventions
// Messages: PascalCase
message UserProfile {}
// Fields: snake_case
string user_name = 1;
int32 page_size = 2;
// Enums: SCREAMING_SNAKE_CASE, prefix với type name
enum UserStatus {
USER_STATUS_UNSPECIFIED = 0; // always have 0 = unspecified
USER_STATUS_ACTIVE = 1;
}
// Services: PascalCase
service UserService {}
// RPCs: PascalCase
rpc GetUser(GetUserRequest) returns (UserResponse);
// Files: snake_case.proto
// user_service.proto# common patterns
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/empty.proto";
message AuditableEntity {
google.protobuf.Timestamp created_at = 1;
google.protobuf.Timestamp updated_at = 2;
string created_by = 3;
}
// Wrapper types cho nullable primitives
// (từ protobuf 3.15 có thể dùng `optional int32 max_results = 1;` thay cho wrapper)
message UserPreferences {
google.protobuf.Int32Value max_results = 1; // nullable int
google.protobuf.BoolValue notifications_enabled = 2; // nullable bool
}
// Empty request/response
rpc Ping(google.protobuf.Empty) returns (google.protobuf.Empty);
// Pagination pattern
message PageRequest {
int32 page_size = 1;
string page_token = 2;
}
message PageResponse {
string next_page_token = 1;
int32 total_count = 2;
}
message ListUsersResponse {
repeated UserResponse users = 1;
PageResponse page = 2;
}# testing
# unit test server
@ExtendWith(MockitoExtension.class)
class UserGrpcServiceTest {
@Mock
private UserService userService;
@InjectMocks
private UserGrpcService grpcService;
@Test
void getUser_success() {
// Given
User user = new User("123", "John", "john@test.com");
when(userService.findById("123")).thenReturn(user);
StreamRecorder<UserResponse> recorder = StreamRecorder.create();
GetUserRequest request = GetUserRequest.newBuilder().setUserId("123").build();
// When
grpcService.getUser(request, recorder);
// Then
assertNull(recorder.getError());
List<UserResponse> results = recorder.getValues();
assertEquals(1, results.size());
assertEquals("123", results.get(0).getId());
assertEquals("John", results.get(0).getName());
}
@Test
void getUser_notFound() {
when(userService.findById("999")).thenThrow(new NotFoundException("User not found"));
StreamRecorder<UserResponse> recorder = StreamRecorder.create();
GetUserRequest request = GetUserRequest.newBuilder().setUserId("999").build();
grpcService.getUser(request, recorder);
assertNotNull(recorder.getError());
assertEquals(Status.NOT_FOUND.getCode(),
Status.fromThrowable(recorder.getError()).getCode());
}
}# integration test (in-process server)
@AutoConfigureTestGrpcTransport cho server và client nói chuyện với nhau in-process, không mở port thật, test chạy nhanh và không đụng nhau về port.
import org.springframework.boot.grpc.test.autoconfigure.AutoConfigureTestGrpcTransport;
import org.springframework.grpc.client.ImportGrpcClients;
@SpringBootTest
@AutoConfigureTestGrpcTransport
@ImportGrpcClients(types = UserServiceGrpc.UserServiceBlockingStub.class)
class UserGrpcServiceIntegrationTest {
@Autowired
private UserServiceGrpc.UserServiceBlockingStub userStub;
@Test
void getUser_integration() {
GetUserRequest request = GetUserRequest.newBuilder()
.setUserId("123")
.build();
UserResponse response = userStub.getUser(request);
assertNotNull(response);
assertEquals("123", response.getId());
}
@Test
void getUser_invalidArgument() {
GetUserRequest request = GetUserRequest.newBuilder()
.setUserId("")
.build();
StatusRuntimeException exception = assertThrows(
StatusRuntimeException.class,
() -> userStub.getUser(request)
);
assertEquals(Status.INVALID_ARGUMENT.getCode(), exception.getStatus().getCode());
}
}Muốn test qua mạng thật (kiểm tra TLS, keep-alive...) thì dùng @SpringBootTest(properties = "spring.grpc.server.port=0") để server chọn port ngẫu nhiên, rồi lấy port qua @LocalGrpcServerPort.
# observability
# metrics (micrometer + prometheus)
// Có actuator + micrometer-registry-prometheus là Spring Boot tự bật observation cho gRPC.
// Tên trên Prometheus (đo thật với Spring Boot 4.1.1):
// grpc_server_seconds_count / _sum / _max{rpc_service, rpc_method, rpc_type, grpc_status_code, error}
// grpc_server_received_total, grpc_server_sent_total (số message nhận/gửi)
// grpc_client_seconds_count / _sum / _max{..., net_peer_name, net_peer_port} (phía client)
// Custom metrics
@GrpcService
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
private final Counter userLookupCounter;
private final Timer userLookupTimer;
public UserGrpcService(MeterRegistry registry) {
this.userLookupCounter = registry.counter("grpc.user.lookup.total");
this.userLookupTimer = registry.timer("grpc.user.lookup.duration");
}
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
userLookupCounter.increment();
userLookupTimer.record(() -> {
// ... implementation
});
}
}Có một chi tiết mình gặp lúc đo: với lỗi do exception ném ra rồi được GrpcExceptionHandler đổi thành NOT_FOUND, metric phía server vẫn ghi grpc_status_code="UNKNOWN" kèm tag error="NotFoundException", trong khi client nhận đúng NOT_FOUND. Lỗi trả bằng onError() thủ công thì không bị vậy. Nên khi dựng dashboard tỉ lệ lỗi theo status code, hãy đối chiếu thêm metric phía client (grpc_client_seconds_count) hoặc tag error, đừng chỉ nhìn status phía server.
# distributed tracing (opentelemetry)
// Với Spring Boot: có Micrometer Tracing (vd micrometer-tracing-bridge-otel) là trace context
// tự đi qua gRPC metadata headers, cả server lẫn client, không cần code thêm.
// Không dùng Spring thì có thể dùng opentelemetry-instrumentation-grpc.
// Manual span creation
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
Span span = tracer.spanBuilder("UserService.getUser")
.setAttribute("user.id", request.getUserId())
.startSpan();
try (Scope scope = span.makeCurrent()) {
// ... implementation
span.setStatus(StatusCode.OK);
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
span.recordException(e);
throw e;
} finally {
span.end();
}
}# production checklist
- Bật TLS (hoặc mTLS cho service nội bộ)
- Client call nào cũng có deadline
- Có retry policy (kèm backoff)
- Có circuit breaker cho các call gRPC ra ngoài
- Đã chỉnh max message size (đừng để mặc định 4MB nếu cần lớn hơn)
- Đã cấu hình keep-alive (để LB không kill connection đang rảnh)
- Có health check service (
grpc.health.v1.Health) - Graceful shutdown (drain hết connection rồi mới stop)
- Có interceptor cho logging, tracing, auth
- Chọn chiến lược load balancing cho hợp
- Versioning schema Protobuf (reserved fields)
- Monitoring: latency, error rate, throughput theo từng method
- Connection pooling / dùng lại channel (không tạo channel mới mỗi request)
# common pitfalls
| Pitfall | Vấn đề | Fix |
|---|---|---|
| Tạo channel mỗi request | Tốn chi phí mở connection, rò tài nguyên | Dùng lại channel (thread-safe) |
| Không set deadline | Request treo mãi | Luôn set deadline |
| Blocking trong callback của async stub | Treo thread của gRPC, có thể deadlock | Dùng blocking stub, hoặc đẩy việc nặng sang executor riêng |
| Unary message quá lớn | Ngốn bộ nhớ, timeout | Data > 1MB thì dùng streaming |
| Không xử lý UNAVAILABLE | Client crash khi server restart | Retry với exponential backoff |
| Dùng lại field number | Hỏng dữ liệu | Dùng reserved |
| L4 LB cho gRPC | Toàn bộ traffic dồn vào 1 backend | Dùng L7 LB hoặc client-side LB |
| Không graceful shutdown | Request đang chạy dở bị rớt | server.shutdown() + awaitTermination() |
| Bỏ qua flow control | OOM khi stream nhanh hơn bên nhận | Kiểm tra isReady() trước khi gửi |
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.