--- 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 分