Files
cs-note/hzh/TEST/gRPC.md
T
2026-05-24 11:42:38 +08:00

43 KiB
Raw Blame History

tags, create time, update time, status
tags create time update time status
后端
Go
gRPC
测试
自我考察
2026-05-07 2026-05-07 reviewed

gRPC 自测题

概述

本文档是一份 gRPC 知识的自测题库,覆盖从底层协议到生产实践的全链路知识点。包含选择题、填空题、代码补全和主观题四种题型,附带详细参考答案与解析,适合用于自我评估或团队内部考核。

正文

使用说明

本试卷覆盖 hzh/MS/06-gRPC 知识索引中 协议与架构 → Proto设计 → RPC模式 → 拦截器 → 错误处理 → 连接管理 → 最佳实践 全链路知识点。共四部分:选择题、填空题、代码补全、主观题。

建议用时: 50 分钟 | 及格线: 70 / 100

做完后对照下方「参考答案」自评,错的地方回到对应笔记重新理解。


一、选择题(每题 4 分,共 40 分)

每题只有一个正确答案。

Q1.【gRPC 性能优势的根本原因】

以下哪一项是 gRPC 在高频微服务调用场景下比 HTTP/1.1 REST 性能优势最大的特性?

A. Protocol Buffers 的二进制序列化比 JSON 快
B. HTTP/2 多路复用——一个 TCP 连接上并行多个 Stream
C. gRPC 自动维护连接池,不用手动管理
D. HPACK 头部压缩减小了传输体积

[!tip]- Q1 答案 B — HTTP/2 多路复用

解析: Protocol Buffers 确实比 JSON 快、连接复用也确实减少了握手开销,但真正的分水岭在于 HTTP/2 多路复用。

HTTP/1.1 开 10 个并行请求需要 10 条 TCP 连接 → 握手开销大、端口耗尽。一条 HTTP/2 连接就能承载几百个并发 RPC,这就是 gRPC 在高频内部调用的性能优势来源。

为什么不是其他选项:

  • ❌ A:序列化速度差通常在微秒级,而建立 TCP 连接的 RTT 在毫秒级——网络开销远大于序列化开销
  • ❌ C:连接复用是结果不是根源,HTTP/1.1 也可以用 Keep-Alive + 连接池实现
  • ❌ D:头部压缩对频繁传相同 header 的场景有帮助,但不是核心差异

💡 关键洞察: 如果 gRPC 跑在只有单次请求的单连接上,它相比 REST 的优势会大打折扣。真正发挥优势的是"大量并发短请求"的微服务场景。


Q2.【Proto3 版本兼容性】

你的 Service B 更新了 .proto 文件,将 GetUserRequest 中的 field_number = 5 从 string email 删除了,但没有修改任何编号。旧版客户端收到新版服务端响应时,会发生什么?

A. 反序列化失败,因为字段消失了
B. 旧客户端忽略未知编号的字段,正常处理其他字段
C. 旧客户端 panic,因为收到了无法识别的字段类型
D. 旧客户端把该字段当作空字符串处理

[!tip]- Q2 答案 B — 未知编号的字段会被忽略

解析: Protocol Buffers 的二进制编码规则天然保证了向前兼容。每个字段在 wire format 中编码为 (field_number << 3) | wire_type,解码器只认识自己定义过的编号,遇到不认识的直接跳过(skipped as unknown field)。

新版服务端写入 → wire: (5 << 3) | TYPE_LENGTH_DELIMITED + "alice@example.com"
  ↓
旧版客户端解码 → 不认识 field_number=5 → 跳过 → 继续解码其他字段 ✅

注意反向兼容: 如果新客户端收到旧服务端的响应,同样也会忽略新增字段的缺失(用默认值),这也是安全的。

💡 铁律: "新增字段安全,删除字段也安全(前提是保留编号不重用)"——只要你不重编已使用的编号,双向都是兼容的。


Q3.【Oneof vs 普通字段】

关于 oneof 的使用,以下说法正确的是:

A. oneof 可以修饰 repeated 类型的字段,方便存储一组互斥值
B. Proto3 中 oneof 所有变体共享同一个零值 NONE(0),所以反序列化时无法区分哪个变体被设置过
C. Proto3 会为 oneof 自动生成 IsXXX() setter 方法以支持精确的状态判断
D. oneof 中的成员编号必须跨不同 oneof 组全局唯一

[!tip]- Q3 答案 B — oneof 零值为 NONE(0),无法区分未设置状态

解析: oneof 的核心语义是"同一时刻最多一个有值",它是 Proto3 表达可选联合类型的方式。

逐项分析:

  • ❌ A:oneof 不能加 repeated 修饰符,因为 multiple 值和 oneof 互斥语义矛盾
  • ✅ B:正确。Proto3 没有 Optional 概念,当 all fields 都等于其默认值时,你无法知道用户到底传了 "none" 还是没传
  • ❌ C:Proto3 不会为 oneof 自动生成任何 IsXXX() 方法。这正是 Proto3 oneof 的核心痛点——你无法通过 API 判断哪个变体被设置过(因为当所有值都等于默认值时,语义上就是不可区分的)。如果需要区分"未设置"和"设置为空值",必须使用 Well-Known Types 包装器。
  • ❌ D:错误。Protobuf 规范只要求字段编号在同一个 oneof 内部唯一,不同 oneof 之间可以重用编号(proto 的二进制编码只看 (field_number, wire_type),不同 oneof 之间不会产生冲突)

如果需要精确表达"可选",推荐使用 Well-Known Types 中的 StringValue 包装:

google.protobuf.StringValue optional_field = 1; // nil = 未传, "" = 传了空串

💡 工程选择: oneof 适合"多选一"的业务建模(如支付方式选一种),StringValue 适合"可能有也可能没有"的补充字段。两者不冲突,可以同时出现在一个 message 里。


Q4.【RPC 模式选型】

你需要实现一个实时聊天功能:双方持续互发消息,服务器需要对每条消息做内容审核后再转发给另一方。最适合的 gRPC 模式是:

A. Unary —— 每发一条消息建立一个 RPC 调用
B. Server Streaming —— 客户端发送消息,服务端流式返回审核结果
C. Client Streaming —— 客户端批量发送,服务端最后返回批量审核结果
D. Bidirectional Streaming —— 双方同时独立发送和接收消息流

[!tip]- Q4 答案 D — 双向流式是实时聊天场景的最佳选择

解析: 聊天的核心特征是:双方随时都可以发消息(全双工),且消息到达即推送(低延迟),不需要等对方发完再统一处理。

sequenceDiagram
    participant A as 用户A
    participant G as gRPC Bridge
    participant B as 用户B
    A->>G: ChatMsg("Hello")
    G->>B: ChatMsg("Hello")
    B->>G: ChatMsg("Hi!")
    G->>A: ChatMsg("Hi!")
    Note over A,B: 各自独立发送,实时接收

为什么不适合其他模式:

  • ❌ A:每条消息一次 RPC → 巨大的 HTTP/2 Stream 开销,连接数爆炸
  • ❌ B:Server Streaming 是单向推(客户端→服务端 1:N),聊天是双向交互
  • ❌ C:Client Streaming 是 N→1,先攒一批再说——聊天不能等有 N 条才处理第一条

💡 对比 WebSocket: gRPC 双向流提供了强类型 Proto 契约、自动生成客户端代码、内置认证重试;WebSocket 更灵活(任意 payload、浏览器原生支持),但缺乏类型约束。内部系统推荐 gRPC,面向公众浏览器端推荐 WebSocket。


Q5.【服务端流式关闭陷阱】

关于服务端流式 RPC(Server Streaming),以下说法最准确的是:

A. 如果服务端 Send 循环中发生异常没有 return,客户端 Recv() 会一直阻塞直到连接断开
B. 服务端只需要在 for 循环结束后正常退出函数即可,不需要显式 return nil
C. 如果服务端 Send 循环中没有任何 return,Stream 不会结束,客户端会永久阻塞在 Recv()
D. gRPC 框架会自动检测并关闭超时的 Stream,不需要开发者关心

[!tip]- Q5 答案 C — 服务端必须确保所有路径都会退出循环或返回错误

解析: 服务端流式的生命周期完全由服务端控制。如果服务端没有在任何路径上返回(无论是 nil 还是 error),Stream 就永远不会结束,客户端将永远阻塞在 Recv()。

常见陷阱场景:

func (s *orderServer) ListOrders(req *pb.ListOrdersRequest, stream pb.OrderService_ListOrdersServer) error {
    orders := s.store.ListAll()
    for _, o := range orders {
        stream.Send(o)  // ❌ 没有检查 error,也没有 return
    }
    return nil  // ✅ 但至少这里有 return,不会永久阻塞
}
func buggyList(stream pb.OrderService_ListOrdersServer) error {
    for {
        order := getNextOrder()
        if order == nil {
            break  // break 只是跳出循环,下面必须有 return!
        }
        stream.Send(order)
    }
    // ⚠️ 这里如果不写 return nil,编译器可能会放过但你应该养成习惯
}

黄金法则: "要么在循环内遇到错误 return err,要么在循环结束 return nil"——两条路径都必须明确存在。

💡 扩展: 如果是客户端流式,客户端也需要主动调用 CloseSend() 来告诉服务端"我不再发数据了",否则服务端 Recv() 也会永远等待。


Q6.【拦截器注册陷阱】

Go 语言中 gRPC 服务端拦截器注册的常见陷阱是:

A. grpc.UnaryInterceptor() 每次调用会追加而非覆盖,导致重复执行
B. grpc.UnaryInterceptor() 会覆盖而非追加,多次注册只有最后一次生效
C. Stream Interceptor 不能与 Unary Interceptor 同时注册
D. 拦截器只在第一次调用时生效,后续调用被缓存忽略了

[!tip]- Q6 答案 B — 多次注册会覆盖,需要用链式包装函数串联

解析: Go 的 grpc.NewServer(grpc.UnaryInterceptor(f)) 只接受一个拦截器。如果多次调用 grpc.WithUnaryInterceptor() 或使用 NewServer 多次指定拦截器,后面的会覆盖前面的。

// ❌ 错误做法——后面覆盖了前面,LoggingInterceptor 永远不会被执行
server := grpc.NewServer(
    grpc.UnaryInterceptor(LoggingInterceptor()),
    grpc.UnaryInterceptor(AuthInterceptor()), // ← 这个覆盖上一个
)

// ✅ 正确做法——手动链式串联
server := grpc.NewServer(
    grpc.UnaryInterceptor(chainUnaryInterceptors(
        AuthInterceptor(),
        LoggingInterceptor(),
        MetricsInterceptor(),
    )),
)

注意: 这个问题只在 Go 中存在。Java 的 Server.intercept() 支持注册多个拦截器并按序执行,不需要手动串联。

💡 链式封装思路: 从右往左包裹 handler。最后一个拦截器最先收到请求(离 handler 最近),第一个拦截器最后收到请求(站在最外层做全局处理)。


Q7.【状态码选择】

客户端发起订单创建请求时,服务端校验发现 user_id 格式不合法。最合适的 gRPC 状态码是:

A. Internal —— 因为这是一个服务端的错误
B. InvalidArgument —— 客户端传入的参数不符合要求
C. FailedPrecondition —— 前置条件不满足
D. Unknown —— 不确定应该用哪个状态码

[!tip]- Q7 答案 B — InvalidArgument 是最准确的语义

解析: gRPC 的 16 个标准状态码各有明确的语义边界:

状态码 适用场景 本例是否匹配
InvalidArgument 参数校验失败、格式不合法 ✅ user_id 格式不对
Internal 代码 bug、不可恢复的内部状态 ❌ 这是预期的业务校验
FailedPrecondition 系统状态不允许操作(如账户已被封禁) ❌ 不是系统状态问题,是入参问题
Unknown 无法归类 ❌ 完全可以归入已有 code

关键原则: 永远用 codes.* 而不是 fmt.Errorf 做返回值。这样才能让客户端通过 status.Code(err) 精准判断该重试、降级还是报错。

// ❌ 丢掉语义
return nil, fmt.Errorf("invalid user_id format")

// ✅ 保持 gRPC 一致性
return nil, status.Error(codes.InvalidArgument, "user_id must be a valid UUID")

💡 扩展: 如果需要更精细的错误信息,可以用 status.New(codes.InvalidArgument, "...").WithDetails(detail).Err() 附加结构化详情。


Q8.【Keepalive 配置目的】

gRPC 默认不发送 keepalive ping,生产环境中开启 Keepalive 的主要目的是:

A. 检测对端是否还活着,实现心跳机制
B. 防止中间代理(Nginx/云 LB)的 idle timeout 切断空闲连接
C. 提高数据传输的吞吐量
D. 触发客户端自动负载均衡切换

[!tip]- Q8 答案 B — 防止中间代理切断空闲连接

解析: 两个微服务之间建立了 gRPC 连接后,如果 30 分钟没有任何请求,中间经过的 Nginx、云厂商 LB 或 AWS ALB 很可能已经因 idle timeout 切断了连接。但两端都还以为连接是活的,于是第一个 Send() 报 use of closed network connection。

解决方案:

grpc.KeepaliveParams(keepalive.ServerParameters{
    Time:    10 * time.Second, // 每 10s 发一次 ping
    Timeout: 5 * time.Second,  // 等了 5s 没回复就算对方挂了
})

逐项分析:

  • ❌ A:Keepalive 确实能检测存活,但这不是主要目的——gRPC 已经有内置的健康检查机制
  • ✅ B:正确。大多数生产环境故障源于此,开启 Keepalive 是稳定性标配
  • ❌ C:Keepalive 增加的是很小的 ping 帧,不影响业务数据的吞吐
  • ❌ D:负载均衡切换由 picker 和 health check 驱动,和 Keepalive 无关

💡 额外收获: MaxConnectionAge 配合 Keepalive 使用可以实现平滑退役——让旧连接到期失效,新流量走新连接。


Q9.【OpenTelemetry 上下文传播】

通过 otelgrpc 中间件实现分布式追踪时,gRPC 上下文中 trace context 的传播方式是:

A. 通过 context.Value 传递,不需要序列化
B. 自动注入 W3C Trace Context 到 HTTP/2 metadata 中
C. 需要在每个 handler 中手动传递 traceparent header
D. 通过 gRPC 的 payload 字段携带 trace ID

[!tip]- Q9 答案 B — otelgrpc 自动注入 TraceContext 到 metadata

解析: gRPC 使用 HTTP/2 metadata 作为键值对的元数据通道。otelgrpc 中间件会在每次 RPC 调用时自动:

  1. 读取上游传入的 traceparent/tracestate metadata
  2. 从中提取 Span Context 并创建新的 Span
  3. 在新的出向调用中自动注入新的 TraceContext 到 metadata
// 服务端——自动从 incoming metadata 中提取并继承 trace context
server := grpc.NewServer(
    grpc.StatsHandler(otelgrpc.NewServerStatsHandler()),
)

// 客户端——自动把 span context 注入 outgoing metadata
conn, _ := grpc.Dial(target,
    grpc.WithStatsHandler(otelgrpc.NewClientStatsHandler()),
)

为什么不手动传: 手动维护 metadata 繁琐且易遗漏。一旦漏传了一跳,整个链路就在这一跳断开了,Jaeger/Grafana 上看不到完整链路图。

💡 W3C Trace Context 格式: Traceparent: 00-trace_id-span_id-flags,是一个行业通用的标准,不仅适用于 gRPC,HTTP 请求也是同样的传播方式。


Q10.【Buf 工具链】

关于 Buf 代码生成工具链的作用,以下描述最全面的是:

A. Buf 替代 protoc 生成 Go 代码,但不支持 JavaScript
B. buf.lock 锁定 proto 依赖的确切 commit,类似 go.mod 锁定 Go 模块版本
C. Buf 只能在 CI 中使用,不能在本地开发环境中运行
D. Buf 只能生成 gRPC 桩代码,不能处理自定义 validator

[!tip]- Q10 答案 B — buf.lock 锁定依赖版本,保证构建可重现

解析: Buf 是 Protocol Buffers 的现代工具链,提供统一依赖管理、linting 和 CI 集成。

逐项分析:

  • ❌ A:Buf 支持多种语言插件(Go、TS/JS、Python 等),不限于 Go
  • ✅ B:正确。像 go mod tidy 锁定 go.sum,buf dep update 生成 buf.lock,CI 中加入 git diff --exit-code 检查可以防止生成的代码过期
  • ❌ C:Buf 可以在本地运行 buf generate 生成代码,也可以和 buf watch 配合实现热更新
  • ❌ D:Buf 支持 validate.proto 插件,可以为 message 字段添加校验规则并自动生成校验代码
# buf.gen.yaml
plugins:
  - remote: buf.build/protocolbuffers/go      # 生成 Go struct
  - remote: buf.build/grpc/go                  # 生成 gRPC stub
  - remote: buf.build/bufbuild/validate-go     # 生成校验逻辑

💡 CI 集成模式:

buf generate        # 生成代码
buf mod update      # 更新 lock
git diff --exit-code  # 如果生成的代码不一致说明 lock 过期了

二、填空题(每题 3 分,共 24 分)

根据知识填写空缺的概念或代码。

Q11.【gRPC 协议栈层次】

请按照从上到下的顺序排列 gRPC 的协议栈层级:

___(Application)→ Generated Stub → ___ → HTTP/2 → ___

[!tip]- Q11 答案 gRPC Framework ; TCP/IP

解析:

层级 解决的问题 类比
Application 业务逻辑 写信的内容
Generated Stub Proto 对象映射为二进制编码 翻译官(Proto = 字典)
gRPC Framework 重试、拦截器、流控 邮局分拣系统
HTTP/2 多路复用 + 头部压缩 + 二进制帧 快递包裹分装规范
TCP/IP 可靠传输 + 路由寻址 公路运输网络

💡 调试视角: 排查问题时定位在哪一层很重要——超时通常是 HTTP/2/Transport 层,序列化问题是 Stub 层,业务逻辑 Bug 在 Application 层。


Q12.【Proto 命名约定】

Proto 文件的组织规范中:package 使用 __________ 风格(如 order.v1);service 使用 PascalCase + __________ 后缀;enum 使用 PascalCase + Status 或 Type 后缀;message 中的 field 使用 __________ 风格(如 user_id)。

[!tip]- Q12 答案 小写点分隔 ; Service ; snake_case

解析:

元素 命名风格 示例
package 小写点分隔,含版本前缀 order.v1
service PascalCase + Service OrderService
rpc method PascalCase CreateOrder, GetUserInfo
message PascalCase CreateOrderRequest
enum PascalCase + Status/Type OrderStatus, RoleType
field snake_case user_id, created_at

💡 为什么 field 用 snake_case: Proto3 的代码生成器会自动将 snake_case field 名转换为目标语言的驼峰命名(Go 转驼峰、Java 也转驼峰),这样在不同语言间保持一致性。


Q13.【状态码分类速记】

gRPC 定义了 16 个标准状态码,按业务含义可分为三类:

业务场景 推荐状态码
参数校验失败 (空1)
权限不足 (空2)
服务不可用(连接断开) (空3)
限流 (空4)

[!tip]- Q13 答案 (空1) InvalidArgument ; (空2) PermissionDenied ; (空3) Unavailable ; (空4) ResourceExhausted

解析:

状态码 等价 HTTP 典型场景
InvalidArgument 400 参数格式不对、必填项缺失
PermissionDenied 403 Token 无效但已提供
Unavailable 503 下游宕机、连接断开、过载
ResourceExhausted 429 限流、配额用完

记忆口诀: "参数不正(InvalidArgument)、权限不够(PermissionDenied)、服务不稳(Unavailable)、资源耗尽(ResourceExhausted)"。

💡 客户端侧策略: InvalidArgument 不应该重试(改了也没用),Unavailable 可以重试(可能临时抖动),ResourceExhausted 需要退避(限流正在生效)。


Q14.【拦截器执行顺序】

在一个完整的 gRPC 请求链中,典型的拦截器执行顺序如下:

客户端开始
  → Metrics(记录耗时)
  → Retry(根据错误码决定是否重试)
  → Auth(注入 Token)
  ↕ HTTP/2 传输
  → Auth(验证 Token)
  → Logging(记录请求/响应)
  → Metrics(统计服务端耗时)
  → Handler(业务逻辑)
  → _________(后置:服务端 metrics 记录)
  → _________(后置:服务端日志记录)
  → _________(后置:服务端 auth 释放资源)
客户端结束

[!tip]- Q14 答案 Metrics ; Logging ; Auth

解析: 拦截器的执行遵循洋葱模型:前置部分正序执行(靠近 handler 的内层先启动),后置部分逆序执行(内层先返回再逐层退出)。

正向:[Metrics] → [Retry] → [Auth] → Handler
                   ↑          ↑
逆向:              [Auth释放] ← [Logging后置] ← [Metrics后置]

注意这里的"后置"顺序和前序相反,就像进出电梯——进来的时候按楼层排好队,出去的时候最后一个先进的人反而最早出来。

💡 Debug 技巧: 如果你在每个拦截器的进入和退出处打印时间戳,可以看到典型的"进出栈"模式——这正是中间件链的本质。


Q15.【Well-Known Types 用法】

Proto3 内置了 Well-Known Types,以下是三个常用类型的用途:

Well-Known Type 用途
google.protobuf.Timestamp (空1)
google.protobuf.Empty (空2)
google.protobuf.StringValue (空3)

[!tip]- Q15 答案 (空1) RFC 3339 纳秒级时间戳;(空2) 无参数/无返回值的 RPC 占位;(空3) 包装基本类型表示"可选"语义(区分 unset 和空串)

解析:

Proto3 没有 Optional 关键字,这意味着当你声明 string name = 1; 时,你无法区分"用户传了空串"和"用户根本没传这个字段"。

StringValue 等 Wrapper 类型解决了这个问题:

string normal_field = 1;            // "" 无法区分 unset 还是传了空串
StringValue optional_field = 2;     // nil = 未传, Some("") = 传了空串

各类型速览:

Type 包装的基本类型 什么时候用到
Timestamp 时间 替代手动拼字符串
Duration 时长 替代 float/double
Empty 无 Oneof RPC 返回类型(无返回参数)
StringValue/Int32Value/BoolValue 对应的基元类型 需要区分 null/empty/实际值
Any 任意消息 泛型容器,需搭配 type URL

💡 工程经验: 如果一个字段未来可能会被废弃,先用 reserved 锁定编号而不是直接删除。


Q16.【负载均衡策略选择】

gRPC 内建的几种负载均衡策略各有适用场景:

策略 行为特点 适用场景
pick_first(默认) 每次只用一个 SubConn (空1)
round_robin (空2) 无状态服务、均匀负载
hash-based 按 key hash 选定后端 (空3)

[!tip]- Q16 答案 (空1) 单一后端或简单场景;(空2) 轮询分发到新连接;(空3) 需要会话粘性的场景

解析:

策略 风险 建议
pick_first 单点故障——该连接对应的后端挂了要等 health check 失败才切 高可用场景切换到 round_robin
round_robin 不适用于后端规格不一致的情况 集群所有实例同规格时使用
weighted_round_robin Go 1.25+ 才支持 混合部署(新机器+旧机器)
hash-based hash 变化会导致粘性失效 需要持久化用户关联时使用
// 指定 round_robin
conn, _ := grpc.Dial(
    "dns:///orderservice:9000",
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
)

💡 pick_first 的隐患: 如果你的服务有多个实例,但用了默认的 pick_first,gRPC 只会连接到其中一个实例——其余实例完全收不到请求。这不是 bug,是设计如此,但很多人上线时忘了改。


Q17.【重试与安全边界】

gRPC 客户端配置自动重试时,有以下三条安全红线:

  1. 仅 __________ 操作(GET、DELETE)可以安全重试
  2. POST/create 操作重试可能产生 __________,必须在业务层加幂等键
  3. 重试会增加 __________,特别是涉及 DB 的场景

[!tip]- Q17 答案 幂等 ; 重复数据 ; 读放大

解析:

操作类型 可重试? 原因
Query / Get ✅ 本身幂等,查十次结果一样
Update / Patch ⚠️ 有条件 DB 层加乐观锁或唯一索引
Create / Insert ❌ 谨慎 可能重复插入,必须有幂等键机制
Delete ✅ 幂等操作
{
  "retryPolicy": {
    "maxAttempts": 3,
    "initialBackoff": "0.1s",
    "maxBackoff": "1s",
    "backoffMultiplier": 2,
    "retryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
  }
}

💡 重要提示: retryableStatusCodes 必须精确——不是所有错误都能重试。INTERNAL、InvalidArgument 重试没有意义,反而浪费资源。通常只重试瞬态错误(Unavailable、DeadlineExceeded)。


Q18.【Dial 配置选项】

gRPC 客户端 Dial 时的关键配置项:

配置项 默认值 建议值 方向
MaxCallRecvMsgSize ___ 10MB 客户端
InitialWindowSize 64KB 1MB(高吞吐) 连接级
BackoffBaseDelay 100ms 100ms 重连

[!tip]- Q18 答案 4MB

解析:

gRPC 默认的最大收/发包大小都是 4MB。对于大多数 CRUD 操作绰绰有余,但如果你的业务涉及大文件元数据或批量查询返回大量记录,会遇到如下错误:

grpc: received message larger than max (12345678 vs 4194304)

解决方式:

grpc.MaxCallRecvMsgSize(10 << 20),  // 10 MB
grpc.MaxCallSendMsgSize(10 << 20),  // 10 MB

⚠️ 风险提示: 设得太大会消耗大量内存。建议配合分块传输(Client Streaming)使用,避免一次性拉取过大数据。

💡 Initial Window Size: 默认 64KB 在高吞吐场景会成为瓶颈。调大到 1MB 后可以显著减少 flow control 导致的停顿。


三、代码补全(每题 6 分,共 30 分)

补充代码中空缺的部分。有些题目有多个空。

Q19.【优雅降级——按状态码处理错误】

resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: userId})
if err != nil {
    switch status.Code(err) {
    case codes.NotFound:
        // 用户不存在 → 降级:返回默认值
        return defaultUser, nil
    case codes.Unavailable:
        // 下游挂了 → 降级:尝试从缓存加载
        return fallbackFromCache(ctx, userId)
    case codes.DeadlineExceeded:
        // 超时 → 降级:返回友好提示
        return cachedUser, nil
    _________:
        // 其他错误 → 直接返回
        return nil, err
    }
}

[!tip]- Q19 答案 default

解析: 这是 gRPC 客户端优雅降级的经典模式:根据状态码选择不同的恢复策略,而不是所有错误一律重试或一律上报。

状态码 策略 原因
NotFound 返回默认值 不是系统问题,重试也无济于事
Unavailable 缓存降级 下游暂时不可用,尝试兜底
DeadlineExceeded 缓存兜底 网络抖动的临时现象
default 透传错误 其他错误应该暴露给上层处理

关键点: status.Code() 会从错误中提取 gRPC status code,不受 fmt.Errorf 包装的影响。这是正确使用 gRPC 错误处理的基础。

// 别忘了先 import "google.golang.org/grpc/status"
import "google.golang.org/grpc/status"

💡 防御编程思想: 不要把所有错误归为一类处理——每种错误码代表了不同类型的故障,针对性的恢复策略远比"一刀切"效果好。


Q20.【拦截器——鉴权中间件】

补全一个 gRPC 服务端鉴权拦截器:

func AuthInterceptor() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any,
        info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {

        // 白名单:健康检查和反射服务跳过鉴权
        skipPaths := []string{"/health.Health/Check"}
        for _, p := range skipPaths {
            if info.FullMethod == p {
                _________  // 空1:放行到下一个拦截器/handler
            }
        }

        // 从 incoming context 中提取 metadata
        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
            return nil, status.Error(codes.Unauthenticated, "missing metadata")
        }

        tokens := md.Get("authorization")
        if len(tokens) == 0 || !validateToken(tokens[0]) {
            return nil, status.Error(codes.Unauthenticated, "invalid token")
        }

        // 提取 userID 并注入 context(传递给下游 handler)
        userCtx := context.WithValue(ctx, "userID", extractUserID(tokens[0]))
        _________  // 空2:带着 userID 进入 handler
    }
}

[!tip]- Q20 答案 空1: return handler(ctx, req) ; 空2: return handler(userCtx, req)

解析:

空 填空 作用
空1 return handler(ctx, req) 白名单路径不鉴权,直接进入 handler
空2 return handler(userCtx, req) 鉴权通过后将增强后的 context 传给 handler

完整流程:

请求进来 → AuthInterceptor 检查 path
  → skip? → handler(ctx, req) [直接放行]
  → not skip? → 从 metadata 取 token
    → invalid? → status.Error(Unauthenticated)
    → valid? → handler(userCtx, req) [带 userID 进入]

关键细节:

  • 白名单路径非常必要——健康检查 /health.Health/Check 和 gRPC reflection 不需要鉴权
  • info.FullMethod 的格式是 /package.Service/Method,例如 /order.v1.OrderService/CreateOrder
  • 将用户信息放入 context 而非 global variable,保证并发安全

💡 进阶: 生产环境通常会用一个专门的 middleware package 集中管理所有 interceptor,并通过配置文件决定哪些接口需要鉴权、哪些走白名单。


Q21.【客户端流式——批量上传】

补全一个客户端流式 RPC,用于批量上传日志:

func (s *logServer) UploadLogs(stream pb.LogService_UploadLogsServer) error {
    var count int32
    for {
        log, err := stream.Recv()
        if err == io.EOF {
            _________  // 空1:正常结束循环
        }
        if err != nil {
            return err
        }
        s.store.Save(log.Message)
        _________  // 空2:计数
    }
    return stream.SendAndClose(&pb.UploadResult{Count: _________})  // 空3
}

[!tip]- Q21 答案 空1: break ; 空2: count++ ; 空3: count

解析:

客户端流式的模式固定:for 循环 + Recv() + EOF 判断:

for {
    item, err := stream.Recv()
    if err == io.EOF {
        break  // 客户端调用了 CloseSend(),对流发送端关闭
    }
    if err != nil {
        return err  // 非 EOF 的错误直接返回
    }
    // 处理业务...
}
return stream.SendAndClose(&result)  // 最后一次性返回结果
空 填空 说明
空1 break Recv() 返回 EOF 表示发送端关闭,退出循环
空2 count++ 累加已处理的日志条数
空3 count 聚合结果作为最终响应返回

💡 客户端写法: 客户端需要用 CloseSend() 通知服务端"我不再发了":

stream, _ := client.UploadLogs(ctx)
for _, log := range logs {
    stream.Send(&pb.LogEntry{Message: log})
}
result, err := stream.CloseSend()  // CloseSend 同时返回最终响应

Q22.【OKTEL 自动注入 StatsHandler】

补全 OpenTelemetry gRPC 集成配置:

import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"

func main() {
    // 服务端
    server := grpc.NewServer(
        _________  // 空1:注册服务端 stats handler
    )
    
    // 客户端
    conn, _ := grpc.Dial(target,
        _________  // 空2:注册客户端 stats handler
    )
    defer conn.Close()
    
    client := pb.NewOrderServiceClient(conn)
}

[!tip]- Q22 答案 空1: grpc.StatsHandler(otelgrpc.NewServerStatsHandler()) ; 空2: grpc.WithStatsHandler(otelgrpc.NewClientStatsHandler())

解析:

otelgrpc 通过 gRPC 的 StatsHandler 接口实现自动埋点:

  • 服务端 NewServerStatsHandler():自动在入向请求时创建 Span、提取上游 trace context
  • 客户端 NewClientStatsHandler():自动在出向请求时创建 Span、注入 trace context 到 metadata
请求流向:
ClientSpan.Start() → otelgrpc.ClientStatsHandler.InjectTraceContext → metadata
                         ↓ HTTP/2
otelgrpc.ServerStatsHandler.ExtractTraceContext → ServerSpan.Start()
空 填空 要点
空1 grpc.StatsHandler(otelgrpc.NewServerStatsHandler()) 注意是 grpc.StatsHandler() 不是 grpc.UnaryInterceptor()
空2 grpc.WithStatsHandler(otelgrpc.NewClientStatsHandler()) 客户端用 WithStatsHandler 选项

💡 statsHandler vs Interceptor: StatsHandler 负责指标采集和链路追踪,Interceptor 负责业务横切逻辑(鉴权、日志、重试)。两者互补,各司其职。


Q23.【重试策略配置】

补全 gRPC 客户端重试服务的 JSON 配置:

retryPolicy := `{
    "retryPolicy": {
        "maxAttempts": ___ ,           // 空1:最大重试次数(含首次)
        "initialBackoff": "___" ,      // 空2:初始退避时间
        "maxBackoff": "___" ,          // 空3:最大退避时间
        "backoffMultiplier": ___ ,     // 空4:退避倍率
        "retryableStatusCodes": ["___", "___"]  // 空5~6:可重试的状态码
    }
}`

[!tip]- Q23 答案 空1: 3 ; 空2: 0.1s(或合理值如 100ms); 空3: 1s(或合理值); 空4: 2 ; 空5: UNAVAILABLE ; 空6: DEADLINE_EXCEEDED

解析:

指数退避计算公式: wait = min(initialBackoff × backoffMultiplier^n, maxBackoff)

第几次 计算 实际等待
第1次(首次) — 立即
第2次(重试1) 0.1s × 2^0 = 0.1s 100ms
第3次(重试2) 0.1s × 2^1 = 0.2s 200ms
(理论第4次) 0.1s × 2^2 = 0.4s → capped at 1s ≤ 1s

| 空 | 填空 | 说明 | | |----|------|------| | 空1 | 3 | maxAttempts 包含首次调用,总共尝试 3 次 | | 空2 | 0.1s | 第一次重试的等待基数,太短打满服务器,太长影响体验 | | 空3 | 1s | 上限防止无限等待 | | 空4 | 2 | 每次退避时间翻倍 | | 空5 | UNAVAILABLE | 下游不可用(连接断开、过载),通常可恢复 | | 空6 | DEADLINE_EXCEEDED | 超时也可能是暂时的(GC pause、网络抖动) |

💡 为什么要限制 retryableStatusCodes: 很多开发者把所有错误都设为可重试,这会导致重试风暴——下游本来就挂了,你还拼命重试。只重试瞬态错误,不重试确定性错误(InvalidArgument、PermissionDenied 等)。


四、主观题(每题 4 分,共 16 分)

简要回答即可,不需要长篇大论。关键是说出你的理解。

Q24.【Proto 版本演进策略】

你的 .proto 文件中有一个 message 定义了 20 个字段,已经被多个服务引用。半年后你想在这个 message 中添加第 21 个字段,但在添加之前你要删除第 10 个字段(已废弃)。请说明正确的操作步骤,以及如果操作不当(比如直接删除编号并重用)会带来什么后果。

[!tip]- Q24 参考答案

正确步骤:

  1. 在 message 中加上 reserved <旧的编号>; 锁定已删除字段的编号
  2. 如果有对应的字段名也想一起删除,一并写上 reserved "old_field_name";
  3. 新增字段使用更大的编号(如 21),不要使用已 reserved 的编号
  4. 旧客户端收到包含新编号字段的数据时会忽略它(向后兼容)
  5. 新客户端收到旧服务端响应时,新字段会使用默认值(向前兼容)
message User {
    reserved 10;                     // 锁定已删除编号
    reserved "deprecated_email";     // 锁定已删除字段名
    
    string name = 1;
    int32 age = 2;
    // ... 其他字段
    string new_field = 21;           // 新增字段
}

错误操作的后果: 如果把原来编号 10 的字段删掉后,又新建一个字段也用编号 10:

  • 旧客户端把新字段的数据解析成了已废弃的旧字段类型 → 静默数据损坏
  • 新旧客户端语义错位,bug 排查极其困难
  • 这种问题在编译期不会报错,运行时也不会 panic,属于"最危险的兼容性问题"

💡 核心认知: Proto 编号是合约的一部分。编号一旦分配就不再收回,这是 protobuf 向前兼容的设计哲学。


Q25.【Keepalive 与 MaxConnectionAge 的组合】

在生产环境中部署了一个有 5 个后端的 gRPC 服务。你需要进行滚动升级:逐个替换后端实例为新版本。请问应该如何配置 Keepalive 相关参数来确保升级过程平滑?具体说明 MaxConnectionAge 的作用和它与 Keepalive Time 的区别。

[!tip]- Q25 参考答案

平滑升级策略:

  1. 开启 Keepalive:Time = 10~30s,防止中间代理在空闲时切断连接
  2. 设置 MaxConnectionAge:比如 MaxConnectionAge = 5min,让连接最大存活时间为 5 分钟
  3. 当一个后端实例准备升级时,等待该实例上的旧连接自然到期(最多 5 分钟后)
  4. 客户端检测到连接关闭后自动创建新连接,指向新的后端实例
  5. 完成升级后再处理下一个实例

MaxConnectionAge vs Keepalive Time 的区别:

参数 作用 类比
Keepalive Time 保活 ping 间隔——防止代理切断空闲连接 定期跟朋友打个招呼:"你还在吗?"
MaxConnectionAge 连接最大寿命——强制旧连接到期换新 合同到期不再续签,换一家供应商

两者目的不同:Keepalive 是为了维持连接不被外部杀死;MaxConnectionAge 是为了主动销毁旧连接实现无缝迁移。

💡 注意事项: MaxConnectionAge 是服务端控制的,客户端无权修改。如果客户端不支持动态感知连接关闭,需要配合 health check 一起使用。


Q26.【何时选择 gRPC 而非 HTTP/JSON】

gRPC 有很多优势(类型安全、代码即 SDK、流式能力),但也并非万能。请列举至少三个 gRPC 不适用的场景,并说明在这些场景中为什么 HTTP/JSON(REST)仍然是更好的选择。

[!tip]- Q26 参考答案

gRPC 不适用的场景:

  1. 对外暴露公开 API——第三方接入者不想安装 Protocol Buffer 编译器,浏览器也不能直接调用 gRPC(没有 HTTP/2 原生支持)。HTTP/JSON 通过 fetch() 即可调用,生态成熟。

  2. 需要浏览器直接交互的前端页面——虽然 HTTP/2 已在现代浏览器普及,但浏览器的 Fetch API 不支持 gRPC 的 proto 定义格式。如需全双工通信,WebSocket + JSON 仍是首选。

  3. 简单的单体应用内部通信——如果应用只有一两个服务、QPS 不高,引入 gRPC 增加了 proto 编译、代码生成的复杂度,收益不明显。HTTP/JSON 足以应付。

  4. 搜索引擎爬虫抓取内容——Google/Bing 的爬虫只支持 HTTP/HTML,不可能去编译 proto 文件然后发起 gRPC 请求。

  5. 遗留系统集成——如果老系统只支持 SOAP/XML,引入 gRPC 需要做桥接转换,不如直接在原系统上加一层 HTTP API 网关。

总结: gRPC 的定位是微服务之间的内部通信——强类型、全团队统一 proto、高性能需求。边界清晰,不要把锤子当钉子用。

💡 混合部署模式: 实际项目中经常是"对内 gRPC + 对外 REST"的双栈模式——内部服务用 gRPC 高效通信,API Gateway 层把 gRPC 转换为 HTTP/JSON 暴露给外部。


Q27.【流式调用的错误处理特殊性】

普通 Unary RPC 可以通过 err != nil 判断调用是否成功。但对于 Server Streaming 和 Client Streaming 等流式调用,错误发生在 Recv() 或 Send() 的迭代过程中。请解释流式调用中错误处理的特殊性,并给出一个正确处理 Server Streaming 错误的代码示例。

[!tip]- Q27 参考答案

特殊性: 流式调用的错误不是一次性的返回值,而是在遍历流的过程中逐步产生的。Recv() 可能在接收到最后一个有效消息之前就返回错误。

处理流程:

stream, err := client.ListOrders(ctx, req)
if err != nil {
    // 调用建立阶段就失败了
    return nil, err
}

for {
    order, err := stream.Recv()
    if err == io.EOF {
        // 正常结束:服务端发完了所有消息
        break
    }
    if err != nil {
        // 流中间出错:可能是网络断开、服务端 panic、deadline 到了
        // status.Code(err) 可以判断具体的错误类型
        log.Errorf("stream recv error: %v", err)
        return nil, err
    }
    // 处理正常消息
    process(order)
}

关键点:

  1. EOF ≠ 错误:io.EOF 表示服务端正常关闭了发送端,是预期行为
  2. 需要先判断 EOF:因为 Recv() 在 EOF 时也返回 err != nil,必须先判断 err == io.EOF
  3. 状态码解析:流中间的错误需要通过 status.Code(err) 判断具体是什么故障
  4. 服务端也必须主动关闭:服务端如果忘记 return nil 或 return error,客户端会永远阻塞在 Recv()

Client Streaming 同理:

stream, _ := client.UploadLogs(ctx)
for _, log := range logs {
    if err := stream.Send(&pb.LogEntry{Message: log}); err != nil {
        return err  // Send 期间出错也要处理
    }
}
result, err := stream.CloseAndRecv()
return result, err

💡 核心认知: 流式错误处理的关键是"区分正常结束和异常结束"——EOF 是好的结束,其他 err 是不好的结束。这个判断顺序不能颠倒。


评分参考

题目类型 满分 权重
选择题(Q1-Q10) 40 分 40%
填空题(Q11-Q18) 24 分 24%
代码补全(Q19-Q23) 30 分 30%
主观题(Q24-Q27) 16 分 16%
总计 100 分 100%

及格线: ≥70 分
优秀线: ≥85 分