没有理想的人不伤心

一文深入了解 gRPC

2025/06/06
4
0

image.png

gRPC

gRPC 是一款高性能开源通用的 RPC 框架,同时面向服务端跟移动端,基于 HTTP/2 协议设计。gRPC**不是一款服务治理框架,但是提供了服务治理的若干原材料,例如客户端负载均衡、KeepAlive、流控(自动跟手动)等等。下面简单介绍一下何为RPC 框架**,gRPC 作为 RPC 框架的特点和依赖的重要协议 – HTTP/2 协议

特点:

  1. 支持多语言(其实是每个语言实现了一遍…)
  2. 基于 IDL 定义服务,即 proto 文件(其实是使用了 Protocol Buffers 协议,ProtoBuf 也没大家想象中的那么好,第二章节会进行说明),每个语言提供一个代码生成工具,因此可以基于同一份 proto 生成不同语言的文件从而支持跨语言使用
  3. 序列化支持 PB、JSON 等,支持自定义 Marshaller
  4. Client 支持 Netty 跟 Okhttp(一般给客户端使用的),Server 是 Netty;同时 Client 与 Server 都支持 InProcess 方式调用(可以理解为 Mock)
  5. 暴露较多 LowLevel API(类似 StreamObserver,XXXCall 之类的给使用者),在gRPC API 设计思路子节中会详细讲解它为何这么做
  6. 对于实现全链路异步而言,个人认为 gRPC 是首选

http/2 协议

详细介绍:https://developers.google.com/web/fundamentals/performance/http2/?hl=zh-CN

协议协商

  1. HTTP/2 部署需要基于 HTTPS 是当前主流浏览器的要求,但是并非强制,因此主要包括如下两种协商类型:
    1. 基于 TLS 的 HTTP/2 协议,使用 h2 标识(ALPN)
    2. 基于 TCP 的 HTTP/2 协议,使用 h2c 标识
  2. gRPC 客户端跟服务端建立连接时会进行协议协商,过程如下:
    1. 客户端在不确定服务端是否支持 HTTP/2 的情况下发起协商升级请求,如果服务端支持 HTTP/2 会通过 header 带回来 Upgrade:h2/h2c 标识,如果不支持就默认按照 HTTP/1.1 返回
    2. 协议协商成功后,如果使用 HTTP/2 协议双方会互发 SETTINGS 帧(下面会介绍)作为连接序言,回执后开始发送数据,Client 侧可以不必等待 Server 的回执来提高效率
  3. gRPC 支持的三种协议协商策略
    1. PlainText:明确服务端支持 HTTP/2 协议,省去上述协商升级过程,直接通过 SETTINGS 帧作为连接序言建立连接后即可发送数据帧
    2. **PlainTextUpgrade:**不清楚服务端是否支持 HTTP/2,即 2 中描述的内容
    3. TLS:基于 TLS 建立 HTTP/2 连接,协商采用 ALPN 扩展协议,以 h2 作为标识,譬如跟 KeyCenter 交互

消息头压缩

简单来讲就是原先 HTTP/1.1 中的 Content-Type、Content-Encoding 等 Key 都被赋予一个标准的序号(索引)来减少数据量,Value 则进行相应的编码(哈夫曼)从而提升传输效率,gRPC 默认限制 HeaderList 的大小为8192,即不能超过8192个 K-V

http/2 的多路复用

http/2 实现多路复用(多个请求共用一个 TCP 连接)的原理:
http/2 通过 stream 来实现多路复用,每个请求相当于一个 stream,并分配一个 stream ID。http/2 将每个请求分割为多个帧(http/2 中的最小传输单元),每个帧都附带对应请求的 stream ID,多个 stream(请求) 的帧交替发送(流实现的并发),而不必等待前一个请求完全发送,服务端收到后根据 stream ID 合并为请求并进行解析。响应亦复如是。

感觉类似于计算机的 cpu 时间片,进程的交替运行。

http/1.x 发送每个请求都会占用一个 TCP 连接,占用系统资源

http/1.x 的队头阻塞问题:在 HTTP/1.x 中,浏览器通常会为每个域名建立 6-8 个并发连接,但每个连接只能处理一个请求。如果某个请求处理较慢,就会阻塞后续请求

http/2 通过多路复用,允许多个请求和响应并发传输,消除了 http/1 的队头阻塞问题,然而并不能消除 TCP 的队首阻塞问题

虽然 HTTP/2 的多路复用解决了 HTTP/1.x 的许多问题,但它仍然依赖于 TCP。如果底层 TCP 连接出现丢包,整个连接中的所有流都会受到影响(即 TCP 层的队头阻塞问题)。为了解决这一问题,HTTP/3 引入了基于 UDP 的 QUIC 协议。

常见的帧类型:

  1. SETTINGS帧:用于设置连接级别的配置,协议协商与流控窗口变更相关依赖于它
  2. PING帧:用于心跳保持,gRPC KeepAlive 就是利用这个帧实现
  3. GOAWAY帧:发起关闭连接请求时候使用,通常是连接达到 IDLE 状态之后
  4. RST_STREAM帧:关闭当前流的帧,gRPC 会经常用到,特别是当错误发生的时候
  5. WINDOW_UPDATE帧:流控帧,gRPC 的流控实现依赖于它
  6. DATA帧:实际传输数据帧,如果数据发送完毕会带 END_STEAM 标识
  7. HEADER帧:消息头帧,通常是一个请求开始帧,基于此帧创建请求初始化相关内容

流量控制

为了防止某个流占用过多的带宽,HTTP/2 提供了基于窗口大小的流量控制机制

  1. 每个流和整个连接都有独立的流量控制窗口,接收方可以通过 WINDOW_UPDATE 帧调整窗口大小。
  2. 流控机制是确保同一个 TCP 连接上的 Stream 不会互相干扰,因为 TCP 毕竟也有其局限性
  3. 初始窗口大小都是 65535 字节,发送端需要严格遵守接收端的窗口限制,连接序言中可以通过 SETTINGS 帧设置SETTINGS_INITIAL_WINDOW_SIZE来指定 Stream 的初始窗口大小,但是无法配置连接级别的初始窗口大小,gRPC 支持设置初始窗口的大小
  4. gRPC 通过WINDOW_UPDATE帧来实现流量控制,相关说明如下
    1. gRPC 支持自定义流控但默认没开启,仍处于 VisableForTesting 阶段;使用 Netty 内置的默认流控功能:基本思路就是当已经处理过的数据超过窗口一半是就发送 WINDOW_UPADTE 来更新窗口
    2. gRPC 默认流控初始窗口大小是 1M
    3. gRPC 如果开启 KeepAlive 功能那么 Ping 也会占用窗口大小
    4. gRPC 默认给每个 Stream 分配的字节数是 16K
    5. gRPC 内部支持的自动跟手动流控,只是针对 gRPC 本身而言,这里流控的含义是 Client 何时发起从 buffer 中读取需要数据的请求,类似于一种应用层级的流控,默认是自动的,当有特殊需求时可以开启手动控制,但是比较复杂而且容易出错,不推荐使用

gRPC 与 protobuf 协议

protobuf 全名是 ProtocolBuffers,是谷歌推出的二进制序列化协议,提供 IDL 文件来定义各种类型的数据。目前整体协议版本是 proto3,protobuf 提供了从 proto 文件编译生成各个语言文件的功能。与此同时 protobuf 提供了丰富的插件机制,用户可以扩展生成的对应语言的文件,俗称桩代码生成

gRPC 使用 Protocol Buffers(Protobuf)作为序列化协议

.proto 文件定义规则

在 protobuf 中,gRPC 使用 Protocol Buffers(Protobuf)作为序列化协议

// 表示语法版本声明,有 proto2 和 3,基本上都使用 3
syntax = "proto3";

// 使用 package 关键字定义包名,通常与代码的命名空间相对应。
package greeter;

// 定义请求消息
message HelloRequest {
  // 字段类型 字段名 = 字段编号;
  string name = 1; // 客户端发送的名字
}

// 定义响应消息
message HelloResponse {
  string message = 1; // 服务端返回的问候消息
}

// 定义服务
service Greeter {
  rpc SayHello(HelloRequest)returns (HelloResponse);
}

消息(message)是 protobuf 的核心,定义了数据结构。每个字段都有类型、名称和唯一的编号(字段编号必须是正整数)。

服务(service)定义了一组 RPC 方法。每个方法有输入消息和输出消息类型。

gRPC 桩代码生成

gRPC 通过 Protocol Buffers 提供的 Plugin 机制来实现原生桩代码生成

gRPC 调用全流程

以 go 编写一个简单的“问候服务(Greeter Service)”为例,定义一个服务 Greeter,包含一个方法 SayHello

编写.proto 文件

greeter.proto 文件如下

syntax = "proto3";

package greeter;

// 定义服务
service Greeter {
  rpc SayHello(HelloRequest)returns (HelloResponse);
}

// 定义请求消息
message HelloRequest {
  string name = 1; // 客户端发送的名字
}

// 定义响应消息
message HelloResponse {
  string message = 1; // 服务端返回的问候消息
}

桩代码生成

在 Go 中,我们需要安装以下工具来生成 gRPC 的代码:

1. **安装**** **`**protoc**`** ****编译器**(如果尚未安装):  

下载并安装 Protocol Buffers 编译器
2. 安装 Go 的 gRPC 插件

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
3. **设置环境变量**:  

确保 protoc-gen-goprotoc-gen-go-grpc 已添加到 PATH 中。
4. 桩代码生成

protoc --go_out=. --go-grpc_out=. greeter.proto

生成的文件:

    * ` greeter.pb.go `:包含消息类型定义。
    * ` greeter_grpc.pb.go `:包含服务端和客户端的接口定义。

greeter.pb.go:含了 HelloRequestHelloResponse 消息的结构体

// greeter.pb.go
package greeter

import(
	"google.golang.org/protobuf/proto"
	"google.golang.org/grpc"
	"io"
)

// HelloRequest 消息
type HelloRequest struct {
	Name string ` protobuf:"bytes,1,opt,name=name,proto3"json:"name,omitempty"`
}

// HelloResponse 消息
type HelloResponse struct {
	Message string ` protobuf:"bytes,1,opt,name=message,proto3"json:"message,omitempty"`
}

// Reset 重置
func(x *HelloRequest)Reset() {
	*x = HelloRequest{}
}

// String 返回字符串表示
func(x *HelloRequest)String()string {
	return proto.CompactTextString(x)
}

// ProtoMessage 方法
func(*HelloRequest)ProtoMessage() {}

// Reset 重置
func(x *HelloResponse)Reset() {
	*x = HelloResponse{}
}

// String 返回字符串表示
func(x *HelloResponse)String()string {
	return proto.CompactTextString(x)
}

// ProtoMessage 方法
func(*HelloResponse)ProtoMessage() {}

greeter_grpc.pb.go:包含了 gRPC 服务端和客户端的代码桩,定义了 Greeter 服务的 SayHello 方法

// greeter_grpc.pb.go
package greeter

import(
	"context"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// GreeterClient 是客户端的接口
type GreeterClient interface {
	SayHello(ctx context.Context,in *HelloRequest,opts ...grpc.CallOption) (*HelloResponse,error)
}

// GreeterServiceClient 是客户端的结构体
type greeterClient struct {
	cc grpc.ClientConnInterface
}

// SayHello 调用 SayHello RPC 方法
func(c *greeterClient)SayHello(ctx context.Context,in *HelloRequest,opts ...grpc.CallOption) (*HelloResponse,error) {
	out:= new(HelloResponse)
	err:= c.cc.Invoke(ctx, "/greeter.Greeter/SayHello",in,out,opts...)
	if err!= nil {
		return nil,err
	}
	return out,nil
}

// NewGreeterClient 创建新的 GreeterClient
func NewGreeterClient(cc grpc.ClientConnInterface)GreeterClient {
	return &greeterClient{cc}
}

// GreeterServer 是服务端接口
type GreeterServer interface {
	SayHello(context.Context, *HelloRequest) (*HelloResponse,error)
}

// UnimplementedGreeterServer 是一个空的服务端实现
type UnimplementedGreeterServer struct{}

// SayHello 是服务端实现
func(UnimplementedGreeterServer)SayHello(ctx context.Context,req *HelloRequest) (*HelloResponse,error) {
	return &HelloResponse{Message: "Hello, " + req.Name},nil
}

// RegisterGreeterServer 注册服务
func RegisterGreeterServer(s grpc.ServiceRegistrar,srv GreeterServer) {
	s.RegisterService(&_Greeter_serviceDesc,srv)
}

var _Greeter_serviceDesc = grpc.ServiceDesc{
	ServiceName: "greeter.Greeter",
	HandlerType: (*GreeterServer)(nil),
	Methods: []grpc.MethodDesc{
		{
			MethodName: "SayHello",
			Handler:    _Greeter_SayHello_Handler,
		},
	},
	Streams:  []grpc.StreamDesc{},
	Metadata: "greeter.proto",
}

// _Greeter_SayHello_Handler 处理 SayHello RPC
func _Greeter_SayHello_Handler(srv interface{},ctx context.Context,codec grpc.Codec,buf []byte) (interface{},error) {
	in:= new(HelloRequest)
	if err:= codec.Unmarshal(buf,in);err != nil {
		return nil,err
	}
	return srv.(GreeterServer).SayHello(ctx,in)
}

服务端实现

服务端需要实现 Greeter 服务的接口,并启动 gRPC 服务。

server.go

package main

import(
    "context"
    "log"
    "net"

    pb"path/to/your/generated/code" // 替换为生成代码的实际路径

    "google.golang.org/grpc"
)

// 定义服务端结构体
type server struct {
    pb.UnimplementedGreeterServer
}

// 实现 SayHello 方法
func(s *server)SayHello(ctx context.Context,req *pb.HelloRequest) (*pb.HelloResponse,error) {
    log.Printf("Received: %s",req.GetName())
    return &pb.HelloResponse{Message: "Hello, " + req.GetName() + "!"},nil
}

func main() {
    // 监听端口
    listener,err := net.Listen("tcp", ":50051")
    if err!= nil {
        log.Fatalf("Failed to listen: %v",err)
    }

    // 创建 gRPC 服务器
    grpcServer:= grpc.NewServer()
    pb.RegisterGreeterServer(grpcServer, &server{})

    log.Println("Server is running on port 50051...")
    // 启动服务
    if err:= grpcServer.Serve(listener);err != nil {
        log.Fatalf("Failed to serve: %v",err)
    }
}
1. ` server ` 结构体嵌套了 ` UnimplementedGreeterServer `,这是生成代码中提供的默认实现。
2. 实现了 ` SayHello ` 方法,处理客户端请求并返回响应。
3. 使用 ` grpc.NewServer()` 创建 gRPC 服务,并注册服务实现。

客户端实现

客户端通过生成的客户端接口调用服务端的方法。

client.go

package main

import(
	"context"
	"log"
	"time"

	pb"path/to/your/generated/code" // 替换为生成代码的实际路径

	"google.golang.org/grpc"
)

func main() {
	// 连接到服务端
	conn,err := grpc.Dial("localhost:50051",grpc.WithInsecure())
	if err!= nil {
		log.Fatalf("Failed to connect: %v",err)
	}
	defer conn.Close()

	// 创建客户端
	client:= pb.NewGreeterClient(conn)

	// 调用 SayHello 方法
	name:= "Alice"
	ctx,cancel := context.WithTimeout(context.Background(),time.Second)
	defer cancel()

	response,err := client.SayHello(ctx, &pb.HelloRequest{Name:name})
	if err!= nil {
		log.Fatalf("Could not greet: %v",err)
	}

	log.Printf("Server response: %s",response.GetMessage())
}

1. 使用 ` grpc.Dial ` 连接到服务端。
2. 创建 ` GreeterClient `,这是生成代码中定义的客户端接口。
3. 调用 ` SayHello ` 方法时,构造 ` HelloRequest ` 请求对象,并接收 ` HelloResponse ` 响应对象。

运行客户端和服务端

1. 启动服务端:

go run server.go

服务端会在 50051 端口监听请求,输出类似:

Server is running on port 50051...

2. 启动客户端

go run client.go

客户端会调用服务端的 SayHello 方法,输出类似:

Server response:Hello,Alice!

服务端的日志中会显示收到的请求:

Received:Alice

gRPC 连接状态机

gRPC 内部在 Channel 构建到销毁的生命周期内,维护了该链接的整个状态的运转,了解内部具体的运转流程有助于我们更好的定位问题。gRPC 内部链接主要分成如下几个状态:

  1. CONNECTING:代表 Channel 正在初始化建立连接,流程会涉及 DNS 解析、TCP 建连、TLS 握手等
  2. READY:成功建立连接,包括 HTTP2 协商,代表 Channel 可以正常收发数据
  3. TRANSIENT_FAILURE:建连失败或者 CS 之间网络问题导致,Channel 最终会重新发起建立连接请求,gRPC 提供了一套 backoff Retry 机制来保证不会出现重连风暴
  4. IDLE:Channel 中长期没有请求或收到 HTTP2 的 GO_AWAY 信号会进入此状态,此时 CS 之间连接已经断开,一旦新请求发起会转移到 CONNECTING。主要目的是保障 SERVER 连接数不会太大造成压力
  5. SHUTDOWN:Channel 已经关闭,状态不可逆,新请求会立即失败掉,排队的请求会继续处理完

下图描述了五种状态之间的转换:

1741748916294-9f173eab-7b12-4a86-b47f-8af0d02a2487.png

gRPC 常见状态码

所有的状态码参考:https://github.com/grpc/grpc/blob/master/doc/statuscodes.md

  • 0–OK:代表请求成功执行
  • **1–CANCELLED:**请求被取消,通常是超时 or Deadline Exceed 导致
  • 2–UNKNOWN:通常是 Server 抛异常导致,一般 Server 业务执行抛异常非 StatusException or RuntimeStatusException,所以 gRPC 内部会用 UNKNOWN 状态码进行处理
  • **4–DEADLINE_EXCEEDED:**客户端请求配置了 withDeadlineAfter CallOption,双端都生效,服务端超时会取消请求,Client 侧表现为收到这个状态码的 RuntimeStatusException,也是我们最常见的异常
  • **7–PERMISSION_DENIED:**顾名思义,代表没有访问权限,可以对应 HTTP 的 403,常见如 IP 白名单
  • **16–UNAUTHENTICATED:**gRPC 特意用这个状态码代表没有授权的访问,通常是开启 tls 的服务
  • **8–RESOURCE_EXAUSTED:**代表服务端资源不足,如带宽,磁盘,连接数等等;业务也可以使用它来描述业务资源不足的情况
  • **12–UNIMPLEMENTED:**gRPC 内部异常,请求的 FullPath 方法未实现,如果没有 FallbackRegistry 会抛这个状态码异常,通常可能是 proto 文件有不兼容改动
  • **14–UNAVAILABLE:**代表当前服务不可用,一般是暂时性问题,代表客户端可以通过重试来解决的情况
  • **13–INTERNAL:**gRPC 内部严重错误,通常是有严重 bug 才会出现,业务不应该使用此状态码