跳到内容

6.2 gRPC 合约、流式 RPC 与失败边界

信标塔准备让不同语言编写的服务协作,阿花却先追问:接口契约能否消除远程调用的不确定性?

gRPC 是 RPC framework,常使用 Protocol Buffers 定义合约,并将每次 RPC 映射到 HTTP/2 stream。它屏蔽了部分 wire detail,但 remote call 仍然不是 local function call:它会 timeout、可能只执行了一部分,也可能 server 已经成功而 client 没收到 result。

1. .proto 同时定义 data 与 service

protobuf
syntax = "proto3";

package atlas.weather.v1;

service Weather {
  rpc Get(GetRequest) returns (Reading);
  rpc Watch(WatchRequest) returns (stream Reading);
  rpc Upload(stream Reading) returns (UploadSummary);
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

message GetRequest {
  string city = 1;
}

message WatchRequest {
  string city = 1;
  uint32 count = 2;
}

message Reading {
  string city = 1;
  double celsius = 2;
  int64 observed_unix_seconds = 3;
}

message UploadSummary {
  uint32 accepted = 1;
}

message ChatMessage {
  string room = 1;
  string sender = 2;
  string text = 3;
}

四种 method shape 是:

shaperequestresponse常见用途
Unaryoneonequery / command
Server streamingonestreamwatch、大 result set
Client streamingstreamonebatch upload / aggregation
Bidirectional streamingstreamstream独立双向 event flow

Field number 是 wire contract 的一部分。删除 field 后不要重用它的 number;使用 reserved 保留 number/name。新 field 要考虑 old client 看不见时的 default behavior。不要仅以“Protobuf 能 decode”就判定 semantic compatibility。

2. gRPC 如何使用 HTTP/2

一次 RPC 对应一个 HTTP/2 stream。多个 RPC stream 可在同一 connection 上 multiplex,各自有 HTTP/2 flow-control state。Typical request 使用 POST,path 类似:

text
/atlas.weather.v1.Weather/Get

Message 在 gRPC length-prefixed framing 中传输,HTTP/2 DATA frame boundary 不等于 Protobuf message boundary。Final gRPC status 通常通过 trailers 传递:

text
grpc-status: 0
grpc-message: ...

因此,只看 HTTP status 200 不足以判定 RPC 成功。Client library 会合并 HTTP/2 transport state、gRPC status 和 application response。Proxy 必须支持 trailers、HTTP/2 和 gRPC content type,否则可能出现“HTTP 连通,RPC 失败”。

Multiplexing 消除了 HTTP/1.1 connection-level response ordering,但多个 stream 仍共享一条 TCP connection。Packet loss 会在 TCP transport 层影响该 connection 上的 data delivery,不应将 HTTP/2 multiplexing 描述成“完全没有 head-of-line blocking”。

3. Deadline 是 API contract,不是 client 的附加选项

Client 应为 RPC 设置 deadline。没有 deadline 的 call 可能一直等待,占住 thread、connection capacity 和上游 resource。

Server 需要:

  • 查看 remaining deadline;
  • 在 client cancellation 或 deadline exceeded 后停止无意义工作;
  • 调用 downstream 时传递或收紧 deadline,为 cleanup 保留 budget;
  • 不要把 deadline exceeded 当作 server 一定没有执行 operation 的证明。

Cancellation 是 signal,不是 transaction rollback。如果 RPC 可能产生重要 side effect,API 要设计 idempotency key、operation status query 或 compensating action。

4. Status code 要保留语义

常见 code 边界:

  • INVALID_ARGUMENT:request 本身无效,不会因 system state 改变而变对;
  • FAILED_PRECONDITION:request 可能有效,但当前 system state 不允许;
  • NOT_FOUND:指定 entity 不存在;
  • ALREADY_EXISTS:create 目标已存在;
  • PERMISSION_DENIED:caller identity 已知但无权;
  • UNAUTHENTICATED:没有可用的 authentication credential;
  • RESOURCE_EXHAUSTED:quota 或 resource limit;
  • UNAVAILABLE:transient service availability problem,可能适合受控 retry;
  • DEADLINE_EXCEEDED:caller 的 deadline 耗尽;
  • INTERNAL:invariant broken 或不应暴露的 internal failure。

不要把所有 exception 都映射到 UNKNOWNINTERNAL,也不要将 database exception text 直接放进 grpc-message。对 client 公开稳定 code 和 structured error detail,把 sensitive diagnostic 留在 server log/trace。

5. Retry 必须和 idempotency 一起设计

Retry 可能来自 client library、service mesh、proxy 或 application,多层叠加会放大 load。在启用 retry 前先回答:

  1. Method 重复执行是否安全?
  2. 哪些 status code 是 transient?
  3. Per-attempt timeout 和 overall deadline 多少?
  4. Max attempts、backoff 和 jitter 多少?
  5. Server 如何用 idempotency key 去重?

Client 没收到 response 时,不能推断 server 没有 commit。对创建 order 之类 RPC,要用 caller-generated stable request ID 建立 deduplication contract,而不是看到 UNAVAILABLE 就无条件重发。

6. Streaming 不等于无限 buffer

HTTP/2 和 gRPC library 提供 flow control,但 application 仍需要 bounded memory policy。Streaming handler 要考虑:

  • 单条 message 大小上限;
  • inbound / outbound queue limit;
  • slow reader 对 producer 的 backpressure;
  • cancellation 时停止 producer;
  • partial progress 是否可 resume;
  • stream 最终 status 到达前,已收到的 message 是否可 commit。

Bidirectional streaming 只表示双方的 message stream 可以独立推进,不自动提供 business ordering、exactly-once delivery 或 durable replay。

7. 可运行的 Unary + Server Streaming 最小项目

安装工具:

bash
python3 -m pip install grpcio grpcio-tools

将前面 .proto 缩减为实验所需的 weather.proto

protobuf
syntax = "proto3";
package atlas.weather.v1;

service Weather {
  rpc Get(GetRequest) returns (Reading);
  rpc Watch(WatchRequest) returns (stream Reading);
}

message GetRequest { string city = 1; }
message WatchRequest { string city = 1; uint32 count = 2; }
message Reading { string city = 1; double celsius = 2; }

生成 Python code:

bash
python3 -m grpc_tools.protoc \
  -I. \
  --python_out=. \
  --grpc_python_out=. \
  weather.proto

server.py

python
from concurrent import futures
import time

import grpc
import weather_pb2
import weather_pb2_grpc


class Weather(weather_pb2_grpc.WeatherServicer):
    def Get(self, request, context):
        if not request.city:
            context.abort(grpc.StatusCode.INVALID_ARGUMENT, "city is required")
        return weather_pb2.Reading(city=request.city, celsius=21.5)

    def Watch(self, request, context):
        if not request.city:
            context.abort(grpc.StatusCode.INVALID_ARGUMENT, "city is required")
        count = min(request.count or 3, 10)
        for index in range(count):
            if not context.is_active():
                return
            yield weather_pb2.Reading(
                city=request.city,
                celsius=21.5 + index * 0.1,
            )
            time.sleep(0.1)


def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=8))
    weather_pb2_grpc.add_WeatherServicer_to_server(Weather(), server)
    if server.add_insecure_port("127.0.0.1:50051") == 0:
        raise RuntimeError("failed to bind 127.0.0.1:50051")
    server.start()
    print("listening on 127.0.0.1:50051")
    server.wait_for_termination()


if __name__ == "__main__":
    serve()

client.py

python
import grpc
import weather_pb2
import weather_pb2_grpc


with grpc.insecure_channel("127.0.0.1:50051") as channel:
    stub = weather_pb2_grpc.WeatherStub(channel)
    current = stub.Get(weather_pb2.GetRequest(city="Nanjing"), timeout=2)
    print(current)

    stream = stub.Watch(
        weather_pb2.WatchRequest(city="Nanjing", count=3),
        timeout=2,
    )
    for reading in stream:
        print(reading)

这里的 insecure channel 只绑定 loopback,用于实验。Production 要根据 trust model 使用 TLS server credential / channel credential,并在 application 层设计 authentication 与 authorization。“开了 TLS”不等于 caller 已拥有 method permission。

8. Load balancing 与 browser boundary

gRPC channel 往往长时间复用 connection。如果 load balancer 只在 TCP connection 建立时选 backend,一条长连接上的多个 RPC 不会被逐请求重新分配。可选策略包括 gRPC-aware L7 proxy、client-side load balancing 和 xDS,并要与 health checking、connection age 和 outlier detection 一起设计。

Browser 不直接暴露通用 raw HTTP/2 framing API,gRPC-Web 通常通过 compatible client 和 proxy 与 gRPC backend 交互。它的 streaming support、content type 和 transport behavior 与 native gRPC 不完全相同,不应将 native bidirectional streaming 能力直接假设到 browser client。

9. 验收问题

  1. 为什么 HTTP status 200 不足以证明 gRPC 成功?
  2. 区分 deadline exceeded、cancellation 和 transaction rollback。
  3. 设计一个可安全 retry 的 create operation,说明 idempotency key 存在哪里。
  4. 为 server-streaming RPC 定义 message limit、queue limit 和 slow-reader policy。
  5. 为什么一条 HTTP/2 connection 的 multiplexing 不能消除 TCP packet loss 的 connection-level influence?

参考

Built with VitePress | Software Systems Atlas