跳转至

gRPC 专题:从接口定义到生产实践

gRPC 适合服务之间的结构化通信。它用 Protobuf 描述数据与服务,通常运行在 HTTP/2 之上,并通过代码生成把协议变成强类型接口。本章以 Go 为主线,从一个 UserService 开始,逐步讲清 Protobuf、四类 RPC、grpc-gateway、错误模型、超时、重试、并发治理、高可用和可观测性。

学完本章,你应该能独立完成三件事:

  1. 设计一份兼容演进的 .proto 文件。
  2. 实现 Go 版 gRPC 服务端、客户端和 HTTP JSON 网关。
  3. 解释 Deadline、状态码、幂等重试、流式调用、过载保护与线上排查。

1. 先理解一次 gRPC 调用

客户端调用 CreateUser(ctx, req) 时,看起来像调用本地函数,实际经历了完整的网络通信:

sequenceDiagram participant C as Go Client participant CS as Client Stub participant N as HTTP/2 Transport participant SS as Server Stub participant S as UserService C->>CS: CreateUser(ctx, request) CS->>CS: Protobuf 序列化 CS->>N: 发送 HTTP/2 帧 N->>SS: 接收请求 SS->>SS: Protobuf 反序列化 SS->>S: 调用业务方法 S-->>SS: reply / error SS-->>N: 序列化响应 N-->>CS: 返回 HTTP/2 帧 CS-->>C: reply / status

这里有四个核心角色:

角色 作用
.proto 文件 描述消息结构和服务方法,是通信契约
Protobuf 将结构化消息编码成紧凑的二进制数据
生成代码 提供消息类型、客户端 Stub、服务端接口和注册函数
gRPC 运行时 管理连接、HTTP/2 传输、状态码、Deadline、流和拦截器

1.1 gRPC 与 REST 的差异

维度 gRPC 常见 REST API
接口描述 .proto,契约明确 OpenAPI 或文档,也可能只靠约定
数据编码 Protobuf 二进制 JSON 文本最常见
传输 通常使用 HTTP/2 HTTP/1.1、HTTP/2 均可
调用方式 方法调用,如 GetUser 资源路径与 HTTP Method
流式通信 原生支持四类 RPC 常借助 SSE、WebSocket 等方案
浏览器调试 需要 gRPC-Web 或网关 直接使用浏览器、curl 即可
典型场景 内部服务通信、低延迟、强契约 对外开放 API、浏览器和第三方接入

工程里经常同时使用两者:内部服务走 gRPC,对外通过 grpc-gateway 提供 HTTP JSON。

1.2 为什么常说 gRPC 基于 HTTP/2

HTTP/2 为 gRPC 提供了几项关键能力:

  • 多路复用:一条 TCP 连接可以承载多个并发 Stream,减少连接数量。
  • 二进制分帧:请求头和消息体会拆成不同类型的帧传输。
  • 头部压缩:HPACK 可降低重复元数据的传输开销。
  • 双向流:客户端和服务端都能持续发送消息。

多路复用不能消除 TCP 层的队头阻塞。如果底层 TCP 丢包,同一连接上的多个 HTTP/2 Stream 仍可能一起等待重传。


2. Protobuf:先把通信契约写清楚

下面是一份最小但完整的用户服务定义:

api/user/v1/user.proto
syntax = "proto3";

package user.v1;

option go_package = "example.com/project/api/user/v1;userv1";

service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserReply);
  rpc GetUser(GetUserRequest) returns (GetUserReply);
}

message CreateUserRequest {
  string email = 1;
  string display_name = 2;
}

message CreateUserReply {
  User user = 1;
}

message GetUserRequest {
  int64 id = 1;
}

message GetUserReply {
  User user = 1;
}

message User {
  int64 id = 1;
  string email = 2;
  string display_name = 3;
}

2.1 packagego_package

package user.v1 是 Protobuf 命名空间,防止不同模块出现同名消息。把版本放进包名后,破坏性变更可以进入 user.v2,旧客户端仍能继续使用 v1。

go_package 决定生成的 Go 包路径与包名:

option go_package = "example.com/project/api/user/v1;userv1";

分号前是 import path,分号后是 Go package name。两者职责不同,不应省略 package,也不应依赖工具猜测 go_package

2.2 字段编号才是线上的身份

这一行包含字段名、类型和字段编号:

string email = 1;

Protobuf 的二进制数据主要记录字段编号和 wire type,字段名不会随每条消息发送。字段编号一旦上线,就应视为协议身份:

  • 可以在保持语义一致的前提下修改字段名。
  • 不要修改已经使用的字段编号。
  • 删除字段后,使用 reserved 保留旧编号和旧名称。
  • 115 的字段编号编码通常更短,可留给高频字段。
  • 1900019999 是 Protobuf 实现保留范围,不要使用。

正确删除字段:

message User {
  reserved 4;
  reserved "nickname";

  int64 id = 1;
  string email = 2;
  string display_name = 3;
}

如果把旧编号 4 分配给新字段,旧客户端可能把新数据解释成已删除字段,产生静默的数据错误。

这里的 User 只是一个独立的语法示例(假设 nickname 字段被删除后用 reserved 封存编号 4),与 12.2 节案例中 User 消息把编号 4 用于 department_id 是两条不同的演进线,并不是同一个消息复用了编号 4

2.3 常用字段类型

需求 推荐类型 说明
整数编号 int64 Go 中生成 int64
普通文本 string 必须是合法 UTF-8
原始字节 bytes 图片内容、摘要等二进制数据
列表 repeated T 默认是空列表语义
键值结构 map<K, V> key 类型受限,顺序没有协议保证
多选一 oneof 同一时刻最多设置一个成员
时间点 google.protobuf.Timestamp 避免自定义不统一的时间字符串
时长 google.protobuf.Duration 表达超时、间隔等持续时间

oneof 适合表达互斥条件:

message LookupUserRequest {
  oneof key {
    int64 id = 1;
    string email = 2;
  }
}

客户端只能按编号或邮箱选择一种查询方式,生成代码会提供明确的分支类型。

2.4 为什么 Protobuf 更紧凑:varint 与 wire type

Protobuf 的二进制编码不传输字段名,只传输"字段编号 + wire type + 值"。每个字段的 tag 由 (字段编号 << 3) | wire_type 组成,常见 wire type 有:

wire type 编号 对应类型
Varint 0 int32/int64uint32/uint64sint32/sint64(zigzag)、bool、枚举
64-bit 1 fixed64sfixed64double
Length-delimited 2 stringbytes、嵌套消息、packed repeated
32-bit 5 fixed32sfixed32float

Varint 是一种变长编码:每字节用 7 位存数据,最高位标记"后面是否还有字节"。数值越小,占用字节越少,小整数通常只需 1~2 字节。这是 Protobuf 通常比 JSON 更紧凑的原因之一:JSON 数字以文本形式存储,Protobuf 用 varint 压缩了绝大多数业务中常见的小整数。

Varint 对负数不友好:int32 的负数会按补码解释成一个很大的无符号数,固定占用 10 字节。sint32/sint64 通过 zigzag 编码把有符号数映射成无符号数(0→0-1→11→2-2→3 ...),让绝对值小的负数也能用少量字节表示。字段经常出现负数时应选 sint32/sint64,而不是默认的 int32/int64

fixed32/fixed64 放弃变长编码,固定占用 4/8 字节。当数值经常大于 2^28(varint 不再省字节)时,fixed32 反而比 int32 更紧凑,浮点数、哈希值等场景常用固定长度类型。

面试可以这样回答:Protobuf 省空间主要靠两点——不传字段名只传编号,以及用 varint 变长编码压缩小整数;wire type 决定了值按什么格式解析,这也解释了为什么修改字段类型可能破坏兼容性——wire type 变了,接收方会按错误的格式解析后续字节。

2.5 默认值与字段存在性

proto3 标量字段有默认值:数字为 0,布尔值为 false,字符串为空字符串。普通标量字段在部分语言和生成模式下无法直接区分“没有传”与“传了默认值”。

更新接口尤其需要区分这两种情况。例如把昵称更新为空字符串,与保持昵称不变,含义不同。可以使用 optional

message UpdateUserRequest {
  int64 id = 1;
  optional string display_name = 2;
}

复杂的部分更新也常使用 google.protobuf.FieldMask,明确告诉服务端哪些字段需要更新。

2.6 枚举要保留零值

enum UserStatus {
  USER_STATUS_UNSPECIFIED = 0;
  USER_STATUS_ACTIVE = 1;
  USER_STATUS_DISABLED = 2;
}

零值命名为 UNSPECIFIED,便于识别调用方没有明确设置状态。上线后的枚举值同样不能随意复用;删除枚举项后应保留其编号和名称。

2.7 兼容性速查

变更 一般结论 原因
新增字段 兼容 旧端会忽略未知字段
修改字段名 二进制层通常兼容 线上编码主要依赖编号
修改字段编号 不兼容 接收方会按另一个字段解析
复用已删除编号 高风险 新旧消息可能互相误读
int32 改为 int64 需谨慎评估 wire type 可相同,语言范围和业务语义可能变化
string 改为 bytes 不建议 校验规则和生成类型变化
新增枚举值 需验证客户端 旧代码必须能处理未知枚举值

兼容性不只看能否反序列化,还要看业务语义、JSON 映射、校验规则和各语言客户端行为。


3. 工具链与代码生成

Go 项目常用三个工具:

工具 作用
protoc Protobuf 编译器,读取 .proto 文件
protoc-gen-go 生成消息类型与 Protobuf 编解码代码
protoc-gen-go-grpc 生成 gRPC 客户端和服务端接口代码

安装 Go 插件:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

生成代码:

protoc \
  --go_out=. --go_opt=paths=source_relative \
  --go-grpc_out=. --go-grpc_opt=paths=source_relative \
  api/user/v1/user.proto

输出通常包括:

api/user/v1/user.pb.go       # 消息类型与序列化相关代码
api/user/v1/user_grpc.pb.go  # Client、Server 接口与注册函数

生成文件不应手工修改。协议调整写回 .proto,然后重新生成。团队项目还应固定 protoc 和插件版本,并在 CI 中检查生成结果是否最新。Buf 可以进一步处理格式化、Lint、依赖和破坏性变更检测。


4. 写出第一个 Unary RPC

Unary RPC 是一请求、一响应,也是最常用的调用形式。

4.1 服务端实现

internal/service/user.go
package service

import (
    "context"
    "strings"

    userv1 "example.com/project/api/user/v1"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type UserService struct {
    userv1.UnimplementedUserServiceServer
    repo UserRepository
}

func (s *UserService) CreateUser(
    ctx context.Context,
    req *userv1.CreateUserRequest,
) (*userv1.CreateUserReply, error) {
    email := strings.TrimSpace(req.GetEmail())
    if email == "" {
        return nil, status.Error(codes.InvalidArgument, "email is required")
    }

    user, err := s.repo.Create(ctx, email, req.GetDisplayName())
    if err != nil {
        return nil, mapRepositoryError(err)
    }

    return &userv1.CreateUserReply{
        User: &userv1.User{
            Id:          user.ID,
            Email:       user.Email,
            DisplayName: user.DisplayName,
        },
    }, nil
}

嵌入 UnimplementedUserServiceServer 可以帮助服务在协议新增方法后保持前向兼容,也是新版生成代码推荐的写法。

4.2 启动服务器

cmd/server/main.go
lis, err := net.Listen("tcp", ":9000")
if err != nil {
    log.Fatal(err)
}

grpcServer := grpc.NewServer()
userv1.RegisterUserServiceServer(grpcServer, userService)

if err := grpcServer.Serve(lis); err != nil {
    log.Fatal(err)
}

服务实现完成后还必须注册到 grpc.Server。忘记注册时,客户端会收到 Unimplemented

4.3 客户端调用

conn, err := grpc.NewClient(
    "localhost:9000",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

client := userv1.NewUserServiceClient(conn)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

reply, err := client.CreateUser(ctx, &userv1.CreateUserRequest{
    Email:       "alice@example.com",
    DisplayName: "Alice",
})
if err != nil {
    st, _ := status.FromError(err)
    log.Fatalf("CreateUser failed: code=%s message=%s", st.Code(), st.Message())
}

log.Printf("created user: %d", reply.GetUser().GetId())

grpc.NewClient 是懒连接语义:调用它只做参数校验和内部初始化,不会立即建立 TCP/TLS 连接;真正的拨号发生在第一次 RPC 调用时(也可以主动调用 conn.Connect() 提前触发)。这与旧版 grpc.Dial 不同,理解这一点才能正确解读下一段"复用连接"的建议——复用的是 ClientConn 这个长期对象本身,而不是指望 NewClient 帮你提前把连接建好。

示例使用明文连接,只适合本地开发。生产环境应配置 TLS,并根据部署环境决定服务发现、负载均衡和认证方式。

4.4 分层边界

推荐让 gRPC Handler 保持轻量:

flowchart LR A["gRPC Handler<br/>协议转换与参数校验"] --> B["Usecase / Service<br/>业务规则"] B --> C["Repository<br/>数据访问接口"] C --> D[(MySQL)]

Handler 负责把 Protobuf 请求转换为业务输入,把领域错误转换为 gRPC 状态。业务层不应到处依赖生成的 Protobuf 类型,否则更换入口协议、写单元测试和复用业务逻辑都会变难。


5. 四类 RPC 与适用场景

5.1 Unary:一请求,一响应

rpc GetUser(GetUserRequest) returns (GetUserReply);

适合查询详情、创建订单、更新用户等普通接口。

5.2 Server Streaming:一请求,多响应

rpc WatchUserEvents(WatchUserEventsRequest) returns (stream UserEvent);

服务端持续返回用户事件:

func (s *UserService) WatchUserEvents(
    req *userv1.WatchUserEventsRequest,
    stream userv1.UserService_WatchUserEventsServer,
) error {
    for event := range s.events.Subscribe(stream.Context(), req.GetUserId()) {
        if err := stream.Send(event); err != nil {
            return err
        }
    }
    return nil
}

适合事件订阅、实时进度和批量结果逐步返回。客户端取消请求后,服务端应及时检查 stream.Context() 并停止工作。

5.3 Client Streaming:多请求,一响应

rpc UploadChunks(stream UploadChunkRequest) returns (UploadReply);

客户端持续上传分片,服务端收完后返回汇总结果。适合大文件分片上传、批量采集数据。

func (s *UserService) UploadChunks(
    stream userv1.UserService_UploadChunksServer,
) error {
    var total int64
    for {
        chunk, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&userv1.UploadReply{
                TotalBytes: total,
            })
        }
        if err != nil {
            return err
        }
        total += int64(len(chunk.GetData()))
    }
}

服务端在收到 io.EOF 前持续 Recvio.EOF 代表客户端已经调用 CloseAndRecv;此时用 SendAndClose 一次性返回汇总结果。

5.4 Bidirectional Streaming:多请求,多响应

rpc Chat(stream ChatMessage) returns (stream ChatMessage);

双方独立读写,同一个 Stream 中的单向消息顺序会被保留。双向流适合聊天、协同控制和长连接事件交换,但状态管理、背压、重连和半关闭处理更复杂。

func (s *UserService) Chat(stream userv1.UserService_ChatServer) error {
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }

        reply, err := s.buildChatReply(stream.Context(), msg)
        if err != nil {
            return err
        }
        if err := stream.Send(reply); err != nil {
            return err
        }
    }
}

收发是两个独立方向:这里的收发循环串行处理,真实场景常把 Recv 放在单独的 goroutine 里,通过 channel 把收到的消息交给发送逻辑,避免慢速发送阻塞接收,或者反过来让接收堆积失控——这正是背压需要设计的地方(见 5.5)。客户端一侧结构类似,用同一个 Stream 对象分别调用 SendRecv

flowchart TB U["Unary<br/>1 request → 1 response"] S["Server Streaming<br/>1 request → N responses"] C["Client Streaming<br/>N requests → 1 response"] B["Bidirectional Streaming<br/>N requests ↔ N responses"]

面试可以这样回答:四类 RPC 的选型本质是"谁需要持续发送/接收"——普通请求响应用 Unary,服务端持续推送用 Server Streaming,客户端持续上传用 Client Streaming,双方都要长期交互用 Bidirectional。选流式通常意味着要同时设计背压、断线恢复和连接生命周期,不是简单的"支持流所以更强"。

5.5 流不等于无限吞吐

流式 RPC 仍受 HTTP/2 流控、网络带宽、内存和消费速度限制。工程上要明确:

  • 单条消息大小和单个 Stream 的生命周期。
  • 消费者变慢时如何施加背压。
  • 断线后从哪里恢复,是否需要序列号或游标。
  • 心跳与空闲超时如何设置。
  • 长连接期间凭证过期如何处理。

6. grpc-gateway:同时提供 HTTP JSON

移动端、浏览器和第三方系统通常更容易调用 HTTP JSON。grpc-gateway 根据 .proto 中的 HTTP 注解生成反向代理代码,将 HTTP 请求转成 gRPC 请求。

flowchart LR A["Web / APP<br/>HTTP JSON"] --> B["grpc-gateway"] B -->|"Protobuf + gRPC"| C["UserService"] C --> D["Usecase"] D --> E["Repository"] E --> F[(MySQL)]

6.1 添加 HTTP 注解

syntax = "proto3";

package user.v1;

import "google/api/annotations.proto";

service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserReply) {
    option (google.api.http) = {
      post: "/v1/users"
      body: "*"
    };
  }

  rpc GetUser(GetUserRequest) returns (GetUserReply) {
    option (google.api.http) = {
      get: "/v1/users/{id}"
    };
  }
}

body: "*" 表示将请求 JSON Body 映射到整个 Protobuf 请求消息。路径参数 {id} 会映射到 GetUserRequest.id

6.2 生成网关代码

除前面的插件外,还需要 protoc-gen-grpc-gateway

go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
protoc \
  --grpc-gateway_out=. \
  --grpc-gateway_opt=paths=source_relative \
  api/user/v1/user.proto

常见坑:上面这条命令常会报 google/api/annotations.proto: File not found——protoc 不知道去哪里找 googleapis 定义的 HTTP 注解文件。需要把 googleapis 仓库中的 google/api/annotations.protogoogle/api/http.proto 拉到 -I--proto_path)能找到的目录(常见做法是 vendor 进 third_party/),或者直接改用 buf——它能按依赖声明自动拉取远程 proto,省去手工 vendor 和路径维护。

随后注册生成的 Handler:

mux := runtime.NewServeMux()
err := userv1.RegisterUserServiceHandlerFromEndpoint(
    ctx,
    mux,
    "localhost:9000",
    []grpc.DialOption{
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    },
)
if err != nil {
    log.Fatal(err)
}

http.ListenAndServe(":8080", mux)

HTTP 调用示例:

curl -X POST http://localhost:8080/v1/users \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@example.com","displayName":"Alice"}'

6.3 JSON 映射中的细节

  • Protobuf 的 display_name 默认映射为 JSON 的 displayName
  • int64 在标准 ProtoJSON 中通常编码为 JSON 字符串,避免 JavaScript 精度丢失。
  • 未知字段默认可能导致解析失败,具体行为取决于网关配置。
  • Timestamp 通常映射为 RFC 3339 字符串。
  • ProtoJSON 的兼容规则与二进制 Protobuf 不完全相同,修改字段名可能影响 JSON 客户端。

网关负责协议转换,认证、限流、日志和跨域等能力可以放在网关中间件或外层 API Gateway;核心业务规则仍应由服务层统一执行。


7. 错误处理:状态大类与业务原因

gRPC 错误由状态码、错误消息和可选详情组成。推荐让状态码表达通用类别,让结构化详情表达业务原因。

gRPC Code: AlreadyExists
Reason: USER_EMAIL_ALREADY_EXISTS
Message: 该邮箱已经注册

7.1 常用状态码

状态码 典型场景 常见 HTTP 映射
InvalidArgument 参数格式或取值错误 400
Unauthenticated 未登录、凭证无效 401
PermissionDenied 已识别身份,但无权限 403
NotFound 资源不存在 404
AlreadyExists 邮箱、业务编号等唯一冲突 409
FailedPrecondition 当前业务状态不允许操作 400 / 409
ResourceExhausted 配额耗尽、限流、资源不足 429
Canceled 调用方主动取消 499 或自定义处理
DeadlineExceeded 超过调用截止时间 504
Unavailable 服务暂时不可用、连接失败 503
Internal 未预期的服务端内部错误 500
Unimplemented 方法未实现或服务未注册 501

HTTP 映射应以项目网关规则为准。业务失败全部返回 Internal 会让客户端无法正确决定提示、重试或降级策略。

面试可以这样回答:状态码只表达通用错误大类,真正的业务原因(比如"邮箱已存在")应该放进结构化的 ErrorInfo.Reason,客户端按 Code 决定通用处理(要不要重试、要不要提示登录),再按 Reason 做具体的业务分支或文案。

7.2 创建结构化错误详情

st := status.New(codes.AlreadyExists, "email already exists")

withDetails, err := st.WithDetails(&errdetails.ErrorInfo{
    Reason: "USER_EMAIL_ALREADY_EXISTS",
    Domain: "user.example.com",
    Metadata: map[string]string{
        "field": "email",
    },
})
if err != nil {
    return nil, st.Err()
}

return nil, withDetails.Err()

客户端可以按 Code 做通用处理,再读取 ErrorInfo.Reason 区分具体业务错误。不要把数据库错误、堆栈、SQL 或内部地址直接放进面向客户端的消息。

7.3 领域错误的统一映射

Repository 可以返回领域层可识别的错误:

func mapRepositoryError(err error) error {
    switch {
    case errors.Is(err, domain.ErrUserNotFound):
        return status.Error(codes.NotFound, "user not found")
    case errors.Is(err, domain.ErrEmailExists):
        return status.Error(codes.AlreadyExists, "email already exists")
    default:
        return status.Error(codes.Internal, "internal server error")
    }
}

日志中记录原始错误,客户端只收到稳定、可理解、不会泄密的协议错误。

7.4 grpc-gateway 怎样生成 HTTP 错误响应

grpc-gateway 收到 gRPC Status 后,会根据 Code 选择 HTTP 状态码,再把错误内容编码为 JSON。默认映射大致遵循下面的关系:

InvalidArgument   → 400 Bad Request
Unauthenticated   → 401 Unauthorized
PermissionDenied  → 403 Forbidden
NotFound          → 404 Not Found
AlreadyExists     → 409 Conflict
ResourceExhausted → 429 Too Many Requests
Unavailable       → 503 Service Unavailable
DeadlineExceeded  → 504 Gateway Timeout

项目通常还需要稳定的业务错误结构,例如:

{
  "code": "USER_EMAIL_ALREADY_EXISTS",
  "message": "该邮箱已经注册",
  "requestId": "req_01J..."
}

可以通过 runtime.WithErrorHandler 统一完成转换:

func gatewayErrorHandler(
    ctx context.Context,
    mux *runtime.ServeMux,
    marshaler runtime.Marshaler,
    w http.ResponseWriter,
    r *http.Request,
    err error,
) {
    st := status.Convert(err)
    reason := reasonFromDetails(st.Details())

    body := struct {
        Code      string `json:"code"`
        Message   string `json:"message"`
        RequestID string `json:"requestId"`
    }{
        Code:      reason,
        Message:   publicMessage(st),
        RequestID: requestIDFromContext(ctx),
    }

    w.Header().Set("Content-Type", marshaler.ContentType(body))
    w.WriteHeader(runtime.HTTPStatusFromCode(st.Code()))
    _ = json.NewEncoder(w).Encode(body)
}

mux := runtime.NewServeMux(
    runtime.WithErrorHandler(gatewayErrorHandler),
)

自定义错误处理器仍要遵守几个边界:HTTP 状态表达通用失败类别,业务 Code 保持稳定,Message 面向用户,原始数据库错误与堆栈只进入内部日志。错误详情解析失败时也要有兜底 Code,避免返回空字符串。


8. Deadline、Timeout 与取消传播

8.1 三个概念

  • Timeout:允许一次操作持续多久,例如 2 秒。
  • Deadline:操作最晚应在哪个时间点结束。
  • Cancellation:调用方主动终止,或 Deadline 到期后由 Context 触发取消。

gRPC 默认不会替业务自动选择 Deadline。客户端若不设置,上游请求可能长时间占用连接、Goroutine 和数据库资源。

面试可以这样回答:gRPC 不会替业务自动设置 Deadline,必须显式传递,并在跨服务调用中层层收窄预算(见 8.2);否则一次慢请求会顺着调用链一直占用上游的连接、Goroutine 和数据库资源,直到客户端自己放弃等待。

8.2 调用链上的时间预算

sequenceDiagram participant A as API Gateway participant U as UserService participant D as DepartmentService participant DB as MySQL Note over A,DB: 总预算 800ms A->>U: 剩余约 800ms U->>D: 传播剩余 Deadline D->>DB: 使用更短的查询预算 DB-->>D: result / timeout D-->>U: result / DeadlineExceeded U-->>A: response

服务 A 调用服务 B 时,应传播现有 ctx,让下游感知剩余时间:

reply, err := departmentClient.GetDepartment(ctx, req)

不要把 context.Background() 塞进下游调用,否则上游取消无法传播。若某个下游需要更紧的预算,可以从当前 ctx 派生更短的超时。

8.3 服务端主动响应取消

for {
    select {
    case <-ctx.Done():
        return nil, status.FromContextError(ctx.Err()).Err()
    case item := <-work:
        // 处理当前结果
    }
}

数据库驱动、HTTP 客户端和下游 gRPC 调用也要使用同一个 Context。只在 Handler 检查一次取消,随后继续执行不受控的查询,仍会浪费后端资源。

8.4 客户端超时不代表服务端回滚

客户端看到 DeadlineExceeded 时,无法仅凭状态码判断服务端是否已经完成写入——写请求可能已经提交成功,只是响应没能及时返回。这个不确定性从何而来、如何靠幂等键应对,第 9 节展开讲。


9. 重试、幂等与退避

一次调用失败可能发生在不同位置:

flowchart LR A[客户端] -->|1 请求未到达| B[网络] B -->|2 请求已到达| C[服务端] C -->|3 业务已提交| D[(数据库)] C -.->|4 响应丢失| A

客户端看到的都可能是 Unavailable 或超时,却无法知道服务端是否已经完成写入。

9.1 哪些调用更适合重试

  • 只读查询通常更容易安全重试。
  • 使用幂等键保护的创建请求可以按规则重试。
  • 状态设置型操作,如“将状态设为 disabled”,比“余额增加 10”更容易做到幂等。
  • 非幂等写操作不能只看 Unavailable 就立即重试。

面试可以这样回答:能不能重试取决于操作是否幂等,而不是取决于收到的错误码本身;即使是看起来"安全"的 Unavailable,也要结合幂等键、总 Deadline 和退避策略,否则重试本身就可能造成故障放大。

9.2 幂等键示例

继续用 UserService 场景:给 CreateUser 请求加一个客户端生成的 request_id 字段作为幂等键,完整字段设计见 12.2(emaildisplay_name 保留第 2 节定义的编号不变,request_id 作为新增字段追加编号)。数据库对 request_id 建唯一索引,重复请求命中同一条幂等记录时,返回第一次创建的用户结果,避免重复创建。

9.3 指数退避

重试间隔可以近似为:

delay = min(initial_backoff × multiplier^attempt, max_backoff) + jitter

jitter 用于打散大量客户端的重试时间,降低服务恢复瞬间的流量尖峰。重试必须受到最大次数、总 Deadline 和重试节流限制。

9.4 不要在多层同时盲目重试

如果网关、服务 A、服务 B 都重试 3 次,一次用户请求可能放大为多次下游调用。通常在最了解幂等语义的一层配置重试,并把总时间限制在上游 Deadline 内。


10. Metadata、认证与拦截器

10.1 Metadata

Metadata 是随 RPC 传递的键值信息,底层对应 HTTP/2 Header。常见内容包括:

  • authorization
  • x-request-id
  • x-tenant-id
  • 链路追踪上下文

客户端发送:

md := metadata.Pairs(
    "authorization", "Bearer "+token,
    "x-request-id", requestID,
)
ctx := metadata.NewOutgoingContext(parent, md)
reply, err := client.GetUser(ctx, req)

业务数据应放进 Request Message。Metadata 更适合认证、追踪和传输层上下文,且要限制大小、避免写入敏感明文。

10.2 Unary Interceptor

拦截器类似 HTTP 中间件,可以统一处理认证、日志、指标、恢复和追踪:

func loggingUnaryInterceptor(
    ctx context.Context,
    req any,
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (any, error) {
    started := time.Now()
    resp, err := handler(ctx, req)
    code := status.Code(err)

    slog.Info("grpc request",
        "method", info.FullMethod,
        "code", code.String(),
        "duration_ms", time.Since(started).Milliseconds(),
    )
    return resp, err
}

注册多个拦截器时,顺序会影响行为。常见顺序是恢复、请求标识、追踪、认证、日志/指标、业务 Handler。恢复拦截器应把 panic 转为 Internal,同时完整记录内部堆栈。

流式 RPC 使用 Stream Interceptor。它拦截的是整个 Stream;若需要统计每条消息,通常还要包装 ServerStreamRecvMsgSendMsg

10.3 TLS 与身份认证

  • TLS 保护传输内容并验证服务端身份。
  • mTLS 让服务端也验证客户端证书,适合严格的服务间身份认证。
  • Token、JWT 或 API Key 常通过 Metadata 传递,由拦截器校验。
  • 认证回答“调用者是谁”,授权回答“调用者能做什么”,两者需要分开设计。

11. 连接管理、服务发现与负载均衡

gRPC 的 ClientConn 代表一组长期维护的底层连接,应尽量复用。每次请求都新建连接会重复付出 DNS、TCP、TLS 和 HTTP/2 建连成本。

flowchart LR C[ClientConn] --> R[Resolver] R --> A1[10.0.0.11] R --> A2[10.0.0.12] R --> A3[10.0.0.13] C --> LB[Load Balancer] LB --> A1 LB --> A2 LB --> A3

典型过程:

  1. Resolver 将服务名解析为多个后端地址。
  2. gRPC 维护 SubConn 和连接状态。
  3. 负载均衡策略选择本次 RPC 使用的后端。
  4. 健康检查、连接失败和地址更新会改变可选节点。

常见策略有 pick_firstround_robin。具体默认值和服务配置取决于语言、版本与部署环境。

11.1 Kubernetes 下为什么普通 Service 会导致负载不均

这是一个经典面试点。Kubernetes 的 ClusterIP Service 是 L4(TCP 层)负载均衡:kube-proxy 通过 iptables/IPVS 规则,在新建 TCP 连接时随机或轮询选择一个后端 Pod,之后同一条连接的所有流量都固定发往这个 Pod,直到连接断开。

问题在于 gRPC 基于 HTTP/2,而 HTTP/2 的核心设计就是一条 TCP 连接长期复用、承载大量并发 Stream(见 1.2)。客户端 ClientConn 建立一次连接后,后续成百上千次 RPC 都会复用这同一条连接、同一个 TCP 五元组。对 kube-proxy 而言,这只是"一条连接",负载均衡只在建连那一刻发生过一次——结果就是所有请求实际上都打到了最初选中的那一个 Pod,其余 Pod 处于空闲状态,L4 负载均衡在连接层面之上完全失效。这个现象在连接长期存活、Pod 数量较多、或者发布后新 Pod 迟迟分不到流量时尤其明显。

常见解法有三类:

  • 客户端负载均衡:用 headless Service(clusterIP: None)让 DNS 直接返回所有 Pod IP,客户端 Resolver 拿到完整地址列表后,通过 round_robin 等策略自己在多条连接间分发 RPC,而不是依赖 kube-proxy。
  • L7 感知的代理/Mesh:引入 Envoy、Linkerd 等 Sidecar 或网关,它们理解 HTTP/2 Stream,可以按请求粒度而不是连接粒度做负载均衡。
  • xDS / 服务网格的原生 gRPC 负载均衡:让 gRPC 客户端直接对接支持 xDS 协议的控制面,动态获取后端列表并做客户端侧负载均衡,避免额外的代理跳数。

面试可以这样回答:普通 K8s Service 是 L4 负载均衡,只在新建 TCP 连接时选一次后端;而 gRPC 基于 HTTP/2 长连接复用,一条连接可能承载成千上万次 RPC,结果是负载均衡实际只发生过一次,其余副本被晾在一边。解法要么让客户端自己做负载均衡(headless Service + round_robin),要么换成理解 HTTP/2 的 L7 代理或 xDS 服务网格,按请求粒度而不是连接粒度分发流量。

Keepalive 用于探测连接状态和维持必要的长连接,参数过于激进会产生额外流量,甚至被服务端以 GOAWAY 拒绝。生产配置应结合代理、负载均衡器的空闲超时统一设计。


12. 工程案例:创建用户前校验部门

本节使用“用户属于某个部门”作为教学案例,用来串联服务边界、跨服务调用和数据一致性。示例中的目录、字段和超时值用于解释设计方法,不代表读者真实项目中的实现。

12.1 先判断模块边界

用户和部门可以放在同一个服务的两个模块,也可以拆成两个服务。判断依据来自业务边界和运行需求:

判断维度 更适合同一服务 更适合拆成两个服务
业务归属 用户与组织强绑定,经常一起修改 部门是独立组织域,被多个系统复用
数据一致性 创建用户必须与部门变更强一致 可以接受跨服务校验和最终一致性
部署扩缩容 流量和发布节奏接近 两者流量、容量和发布节奏差异明显
团队 ownership 同一团队维护 不同团队独立负责
故障影响 拆分收益有限 需要隔离故障和独立降级

小型系统优先采用一个服务内的两个模块,事务和调试都更简单。只有业务边界、数据所有权或独立部署需求已经明确时,拆分才有足够收益。

12.2 一份贯穿案例的 Proto

下面在第 2 节 CreateUserRequest 的基础上继续演进:email = 1display_name = 2 两个已上线的编号保持不变,department_idrequest_id 是本节新增的字段,按 2.2 节的规则追加新编号,而不是复用或打乱原有编号。把请求、响应、服务方法、HTTP 注解和幂等键放在同一份定义中:

api/user/v1/user.proto
syntax = "proto3";

package user.v1;

import "google/api/annotations.proto";
import "google/protobuf/timestamp.proto";

option go_package = "example.com/project/api/user/v1;userv1";

service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserReply) {
    option (google.api.http) = {
      post: "/v1/users"
      body: "*"
    };
  }
}

message CreateUserRequest {
  string email = 1;
  string display_name = 2;
  int64 department_id = 3;
  string request_id = 4;
}

message CreateUserReply {
  User user = 1;
}

message User {
  int64 id = 1;
  string email = 2;
  string display_name = 3;
  int64 department_id = 4;
  google.protobuf.Timestamp created_at = 5;
}

request_id 是客户端生成的业务幂等键。服务端需要用唯一索引保存它,重复请求返回第一次创建的用户。它和仅用于日志关联的 trace_id 职责不同。

12.3 两种校验路径

同一服务内的模块可以直接在本地事务前查询部门表:

校验请求 → SELECT departments → INSERT users → COMMIT

拆成两个服务后,UserService 不能直接读取 DepartmentService 的数据库。它通过对方公开的 gRPC 接口检查部门:

sequenceDiagram participant GW as grpc-gateway participant U as UserService participant D as DepartmentService participant DB as User DB GW->>U: CreateUser(ctx, request) U->>D: GetDepartment(ctx, department_id) alt 部门不存在 D-->>U: NotFound U-->>GW: FailedPrecondition else 部门服务超时 D-->>U: DeadlineExceeded U-->>GW: Unavailable / DeadlineExceeded else 部门有效 D-->>U: Department U->>DB: INSERT user + idempotency record DB-->>U: committed user U-->>GW: CreateUserReply end

12.4 调用顺序和一致性边界

创建用户只写 UserService 自己的数据时,推荐先完成部门校验,再开启短事务写用户。不要在数据库事务中等待远程 RPC,否则部门服务变慢时会长期占用数据库连接和锁。

func (s *UserService) CreateUser(
    ctx context.Context,
    req *userv1.CreateUserRequest,
) (*userv1.CreateUserReply, error) {
    if err := validateCreateUser(req); err != nil {
        return nil, status.Error(codes.InvalidArgument, err.Error())
    }

    departmentCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
    defer cancel()

    _, err := s.departmentClient.GetDepartment(
        departmentCtx,
        &departmentv1.GetDepartmentRequest{Id: req.GetDepartmentId()},
    )
    if err != nil {
        return nil, mapDepartmentError(err)
    }

    user, err := s.repo.CreateIdempotently(ctx, CreateUserParams{
        RequestID:    req.GetRequestId(),
        Email:        req.GetEmail(),
        DisplayName:  req.GetDisplayName(),
        DepartmentID: req.GetDepartmentId(),
    })
    if err != nil {
        return nil, mapRepositoryError(err)
    }

    return toCreateUserReply(user), nil
}

示例中的 200ms 是子预算演示值。真实数值应根据端到端 SLO、历史 P99、网络开销和数据库预算确定,并写入配置,而非散落在业务代码中。

12.5 失败时怎样处理

失败 是否创建用户 返回建议 是否重试
部门不存在 FailedPreconditionInvalidArgument
部门服务暂时不可用 Unavailable 仅在总预算内有限重试
部门校验超时 DeadlineExceeded 通常不在服务端继续重试
邮箱已存在 AlreadyExists
相同 request_id 重复请求 返回第一次结果 正常响应 无需再次写入
用户已创建,响应丢失 已创建 重试后返回第一次结果 依靠幂等键安全重试

部门在校验后仍可能被删除,这是典型的跨服务竞态。处理方式取决于业务规则:组织系统可以禁止删除仍被引用的部门;也可以发布 DepartmentDisabled 事件,让 UserService 异步冻结或迁移相关用户。跨服务强一致事务通常代价很高,只有业务确实要求原子提交时才考虑更重的协调方案。


13. 高并发:先控制资源,再追求吞吐

高并发能力来自整条链路的容量设计。只提高 gRPC 的并发参数,往往会把压力推向数据库或下游服务。

13.1 用并发量理解容量

稳定状态下可以用一个简单关系估算在途请求数:

并发量 ≈ QPS × 平均响应时间

当接口达到 2000 QPS、平均耗时 50ms 时,平均约有 100 个请求同时执行。P99 延迟升高、下游抖动或重试都会让在途请求快速增加,因此容量测试既要看 QPS,也要看 P95/P99、错误率、Goroutine、连接池等待和资源饱和度。

13.2 连接复用与连接数

一个 grpc.ClientConn 可以承载多个并发 RPC,应作为长期对象复用。是否需要建立多个连接,取决于服务端 MAX_CONCURRENT_STREAMS、单连接流控、TLS 开销、代理行为和压测结果。

调用方进程
├── ClientConn A → HTTP/2 connection → Pod 1
├── ClientConn B → HTTP/2 connection → Pod 2
└── ClientConn C → HTTP/2 connection → Pod 3

连接越多并不必然越快。大量连接会增加文件描述符、内存、TLS 握手和服务端状态。先复用少量长连接,再通过指标判断是否存在单连接瓶颈。

13.3 限制在途请求

服务容量是有上限的。请求无限进入时,会产生 Goroutine 堆积、数据库连接池等待和尾延迟扩散。可以在服务端拦截器中限制同时执行的 Unary RPC:

func concurrencyLimitInterceptor(limit int) grpc.UnaryServerInterceptor {
    sem := make(chan struct{}, limit)

    return func(
        ctx context.Context,
        req any,
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (any, error) {
        select {
        case sem <- struct{}{}:
            defer func() { <-sem }()
            return handler(ctx, req)
        case <-ctx.Done():
            return nil, status.FromContextError(ctx.Err()).Err()
        default:
            return nil, status.Error(
                codes.ResourceExhausted,
                "server concurrency limit reached",
            )
        }
    }
}

实际项目通常按方法、租户或流量等级设置不同阈值。读取接口与创建用户共享一个全局小阈值,可能导致写入流量挤占所有查询;按业务优先级隔离容量更稳妥。

13.4 限流与过载保护

限流控制单位时间内的请求量,并发限制控制同时执行的请求数,两者解决的问题不同:

机制 主要保护对象 常见算法
API 限流 租户配额、突发流量 Token Bucket、Leaky Bucket
并发限制 CPU、Goroutine、下游容量 Semaphore、自适应并发
数据库连接池 数据库连接与锁 MaxOpenConns、等待队列
消息大小限制 内存和网络 MaxRecvMsgSizeMaxSendMsgSize
Deadline 单次请求资源占用时间 端到端时间预算

容量耗尽时应快速返回 ResourceExhausted,让调用方执行退避或降级。把请求全部排进无界队列,只会延后失败并放大超时。

面试可以这样回答:限流管的是"允许多少请求进来",并发限制管的是"同时能处理多少请求",连接池管的是"底层资源能撑住多少",三者要一起设计,而不是只调大 gRPC 一层的参数;容量到顶时应该让上游快速拿到 ResourceExhausted 去重试或降级,而不是把请求堆进无界队列,把过载拖成一堆超时。

13.5 数据库才常是最终瓶颈

创建用户会消耗数据库连接、唯一索引检查、事务日志和磁盘 I/O。服务端并发上限应与数据库连接池配合:

gRPC 最大业务并发
Repository 可用连接数
MySQL 实际可承受写并发

MaxOpenConns 过大会把压力直接推给 MySQL,过小则会让请求长时间等待连接。应同时监控 WaitCountWaitDuration、SQL P99、锁等待和数据库 CPU,再通过压测确定阈值。

13.6 流式 RPC 的背压

流式接口中,接收方处理速度低于发送方时,数据会在 gRPC、应用缓冲区或业务队列中积压。可靠设计需要:

  • 使用有界 Channel,不创建无限缓冲。
  • 发送和接收循环持续检查 Context。
  • 限制单条消息大小和每个 Stream 的最大生命周期。
  • 明确消费落后时选择等待、丢弃、采样还是断开。
  • 用序列号或游标支持断线恢复,不依赖无限长连接保存全部状态。

13.7 压测要回答什么

进阶/选学:下面这份清单更接近 SRE 压测报告的完整度,超出常规面试问答的必答范围,作为加分项了解即可。

压测目标不能只写“达到多少 QPS”。完整报告至少包含:

  1. 请求模型:读写比例、请求大小、连接数和并发数。
  2. 延迟分布:P50、P95、P99 与最大值。
  3. 失败分布:各 gRPC Code 的数量和比例。
  4. 资源曲线:CPU、内存、GC、Goroutine、连接池等待。
  5. 饱和点:从哪一档并发开始,吞吐不再增长而延迟快速上升。
  6. 降载表现:超过容量后是否快速返回,恢复后是否自行回稳。

14. 高可用:让局部故障保持局部

高可用的目标是在节点故障、下游变慢和发布过程中保持核心能力可用。它依赖冗余、故障检测、流量切换和受控降级共同完成。

14.1 多副本与健康检查

flowchart LR C[Clients] --> LB[Service / Load Balancer] LB --> P1[UserService Pod 1] LB --> P2[UserService Pod 2] LB --> P3[UserService Pod 3] P1 --> DB[(Primary DB)] P2 --> DB P3 --> DB

多副本可以承受单个进程或节点故障,前提是请求状态没有只保存在某个进程内。登录会话、幂等记录和任务状态应放在可共享、可恢复的存储中。

gRPC Health Checking Protocol 可以暴露服务健康状态。部署平台通常还区分:

  • 启动探针:服务是否完成初始化。
  • 就绪探针:当前实例是否可以接收新流量。
  • 存活探针:进程是否陷入无法恢复的状态,需要重启。

依赖短暂变慢时不要轻易让存活探针失败,否则多个实例可能同时重启。就绪探针用于摘除流量,存活探针处理进程级死锁等不可恢复故障。

14.2 分层时间预算

预算为什么要逐级收窄、以及服务端应如何响应剩余时间,见 8.2。这里补一组更具体的数值,展示同一条链路里各层预算是怎样一层层分下去的:

APP 总等待上限                 1200ms
└── grpc-gateway 请求 Deadline 1000ms
    └── UserService 业务预算    850ms
        ├── Department RPC      200ms
        └── User DB 写入         300ms

数值仅为教学示例,真实项目应按 SLO、历史 P99 和数据库预算配置化管理,而非写死在代码里。

14.3 重试、熔断与故障隔离

三种机制承担不同职责:

机制 作用 使用边界
重试 跨过一次短暂网络或节点故障 仅限可重试错误、幂等请求和剩余预算
熔断 下游持续失败时暂时停止调用 需要半开探测和恢复条件
隔舱 为不同下游或业务分配独立容量 防止一个慢依赖耗尽全部资源

DepartmentService 大量超时时,UserService 若不断重试,会同时占用调用方 Goroutine、连接和总 Deadline。更稳的行为是有限重试、达到阈值后快速失败,并让其他不依赖部门服务的接口继续运行。

面试可以这样回答:重试、熔断、隔舱三者分工不同——重试跨过一次瞬时抖动,熔断防止下游持续故障时还在不断浪费资源等待超时,隔舱确保一个慢依赖不会把其他业务的连接、Goroutine 和 Deadline 一起拖垮。三者组合起来,才能让 DepartmentService 变慢时,UserService 里不依赖它的接口仍然正常响应。

14.4 重试配置示例

gRPC 可以通过 Service Config 为指定方法配置重试策略。下面只对只读方法 GetDepartment 重试:

{
  "methodConfig": [{
    "name": [{
      "service": "department.v1.DepartmentService",
      "method": "GetDepartment"
    }],
    "timeout": "0.200s",
    "retryPolicy": {
      "maxAttempts": 3,
      "initialBackoff": "0.020s",
      "maxBackoff": "0.080s",
      "backoffMultiplier": 2,
      "retryableStatusCodes": ["UNAVAILABLE"]
    }
  }]
}

最大尝试次数包含第一次调用。即使策略允许三次尝试,所有尝试仍受上游 Deadline 限制。创建用户方法自身只有在幂等键生效后才具备安全重试条件。

14.5 优雅下线

滚动发布时,实例应先停止接收新流量,再等待在途 RPC 完成:

stopped := make(chan struct{})
go func() {
    grpcServer.GracefulStop()
    close(stopped)
}()

select {
case <-stopped:
case <-time.After(15 * time.Second):
    grpcServer.Stop()
}

完整顺序通常是:

收到终止信号
→ 就绪状态变为失败
→ 负载均衡停止发送新请求
→ 等待服务发现和连接端感知
→ GracefulStop 等待在途 RPC
→ 超过宽限期后强制停止

流式 RPC 可能长期不结束,需要设置最大生命周期、服务端关闭通知或断线续传机制,否则优雅下线会一直等待。

14.6 高可用仍需要数据层支撑

服务部署三个副本,数据库只有一个无备份节点,整体系统仍有单点。生产设计还要考虑数据库主从或托管高可用、备份恢复、唯一约束、幂等记录、缓存故障和配置中心可用性。

高可用需要用故障演练验证:终止一个实例、让 DepartmentService 延迟升高、断开数据库连接、制造连接池耗尽,再观察错误率、P99、流量切换、告警和恢复时间。


15. 可观测性与线上排查

15.1 最少记录哪些字段

request_id
trace_id
service
method
peer
status_code
duration_ms
error_reason
attempt
deadline_ms
instance_id

不要默认记录完整 Request 和 Response,其中可能含密码、Token、手机号和大块二进制数据。

15.2 三类信号

信号 主要回答的问题
Metrics 错误率是否升高?P95/P99 延迟是否恶化?
Traces 慢在网关、服务、下游 RPC,还是数据库?
Logs 某一次调用的参数摘要、错误原因和运行上下文是什么?

OpenTelemetry 可以让 HTTP 入口、grpc-gateway、gRPC 服务和数据库查询处在同一条 Trace 上:

flowchart LR A["HTTP Span<br/>POST /v1/users"] --> B["gRPC Client Span<br/>UserService/CreateUser"] B --> C["gRPC Server Span<br/>UserService/CreateUser"] C --> D["DB Span<br/>INSERT users"]

15.3 一次 Unavailable 的排查顺序

  1. 看客户端错误码、Deadline 和目标地址。
  2. 检查 DNS 或服务发现是否返回有效后端。
  3. 检查代理、Ingress、Service 是否支持 HTTP/2 和 gRPC。
  4. 查看服务端进程、端口、健康状态和连接日志。
  5. trace_id 判断请求有没有抵达服务端。
  6. 检查连接是否被负载均衡器空闲超时关闭。
  7. 查看是否正在发布、扩缩容或发生证书问题。

Unavailable 只说明服务当前不可用这一大类现象,不应直接等同于”服务端宕机”。

面试可以这样回答:排查 Unavailable 要按”由近到远”的顺序推进——先看客户端本地的错误码和目标地址,再查服务发现和网络层(DNS、代理是否支持 HTTP/2),然后看服务端进程和健康状态,最后用 trace_id 确认请求到底有没有抵达服务端;顺序错了,很容易把网络或负载均衡问题误判成服务端故障。

15.4 面向容量和可用性的指标

进阶/选学:15.4、15.5 的指标粒度和排障细节接近 SRE 运维手册,超出常规面试问答深度,可作为加分项了解。

只统计请求总数很难判断系统是否接近饱和。生产环境至少要建立下面几组指标:

类别 指标示例 用途
流量 各 Method QPS、请求/响应字节数 判断负载规模与消息成本
延迟 P50、P95、P99、Deadline 剩余时间 发现尾延迟和预算不足
错误 各 gRPC Code、业务 Reason 区分参数错误、过载与依赖故障
饱和度 在途 RPC、Goroutine、CPU、GC 判断实例是否接近容量上限
连接 ClientConn 状态、连接建立失败、活跃 Stream 排查网络和服务发现问题
下游 Department RPC 延迟、错误率、熔断状态 识别依赖传播故障
数据库 连接池等待、SQL P99、锁等待 判断瓶颈是否已经进入存储层

告警应聚焦用户影响和持续时间。例如 CreateUser 的 P99 连续五分钟超过 SLO,同时 DeadlineExceeded 比例升高,比单次请求失败更适合作为告警条件。

15.5 教学故障:部门服务变慢

假设 DepartmentService 的 P99 从 40ms 升到 500ms,而 UserService 为该调用分配的子预算是 200ms。排查链路可以这样展开:

  1. 网关看到 CreateUser 的 504 比例和 P99 同时升高。
  2. Trace 显示时间主要消耗在 DepartmentService/GetDepartment Span。
  3. UserService 日志出现相同 trace_id,Code 为 DeadlineExceededdeadline_ms 接近 0。
  4. DepartmentService 指标显示数据库连接池等待增加,SQL 本身耗时没有明显变化。
  5. 根因定位为连接池容量过小或连接泄漏,而非 gRPC 序列化性能。
  6. 修复后用相同流量模型回放,验证连接池等待、P99 和错误率恢复。

这个例子展示的是排查方法。真正复盘项目故障时,要把示例名称替换为实际服务、文件、日志、指标和验证数据。


16. 测试与调试

16.1 使用 grpcurl 手工调用

服务开启 Server Reflection 后,可以像使用 curl 一样检查 gRPC:

grpcurl -plaintext localhost:9000 list
grpcurl -plaintext localhost:9000 list user.v1.UserService
grpcurl -plaintext \
  -d '{"email":"alice@example.com","displayName":"Alice"}' \
  localhost:9000 user.v1.UserService/CreateUser

生产环境是否开放 Reflection 要按安全要求决定。即使不开放,也可以给 grpcurl 提供 proto 文件或 descriptor set。

16.2 Handler 测试

Go 中可使用 bufconn 创建内存 Listener,启动真实的 gRPC Server,再通过生成客户端调用。它能覆盖序列化、注册、拦截器和状态码,比直接调用 Handler 更接近真实链路,同时不需要占用网络端口。

测试至少覆盖:

  • 正常请求与响应字段。
  • 参数错误对应的 InvalidArgument
  • 唯一冲突对应的 AlreadyExists
  • Deadline 和取消是否传播。
  • 重复幂等键是否返回同一业务结果。
  • 未认证和无权限是否正确区分。
  • 网关的 JSON 字段、路径参数与错误映射。

16.3 Proto 契约检查

团队应在 CI 中执行格式、Lint、生成代码一致性和兼容性检查。常见失败包括:复用字段编号、删除对外方法、修改字段类型、改变包名或生成代码没有更新。


17. 一个生产级请求的完整旅程

把前面的知识串起来,一次创建用户请求可以这样流动。这张图在 12.3 的基础上,把 grpc-gateway 和 Interceptor 泳道补全,展示同一条请求从 HTTP 入口到数据库的完整链路,而不是重复论证 12.3 已经讲过的三个分支:

sequenceDiagram participant APP as APP participant GW as grpc-gateway participant INT as Interceptors participant US as UserService participant DS as DepartmentService participant REPO as Repository participant DB as MySQL APP->>GW: POST /v1/users + JSON + Token GW->>GW: JSON → Protobuf GW->>INT: CreateUser + Metadata + Deadline INT->>INT: Trace / Auth / Log / Metrics INT->>US: CreateUser(ctx, req) US->>US: 参数与业务规则校验 US->>DS: GetDepartment(ctx, department_id) alt 部门不存在 DS-->>US: NotFound US-->>INT: FailedPrecondition + ErrorInfo INT-->>GW: gRPC Status GW-->>APP: HTTP 400 + JSON error else 部门调用超时 DS-->>US: DeadlineExceeded US-->>INT: DeadlineExceeded INT-->>GW: gRPC Status GW-->>APP: HTTP 504 + JSON error else 部门有效 DS-->>US: Department US->>REPO: CreateIdempotently(ctx, user) REPO->>DB: INSERT user + request_id alt 邮箱唯一冲突 DB-->>REPO: duplicate key REPO-->>US: ErrEmailExists US-->>INT: AlreadyExists + ErrorInfo INT-->>GW: gRPC Status GW-->>APP: HTTP 409 + JSON error else 创建成功或幂等命中 DB-->>REPO: user row REPO-->>US: domain.User US-->>INT: CreateUserReply INT-->>GW: Protobuf response GW-->>APP: HTTP JSON response end end

这条链路中的职责边界很清楚:

核心职责
grpc-gateway HTTP 与 Protobuf 转换,对外协议接入
Interceptor 横切能力,如认证、日志、追踪、指标
gRPC Handler 参数检查、协议与领域对象转换、错误映射
Usecase / Service 业务规则和流程编排
DepartmentService Client 校验部门并传播 Deadline 与 Trace Context
Repository 数据访问抽象与存储错误转换
Database 唯一约束、事务和持久化

18. 关键概念回顾

不重复展开论证,只留一张可以脱口而出的速查卡,详细推导见对应小节。

主线 一句话结论 详见
契约 字段编号是线上身份:只加、不改、不复用;删除字段用 reserved 封存(对应第 12.2 节 CreateUserRequest 追加 department_idrequest_id 时的编号演进) §2
生成代码 代码由协议生成,不手改;协议变了就重新生成,别在生成文件里写业务逻辑 §3
调用生命周期 Deadline、取消、Metadata、Trace 都跟着 ctx 走完全程,谁丢了 ctx 谁就切断了资源控制(对应第 17 节请求旅程图中 ctx 贯穿 Interceptor → UserService → DepartmentService → Repository 全程) §8, §10
错误模型 Code 管大类、Reason 管业务原因、Message 给用户看、堆栈只进日志(对应第 12.5 节的失败表与第 17 节里各分支返回的 HTTP 状态码) §7
重试与幂等 网络错误只说明结果不确定,不说明写入是否发生;能不能重试看幂等设计,不看错误码(对应第 12.2 节 request_id 幂等键的设计) §8.4, §9
网关 grpc-gateway 让 HTTP JSON 复用同一份 Protobuf 契约,业务规则仍然只在 Service 层写一份 §6
工程边界 高并发靠限流、限并发、保护数据库、验证饱和点;高可用靠多副本、故障隔离、优雅下线;两者都靠可观测性校准阈值(对应第 13、14 节围绕 CreateUser 展开的容量与故障隔离设计) §13, §14, §15

19. 循序实验与课后练习

如果你是刚接手 UserService 的工程师,前面十八节讲的是它应该长成什么样,这一节就是接下来要做的事——不是一份通用课程大纲,而是延续前面案例的四步任务:先跑通最小闭环,再把它接入对外网关,然后按第 12、17 节的方式补齐生产要求,最后用第 13、14 节的方法验证它扛不扛得住压力和故障。

第一阶段:跑通

  1. UserService.CreateUserGetUser
  2. 生成 Go 代码并实现服务端。
  3. 写一个带 2 秒 Deadline 的客户端。
  4. 用 grpcurl 验证成功和失败状态码。

跑通之后,这个服务该有一个对外的 HTTP 入口了。

第二阶段:接入 HTTP

  1. 添加 google.api.http 注解。
  2. 生成 grpc-gateway 代码。
  3. 用 curl 调用创建和查询接口。
  4. 验证 JSON 字段名、int64 编码和错误映射。

HTTP 入口跑通后,把它按第 12、17 节的样子补成一个真正能上线的服务。

第三阶段:达到生产要求

  1. 增加日志、Tracing、指标、认证和恢复拦截器。
  2. 为创建请求增加幂等键和数据库唯一约束。
  3. 验证 Deadline 能传播到数据库与下游服务。
  4. bufconn 编写集成测试。
  5. 在 CI 中加入 Proto Lint 和兼容性检查。

生产该有的都补齐之后,再回到第 13、14 节,验证它在压力和故障下的真实表现。

第四阶段:验证并发与可用性

  1. 用固定读写比例逐级提高并发,记录 QPS、P95/P99 和错误码分布。
  2. 设置服务端在途请求上限,验证过载时能快速返回 ResourceExhausted
  3. 人为延迟 DepartmentService,观察 Deadline、重试和熔断是否符合预期。
  4. 终止一个服务实例,验证流量能切换到其他健康副本。
  5. 执行优雅下线,确认在途 Unary RPC 完成,长连接按设计重连。
  6. 调小数据库连接池,识别连接等待如何推高 gRPC 尾延迟。

综合练习

不看资料,完整解释下面这条链路:

HTTP JSON
→ grpc-gateway
→ Protobuf Request
→ gRPC Client Stub
→ HTTP/2
→ Server Interceptor
→ UserService Handler
→ Usecase
→ Repository
→ MySQL
→ gRPC Status / Reply
→ HTTP JSON

为这条链路补充四份设计说明:

  1. Deadline 如何从网关传播到数据库。
  2. 邮箱重复时各层分别返回什么。
  3. 客户端超时重试时如何防止重复创建。
  4. 如何用同一个 trace_id 串起 HTTP、gRPC 和 SQL Span。
  5. 超过服务容量后如何限制在途请求并保护数据库。
  6. DepartmentService 变慢时如何阻止故障扩散。

完成代码和这四份说明后,再主动制造超时、重复请求和服务不可用三类故障,观察客户端状态码、服务端日志与 Trace 是否一致。


参考资料