gRPC 是什么?从一次跨服务调用说起

/ 60 min read / #go #grpc #backend

从一次跨服务调用出发,拆开 gRPC 的编码、线路格式、deadline、元数据和错误码,看它怎样把远程调用写得像本地函数,又把哪些网络问题留给我们处理。

订单服务要问用户服务一句话:“42 号用户存在吗?”两个服务在不同的机器上,这句话得走网络。用 HTTP 加 JSON 手写的话,调用方要拼 URL、把参数转成 JSON、发请求、判断状态码、把响应体解回结构体,还要自己想清楚超时多久、对方返回的错误长什么样。被调用方把同样的事反着做一遍。两边对 URL、字段名、错误格式的约定只写在文档里,有一边改了,另一边要到运行时才发现。

RPC(Remote Procedure Call,远程过程调用)想把这些都藏起来,让调用方只写一行 client.GetUser(ctx, req)。gRPC 是 Google 开源的一套 RPC 框架。这篇文章先讲 RPC 这个想法本身,再讲 gRPC 的每个部件怎么工作:Protocol Buffers、四种调用模式、元数据、拦截器、deadline、错误码,最后是把 gRPC 接口转成 HTTP/JSON 的 grpc-gateway。

RPC 这个想法,以及它一开始漏掉的东西

Birrell 和 Nelson 1984 年发表的《Implementing Remote Procedure Calls》完整描述了一套 RPC 的实现,gRPC 今天的结构和它是同一个样子。论文说一次远程调用涉及五段程序:调用方、调用方的 stub、两边各一份的 RPC 运行库、服务方的 stub、服务方。stub 可以译成“桩”,是一段夹在中间的代码:调用方以为自己在调一个普通的本地函数,实际调到的是 stub,stub 负责把“调哪个函数、参数是什么”打包成网络包,交给运行库发出去;对面的 stub 拆包,再用一次普通的本地调用去调真正的实现。结果沿原路返回。

论文里还有一点延续到了今天:stub 不用手写。作者有一个叫 Lupine 的程序,根据接口定义自动生成两边的 stub。gRPC 里对应的东西是 protoc 和它的插件。

HTTP/2 业务代码c.Hello(ctx, req) 客户端 stub(生成的代码) gRPC 运行库 gRPC 运行库 服务端 stub(生成的代码) 你实现的Hello 方法

这篇论文有一个设计原则:远程调用的语义要尽量贴近本地调用。作者据此做了一个现在看来很扎眼的决定:只要没有机器或通信故障,就不限制一次远程调用的时长。理由有两条:本地函数调用没有超时,而且他们的语言本身就有中止一个任务的机制,专门为 RPC 再设计一套超时会让程序员的世界变复杂。

要注意,论文并没有忽略网络。调用方等待时会定期给对方发探测包,对方宕机或者通信中断,调用方会收到异常;丢包靠重传,重复的调用包靠调用 ID 识别。它给的保证是:调用正常返回,说明服务端的过程恰好执行了一次;收到异常,说明执行了一次或者一次都没有,调用方不知道是哪种。它唯一不管的情况是对方还活着、只是迟迟不回,比如死锁或死循环,这时调用方会一直等下去,和本地调用一样。

gRPC 在“慢”这件事上走了相反的方向:慢也算一种要处理的失败,调用方用 deadline 说清楚自己最多等多久,取消还会沿着调用链往下传。gRPC 官方 2015 年 9 月的一篇博客《gRPC Motivation and Design Principles》列出的设计原则里,取消与超时、流量控制、元数据交换、标准化的状态码都单独成条,还明确说要避开“忽略网络”的错误认识。所以读后面几节时可以带着这条线索:gRPC 把调用写得像本地函数,同时把“这是一次网络调用”带来的那些事(等多久、谁取消、出了什么错)放到了接口里,要求你处理。

gRPC 由哪几块拼成

按上面那篇博客的说法,Google 内部用一套叫 Stubby 的 RPC 设施连接数据中心里的大量服务,到当时已经用了十多年;Stubby 不基于任何标准,和内部设施绑得太紧,不适合公开。HTTP/2 这些标准出现之后,他们决定重做一套,这就是 gRPC。

拆开看,gRPC 是三样东西的组合:

  1. 接口定义语言。在 .proto 文件里写清楚有哪些服务、每个方法收什么发什么。默认用 Protocol Buffers,官方文档说也可以换成别的格式。
  2. 代码生成。protoc 读 .proto,生成消息的结构体、客户端 stub 和服务端要实现的接口。
  3. 传输协议。请求和响应走 HTTP/2,具体怎么摆放由《gRPC over HTTP2》这份协议文档规定。

本文的实验用的是下面这份定义,五个方法里前四个各对应一种调用模式:

service Demo {
  // 一元:一个请求,一个响应
  rpc Hello(HelloRequest) returns (HelloReply) {
    option (google.api.http) = {get: "/v1/hello/{name}"};
  }
  // 服务端流:一个请求,多个响应
  rpc Countdown(CountdownRequest) returns (stream Tick);
  // 客户端流:多个请求,一个响应
  rpc Sum(stream Number) returns (SumReply);
  // 双向流:两边各发各的
  rpc Chat(stream ChatMsg) returns (stream ChatMsg);
  // 用来演示 deadline 传播:服务端再去调下游的 Hello
  rpc Relay(HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message SumReply {
  int64 total = 1;
  int32 count = 2;
}

(其余几个消息都只有一个字段,这里省略。option (google.api.http) 那一行是给 grpc-gateway 用的,最后一节再讲。)

生成 Go 代码需要 protoc 加两个插件:protoc-gen-go 生成消息结构体,protoc-gen-go-grpc 生成客户端和服务端接口。

protoc -I proto \
  --go_out=gen --go_opt=paths=source_relative \
  --go-grpc_out=gen --go-grpc_opt=paths=source_relative \
  proto/demo.proto

Protocol Buffers:字段靠编号认,不靠名字

Protocol Buffers(下面简称 protobuf)负责把结构体变成字节。它的官方文档这样介绍自己:像 JSON,但更小更快,并且能生成各语言的原生代码。Google 在 2008 年把它开源。

它和 JSON 最大的区别在 string name = 1; 里那个 = 1。这是字段编号。JSON 把字段名原样写进数据里,protobuf 的二进制数据里只有编号,没有名字,也没有声明的类型。官方编码文档说得很直接:字段的名字和类型只能在解码那一端对照 .proto 文件才能知道。

把几个消息编码后打印出十六进制,能直接看到这一点:

HelloRequest{Name:"world"}   7 字节  0a 05 77 6f 72 6c 64
Number{Value:150}            3 字节  08 96 01
Number{Value:-1}             11 字节  08 ff ff ff ff ff ff ff ff ff 01
SumReply{Total:6, Count:3}   4 字节  08 06 10 03
SumReply{}(全是零值)             0 字节  

第一行的 0a 是标签(tag)。按编码文档的公式,标签等于 (字段编号 << 3) | 线路类型。0a 是二进制 00001 010,前五位是字段编号 1,后三位是线路类型 2,表示“后面跟一个长度,再跟这么多字节”。接下来的 05 是长度,再后面五个字节就是 world。

第二行的 08 是字段 1、线路类型 0,表示后面是一个 varint(变长整数)。varint 每个字节用低 7 位存数据,最高位表示“后面还有没有”。96 01 去掉最高位、低位在前拼起来就是 150。数值越小占的字节越少,这是 protobuf 数据紧凑的主要来源。

这套编码带来三个要记住的后果。

编号一旦用了就不能改,也不能复用。 数据里只有编号,把字段 2 从 count 改成别的含义,旧数据就会被按新含义解读。官方文档的要求是:消息投入使用后编号不能改;删掉字段时要用 reserved 把它的编号占住,防止以后有人再拿来用。反过来,改字段名对二进制数据没有任何影响。编号 1 到 15 的标签只占一个字节,16 到 2047 占两个,文档建议把这段小编号留给最常设置的字段。

新旧版本可以共存。 加字段只要用新编号,旧程序解码时遇到不认识的编号不会报错,而是把这段数据原样保留。实验里用有两个字段的 SumReply 编码,再用只认识字段 1 的 Number 去解码:

旧结构解码新数据:value=6,不认识的字段原样留着:10 03

服务可以一个一个升级,不需要所有调用方同时换版本,靠的就是这条规则。

零值不上线,所以分不清“没填”和“填了零”。 上面最后一行,全是零值的 SumReply 编码出来是 0 个字节。proto3 的文档明确提醒:这类字段解析之后没有办法判断它是被显式设成了默认值,还是根本没设。文档举的例子是,不要设计一个“设为 false 时开启某行为”的布尔字段。确实需要区分时,给字段加 optional 修饰。

另外一个小坑在第三行:int64 类型的 -1 占了 11 个字节(1 字节标签加 10 字节数值)。文档的解释是 intN 类型用补码表示负数,当成无符号数看最高位是 1,所以十个字节全得用上。经常出现负数的字段应该用 sint32 / sint64,它们先做一次 ZigZag 变换再编码。

一次调用在网络上长什么样

为了看清 gRPC 到底往网络上发了什么,实验里用 Go 标准库的 net/http 起了一个明文 HTTP/2 服务冒充 gRPC 服务端,把 grpc-go 客户端发来的请求打印出来:

POST /demo.v1.Demo/Hello HTTP/2.0
  content-type: application/grpc
  te:           trailers
  grpc-timeout: 1997945u
  user-agent:   grpc-go/1.84.0
  x-request-id: req-42
  body (12 字节): 00 00 00 00 07 0a 05 77 6f 72 6c 64

对照协议文档,每一行都有着落:

响应可以反过来用 curl 看。手工拼一个带前缀的请求体,直接发给真正的 gRPC 服务端:

printf '\x00\x00\x00\x00\x07\x0a\x05world' | curl -sv --http2-prior-knowledge \
  -H 'content-type: application/grpc' -H 'te: trailers' \
  --data-binary @- http://127.0.0.1:50551/demo.v1.Demo/Hello -o out.bin
< HTTP/2 200 
< content-type: application/grpc
< 
< grpc-status: 0
< grpc-message: 

空行之前是响应头,之后那两行是 trailer,也就是在响应体发完之后才发的一组头。gRPC 的调用结果放在 trailer 的 grpc-status 里,0 表示成功。把名字换成一个会让服务端返回“找不到”的值,结果是这样:

< HTTP/2 200 
< content-type: application/grpc
< grpc-status: 5
< grpc-message: %E6%B2%A1%E6%9C%89%E8%BF%99%E4%B8%AA%E4%BA%BA

HTTP 状态码仍然是 200,错误写在 grpc-status: 5 里。 协议文档规定响应的 HTTP 状态就是 200,gRPC 自己的状态必须放在 trailer 里,成功时也要发。这样安排的理由文档没有写。一个说得通的解释是:流式响应发到一半也可能失败,而 HTTP 状态码在第一个字节发出时就定死了,结果只能放在最后。这是推断,不是官方说法。

对排查问题来说,这意味着只看 HTTP 状态码的日志和监控,会把上面这种业务错误也记成 200。

每一次 RPC 对应一条 HTTP/2 流(stream),协议文档直接拿 HTTP/2 的流编号当调用的标识。HTTP/2 允许一条 TCP 连接上同时跑很多条流,所以很多次调用可以共用一条连接,官方博客把这一点列为 gRPC 相对传统 HTTP/JSON 的优势之一。HTTP/2 能做到这一点,是因为它把每个请求和响应拆成带流编号的二进制帧,不同流的帧在同一条 TCP 连接上交错传输,接收方再按编号各自拼回去。

四种调用模式

.proto 里参数或返回值前面加不加 stream,一共四种组合。生成的 Go 客户端接口是这样的:

type DemoClient interface {
	Hello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
	Countdown(ctx context.Context, in *CountdownRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Tick], error)
	Sum(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[Number, SumReply], error)
	Chat(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ChatMsg, ChatMsg], error)
	Relay(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}

一元(unary) 是一个请求换一个响应,和普通函数调用一样。Hello 直接返回结果。

服务端流 是发一个请求,拿回一条流,从里面不断读消息,直到服务端说发完了。Countdown 返回的流对象有 Recv() 方法,读到 io.EOF 表示正常结束。服务端那边,方法 return nil 就是结束这条流。适合结果很多或者要持续推送的场景,比如导出大量记录、订阅变更。

客户端流 反过来,客户端连着发很多条,服务端最后回一条。Sum 的客户端用 Send() 发,发完调 CloseAndRecv(),意思是“我发完了,把结果给我”。服务端的 Recv() 读到 io.EOF 就知道对方发完了。适合上传大文件、批量上报。

双向流 是两边各有一条独立的流,可以按任意顺序读写。官方文档举的两种用法是:服务端等客户端全部发完再回,或者像打乒乓球一样一来一回。

四种模式连着跑一遍,客户端这边的输出:

一元:
  收到 你好,world
服务端流:
  收到 3
  收到 2
  收到 1
  流结束(io.EOF)
客户端流:
  发了 3 个数,总和 6
双向流:
   收到:a
   收到:b
  CloseSend 之后再 Recv: EOF

关于流有几条边界。

官方文档保证的是单次 RPC 内部消息有序。两次不同的调用之间没有顺序保证。

一条流就是一次 RPC,所以 deadline 管的是整条流,不是其中某一条消息。实验里给一条双向流设了 300ms 的 deadline,每 120ms 往返一次,前三次成功,第四次 Recv 得到 DeadlineExceeded。打算长期开着的流不能直接套用给一元调用准备的超时。

选哪种模式,官方文档没有给规则。从机制上看,能用一元就用一元:它的超时、重试、负载均衡都以“一次调用”为单位,最容易推理。流在消息很多、需要边产生边发送、或者要服务端主动推送时才值得用。

元数据:跟着调用走,但不属于参数的信息

有些信息每次调用都要带,却不是任何一个方法的业务参数:身份凭证、请求 ID、链路追踪的上下文。把它们塞进每个请求消息里显然不合适。gRPC 的办法是元数据(metadata):一组键值对,随调用一起发送,底层就是 HTTP/2 的头,上一节抓到的 x-request-id: req-42 就是一条。

元数据分三段传:

Go 里元数据挂在 context 上。客户端这样发、这样收:

ctx := metadata.AppendToOutgoingContext(context.Background(),
	"X-Request-ID", "req-42", // 键会被转成小写
	"x-tag", "a", "x-tag", "b", // 同一个键可以有多个值
	"trace-bin", string([]byte{0x00, 0xff, 0x10}), // -bin 结尾的键可以放任意字节
)
var header, trailer metadata.MD
_, err = c.Hello(ctx, &demov1.HelloRequest{Name: "world"}, grpc.Header(&header), grpc.Trailer(&trailer))

服务端用 metadata.FromIncomingContext(ctx) 读,用 grpc.SetHeader 和 grpc.SetTrailer 写。运行结果:

  [S] 收到 x-request-id  = ["req-42"]
  [S] 收到 x-tag         = ["a" "b"]
  [S] 收到 trace-bin     = ["\x00\xff\x10"]
  [S] 收到 :authority    = ["127.0.0.1:59038"]
  [S] 收到 content-type  = ["application/grpc"]
  [S] 收到 user-agent    = ["grpc-go/1.84.0"]
  [S] 这个 ctx 上的“待发出”元数据:map[]
客户端收到 header  x-served-by = ["S"],content-type = ["application/grpc"]
客户端收到 trailer x-cost-ms   = ["3"]

从输出里能读出几条规则,都能在官方文档里找到对应:

最容易踩的坑在倒数第三行。服务端收到的元数据不会自动转给下游。 在 Go 里,“收到的”和“待发出的”是 context 上两份独立的数据,服务端的 ctx 上待发出的那份是空的。另做了一个实验:客户端带着 x-request-id 调 A,A 把同一个 ctx 原样传给调 B 的客户端,B 打印出来的 x-request-id 是空的。要透传必须自己读出来再写进去,这件事适合放在拦截器里做。

另一个限制是大小。协议文档建议服务端把请求头的总大小限制在 8 KiB,元数据不适合放大块数据。

拦截器:gRPC 的中间件

日志、鉴权、指标、透传元数据,这些事每个方法都要做,逻辑又和具体方法无关。gRPC 提供的挂载点叫拦截器(interceptor),官方文档直说它就是别处叫“过滤器”或“中间件”的东西。写过 Go net/http 中间件的话,这是同一个模式:用一个函数把真正的处理逻辑包一层,在调用它之前和之后各做点事。

grpc-go 里服务端一元拦截器是一个函数,拿到请求和一个 handler,自己决定在调用 handler 前后做什么:

tag := func(name string) grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		fmt.Printf("  服务端 %s 进入  %s\n", name, info.FullMethod)
		resp, err := handler(ctx, req) // 调下一个拦截器,最后一个调到业务方法
		fmt.Printf("  服务端 %s 返回  code=%v\n", name, status.Code(err))
		return resp, err
	}
}

服务端用 grpc.ChainUnaryInterceptor(tag("A"), tag("B")) 装上两个,客户端用 grpc.WithChainUnaryInterceptor 装一个同样只打印日志的拦截器。其中一次调用的输出:

调用 Hello(nobody):
  客户端拦截器 发出  /demo.v1.Demo/Hello
  服务端 A 进入  /demo.v1.Demo/Hello
  服务端 B 进入  /demo.v1.Demo/Hello
  [S] Hello("nobody") 没有 deadline
  服务端 B 返回  code=NotFound
  服务端 A 返回  code=NotFound
  客户端拦截器 收到  code=NotFound

写在前面的在外层:A 先进后出,把 B 和业务方法整个包在里面。grpc-go 源码里 ChainUnaryInterceptor 的注释也是这么说的,第一个拦截器在最外层。顺序会影响行为。官方文档举的例子是缓存和日志两个客户端拦截器:日志放在靠网络的一侧,记下的是真正发出去的请求;放在靠应用的一侧,命中缓存的调用也会被记下来。

拦截器有三条边界。

一元调用和流式调用的拦截器是两套类型。实验里只装了一元拦截器,然后调服务端流方法 Countdown,没有任何拦截器输出。鉴权这类逻辑如果只写了一元版本,流式方法就是裸奔的,要用 ChainStreamInterceptor 另装一份。

客户端和服务端的拦截器也是两套接口,不能混用。

拦截器是按调用生效的。官方文档特别说明,它管不了 TCP 连接、端口、TLS 配置这类连接层面的事。

deadline 传播:把“我还愿意等多久”告诉下游

deadline 是一个时间点,表示调用方最晚愿意等到什么时候。官方文档有两句话值得先记下:gRPC 默认不设 deadline,客户端可能一直等下去,所以应该总是显式设置;超过 deadline 之后,客户端放弃并得到 DEADLINE_EXCEEDED。

单次调用的超时不新鲜,gRPC 特别的地方在“传播”。假设客户端调 A,A 又调 B。客户端只愿意等 500ms,A 自己花了 100ms,那 B 最多只有 400ms 可用。B 再慢,客户端也已经走了,继续算下去是浪费。deadline 传播就是让 A 把剩余时间告诉 B。

Go 里设置 deadline 用的就是标准库的 context。它给一次请求牵涉到的所有函数和 goroutine 一个共同的“可以停了”的信号,也可以约定最晚什么时候必须停;父 context 被取消,从它派生出来的子 context 会跟着取消。A 的方法收到的 ctx 已经带着客户端的 deadline,调 B 时把这个 ctx 传下去,传播就发生了:

func (s *server) Relay(ctx context.Context, req *demov1.HelloRequest) (*demov1.HelloReply, error) {
	if dl, ok := ctx.Deadline(); ok {
		fmt.Printf("  [%s] Relay(%q) 剩余时间 %v\n", s.name, req.Name, time.Until(dl).Round(10*time.Millisecond))
	}
	time.Sleep(100 * time.Millisecond) // 假装自己先干了 100ms 的活
	if name, ok := strings.CutPrefix(req.Name, "detach:"); ok {
		// 错误示范:没把收到的 ctx 传下去
		return s.downstream.Hello(context.Background(), &demov1.HelloRequest{Name: name})
	}
	return s.downstream.Hello(ctx, req)
}

在网络上传的并不是那个时间点。前面抓包看到的是 grpc-timeout: 1997945u,一个剩余时长。官方文档解释了原因:两台机器的时钟可能不一致,直接传时间点会出错,所以 gRPC 把 deadline 换算成扣掉已用时间的超时再发出去。

下面是四种情况的实际输出。B 的 Hello 在名字是 slow 时要干 1 秒。A 只在有 deadline 时才打印那一行,所以第 4 种情况里没有 A 的输出。

1. 客户端给 500ms,A 把 ctx 传给 B,B 很快:
  [A] Relay("world") 剩余时间 500ms
  [B] Hello("world") 剩余时间 400ms
  客户端 110ms 后返回,code=OK
2. 客户端给 500ms,A 把 ctx 传给 B,B 要干 1 秒:
  [A] Relay("slow") 剩余时间 500ms
  [B] Hello("slow") 剩余时间 400ms
  客户端 500ms 后返回,code=DeadlineExceeded
  [B] 中途收到取消:context canceled
3. 客户端给 500ms,A 用 context.Background() 调 B,B 要干 1 秒:
  [A] Relay("detach:slow") 剩余时间 500ms
  [B] Hello("slow") 没有 deadline
  客户端 500ms 后返回,code=DeadlineExceeded
  [B] 干完了
4. 客户端不设 deadline:
  [B] Hello("world") 没有 deadline
  客户端 100ms 后返回,code=OK

第 1、2 种情况里,B 看到的剩余时间是 400ms,A 用掉的 100ms 已经扣除。第 2 种情况下,时间一到,B 收到取消信号,停了手。

Relay(剩 500ms) Hello(剩 400ms) 自己用掉 100ms 要干 1 秒 500ms 到,不等了DeadlineExceeded ctx 被取消,停手 客户端 服务 A 服务 B

第 3 种情况是最常见的写错方式。A 调 B 时用了 context.Background(),B 那边显示“没有 deadline”。客户端 500ms 后已经拿到超时错误走了,B 还是把 1 秒的活干完了,结果没有人要。请求量大的时候,下游就是这样被已经没人等的请求拖垮的。

还有几件事需要知道。

服务端要自己检查 ctx。 deadline 到了,gRPC 只是把 ctx 取消掉,不会把正在运行的方法强行停下来。官方文档写明,服务端应用要负责停掉自己为这次 RPC 发起的活动。实验里的 Hello 是用 select 等 ctx.Done() 才停得下来的。写成一个不看 ctx 的 time.Sleep(time.Second),它照样睡满 1 秒。

两边各自判断成败,结论可能不同。 官方文档举的例子是,服务端认为“我全发完了”,客户端认为“到我手上时已经过了 deadline”。状态码文档对 DEADLINE_EXCEEDED 的说明里也有一句:对会修改系统状态的操作,即使操作已经成功完成,也可能返回这个错误。所以客户端收到超时,不能推断服务端没执行。要不要重试、重试会不会重复执行,要靠幂等设计来解决:让同一个操作执行一次和执行多次的效果相同。常见做法有数据库唯一约束、去重表、状态机,以及让客户端给每次操作带一个幂等键。

Go 里判断超时要看状态码。 客户端超时拿到的错误,status.Code(err) 是 DeadlineExceeded,但实测 errors.Is(err, context.DeadlineExceeded) 是 false。习惯用 errors.Is 判断 context 错误的代码,在 gRPC 调用上会漏判。

服务端看到的 ctx.Err() 不一定是 DeadlineExceeded。 第 2 种情况里 B 打印的是 context canceled。单独用 curl 带 grpc-timeout: 100m 直接请求服务端 30 次,服务端方法里看到的 30 次都是 context canceled。这是官方写明的行为:Deadlines 指南说,客户端设的 deadline 一过,gRPC 服务端会自动取消这次调用,状态是 CANCELLED。对应到 grpc-go 1.84.0 的源码,服务端除了用 context.WithTimeout 建 ctx,还另起了一个到点就以取消的方式关闭这条流的定时器。两者谁先触发的实现细节没有深究,但实测和文档一致。稳妥的写法是服务端只判断 ctx.Err() != nil,不区分是哪一种。

超时该设多长、怎么和重试配合,是服务稳定性的话题。简单说,重试会放大下游的流量,通常要配上指数退避、随机抖动和重试总量的上限,并且只在调用链的一层重试。

错误码:用一小组固定的状态码说明出了什么事

gRPC 的调用结果是一个状态(status),由一个整数状态码和一段文字消息组成。状态码一共 17 个,编号 0 到 16。客户端应对错误的方式本来就有限,把状态码的范围收窄,错误处理的决定会更清楚。

Go 服务端用 status.Error(codes.NotFound, "没有这个人") 返回带状态码的错误,客户端用 status.FromError(err) 或 status.Code(err) 取出来。下面是各种情况下客户端实际拿到的状态码(省略了服务端打印的行):

Hello("world"): code=OK(0) message="" ok=true
Hello("nobody"): code=NotFound(5) message="没有这个人" ok=true
Hello("plain"): code=Unknown(2) message="数据库连不上" ok=true
Hello("bad"): code=InvalidArgument(3) message="参数不合法" ok=true
  详情:字段 name,不能是 bad
方法没实现: code=Unimplemented message="method Hello not implemented"
客户端取消: code=Canceled
超时: code=DeadlineExceeded,errors.Is(err, context.DeadlineExceeded)=false
服务没开: code=Unavailable
发 5MB 的请求: code=ResourceExhausted message="grpc: received message larger than max (5242885 vs. 4194304)"

第三行值得注意。服务端返回的是一个普通的 errors.New("数据库连不上"),客户端收到的是 Unknown,而且错误文字原样传到了客户端。服务端直接把内部错误 return 出去,等于把数据库报错、内部地址这些信息发给了调用方,同时调用方也没法根据 Unknown 做任何判断。服务端应该在返回前把错误转换成合适的状态码和可以对外的消息。

状态码分两类来源。状态码文档列了一张表,说明哪些是 gRPC 库自己会产生的:客户端取消是 CANCELLED,超时是 DEADLINE_EXCEEDED,服务端找不到方法是 UNIMPLEMENTED,连接断开或服务端正在关闭是 UNAVAILABLE,消息超过大小限制是 RESOURCE_EXHAUSTED,等等。上面输出的后五行都属于这一类,业务代码一行都没写。文档同时列出库永远不会产生的七个:INVALID_ARGUMENT、NOT_FOUND、ALREADY_EXISTS、FAILED_PRECONDITION、ABORTED、OUT_OF_RANGE、DATA_LOSS。看到这几个,可以确定是对方的业务代码返回的。

业务代码最常用到的几个,按文档的定义:

状态码 含义
INVALID_ARGUMENT 参数本身有问题,和系统当前状态无关
NOT_FOUND 请求的东西不存在
ALREADY_EXISTS 要创建的东西已经存在
FAILED_PRECONDITION 系统当前状态不允许这个操作,比如删除非空目录
PERMISSION_DENIED 知道调用者是谁,但他没有权限
UNAUTHENTICATED 没有有效的身份凭证
RESOURCE_EXHAUSTED 资源用尽,比如配额
UNAVAILABLE 服务暂时不可用,多半是临时的
INTERNAL 内部错误,系统的某个不变式被破坏了

文档还给了一条按“客户端该不该重试”来选状态码的准则:只重试这一次调用就可能成功的,用 UNAVAILABLE;需要退回上一层重来的(比如读、改、写这一串要整个重做),用 ABORTED;在系统状态被明确修复之前重试也没用的,用 FAILED_PRECONDITION。同一份文档也提醒,UNAVAILABLE 虽然多半可以退避重试,但对非幂等的操作重试并不总是安全,而且没有一张对所有应用都适用的“可重试状态码”清单,要应用自己决定。

状态码加一段文字经常不够用。 参数校验失败时,调用方想知道是哪个字段、为什么。官方错误处理指南介绍了一个“更丰富的错误模型”:在状态里附带若干个 protobuf 消息作为详情,Google 为常见情况预先定义了一批类型,比如 BadRequest。上面 Hello("bad") 那一行下面的“详情”就是这样传过来的,服务端代码是:

st := status.New(codes.InvalidArgument, "参数不合法")
st, _ = st.WithDetails(&errdetails.BadRequest{
	FieldViolations: []*errdetails.BadRequest_FieldViolation{
		{Field: "name", Description: "不能是 bad"},
	},
})
return nil, st.Err()

指南同时列了它的代价:详情放在 trailer 里传输,各语言库对它的支持并不一致,代理和日志系统看不到里面的内容,详情太大还可能撞上头部大小限制,连原始错误一起丢掉。

输出的最后一行是另一个常见的坑:grpc-go 默认单条消息最多接收 4MB(源码里的常量是 1024 * 1024 * 4),客户端和服务端都是,超过就得到 ResourceExhausted。需要传大块数据时,要么调大这个上限,要么改用流分块发。

grpc-gateway:给 gRPC 服务加一层 HTTP/JSON

gRPC 有一个绕不开的限制:浏览器没法直接说这个协议。gRPC 官方博客解释过原因,浏览器没有哪个 API 能对请求做足够细的控制,既不能强制使用 HTTP/2,也碰不到原始的 HTTP/2 帧。对外的开放接口、给前端用的接口,往往还是需要 HTTP 加 JSON。

grpc-gateway 是 protoc 的一个插件。按它自己 README 的定义,它读取 protobuf 的服务定义,生成一个反向代理,把 RESTful 的 HTTP API 翻译成 gRPC。这样接口只定义一次,同时得到 gRPC 和 HTTP/JSON 两种访问方式。

映射规则写在 .proto 的注解里。前面 Hello 方法上的这一行:

option (google.api.http) = {get: "/v1/hello/{name}"};

意思是 GET /v1/hello/xxx 对应 Hello 方法,路径里的 xxx 填进请求消息的 name 字段。生成代码时多加一个插件参数 --grpc-gateway_out,然后在程序里把生成的处理器注册到网关的路由上,并告诉它 gRPC 服务的地址:

mux := runtime.NewServeMux()
must(demov1.RegisterDemoHandlerFromEndpoint(context.Background(), mux, addr,
	[]grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}))
lis, err := net.Listen("tcp", "127.0.0.1:0")
must(err)
go http.Serve(lis, mux)

用普通的 HTTP 客户端请求它,每个请求都带一个 Grpc-Timeout: 2S 的头。[S] 开头的行是后面的 gRPC 服务端打印的:

  [S] Hello("world") 剩余时间 2s
GET /v1/hello/world
  HTTP 200  application/json
  {"message":"你好,world"}
  [S] Hello("nobody") 剩余时间 2s
GET /v1/hello/nobody
  HTTP 404  application/json
  {"code":5,"message":"没有这个人","details":[]}
  [S] Hello("plain") 剩余时间 2s
GET /v1/hello/plain
  HTTP 500  application/json
  {"code":2,"message":"数据库连不上","details":[]}
  [S] Hello("bad") 剩余时间 2s
GET /v1/hello/bad
  HTTP 400  application/json
  {"code":3,"message":"参数不合法","details":[{"@type":"type.googleapis.com/google.rpc.BadRequest","fieldViolations":[{"field":"name","description":"不能是 bad","reason":"","localizedMessage":null}]}]}

网关做了三件事。请求方向,它把路径参数和 JSON 转成 protobuf 请求,把 Grpc-Timeout 头变成 gRPC 的 deadline(服务端打印的“剩余时间 2s”就是这么来的)。响应方向,它把 protobuf 转回 JSON。出错时,它把 gRPC 状态码换成 HTTP 状态码:NotFound 变 404,InvalidArgument 变 400,Unknown 变 500,错误详情也一并转成了 JSON。完整的对应关系在网关源码的 runtime/errors.go 里,另外几个常用的是 Unauthenticated 对 401、PermissionDenied 对 403、ResourceExhausted 对 429、Unavailable 对 503、DeadlineExceeded 对 504。

HTTP 头和元数据之间也有固定的映射。按 README,Authorization 头会变成 authorization 元数据,以 Grpc-Metadata- 开头的头会被转成元数据。

用之前要知道这几条限制:

对外接口本身怎么设计是另一个话题。常见的是 RESTful 风格:把接口组织成一组资源,用 HTTP 自带的方法和状态码表达操作的含义和结果。

它好在哪,又麻烦在哪

回到开头那句“42 号用户存在吗”。用 gRPC 之后,两个服务之间的约定是一份 .proto 文件,两边的代码都从它生成,契约只有一份,不用靠文档对齐 URL 和字段名。不过这份契约也只在两边用同一版 .proto 时才严丝合缝:一边改了编号或类型、另一边没重新生成,编译器发现不了,只能靠前面讲的编码规则在运行时兜底,改错了照样会把数据解读错。所以“编号不能改不能复用”这条纪律,比 HTTP/JSON 下约定字段名更要紧。超时、取消、元数据、错误码有统一的做法,不用每个接口各自发明。要推送或者传大量数据时有流可用。数据是紧凑的二进制,前面的 SumReply 编码后是 4 个字节,同一个消息用 protobuf 的 JSON 编码器输出是 23 个字节。官方介绍还强调跨语言:Java 写的服务端可以配 Go、Python、Ruby 写的客户端。至于“比 JSON 快多少”,protobuf 官方只说了“更小更快”。

代价也实在。

数据不是给人看的。排查问题时不能像 JSON 接口那样随手 curl 一下看结果。前面手拼 5 字节前缀的那条 curl 命令,已经说明了直接操作它有多别扭。

多一条工具链。这次实验为了生成代码,装了 protoc 和三个插件,还要手工下载 google/api 下的两个 .proto 文件才能使用 HTTP 注解。.proto 文件怎么在团队之间分发、版本怎么管,都是要另外解决的事。

负载均衡要多想一步。gRPC 的负载均衡文档写明,它的设计是按调用做均衡,不是按连接,但这需要客户端配置均衡策略。同一份文档也写明,默认策略是 pick_first:按顺序连上第一个能连通的地址,之后这个 channel 上的所有调用都发往那个地址。也就是说,客户端解析出多个后端地址时,如果不配置 round_robin 之类的策略,默认就只用其中一个。换成在服务前面放代理也有类似问题:前面说过很多次调用会复用同一条连接,官方博客说四层负载均衡器只是在两条连接之间拷贝数据,所以同一条连接上的全部调用都会送到同一个后端。博客的建议是,连接之间的负载差异大时,用能识别 HTTP/2 流的七层负载均衡器。这两点都没有搭环境实测。解决思路有两种:一种是客户端自己均衡,让名字解析返回所有后端地址,再把策略改成 round_robin,在 Kubernetes 里通常要配合 headless Service;另一种是在中间放一个理解 HTTP/2 的七层代理按请求转发,API 网关和服务网格都属于这一类。

还有两条前面已经讲过的:浏览器不能直接调用;它让调用看起来像本地函数,而调用方仍然得处理 deadline、重试和部分失败。

内部服务之间互相调用、接口多、对类型和性能有要求时,gRPC 是合适的选择。面向浏览器和第三方的接口,要么直接用 HTTP 加 JSON,要么在 gRPC 前面放一层 grpc-gateway。

文献与官方文档