Làm chủ gRPC trong Go: Xây dựng Microservices hiệu năng cao với Protocol Buffers, Streaming và Interceptor

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Sự cố lúc 2:14 sáng: Cơn ác mộng Black Friday

Điện thoại của tôi rung liên hồi lúc 2:14 sáng. Cụm dịch vụ checkout liên tục trả về lỗi 504 Gateway Timeout, còn độ trễ p99 trong mạng lưới nội bộ vọt từ 18ms lên tận 4.200ms. Luồng đặt hàng tắc nghẽn hoàn toàn. Các dashboard cảnh báo đỏ rực khắp phòng trực.

Trên lý thuyết, kiến trúc hệ thống chuẩn chỉnh như sách giáo khoa: một edge API gateway kết nối xuôi dòng tới Order Service, Payment Gateway và Inventory Worker. Thế nhưng, từng bước trung chuyển (hop) giữa các Go service lại phụ thuộc vào các REST API nội bộ chạy trên HTTP/1.1, liên tục tuần tự hóa dữ liệu JSON qua lại.

Khi đợt flash sale ập đến với 12.000 request mỗi giây, hàng đợi socket bắt đầu quá tải gần như ngay lập tức. Mỗi body JSON đều kích hoạt reflection, cấp phát chuỗi (string allocations) và tạo mới các cú bắt tay TCP. Inventory worker nghẽn cứng dưới gánh nặng tuần tự hóa dữ liệu. Các service upstream cạn sạch pool socket rảnh rỗi chỉ trong vài phút, kéo theo tình trạng tê liệt toàn bộ lưu lượng.

Phân tích nguyên nhân gốc rễ: JSON và HTTP/1.1 hụt hơi ở đâu?

Trong lúc xử lý sự cố, chúng tôi đã trích xuất heap và CPU profile bằng pprof. Có ba điểm yếu chí mạng dẫn đến sụp đổ hệ thống:

  • Chi phí tuần tự hóa JSON quá lớn: JSON thân thiện với con người, nhưng máy móc phải trả cái giá rất đắt. Thư viện encoding/json tiêu chuẩn của Go lạm dụng runtime reflection nặng nề. Dưới tải cao, các service phải tiêu tốn tới 35% tổng chu kỳ CPU chỉ để đóng gói, giải nén các struct lồng nhau và parse key của map. Một payload checkout duy nhất ngốn 42 KB trên heap qua 180 lần cấp phát bộ nhớ cho mỗi request.
  • Head-of-Line Blocking trong HTTP/1.1: Mỗi lệnh gọi REST nội bộ độc chiếm kết nối TCP cho đến khi nhận đủ toàn bộ response. Khi có 3.000 lệnh gọi đồng thời đẩy xuống downstream, connection pool lập tức chạm giới hạn cứng 512 socket. Các worker của Go bị chững lại từ 45ms đến 120ms chỉ để thiết lập bắt tay TLS mới thay vì xử lý logic nghiệp vụ.
  • Schema lỏng lẻo và lỗi ngầm khó phát hiện: Sự không đồng nhất về kiểu dữ liệu giữa các service dẫn đến lỗi unmarshaling âm thầm. Điều này kích hoạt các vòng lặp retry tốn kém, chẳng khác nào đổ thêm dầu vào chảo lửa khi hệ thống vốn đã quá tải.

Đánh giá kiến trúc: Ba hướng giải quyết

Trong buổi post-mortem, đội ngũ platform đã cân nhắc ba phương án:

1. Sinh mã Fast-JSON tùy biến

Chúng tôi đã thử nghiệm các thư viện như easyjson hay json-iterator kết hợp mở rộng pool keep-alive cho HTTP/1.1. Cách này giảm được khoảng 40% số lần cấp phát bộ nhớ, nhưng bản chất tầng truyền tải (transport) bên dưới vẫn giữ nguyên. Tình trạng head-of-line blocking và cạn kiệt kết nối của HTTP/1.1 vẫn còn đó. Giải pháp này chẳng khác nào dán tem xe đua lên một khối động cơ đang nứt vỡ.

2. Message Broker bất đồng bộ

Một kỹ sư đề xuất định tuyến toàn bộ lệnh gọi nội bộ qua Apache Kafka hoặc RabbitMQ. Hàng đợi thông điệp bất đồng bộ rất xuất sắc cho các tác vụ nền tách biệt như gửi email hóa đơn. Tuy nhiên, chúng mang lại gánh nặng vận hành đáng kể và độ trễ pub/sub từ 15ms–30ms—quá chậm khi cần phản hồi tức thì, chẳng hạn như việc khóa tồn kho trước khi trừ tiền thẻ tín dụng.

3. Giao tiếp cốt lõi liên dịch vụ qua gRPC

Người chiến thắng tuyệt đối là gRPC. Nó chạy trực tiếp trên nền HTTP/2, ghép kênh (multiplex) hàng trăm RPC đồng thời qua một kết nối TCP bền vững duy nhất. Protocol Buffers (Protobuf) biên dịch schema thẳng thành mã nguồn Go tối ưu, định kiểu tĩnh. Quá trình tuần tự hóa nhị phân diễn ra cực nhanh và loại bỏ hoàn toàn runtime reflection.

Triển khai: gRPC chuẩn Production trong Go

Để triệt tiêu các đợt tăng đột biến độ trễ, chúng tôi chuyển đổi các RPC nội bộ sang gRPC. Hệ thống áp dụng hợp đồng Protobuf chặt chẽ, unary interceptor cho telemetry và bidirectional streaming cho luồng dữ liệu tồn kho thời gian thực.

Bước 1: Định nghĩa hợp đồng chặt chẽ với Protocol Buffers

Bắt đầu với định nghĩa schema rõ ràng trong file .proto. Việc này tạo ra một hợp đồng tường minh, định kiểu chặt chẽ giữa các đội ngũ phát triển.

syntax = "proto3";

package order.v1;
option go_package = "order/v1;orderv1";

service OrderService {
  // Unary RPC: Một request, một response
  rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);

  // Server Streaming: Theo dõi sự kiện đơn hàng theo thời gian thực
  rpc StreamOrderUpdates (OrderSubscriptionRequest) returns (stream OrderStatusUpdate);
}

message CreateOrderRequest {
  string customer_id = 1;
  repeated string item_ids = 2;
  double total_amount = 3;
}

message CreateOrderResponse {
  string order_id = 1;
  string status = 2;
  int64 timestamp = 3;
}

message OrderSubscriptionRequest {
  string order_id = 1;
}

message OrderStatusUpdate {
  string order_id = 1;
  string status = 2;
  string details = 3;
}

Sinh mã Go stub bằng protoc cùng các plugin Go chính thức:

protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    order.proto

Bước 2: Unary Interceptor cho Telemetry và Panic Recovery

Tuyệt đối không chạy gRPC server trần (naked) trên production. Interceptor đóng vai trò như middleware, đảm nhiệm logging có cấu trúc, thu thập metric thực thi và bắt lỗi panic xuyên suốt tất cả các lệnh gọi RPC.

package main

import (
	"context"
	"log/slog"
	"os"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func LoggingAndRecoveryInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req interface{},
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (resp interface{}, err error) {
		start := time.Now()

		defer func() {
			if r := recover(); r != nil {
				logger.Error("Bắt được panic trong RPC", "method", info.FullMethod, "panic", r)
				err = status.Errorf(codes.Internal, "Lỗi máy chủ nội bộ")
			}
		}()

		resp, err = handler(ctx, req)
		duration := time.Since(start)

		st, _ := status.FromError(err)
		logger.Info("Đã xử lý RPC",
			"method", info.FullMethod,
			"duration_ms", duration.Milliseconds(),
			"code", st.Code().String(),
		)

		return resp, err
	}
}

Bước 3: Xây dựng Server với Endpoint Streaming

Dưới đây là cách chúng tôi ghép nối order service, gắn middleware và handler xử lý streaming:

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net"
	"os"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	pb "order/v1/orderv1"
)

type OrderServer struct {
	pb.UnimplementedOrderServiceServer
}

func (s *OrderServer) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) {
	if req.CustomerId == "" {
		return nil, status.Error(codes.InvalidArgument, "customer_id là bắt buộc")
	}

	return &pb.CreateOrderResponse{
		OrderId:   "ord-98214",
		Status:    "CONFIRMED",
		Timestamp: time.Now().Unix(),
	}, nil
}

func (s *OrderServer) StreamOrderUpdates(req *pb.OrderSubscriptionRequest, stream pb.OrderService_StreamOrderUpdatesServer) error {
	stages := []string{"PACKING", "DISPATCHED", "DELIVERED"}

	for _, stage := range stages {
		update := &pb.OrderStatusUpdate{
			OrderId: req.OrderId,
			Status:  stage,
			Details: fmt.Sprintf("Đơn hàng đã chuyển sang trạng thái %s", stage),
		}

		if err := stream.Send(update); err != nil {
			return err
		}
		time.Sleep(200 * time.Millisecond)
	}

	return nil
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		logger.Error("Không thể lắng nghe cổng", "error", err)
		os.Exit(1)
	}

	server := grpc.NewServer(
		grpc.UnaryInterceptor(LoggingAndRecoveryInterceptor(logger)),
	)

	pb.RegisterOrderServiceServer(server, &OrderServer{})
	logger.Info("gRPC server đang lắng nghe trên cổng 50051")
	if err := server.Serve(lis); err != nil {
		logger.Error("Server dừng đột ngột", "error", err)
	}
}

Bước 4: Xây dựng Connection Pool cho Client chuẩn Production

Về phía client, hãy duy trì một kết nối singleton bền vững. Lưu ý rằng grpc.Dial đã bị deprecated trong các phiên bản Go gRPC gần đây; hãy chuyển sang dùng grpc.NewClient.

package main

import (
	"context"
	"io"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	pb "order/v1/orderv1"
)

func main() {
	// Sử dụng grpc.NewClient (grpc.Dial đã bị deprecated trong các bản grpc-go mới)
	conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("Thiết lập kết nối thất bại: %v", err)
	}
	defer conn.Close()

	client := pb.NewOrderServiceClient(conn)

	// Đặt ngưỡng timeout rõ ràng cho các lệnh gọi unary
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	resp, err := client.CreateOrder(ctx, &pb.CreateOrderRequest{
		CustomerId:  "cust-441",
		ItemIds:     []string{"sku-101", "sku-202"},
		TotalAmount: 149.99,
	})
	if err != nil {
		log.Fatalf("CreateOrder thất bại: %v", err)
	}
	log.Printf("Đã tạo đơn hàng: id=%s status=%s", resp.OrderId, resp.Status)

	// Stream cập nhật đơn hàng trên cùng kết nối đã được ghép kênh (multiplexed)
	streamCtx, streamCancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer streamCancel()

	stream, err := client.StreamOrderUpdates(streamCtx, &pb.OrderSubscriptionRequest{OrderId: resp.OrderId})
	if err != nil {
		log.Fatalf("StreamSubscription thất bại: %v", err)
	}

	for {
		update, err := stream.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Fatalf("Lỗi đọc stream: %v", err)
		}
		log.Printf("Cập nhật trạng thái: [%s] %s", update.Status, update.Details)
	}
}

Kết quả: Số liệu benchmark thực tế trên Production

Sau khi chuyển đổi toàn bộ cụm Order và Inventory sang gRPC, chúng tôi tiến hành kiểm thử chịu tải lại ở mức 15.000 request mỗi giây. Sự khác biệt rõ rệt như ngày và đêm:

  • Dung lượng truyền tải trên mạng (Wire Footprint): Kích thước payload trung bình của đơn hàng giảm từ 1.240 byte xuống còn 395 byte—giảm 68% nhờ cơ chế field tag và đóng gói nhị phân của Protobuf.
  • Mức chiếm dụng bộ nhớ: Cấp phát heap cho mỗi RPC giảm từ 42 KB xuống chỉ còn 3,8 KB. Thời gian tạm dừng của bộ thu gom rác (GC pause) giảm tới 74%.
  • Tiêu thụ CPU: Mức sử dụng CPU của các order worker hạ mạnh từ 78% xuống còn 49% trong điều kiện tải tương đương.
  • Độ trễ p99: Độ trễ p99 trong mesh ổn định vững chắc ở mức 2,4ms, giảm ngoạn mục từ 18ms ở tải thông thường và hoàn toàn miễn nhiễm với các đợt tăng vọt 4.000ms như trước đây.

Nếu các service nội bộ của bạn vẫn đang truyền nhận JSON thô qua các kết nối HTTP/1.1 không có connection pool, bạn chỉ đang đánh cược thời gian trước đợt bùng nổ lưu lượng tiếp theo. Chuyển sang gRPC trong Go sẽ mang lại thông lượng cao, độ trễ ổn định và tính an toàn schema mà các nền tảng đám mây hiện đại thực sự cần.

Share: