1057 lines
43 KiB
Markdown
1057 lines
43 KiB
Markdown
---
|
||
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 分
|