From 68af73d6d86a546aa6b1dff3a9c44e2e368feda0 Mon Sep 17 00:00:00 2001 From: wonder Date: Thu, 7 May 2026 17:23:42 +0800 Subject: [PATCH] vault backup: 2026-05-07 17:23:42 --- hzh/TEST/gRPC.md | 1056 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1056 insertions(+) create mode 100644 hzh/TEST/gRPC.md diff --git a/hzh/TEST/gRPC.md b/hzh/TEST/gRPC.md new file mode 100644 index 0000000..6146233 --- /dev/null +++ b/hzh/TEST/gRPC.md @@ -0,0 +1,1056 @@ +--- +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. 新增 oneof 变体会破坏旧客户端的兼容性 +D. oneof 中的每个成员必须使用不同的字段编号,且编号不能跨 oneof 组共用 + +> [!tip]- Q3 答案 +> **B — oneof 零值为 NONE(0),无法区分未设置状态** +> +> **解析:** +> `oneof` 的核心语义是"同一时刻最多一个有值",它是 Proto3 表达可选联合类型的方式。 +> +> **逐项分析:** +> - ❌ A:`oneof` **不能**加 `repeated` 修饰符,因为 multiple 值和 oneof 互斥语义矛盾 +> - ✅ B:正确。Proto3 没有 Optional 概念,当 all fields 都等于其默认值时,你无法知道用户到底传了 `"none"` 还是没传 +> - ❌ C:新增 oneof 变体是安全的——旧客户端会把它当成未知编号字段忽略 +> - ❌ D:oneof 内的成员只需在自己的 oneof 内唯一,不同 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 分