Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

gRPC trong Spring Boot

Tung Bui Thanh (TungDaDev)29 mins read

Grpc microservice.webp

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

grpc-structure

# 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 TypeMeaningDùng cho
0Varintint32, int64, uint32, bool, enum
164-bitfixed64, double
2Length-delimitedstring, bytes, embedded messages, packed repeated
532-bitfixed32, 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 int32 luôn tốn 10 byte, nên số hay âm thì dùng sint32)
  • 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

FeatureHTTP/1.1HTTP/2
ConnectionsMỗ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
HeadersText, request nào cũng gửi lạiHPACK 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 formatTextBinary framing: chia thành các frame nhị phân, parse dễ
Server pushKhôngCó trong spec, nhưng gRPC không dùng (Chrome cũng đã bỏ)
StreamingChunked 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ể.

grpc-tcp-connection

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.

grpc-unary

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.

grpc-multi-response
// 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.

grpc-multi-req
// 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.

grpc-bidirectional-streaming
// 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

CodeNameÝ nghĩaTương đương HTTP
0OKThành công200
1CANCELLEDClient hủy request499
2UNKNOWNLỗi không xác định500
3INVALID_ARGUMENTInput không hợp lệ400
4DEADLINE_EXCEEDEDTimeout504
5NOT_FOUNDResource không tồn tại404
6ALREADY_EXISTSResource đã tồn tại409
7PERMISSION_DENIEDKhông có quyền403
8RESOURCE_EXHAUSTEDRate limit / quota429
9FAILED_PRECONDITIONPrecondition failed400
10ABORTEDConflict (optimistic lock)409
11OUT_OF_RANGEGiá trị ngoài range400
12UNIMPLEMENTEDMethod chưa implement501
13INTERNALInternal server error500
14UNAVAILABLEService tạm thời unavailable503
15DATA_LOSSMất hoặc hỏng dữ liệu500
16UNAUTHENTICATEDChưa authenticate401

# 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: 5s

Hai 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

ApproachCách hoạt độngProsCons
Client-side LBClient biết hết các backend, tự chọnKhông nghẽn cổ chai, latency thấpClient phức tạp hơn
L7 Proxy (Envoy, Nginx)Proxy hiểu HTTP/2, route theo từng requestClient 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 proxyObservability đầy đủPhức tạp, tốn tài nguyên
Look-aside LBMột LB service bên ngoài trả danh sách backendPolicy linh hoạtThê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_robin

Policies 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-service

Client 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ígRPCREST
PerformanceThường nhanh hơn (binary, HTTP/2)Đủ dùng cho đa số trường hợp
Payload sizeNhỏ hơn vài lần (Protobuf)JSON dài dòng nhưng người đọc được
StreamingHai chiều, có sẵnWebSocket/SSE (chữa cháy)
ContractChặt (.proto file, code gen)Lỏng (OpenAPI có hay không tuỳ)
Browser supportCần proxy grpc-webChạy thẳng
DebuggingKhó (binary)Dễ (curl, Postman)
ToolingÍt hơnRất nhiều
Learning curveCao hơnThấp
Backward compatTố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):

grpc-hybrid-approach

# 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

PitfallVấn đềFix
Tạo channel mỗi requestTốn chi phí mở connection, rò tài nguyênDùng lại channel (thread-safe)
Không set deadlineRequest treo mãiLuôn set deadline
Blocking trong callback của async stubTreo thread của gRPC, có thể deadlockDùng blocking stub, hoặc đẩy việc nặng sang executor riêng
Unary message quá lớnNgốn bộ nhớ, timeoutData > 1MB thì dùng streaming
Không xử lý UNAVAILABLEClient crash khi server restartRetry với exponential backoff
Dùng lại field numberHỏng dữ liệuDùng reserved
L4 LB cho gRPCToàn bộ traffic dồn vào 1 backendDùng L7 LB hoặc client-side LB
Không graceful shutdownRequest đang chạy dở bị rớtserver.shutdown() + awaitTermination()
Bỏ qua flow controlOOM khi stream nhanh hơn bên nhậnKiể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 😎 👍🏻 🚀 🔥.