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

1057 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- 后端
- Go
- gRPC
- 测试
- 自我考察
create time: 2026-05-07
update time: 2026-05-07
status: 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` 包装:
> ```proto
> 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 — 双向流式是实时聊天场景的最佳选择**
>
> **解析:**
> 聊天的核心特征是:**双方随时都可以发消息**(全双工),且消息到达即推送(低延迟),不需要等对方发完再统一处理。
>
> ```mermaid
> 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()`。
>
> **常见陷阱场景:**
> ```go
> 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,不会永久阻塞
> }
> ```
>
> ```go
> 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` 多次指定拦截器,后面的会**覆盖**前面的。
>
> ```go
> // ❌ 错误做法——后面覆盖了前面,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)` 精准判断该重试、降级还是报错。
>
> ```go
> // ❌ 丢掉语义
> 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`。
>
> **解决方案:**
> ```go
> 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
>
> ```go
> // 服务端——自动从 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 字段添加校验规则并自动生成校验代码
>
> ```yaml
> # 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 集成模式:**
> ```bash
> 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 类型解决了这个问题:
> ```proto
> 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 变化会导致粘性失效 | 需要持久化用户关联时使用 |
>
> ```go
> // 指定 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 | ✅ | 幂等操作 |
>
> ```json
> {
> "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)
> ```
>
> **解决方式:**
> ```go
> grpc.MaxCallRecvMsgSize(10 << 20), // 10 MB
> grpc.MaxCallSendMsgSize(10 << 20), // 10 MB
> ```
>
> **⚠️ 风险提示:** 设得太大会消耗大量内存。建议配合分块传输(Client Streaming)使用,避免一次性拉取过大数据。
>
> 💡 **Initial Window Size:** 默认 64KB 在高吞吐场景会成为瓶颈。调大到 1MB 后可以显著减少 flow control 导致的停顿。
---
## 三、代码补全(每题 6 分,共 30 分)
> 补充代码中空缺的部分。有些题目有多个空。
### Q19.【优雅降级——按状态码处理错误】
```go
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 错误处理的基础。
>
> ```go
> // 别忘了先 import "google.golang.org/grpc/status"
> import "google.golang.org/grpc/status"
> ```
>
> 💡 **防御编程思想:** 不要把所有错误归为一类处理——每种错误码代表了不同类型的故障,针对性的恢复策略远比"一刀切"效果好。
---
### Q20.【拦截器——鉴权中间件】
补全一个 gRPC 服务端鉴权拦截器:
```go
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,用于批量上传日志:
```go
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 判断**:
>
> ```go
> 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()` 通知服务端"我不再发了":
> ```go
> stream, _ := client.UploadLogs(ctx)
> for _, log := range logs {
> stream.Send(&pb.LogEntry{Message: log})
> }
> result, err := stream.CloseSend() // CloseSend 同时返回最终响应
> ```
---
### Q22.【OKTEL 自动注入 StatsHandler】
补全 OpenTelemetry gRPC 集成配置:
```go
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 配置:
```go
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. 新客户端收到旧服务端响应时,新字段会使用默认值(向前兼容)
>
> ```proto
> 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()` 可能在接收到最后一个有效消息之前就返回错误。
>
> **处理流程:**
> ```go
> 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 同理:**
> ```go
> 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 分