vault backup: 2026-05-11 19:02:38
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
---
|
||||
tags: [gRPC, RPC, Streaming, Go, Microservice]
|
||||
create time: 2026-05-11 16:40
|
||||
---
|
||||
|
||||
# RPC 调用模式总览
|
||||
|
||||
## 概述
|
||||
|
||||
gRPC 提供四种 RPC 调用模式,从最简单的请求-响应到完全的双向流。选择合适的模式是设计高性能 API 的第一步。搞懂它们的区别,你就知道什么时候该用简单调用、什么时候需要双向通信。
|
||||
|
||||
> [!question] 如果只选一种模式能走天下吗?
|
||||
> 技术上可以——Unary RPC 确实能解决几乎所有问题。但强行用 Unary 实现实时推送,意味着你要轮询(polling),这会产生大量无效请求和延迟。模式选择本质上是在"延迟 vs 资源消耗"之间做 tradeoff。
|
||||
|
||||
## RPC 模式全景图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Unary] -->|"一问一答"| B[最简单]
|
||||
C[Server Stream] -->|"一问多答"| D[广播式]
|
||||
E[Client Stream] -->|"多问一答"| F[收集式]
|
||||
G[BiDi Stream] -->|"多问多答"| H[全双工]
|
||||
|
||||
style A fill:#00B6BC,color:#fff
|
||||
style G fill:#EE5A24,color:#fff
|
||||
```
|
||||
|
||||
> [!example] 各模式数据流向速览
|
||||
> 左列为 Client,右列为 Server,箭头方向表示数据流动方向。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Server
|
||||
|
||||
rect rgba(0, 182, 188, 0.1)
|
||||
Note over C,S: Unary — 阻塞式一次往返
|
||||
C->>S: request
|
||||
S-->>C: response
|
||||
end
|
||||
|
||||
rect rgba(0, 216, 102, 0.1)
|
||||
Note over C,S: Server Stream — 请求后连续响应
|
||||
C->>S: request
|
||||
S-->>C: response 1
|
||||
S-->>C: response 2
|
||||
S-->>C: ... n (EOF)
|
||||
end
|
||||
|
||||
rect rgba(79, 195, 247, 0.1)
|
||||
Note over C,S: Client Stream — 连续发送后一次性响应
|
||||
C->>S: chunk 1
|
||||
C->>S: chunk 2
|
||||
C->>S: ... n (done)
|
||||
S-->>C: result
|
||||
end
|
||||
|
||||
rect rgba(238, 90, 36, 0.1)
|
||||
Note over C,S: BiDi Stream — 双向独立通信
|
||||
C->>S: msg 1
|
||||
S-->>C: reply 1
|
||||
C->>S: msg 2
|
||||
S-->>C: reply 2
|
||||
end
|
||||
```
|
||||
|
||||
## Unary RPC(普通调用)
|
||||
|
||||
最经典、最常见的模式,等同于 REST 的 request-response。
|
||||
|
||||
**特点:**
|
||||
- 一次客户端请求,一次服务端响应
|
||||
- 简单、易调试、可直接映射 HTTP GET/POST
|
||||
- 适合:CRUD 操作、短查询、标准 API 端点
|
||||
|
||||
```go
|
||||
// proto 定义
|
||||
// rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
|
||||
// Client side
|
||||
func main() {
|
||||
conn, _ := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
defer conn.Close()
|
||||
client := pb.NewUserServiceClient(conn)
|
||||
|
||||
resp, err := client.GetUser(context.Background(), &pb.GetUserRequest{Id: 42})
|
||||
if err != nil {
|
||||
log.Fatal(err) // error comes from transport or server handler
|
||||
}
|
||||
fmt.Println(resp.Name)
|
||||
}
|
||||
|
||||
// Server side
|
||||
type Server struct {
|
||||
pb.UnimplementedUserServiceServer
|
||||
}
|
||||
|
||||
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
|
||||
user := fetchFromDB(req.GetId())
|
||||
return &pb.GetUserResponse{Name: user.Name}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**思考题:**如果一个接口需要 30 秒才能返回结果,你应该用 Unary 还是其他模式?(提示:考虑超时和连接的持有时间)
|
||||
|
||||
> [!answer]+ 参考答案
|
||||
> **可以用 Unary,但要注意三件事:**
|
||||
>
|
||||
> 1. **设置合理的 context timeout**:`context.WithTimeout`,避免无限期等待
|
||||
> 2. **HTTP/2 ping keepalive**:gRPC 默认会发送 keepalive ping 防止代理(Nginx/LB)因"连接空闲"而断开
|
||||
> 3. **是否真的需要阻塞等待**:如果是异步任务(如报表生成),更好的做法是——Unary 提交任务 + 轮询/回调通知结果,而非让一个 RPC 连接挂 30 秒。
|
||||
>
|
||||
> Streaming 并不会延长超时时间——超时由 context 控制,与使用哪种 RPC 模式无关。
|
||||
|
||||
## Server Streaming RPC(服务端流)
|
||||
|
||||
Client 发一个请求,Server 持续返回多个响应。
|
||||
|
||||
**经典场景:**
|
||||
- 实时通知推送
|
||||
- 大列表分批返回
|
||||
- 日志/事件流订阅
|
||||
|
||||
```protobuf
|
||||
rpc Subscribe(SubscribeRequest) returns (stream Event);
|
||||
```
|
||||
|
||||
```go
|
||||
// Server side - 持续发送事件(注意不要用 log.Fatal,会杀死整个进程)
|
||||
func (s *Server) Subscribe(req *pb.SubscribeRequest, stream pb.UserService_SubscribeServer) error {
|
||||
for _, event := range s.watchEvents(req.Topic) {
|
||||
if err := stream.Send(&pb.Event{Data: event}); err != nil {
|
||||
return err // channel closed or context canceled
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] import 提示
|
||||
> 流式示例中用到以下包:`context`, `fmt`, `io`, `log`.
|
||||
> 每个文件只需 import 实际用到的即可,不必照抄。
|
||||
|
||||
```go
|
||||
// Client side - 遍历接收事件(生产环境建议用 recover + defer 做错误恢复)
|
||||
func main() {
|
||||
client := pb.NewUserServiceClient(conn)
|
||||
stream, err := client.Subscribe(context.Background(), &pb.SubscribeRequest{Topic: "orders"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for {
|
||||
event, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
break // server finished sending
|
||||
}
|
||||
if err != nil {
|
||||
return err // ⚠️ 这里用 return 而非 log.Fatal,服务端的 handler 不能 kill 进程
|
||||
}
|
||||
fmt.Printf("received: %s\n", event.Data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 注意:Client 仍然可以通过 context cancel 随时中断流。这是调试流式问题时最容易忽略的一点——不是 Server 主动关了连接,而是 Client 放弃了。
|
||||
|
||||
## Client Streaming RPC(客户端流)
|
||||
|
||||
Client 持续发送多个请求,Server 在所有数据发送完毕后返回一个响应。
|
||||
|
||||
**经典场景:**
|
||||
- 大批量数据上传
|
||||
- 文件分片聚合处理
|
||||
- 批量日志采集
|
||||
|
||||
```protobuf
|
||||
rpc Upload(stream FileChunk) returns (UploadResult);
|
||||
```
|
||||
|
||||
```go
|
||||
// Client side - 流式发送数据分片
|
||||
func uploadFile(client pb.FileServiceClient, chunks [][]byte) error {
|
||||
stream, err := client.Upload(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
if err := stream.Send(&pb.FileChunk{Data: chunk}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result, err := stream.CloseAndRecv() // 结束发送,获取最终结果
|
||||
return err
|
||||
}
|
||||
|
||||
// Server side - 逐块接收后聚合(用 return 而非 Fatal,让 gRPC 框架处理错误上报)
|
||||
func (s *Server) Upload(stream pb.FileService_UploadServer) error {
|
||||
var buffer bytes.Buffer
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
break // client closed send direction
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer.Write(chunk.Data)
|
||||
}
|
||||
_, err := stream.SendAndReceive(&pb.UploadResult{Size: int32(buffer.Len())})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
**优势:**内存友好——不需要一次性 load 全部数据到内存中,每个 chunk 独立收发。
|
||||
|
||||
## Bidirectional Streaming RPC(双向流)
|
||||
|
||||
Client 和 Server 可以同时独立地发送消息,是全双工通信。
|
||||
|
||||
**经典场景:**
|
||||
- 聊天室
|
||||
- 实时协作编辑
|
||||
- 游戏状态同步
|
||||
|
||||
```protobuf
|
||||
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
|
||||
```
|
||||
|
||||
```go
|
||||
// Server side - 转发逻辑,两端各自独立循环
|
||||
func (s *Server) Chat(stream pb.ChatService_ChatServer) error {
|
||||
done := make(chan struct{})
|
||||
|
||||
// goroutine 1: 读取客户端消息
|
||||
go func() {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 广播给其他 connected clients...
|
||||
s.broadcast(msg)
|
||||
}
|
||||
}()
|
||||
|
||||
// goroutine 2: 定时推送服务器消息
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case t := <-ticker.C:
|
||||
stream.Send(&pb.ChatMessage{Text: fmt.Sprintf("heartbeat: %s", t)})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键点:**
|
||||
- 两端的 Send 和 Recv 是独立的——一端 Recv 完不影响另一端继续 Send
|
||||
- 必须用两个 goroutine 分别处理 recv 和 send 循环
|
||||
- `io.EOF` 只表示对方的关闭,不代表己方也要停止
|
||||
|
||||
### 背压与心跳(生产级要点)
|
||||
|
||||
BiDi Stream 在长连接场景下,有两个必须考虑的问题:
|
||||
|
||||
```go
|
||||
// 背压控制:如果 Send 堆积过多,应该限流或暂停
|
||||
func (s *Server) handleStream(stream pb.ChatService_ChatServer) error {
|
||||
sendCh := make(chan *pb.ChatMessage, 100) // buffer size = 100
|
||||
go func() {
|
||||
for msg := range sendCh {
|
||||
// Send 是阻塞的——buffer 满时自动背压
|
||||
stream.Send(msg)
|
||||
}
|
||||
}()
|
||||
// ...recv loop 往 sendCh 里塞消息即可
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 背压原理
|
||||
> gRPC 的 `Send()` 是**有缓冲阻塞**的。当 internal buffer 写满时,发送端会自动 pause——这就是 HTTP/2 Flow Control 提供的天然背压机制,不需要手动实现。但你应该设置合理的 buffer size,过大浪费内存,过小影响吞吐。
|
||||
|
||||
> [!note] Keepalive 配置示例
|
||||
> ```go
|
||||
> conn, _ := grpc.Dial(addr,
|
||||
> grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||||
> Time: 10 * time.Second, // ping interval
|
||||
> Timeout: 20 * time.Second, // wait for ping ack
|
||||
> PermitWithoutStream: true, // 即使无活跃 RPC 也发 ping
|
||||
> }),
|
||||
> )
|
||||
> ```
|
||||
> 这对穿越 Nginx / AWS ALB 等负载均衡器至关重要——它们通常会对空闲连接执行 tcp idle timeout 断开。
|
||||
|
||||
> [!warning] 复杂度警告
|
||||
> BiDi Streaming 是最强大但也最容易出错的模式。你必须同时处理:context cancel、io.EOF、网络异常、心跳保活、背压(backpressure)。生产环境中除非必要,否则优先考虑其他三种模式。
|
||||
|
||||
## 模式选型决策指南
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start{是否需要<br/>实时交互?}
|
||||
Start -->|否| Simple{单次<br/>请求?}
|
||||
Start -->|是| BiDi{高频<br/>交互?}
|
||||
|
||||
Simple -->|是| U[Unary RPC<br/>最简单]
|
||||
Simple -->|否| SS[Server Stream<br/>一次请求多次返回]
|
||||
|
||||
BiDi -->|是| BD[Bidirectional Stream<br/>全双工通信]
|
||||
BiDi -->|否| CS{数据量<br/>超大?}
|
||||
CS -->|是| CB[Client Stream<br/>分批上传]
|
||||
CS -->|否| SS
|
||||
|
||||
style U fill:#00D866,color:#fff
|
||||
style BD fill:#FF6B35,color:#fff
|
||||
```
|
||||
|
||||
## 性能对比
|
||||
|
||||
| 维度 | Unary | Server Stream | Client Stream | BiDi Stream |
|
||||
|------|-------|---------------|---------------|-------------|
|
||||
| **RTT** | 1 | 1+N | M+1 | M+N |
|
||||
| **实现复杂度** | ⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **内存占用** | 高(一次加载完整响应) | 低(逐条处理) | 低(分片发送) | 低(双向流控) |
|
||||
| **超时风险** | 高(连接全程持有一直到完整响应) | 低(请求已发出,可取消) | 中(大文件需合理 timeout) | 中(长连接需 keepalive) |
|
||||
| **典型场景** | CRUD、短查询 | 订阅推送、列表分页 | 大文件上传、批量采集 | 聊天室、实时协作、游戏同步 |
|
||||
|
||||
## 与 HTTP 方法的映射类比
|
||||
|
||||
| gRPC 模式 | 近似的 HTTP 模式 |
|
||||
|-----------|-----------------|
|
||||
| Unary | GET / POST |
|
||||
| Server Stream | SSE (Server-Sent Events) |
|
||||
| Client Stream | Multipart Upload |
|
||||
| BiDi Stream | WebSocket |
|
||||
|
||||
> [!note] 类比 ≠ 等价
|
||||
> 这些只是功能层面的类比。gRPC 是二进制协议且基于 HTTP/2,行为特征和 HTTP 层语义有本质差异——比如 HTTP/2 的多路复用让 gRPC Stream 比 WebSocket 更高效。
|
||||
|
||||
## 关联笔记
|
||||
- [[hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成]]
|
||||
- [[hhs/gRPC/3. 服务端实现/09-Streaming Handler]]
|
||||
@@ -0,0 +1,439 @@
|
||||
---
|
||||
tags: [gRPC, Protobuf, Go, protoc, Code Generation, proto3]
|
||||
create time: 2026-05-11 16:41
|
||||
---
|
||||
|
||||
# Service 定义与代码生成
|
||||
|
||||
## 概述
|
||||
|
||||
`.proto` 文件的最终目的是定义 Service —— 告诉 gRPC 有哪些远程可调用的 API。Protobuf 编译器会将你的 service 定义翻译成各个语言的 client stub 和 server interface。理解这个从文本到可执行代码的过程,是调试"gRPC 报错说找不到方法"的前提。
|
||||
|
||||
> [!question] 为什么需要代码生成?
|
||||
> Protobuf 不是动态语言。所有类型、字段、方法都在编译期确定,这意味着你没法在运行时通过字符串来调用一个 RPC——必须用生成的 Stub。好处是强类型检查能在编码阶段就发现错误,坏处是你改了 proto 文件就得重新生成代码并编译。
|
||||
|
||||
## Proto3 语法速览
|
||||
|
||||
Service 由 message 组成,所以先快速过一遍 proto3 的核心语法。这是写 proto 文件时每天都要用的基础知识。
|
||||
|
||||
### 消息(Message)与字段类型
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package user.v1;
|
||||
|
||||
message User {
|
||||
string name = 1; // 变长字符串,UTF-8 编码
|
||||
int64 id = 2; // 64 位有符号整数
|
||||
bool active = 3; // 布尔值
|
||||
float balance = 4; // 32 位浮点数
|
||||
bytes avatar = 5; // 原始字节(如图片数据)
|
||||
}
|
||||
```
|
||||
|
||||
**常用标量类型一览:**
|
||||
|
||||
| 声明类型 | 对应 Go 类型 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `double` / `float` | `float64` / `float32` | 浮点数 |
|
||||
| `int64` / `uint64` | `int64` / `uint64` | 大整数 |
|
||||
| `int32` / `uint32` | `int32` / `uint32` | 普通整数(用 varint 编码,小值更紧凑) |
|
||||
| `bool` | `bool` | 布尔 |
|
||||
| `string` | `string` | UTF-8 字符串(必须用 string,bytes 存原始二进制) |
|
||||
| `bytes` | `[]byte` | 任意字节序列 |
|
||||
|
||||
> [!tip] 零值语义
|
||||
> proto3 没有 `optional` 标记的字段永远有零值——`string` 是 `""`,`int` 是 `0`,`bool` 是 `false`。你无法区分"字段没设置"和"字段设为零值"。如果需要检测字段是否存在,可以用 `google.protobuf.BoolValue` 包装类型,或者启用 `optional` 关键字(proto3 扩展语法)。
|
||||
|
||||
### 枚举(Enum)
|
||||
|
||||
```protobuf
|
||||
enum UserRole {
|
||||
ROLE_UNKNOWN = 0; // proto3 要求第一个值是 0,且必须是唯一的 zero value
|
||||
ROLE_ADMIN = 1;
|
||||
ROLE_USER = 2;
|
||||
ROLE_GUEST = 3;
|
||||
}
|
||||
|
||||
message CreateUserRequest {
|
||||
string name = 1;
|
||||
UserRole role = 2; // 用枚举替代魔法数字
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] enum 的零值陷阱
|
||||
> proto3 中如果收到未知枚举值,它会被当作零值处理而非报错。这意味着服务端可以优雅地忽略客户端传来的新版本枚举值——这是 proto 向后兼容的设计之一。
|
||||
|
||||
### Oneof(多选一)
|
||||
|
||||
当多个字段互斥时使用 `oneof`,它比用单独字段节省内存,因为底层只有一个字段在存储。
|
||||
|
||||
```protobuf
|
||||
message PaymentMethod {
|
||||
oneof method {
|
||||
string credit_card = 1; // Visa/MC number
|
||||
string paypal_email = 2; // PayPal 账户
|
||||
AlipayAccount alipay = 3; // 自定义 message
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
只能设置 oneof 中的**一个**字段。设置新字段会清除前一个的值。
|
||||
|
||||
### Map Fields
|
||||
|
||||
```protobuf
|
||||
message UserMetadata {
|
||||
map<string, string> tags = 1; // user -> tag mappings
|
||||
map<int64, string> department_map = 2; // dept ID -> name
|
||||
}
|
||||
```
|
||||
|
||||
内部实现是一个哈希表。空 map 序列化为空,不会省略。
|
||||
|
||||
### Reserved 字段
|
||||
|
||||
当你删除或重命名某个字段时,用 `reserved` 占位防止其他人重用同一 field number:
|
||||
|
||||
```protobuf
|
||||
message User {
|
||||
reserved 3, 5 to 8; // 保留 field numbers 3, 5, 6, 7, 8
|
||||
reserved "old_name", "temp"; // 也保留字段名
|
||||
}
|
||||
```
|
||||
|
||||
### Well-Known Types
|
||||
|
||||
Protobuf 内置了一组通用类型,import 后可直接使用:
|
||||
|
||||
```protobuf
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
import "google/protobuf/struct.proto";
|
||||
|
||||
message Event {
|
||||
google.protobuf.Timestamp created_at = 1; // RFC 3339 时间戳
|
||||
google.protobuf.StringValue display_name = 2; // *string,用于 detect missing
|
||||
google.protobuf.Struct metadata = 3; // JSON-like 任意结构
|
||||
}
|
||||
```
|
||||
|
||||
常用 well-known type 速查:
|
||||
|
||||
| Well-Known Type | 对应 Go 类型 |
|
||||
|----------------|-------------|
|
||||
| `Timestamp` | `time.Time` |
|
||||
| `Duration` | `time.Duration` |
|
||||
| `StringValue` | `*string` |
|
||||
| `Int32Value` / `Int64Value` | `*int32` / `*int64` |
|
||||
| `BoolValue` | `*bool` |
|
||||
| `Any` | `any` / `[]byte` |
|
||||
|
||||
## Service 定义语法
|
||||
|
||||
Service 定义使用 `service` 关键字包裹一组 `rpc` 方法:
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package user.v1;
|
||||
option go_package = "example.com/proto/user/v1;userpb";
|
||||
|
||||
message CreateUserRequest {
|
||||
string name = 1;
|
||||
string email = 2;
|
||||
}
|
||||
|
||||
message CreateUserResponse {
|
||||
int64 id = 1;
|
||||
}
|
||||
|
||||
service UserService {
|
||||
// Unary: 普通请求响应
|
||||
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
|
||||
|
||||
// Server Streaming: 一次请求,多个响应
|
||||
rpc ListUsers(ListUsersRequest) returns (stream ListUsersResponse);
|
||||
|
||||
// Client Streaming: 多次请求,一个响应
|
||||
rpc UploadAvatar(stream AvatarChunk) returns (AvatarResult);
|
||||
|
||||
// Bidirectional Streaming: 双方都流式
|
||||
rpc WatchUsers(stream WatchRequest) returns (stream WatchEvent);
|
||||
}
|
||||
```
|
||||
|
||||
**语法要点:**
|
||||
- `stream` 关键字出现在参数侧表示该方向是流式
|
||||
- `stream` 可以出现在 request 侧、response 侧,或两侧都有
|
||||
- 每个 proto 文件可以有**多个** service 定义(但最佳实践建议一个文件一个 service,保持高内聚)
|
||||
|
||||
## Option 系统
|
||||
|
||||
### 必填项
|
||||
|
||||
| Option | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `syntax` | 语法版本,proto3 是当前标准 | `syntax = "proto3";` |
|
||||
| `package` | 命名空间,防止跨项目命名冲突 | `package user.v1;` |
|
||||
| `go_package` | Go 输出路径和包名 | `option go_package = "...";` |
|
||||
|
||||
### go_package 详解
|
||||
|
||||
这是 Go 开发者最容易踩坑的地方。它的格式是 `"模块路径/生成文件存放路径;包名"`。
|
||||
|
||||
```protobuf
|
||||
// ✅ 正确:module path 是 github.com/myorg/services
|
||||
// 生成的文件放在 gen/proto/user/v1/ 目录下
|
||||
// 包名为 userpb
|
||||
option go_package = "github.com/myorg/services/gen/proto/user/v1;userpb";
|
||||
|
||||
// ❌ 错误:go_package 中的路径与实际 import 不匹配
|
||||
// 会导致 Go 编译器报 symbol undefined
|
||||
option go_package = "user/v1;userpb";
|
||||
```
|
||||
|
||||
> [!tip] go_package 拆解
|
||||
> ```
|
||||
> option go_package = "导入路径/子路径;包名";
|
||||
> // ↑ 生成文件相对 module root 的路径 ↑ Go package name
|
||||
> // 生成文件的 import path 将是 module root + 前半段
|
||||
> ```
|
||||
>
|
||||
> 例如:`go_package = "github.com/myorg/api/user/v1;userpb"`,如果当前 `.proto` 所在目录与 `v1` 对齐,则生成的 `_pb.go` 的 import path 为 `github.com/myorg/api/user/v1`,文件中 `package userpb`。
|
||||
|
||||
### 其他语言路径配置
|
||||
|
||||
```protobuf
|
||||
// 如需支持多语言输出,补全对应的 option
|
||||
option java_package = "com.example.user.v1";
|
||||
option java_multiple_files = true; // 每个 message 单独一个 Java 文件
|
||||
|
||||
option py_generic_services = false; // Python 是否生成 service 基类
|
||||
|
||||
option php_namespace = "Example\\User\\V1";
|
||||
```
|
||||
|
||||
> [!note] 如果只开发 Go 服务,其他语言的 option 可以不填,减少维护负担。
|
||||
|
||||
### 标记已废弃字段
|
||||
|
||||
```protobuf
|
||||
message OldUserProto {
|
||||
string old_field = 1 [deprecated = true]; // 前端可用 @deprecated 注解识别
|
||||
}
|
||||
```
|
||||
|
||||
### optimize_for(性能调优选项)
|
||||
|
||||
对于消息体很大的场景,可以用 `optimize_for` 控制代码生成的策略:
|
||||
|
||||
```protobuf
|
||||
option optimize_for = SPEED; // 默认:生成的序列化/反序列化代码最快
|
||||
// option optimize_for = CODE_SIZE; // 优化生成代码大小(使用 lite runtime)
|
||||
// option optimize_for = LITE_RUNTIME; // 生成依赖 lite protobuf runtime 的代码
|
||||
```
|
||||
|
||||
- **`SPEED`**(默认):生成完整的序列化和反序列化代码,速度最优
|
||||
- **`CODE_SIZE`**:使用反射式编解码,减小生成的代码体积,适合嵌入式环境
|
||||
- **`LITE_RUNTIME`**:类似 CODE_SIZE,但保留部分直接编解码逻辑,折中方案
|
||||
|
||||
大多数 Web 微服务不需要改这个选项——默认的 SPEED 就是最好的选择。
|
||||
|
||||
### Custom Options 简介
|
||||
|
||||
通过 `extend google.protobuf.MessageOptions` 可以定义自定义 option,被 gRPC Gateway、Protoc Gen OpenAPI 等工具链借用。了解即可,涉及 proto 元数据反射,属于进阶话题。
|
||||
|
||||
## Proto 编译流程
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[".proto 源文件"] --> B["protoc 编译器"]
|
||||
B --> C["protoc-gen-go — Go struct 定义"]
|
||||
B --> D["protoc-gen-go-grpc — Client Stub + Server Interface"]
|
||||
C --> E["Go 代码编译"]
|
||||
D --> E
|
||||
E --> F["可执行程序"]
|
||||
|
||||
style A fill:#FFD43B
|
||||
style B fill:#00B6BC,color:#fff
|
||||
style F fill:#4FC08D,color:#fff
|
||||
```
|
||||
|
||||
核心流程就是三步:写 `.proto` → `protoc` 生成 Go 代码 → 正常 `go build`。
|
||||
|
||||
### 典型的项目目录结构
|
||||
|
||||
```
|
||||
proto/
|
||||
├── buf.gen.yaml # buf 代码生成配置(如果用 buf)
|
||||
├── user/
|
||||
│ └── v1/
|
||||
│ ├── user.proto # Service + Message 定义
|
||||
│ └── error.proto # 错误码定义
|
||||
├── google/ # third_party 依赖(来自 grpc-ecosystem/grpc-gateway)
|
||||
└── Makefile # 自动化生成脚本
|
||||
```
|
||||
|
||||
> [!tip] 推荐的两种代码生成方式
|
||||
>
|
||||
> 1. **Makefile + protoc**:最传统的做法,用 shell 变量管理 `-I` 路径
|
||||
> 2. **buf**:现代 proto 编译工具,自动处理依赖管理和 plugin 版本,推荐新项目使用
|
||||
>
|
||||
> 具体用法参见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]
|
||||
|
||||
## Go Stub 解析
|
||||
|
||||
运行 protoc 后,每个 `.proto` 文件至少生成两个文件:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `xxx_pb.go` | Message 的 struct 定义(如 `CreateUserRequest{}`) |
|
||||
| `xxx_grpc.pb.go` | Client interface + Server interface + Register 函数 |
|
||||
|
||||
### `_grpc.pb.go` 中的关键符号
|
||||
|
||||
```go
|
||||
// --- 客户端 ---
|
||||
type UserServiceClient interface {
|
||||
CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error)
|
||||
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (UserService_ListUsersClient, error)
|
||||
// ...
|
||||
}
|
||||
|
||||
func NewUserServiceClient(cc grpc.ClientConnInterface) UserServiceClient
|
||||
|
||||
// --- 服务端 ---
|
||||
type UserServiceServer interface {
|
||||
CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error)
|
||||
ListUsers(*ListUsersRequest, UserService_ListUsersServer) error
|
||||
// ...
|
||||
}
|
||||
|
||||
// 你必须嵌入这个来拿到零值安全的方法实现
|
||||
type UnimplementedUserServiceServer struct{}
|
||||
|
||||
// --- 注册 ---
|
||||
func RegisterUserServiceServer(s grpc.ServiceRegistrar, srv UserServiceServer)
|
||||
|
||||
// --- ServiceDesc ---
|
||||
var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "user.v1.UserService",
|
||||
MethodType: grpc.Unary,
|
||||
MethodName: "CreateUser",
|
||||
Handler: ...,
|
||||
}
|
||||
```
|
||||
|
||||
### `_pb.go` 中的 Message
|
||||
|
||||
```go
|
||||
type CreateUserRequest struct {
|
||||
nameState impl.MessageState
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"`
|
||||
}
|
||||
// + getter methods: GetName(), GetEmail()
|
||||
// + JSON marshaler/unmarshaler
|
||||
```
|
||||
|
||||
> [!tip] Getter 方法
|
||||
> Protobuf Go 生成器会为每个字段生成 `GetXxx()` getter方法。即使字段本身是 public 的(大写),你也应该优先用 getter——某些字段未来可能会改为 internal 实现,getter 能保护你的代码不受影响。
|
||||
|
||||
### 实战:客户端调用示例
|
||||
|
||||
```go
|
||||
// 构建客户端并发起请求
|
||||
conn, _ := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
defer conn.Close()
|
||||
|
||||
client := pb.NewUserServiceClient(conn)
|
||||
|
||||
resp, err := client.CreateUser(context.Background(), &pb.CreateUserRequest{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("created user with id=%d\n", resp.Id)
|
||||
```
|
||||
|
||||
## 代码生成命令速查
|
||||
|
||||
### protoc 基本用法
|
||||
|
||||
```bash
|
||||
# 最简形式(generated files 放入 output dir)
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go-grpc_out=. \
|
||||
*.proto
|
||||
```
|
||||
|
||||
### source_relative 模式(推荐)
|
||||
|
||||
生成的文件与原 `.proto` 放在同目录下,更符合 Go 习惯:
|
||||
|
||||
```bash
|
||||
protoc \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-grpc_opt=paths=source_relative \
|
||||
-I . \
|
||||
*.proto
|
||||
```
|
||||
|
||||
### 多目录 / 带 import 的场景
|
||||
|
||||
```bash
|
||||
protoc \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-grpc_opt=paths=source_relative \
|
||||
-I proto/ \
|
||||
-I third_party/googleapis/ \
|
||||
proto/user/v1/*.proto
|
||||
```
|
||||
|
||||
### 集成到 go generate
|
||||
|
||||
在项目的入口文件顶部添加注释指令:
|
||||
|
||||
```go
|
||||
//go:generate protoc \
|
||||
// --go_opt=paths=source_relative \
|
||||
// --go-grpc_opt=paths=source_relative \
|
||||
// -I . \
|
||||
// ./proto/**/*.proto
|
||||
```
|
||||
|
||||
然后在终端只需一行:
|
||||
|
||||
```bash
|
||||
go generate ./...
|
||||
```
|
||||
|
||||
> [!tip] 为什么推荐 go generate?
|
||||
> 相比手写 protoc 命令,`go generate` 让代码生成变成 Go 工作流的一部分,不再需要额外记忆复杂的命令行参数。结合 Makefile 或 Task 更稳定。参见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]。
|
||||
|
||||
## 常见错误排查
|
||||
|
||||
| 错误信息 | 原因 | 解决方法 |
|
||||
|----------|------|----------|
|
||||
| `symbol undefined` | `go_package` 路径不正确 | 检查 `go_package` 中的 module 路径是否匹配当前项目的 `go.mod` |
|
||||
| `service not found` | 没有运行 `protoc-gen-go-grpc` | 确认 `go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest` 且 `$GOPATH/bin` 在 PATH 中 |
|
||||
| `mismatched version` | `protoc` CLI 版本与 plugin 版本不匹配 | 升级 plugin 到最新版,或降级 protoc。两者不需要严格一致,但不要跨太多代 |
|
||||
| `unknown syntax` | `.proto` 缺少 `syntax = "proto3"` | 添加该行或在文件开头加上 `syntax = "proto3";` |
|
||||
| `import not found` | `-I` 导入路径不对 | 检查 `import "..."` 声明与 `-I` 指定的 search path 是否对齐 |
|
||||
| `already defined` | 同一个 message/service 被定义了两次 | 检查是否有重复 import,或多个 proto 文件导出到了同一目标目录 |
|
||||
| `go_package mismatch` | 生成文件所在的包路径不符合 Go module 约定 | 将 `go_package` 调整为相对于 module root 的正确路径 |
|
||||
|
||||
> [!tip] 快速诊断顺序
|
||||
> 1. `protoc --version` 看版本
|
||||
> 2. `which protoc-gen-go-grpc` 确认 plugin 可执行
|
||||
> 3. `grep go_package *.proto` 检查模块路径
|
||||
> 4. 跑 `go generate` 而非手动 protoc——Makefile 更稳定
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览]]
|
||||
- [[hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理]]
|
||||
- [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]
|
||||
@@ -0,0 +1,484 @@
|
||||
---
|
||||
tags: [gRPC, HTTP/2, Protocol, Go, Networking]
|
||||
create time: 2026-05-11 16:42
|
||||
---
|
||||
|
||||
# HTTP/2 传输原理
|
||||
|
||||
## 概述
|
||||
|
||||
gRPC 跑在 HTTP/2 之上,这意味着你天然享受 HTTP/2 带来的所有性能红利:多路复用、头部压缩、服务器推送。但也带来了一些新的心智负担——原来用 TCP 协议栈就能搞定的事,现在又多了一层帧(frame)和流(stream)的概念。理解底层原理才能调试那些"偶发性超时""连接无故断开"的问题。
|
||||
|
||||
> [!question] 为什么 gRPC 不用 HTTP/1.1?
|
||||
> HTTP/1.1 的串行阻塞模型在面对高并发微服务时效率太低——每增加一个并发都要付出一次 TCP 握手开销。而 HTTP/2 在一个 TCP 连接上就能搞定任意数量的并发请求。但代价是你得适应全新的二进制协议层。
|
||||
|
||||
> [!tip] 先搞清楚这层关系
|
||||
> ```
|
||||
> 应用层:Protobuf 消息 → 序列化为字节流
|
||||
> gRPC 层:把字节流包装成 request/response/stream
|
||||
> HTTP/2 层:把 gRPC 数据切分成 frame, multiplex 到 stream 上
|
||||
> TCP 层:可靠的字节流传输
|
||||
> ```
|
||||
> gRPC = Protobuf wire format + HTTP/2 transport + gRPC semantics
|
||||
|
||||
## HTTP/2 vs HTTP/1.1 对比
|
||||
|
||||
| 特性 | HTTP/1.1 | HTTP/2 |
|
||||
|------|----------|--------|
|
||||
| 连接数 | 每主机通常 6 个 | 任意数量 |
|
||||
| 传输格式 | 纯文本 | 二进制 |
|
||||
| 多路复用 | ❌ (head-of-line blocking) | ✅ |
|
||||
| 头部压缩 | ❌ | ✅ HPACK |
|
||||
| 服务器推送 | ❌ | ✅ PushPromise |
|
||||
| 头部顺序 | 明文逐行发送 | header block fragments |
|
||||
|
||||
**核心差异一句话:**HTTP/2 将一切变成了二进制帧(frame),用帧的组合来表达请求、响应和元数据。
|
||||
|
||||
> [!note] 为什么二进制更好?
|
||||
> 纯文本协议(如 HTTP/1.1)需要靠 `\r\n` 分隔,解析容易出错且浪费带宽。二进制协议用 length + type 字段精确定位每个单元,解析更快也更健壮。代价是——你不能再直接用浏览器看明文了,必须用专门的抓包工具。
|
||||
|
||||
## HTTP/2 连接建立过程
|
||||
|
||||
### Connection Preface(连接前置声明)
|
||||
|
||||
HTTP/2 要求在真正的数据交换之前,双方先发一段 "preface" 来确认对方支持 HTTP/2:
|
||||
|
||||
```go
|
||||
// Client Preface(客户端必须首先发送,固定字符串)
|
||||
// PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
|
||||
// SETTINGS frame (length = 0)
|
||||
|
||||
// Server Preface(服务端确认后回复同样长度的 SETTINGS frame)
|
||||
// SETTINGS frame (length = 0)
|
||||
```
|
||||
|
||||
这不是什么代码层面的操作——gRPC 客户端内部自动处理。但你可以在 Wireshark 中看到这个 handshake 过程:Client 发 `PRI * HTTP/2.0`,Server 回复 `SETTINGS ACK`,然后双方才开始交换业务数据。
|
||||
|
||||
> [!warning] 常见坑
|
||||
> 某些老旧代理(如旧版 Squid / Nginx < 1.3.10)不支持 ALPN 或不认 Preface,会导致 HTTP/2 降级为 HTTP/1.1。遇到偶发的 "connection reset" 时可以检查一下中间件版本。
|
||||
|
||||
### TLS Handshake + ALPN
|
||||
|
||||
如果是 HTTPS 连接(gRPC 生产环境的标配),完整的握手流程是:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant T as TCP/TLS
|
||||
participant S as Server
|
||||
|
||||
Note over C,S: Step 1 — TCP 三次握手
|
||||
C->>T: SYN
|
||||
T->>S: SYN-ACK
|
||||
S->>C: ACK
|
||||
|
||||
Note over C,T: Step 2 — TLS 1.3 握手
|
||||
C->>T: ClientHello (ALPN: h2)
|
||||
T->>S: ServerHello (selected: h2)
|
||||
S->>C: finished
|
||||
|
||||
Note over C,S: Step 3 — HTTP/2 Preface
|
||||
C->>S: ClientPreface + SETTINGS
|
||||
S->>C: SETTINGS ACK + ServerSettings
|
||||
|
||||
Note over C,S: Step 4 — 开始 multiplexing
|
||||
C->>S: HEADERS + DATA (Stream 1)
|
||||
C->>S: HEADERS + DATA (Stream 3)
|
||||
S->>C: HEADERS + DATA (Stream 2)
|
||||
```
|
||||
|
||||
关键点是 **ALPN(Application-Layer Protocol Negotiation)**:TLS 握手的 `ClientHello` 中会附带支持的协议列表(`h2` / `http/1.1`),服务端从中选择一个并在 `ServerHello` 中返回。如果协商失败,就不会走 HTTP/2。
|
||||
|
||||
## HTTP/2 帧(Frame)详解
|
||||
|
||||
gRPC 的数据以 frame 为单位在连接上传输。每种 frame 有特定的类型码和控制语义:
|
||||
|
||||
| Frame | Type Code | 作用 | gRPC 中的场景 |
|
||||
|-------|-----------|------|---------------|
|
||||
| DATA | 0x0 | 实际载荷 | Request / Response body |
|
||||
| HEADERS | 0x1 | 头信息(含 HTTP/2 header block) | HTTP headers + gRPC metadata |
|
||||
| PRIORITY | 0x2 | 设置 stream 优先级 | gRPC 不使用 |
|
||||
| RST_STREAM | 0x3 | 异常终止 | Client/Server 主动中断流 |
|
||||
| SETTINGS | 0x4 | 协商参数 | 握手阶段交换 max_concurrent_streams 等 |
|
||||
| PUSH_PROMISE | 0x5 | 服务器推送 | gRPC 不使用 |
|
||||
| PING | 0x6 | 保活探测 | keepalive ping |
|
||||
| GOAWAY | 0x7 | 优雅关闭 | Server 准备停机 |
|
||||
| WINDOW_UPDATE | 0x8 | 流量控制 | 调整接收窗口大小 |
|
||||
| CONTINUATION | 0x9 | 续传 header block | 头部太长时的分片传输 |
|
||||
|
||||
每个 frame 的结构固定为 9 字节头部 + 有效载荷:
|
||||
|
||||
```
|
||||
+-----------------------------------------------+
|
||||
| Length (24 bits) |
|
||||
+---------------+---------------+---------------+
|
||||
| Type (8 bits) | R(1 bit) | Stream ID(31 bits) |
|
||||
+---------------+---------------+---------------+
|
||||
| Payload (... bytes) |
|
||||
+-----------------------------------------------+
|
||||
```
|
||||
|
||||
- **Length**: payload 长度,最大 2^24 - 1 ≈ 16MB
|
||||
- **Type**: frame 类型(上述表格中的 Type Code)
|
||||
- **R**: reserved bit,必须为 0
|
||||
- **Stream ID**: 所属 stream 的 ID,0 表示 connection-level frame
|
||||
- **Payload**: 随类型不同含义各异
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Connection ["HTTP/2 Connection"]
|
||||
direction TB
|
||||
Streams --> S1["Stream 1<br/>HEADERS → DATA → HEADERS → DATA"]
|
||||
Streams --> S2["Stream 2<br/>HEADERS → DATA"]
|
||||
Streams --> SN["Stream N<br/>HEADERS → DATA"]
|
||||
end
|
||||
|
||||
ConnLevel -.-> Settings["SETTINGS / PING / GOAWAY<br/>(Stream ID = 0)"]
|
||||
|
||||
style ConnLevel fill:#A0AEC0,color:#fff
|
||||
style Settings fill:#ED8936,color:#fff
|
||||
style S1 fill:#00B6BC,color:#fff
|
||||
style S2 fill:#4FC08D,color:#fff
|
||||
style SN fill:#FFD43B
|
||||
```
|
||||
|
||||
**关键概念:**
|
||||
- 每个 connection 上有多个 stream,每个 stream 独立收发 data
|
||||
- 所有的 frame 都属于某个 stream(除了 connection-level 的 SETTINGS/GOAWAY/PING/WINDOW_UPDATE)
|
||||
- gRPC 只用了其中一小部分 frame 类型——它把复杂的 protocol 封装在了内部
|
||||
|
||||
## Stream 与 Multiplexing
|
||||
|
||||
这是 HTTP/2 最重要的特性,也是 gRPC 高性能的核心原因。
|
||||
|
||||
**机制:**
|
||||
- 一个 TCP 连接上可以有多个 stream
|
||||
- 每个 stream 有唯一的 stream ID(奇数 = client 发起,偶数 = server 发起,ID 从 1 开始递增)
|
||||
- stream 之间互不干扰——解决了 HTTP/1.1 的队头阻塞(head-of-line blocking)
|
||||
|
||||
### Stream ID 分配规则
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client["Client"] --- TCP[("TCP Connection")]
|
||||
TCP --- Server["Server"]
|
||||
|
||||
subgraph Streams ["Stream IDs"]
|
||||
S1["1<br/>Client→Server<br/>Unary call"]
|
||||
S3["3<br/>Client→Server<br/>Another call"]
|
||||
S5["5<br/>Client→Server<br/>Streaming"]
|
||||
S2["2<br/>Server→Client<br/>Response to #1"]
|
||||
S4["4<br/>Server→Client<br/>Response to #3"]
|
||||
S6["6<br/>Server→Client<br/>Push error"]
|
||||
end
|
||||
|
||||
Client --> S1
|
||||
Client --> S3
|
||||
Client --> S5
|
||||
Server --> S2
|
||||
Server --> S4
|
||||
Server --> S6
|
||||
|
||||
style S1 fill:#00B6BC,color:#fff
|
||||
style S3 fill:#00B6BC,color:#fff
|
||||
style S5 fill:#00B6BC,color:#fff
|
||||
style S2 fill:#4FC08D,color:#fff
|
||||
style S4 fill:#4FC08D,color:#fff
|
||||
style S6 fill:#4FC08D,color:#fff
|
||||
```
|
||||
|
||||
**要点:**
|
||||
- Stream ID 严格递增,Client 永远用奇数,Server 永远用偶数
|
||||
- 即使一个 stream 已经完成(发送了 END_STREAM flag),它的 ID 也不会回收重用
|
||||
- 理论上单条连接最多可以有 2^31 个 stream(受 Stream ID 字段限制)
|
||||
|
||||
### 代码示例
|
||||
|
||||
同一个 conn 上可以并发创建多个 stream,无需额外 TCP 连接:
|
||||
|
||||
```go
|
||||
conn, _ := grpc.Dial("localhost:50051", ...) // 只有一个 TCP 连接
|
||||
client := pb.NewUserServiceClient(conn)
|
||||
|
||||
// 这三个 call 在同一个连接的不同 stream 上并行执行
|
||||
go client.GetUser(ctx1, &pb.GetUserRequest{Id: 1}) // → Stream 1
|
||||
go client.GetUser(ctx2, &pb.GetUserRequest{Id: 2}) // → Stream 3
|
||||
go client.GetUser(ctx3, &pb.GetUserRequest{Id: 3}) // → Stream 5
|
||||
```
|
||||
|
||||
## HPACK 头部压缩
|
||||
|
||||
HTTP/2 使用 HPACK 算法对头部进行压缩,主要靠两个手段:
|
||||
|
||||
1. **静态字典** — RFC 预定义了 61 个常用 header(如 `:method`, `:path`, `content-type`),直接通过索引引用
|
||||
2. **动态表** — 运行时新增的 key-value 对会被缓存,后续请求只需引用索引号
|
||||
3. **Huffman 编码** — 对无法查表的字符串做变长编码
|
||||
|
||||
gRPC 强制要求 hpack table size >= 4096 bytes。好处是 gRPC 的请求头部通常只有几十个字节,相比 HTTP/1.1 每次都要带完整的 User-Agent/Content-Type 等大很多倍。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["原始 Header:<br/>:method=POST<br/>:path=/user.v1.UserService/CreateUser<br/>content-type=application/grpc<br/>grpc-timeout=30s"] --> B["HPACK Encoder"]
|
||||
|
||||
B -->|"索引引用"| D[":method → :POST (static table idx 2)"]
|
||||
B -->|"动态表插入"| E[":path → full string<br/>(dynamic table idx 1)"]
|
||||
B -->|"Huffman 编码"| F["grpc-timeout=30s<br/>(Huffman: 5 bytes)"]
|
||||
|
||||
D --> G["Header Block Fragment<br/>(~15 bytes)"]
|
||||
E --> G
|
||||
F --> G
|
||||
|
||||
style A fill:#FFD43B
|
||||
style B fill:#00B6BC,color:#fff
|
||||
style G fill:#4FC08D,color:#fff
|
||||
```
|
||||
|
||||
> [!example] HPACK 的实际压缩效果
|
||||
>
|
||||
> ```
|
||||
> // HTTP/1.1 头部(每次完整发送,约 200+ bytes)
|
||||
> POST /user.v1.UserService/CreateUser HTTP/1.1
|
||||
> Host: localhost:50051
|
||||
> Content-Type: application/grpc
|
||||
> Grpc-Timeout: 30s
|
||||
> User-Agent: grpc-go/1.50.0
|
||||
> Te: trailers
|
||||
>
|
||||
> // HTTP/2 + HPACK(连续多次调用,压缩后可能只剩十几字节)
|
||||
> HEADERS: {idx 2} {dynamic-table: :path=/user.v1.UserService/CreateUser} {huffman: 30s}
|
||||
> DATA: <protobuf payload>
|
||||
> ```
|
||||
>
|
||||
> 注意第二次调用时,`:method=POST` 和 `content-type=application/grpc` 都可以直接从 static table 引用——不需要再发送这些字符串。
|
||||
|
||||
## Flow Control 流量控制
|
||||
|
||||
HTTP/2 有两层 flow control,各自独立工作:
|
||||
|
||||
| 层级 | 范围 | 默认窗口 | 控制方式 |
|
||||
|------|------|----------|----------|
|
||||
| Connection Level | 整个 TCP 连接 | 65535 bytes | WINDOW_UPDATE frame |
|
||||
| Stream Level | 单个 stream | 继承 connection level | WINDOW_UPDATE frame |
|
||||
|
||||
当接收方缓冲区快满时,发送方会收到 WINDOW_UPDATE 被"堵住"——这就是 HTTP/2 的 **backpressure**。gRPC 在此基础上又加了一层自己的 flow control:
|
||||
|
||||
| gRPC 配置项 | 说明 | 默认值 |
|
||||
|-------------|------|--------|
|
||||
| `MaxReceiveMessageSize` | 单次可接收的最大消息 | **4 MB** |
|
||||
| `MaxSendMessageSize` | 单次可发送的最大消息 | math.MaxInt32 |
|
||||
|
||||
> [!warning] 这是一个常见的坑!
|
||||
> 当你的 protobuf message 超过 4MB 时,你会看到类似这样的错误:
|
||||
> ```
|
||||
> grpc: received message larger than max (5242880 vs 4194304) on XXX
|
||||
> ```
|
||||
> 修复方式:在 dial 或 server 选项中设置更大的 limit。
|
||||
> ```go
|
||||
> grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024))
|
||||
> ```
|
||||
> 注意:这里设置的单位是 bytes。设太大也有风险——对方可能发一个巨型 payload 打爆你的内存。
|
||||
|
||||
### 与 HTTP/2 Flow Control 的关系
|
||||
|
||||
很多人会把两层 flow control 混淆。简单理解:
|
||||
|
||||
- **HTTP/2 Window** 管的是「网络缓冲区」——防止发送方压垮接收方的 TCP 队列
|
||||
- **gRPC Message Size** 管的是「应用层内存」——防止 protobuf unmarshal 时 OOM
|
||||
|
||||
两层都配好了才是真正的安全。
|
||||
|
||||
> [!tip] 调优建议
|
||||
> - HTTP/2 Window:大多数场景不需要手动改,除非出现大量 WINDOW_UPDATE 延迟
|
||||
> - MaxReceiveMessageSize:按业务需要设定,但不要设为无上限;配合上游 LB 的 payload limit 一起考虑
|
||||
|
||||
## 连接生命周期
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
Idle --> Connecting: TCP connect
|
||||
Connecting --> Connected: OK
|
||||
Connecting --> ErrorFailed: Failed
|
||||
ErrorFailed --> Closed
|
||||
|
||||
Connected --> TLSEncrypted: TLS + ALPN (if secure)
|
||||
Connected --> Plaintext: Insecure mode
|
||||
|
||||
TLSEncrypted --> SettingsSent: Send HTTP/2 Preface + SETTINGS
|
||||
Plaintext --> SettingsSent: Send HTTP/2 Preface + SETTINGS
|
||||
|
||||
SettingsSent --> Ready: Receive SETTINGS ACK
|
||||
SettingsSent --> ErrorSettingsTimeout: Timeout
|
||||
ErrorSettingsTimeout --> Closed
|
||||
|
||||
Ready --> Active: Create Streams
|
||||
Active --> HalfClose: Stream done (END_STREAM)
|
||||
HalfClose --> GoAwayReceived: Server sends GOAWAY
|
||||
GoAwayReceived --> DrainPending: Wait for active streams
|
||||
DrainPending --> Closed: All pending done
|
||||
|
||||
state Active {
|
||||
StreamOpen --> Sending
|
||||
StreamOpen --> Receiving
|
||||
Sending --> Done
|
||||
Receiving --> Done
|
||||
}
|
||||
|
||||
note right of ErrorFailed
|
||||
DNS 解析失败
|
||||
TCP connect timeout
|
||||
TLS cert invalid
|
||||
end note
|
||||
|
||||
note right of ErrorSettingsTimeout
|
||||
对方未在规定时间内
|
||||
回复 SETTINGS ACK
|
||||
end note
|
||||
```
|
||||
|
||||
**各阶段要点:**
|
||||
|
||||
1. **Connecting** — TCP 三次握手。如果目标地址不可达或被防火墙拦截,会在这里报 `connection refused`
|
||||
2. **TLS + ALPN** — 对于 secure 连接,先用 TLS 加密并协商出 `h2` 协议。证书过期或 host mismatch 会在这一步失败
|
||||
3. **Settings exchange** — 双方交换 window size、max concurrent streams 等参数。这是 HTTP/2 的正式握手点
|
||||
4. **Ready** — 可以开始创建 stream 了
|
||||
5. **Active** — 正常业务阶段,stream 可随时创建和销毁
|
||||
6. **GOAWAY** — 服务端通知即将关闭(如滚动重启)。已创建的 stream 还能继续完成,新 stream 不能再用这条连接
|
||||
7. **Closed** — 连接彻底关闭,下次调用时 gRPC 会自动重连(reconnect policy)
|
||||
|
||||
> [!note] GOAWAY vs RST_STREAM 的区别
|
||||
> - **GOAWAY**: connection-level,告诉对方"这条连接以后不能新建 stream 了",属于优雅关闭
|
||||
> - **RST_STREAM**: stream-level,仅终止单个 stream,不影响同连接上的其他 stream
|
||||
|
||||
## 对开发者的实际影响
|
||||
|
||||
### 1. 连接数管理
|
||||
|
||||
不需要手动连接池。gRPC 的 `grpc.ClientConn` 已经做了连接复用和自动重建:
|
||||
|
||||
```go
|
||||
// 一个 conn 对象就够了,内部自动管理连接数和重连
|
||||
conn, _ := grpc.Dial("target", grpc.WithTransportCredentials(...))
|
||||
// 所有 client share this conn
|
||||
client1 := pb.NewService1Client(conn)
|
||||
client2 := pb.NewService2Client(conn)
|
||||
```
|
||||
|
||||
> [!tip] 连接池误区
|
||||
> 很多从 HTTP/1.1 转过来的开发者会习惯性建连接池。但在 gRPC 里一个 `Dial` 就够——gRPC 内部维护了一个连接管理器,会根据负载情况自动增减连接。
|
||||
|
||||
### 2. Keepalive 配置
|
||||
|
||||
生产环境务必调优 keepalive 参数,否则可能被负载均衡器或 K8s ingress 切断连接:
|
||||
|
||||
```go
|
||||
grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||||
Time: 10 * time.Second,
|
||||
Timeout: 5 * time.Second,
|
||||
PermitWithoutStream: true, // 即使没有 active stream 也发 ping
|
||||
})
|
||||
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{
|
||||
Time: 10 * time.Second, // PING 间隔
|
||||
Timeout: 20 * time.Second, // 无响应则断开
|
||||
})
|
||||
```
|
||||
|
||||
> [!warning] PermitWithoutStream 的作用
|
||||
> 当这个值为 false 时(默认),如果当前没有任何活跃的 RPC(比如空闲等待期),gRPC 不会发 keepalive ping——此时中间设备恰好会杀掉空闲连接。生产环境建议设为 true。
|
||||
|
||||
生产环境的推荐配置取决于你的中间件策略:
|
||||
|
||||
| 组件 | 典型 idle timeout | 建议 keepalive Time |
|
||||
|------|-------------------|---------------------|
|
||||
| AWS ALB | 60s | 25s |
|
||||
| Nginx proxy_pass | 75s (proxy_read_timeout) | 30s |
|
||||
| K8s kube-proxy (iptables) | 根据 conntrack | 25s |
|
||||
| Cloud Load Balancer | varies | 20–30s |
|
||||
|
||||
### 3. MaxMessageSize
|
||||
|
||||
遇到 `"message too large"` 错误时,第一反应应该是检查这个限制,而不是怀疑代码逻辑。
|
||||
|
||||
### 4. 调试技巧
|
||||
|
||||
| 工具 | 用途 | 常用命令 |
|
||||
|------|------|----------|
|
||||
| `grpcurl` | CLI 工具,支持直接调用 gRPC 方法 | `grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser` |
|
||||
| `nghttp` | HTTP/2 抓包分析,能看到 frame 级别细节 | `nghttp -v http://localhost:50051` |
|
||||
| Wireshark | 原始帧级别的诊断 | filter: `tcp.port == 50051 && http2` |
|
||||
| `GRPC_VERBOSITY=DEBUG GRPC_TRACE=all` | Go 内置的 verbose trace,打印内部事件 | `export GRPC_VERBOSITY=DEBUG && export GRPC_TRACE=http2,transport,subchannel` |
|
||||
|
||||
```bash
|
||||
# 快速测试一个 gRPC endpoint
|
||||
grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser
|
||||
|
||||
# 查看服务提供的全部方法
|
||||
grpcurl -plaintext localhost:50051 list
|
||||
|
||||
# 查看某个 service 的详细定义
|
||||
grpcurl -plaintext localhost:50051 describe user.v1.UserService
|
||||
```
|
||||
|
||||
### 5. 常见问题排查速查表
|
||||
|
||||
| 现象 | 可能原因 | 排查方向 |
|
||||
|------|---------|---------|
|
||||
| 偶发性 `DEADLINE_EXCEEDED` | HTTP/2 Window 满了被 backpressure 堵住 | Wireshark 看 WINDOW_UPDATE 延迟 |
|
||||
| 连接间歇性断开 | Keepalive 没开或时间太长,LB 杀了空闲连接 | 检查 `PermitWithoutStream` 和 LB timeout |
|
||||
| `"message larger than max"` | Proto message 超过 4MB 限制 | 调大 `MaxCallRecvMsgSize` |
|
||||
| `PROTOCOL_ERROR` / `INTERNAL` | 中间代理篡改了 HTTP/2 frame | 检查 Nginx / Envoy 配置,确保启用 http2 |
|
||||
| 某个 stream 卡住但不报错 | Server 端 handler 阻塞或未回写 | 用 `GRPC_TRACE=stream,transport` 定位 |
|
||||
| DNS 解析慢导致首次调用超时 | gRPC 内置 resolver 异步解析但首次调用不等 | 提前预热连接或用固定 IP |
|
||||
|
||||
## 附录:gRPC 协议分层速览
|
||||
|
||||
```mermaid
|
||||
block-beta
|
||||
columns 1
|
||||
block:App
|
||||
columns 1
|
||||
A1["Protobuf Message<br/>(序列化后的 byte[])"]
|
||||
end
|
||||
space
|
||||
block:GRPC
|
||||
columns 1
|
||||
B1["gRPC Frame<br/>(Wire type + Length + Payload)"]
|
||||
end
|
||||
space
|
||||
block:HTTP2
|
||||
columns 1
|
||||
C1["DATA Frame"]
|
||||
C2["HEADERS Frame"]
|
||||
C3["WINDOW_UPDATE Frame"]
|
||||
end
|
||||
space
|
||||
block:Transport
|
||||
columns 1
|
||||
D1["HTTP/2 Stream<br/>(multiplexed over one TCP)"]
|
||||
end
|
||||
space
|
||||
D2["TCP Socket"]
|
||||
|
||||
A1 --> B1
|
||||
B1 --> C1
|
||||
B1 --> C2
|
||||
B1 --> C3
|
||||
C1 --> D1
|
||||
C2 --> D1
|
||||
C3 --> D1
|
||||
D1 --> D2
|
||||
|
||||
style A1 fill:#FFD43B
|
||||
style B1 fill:#00B6BC,color:#fff
|
||||
style D1 fill:#4FC08D,color:#fff
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[../1. Protobuf 基础篇/01-Protobuf 语法与消息定义]]
|
||||
- [[../3. 服务端实现/08-Server 搭建与注册]]
|
||||
- [[../3. 服务端实现/09-Streaming Handler]]
|
||||
- [[../4. 客户端开发/11-Client 连接与 Dial]]
|
||||
- [[../4. 客户端开发/12-Call Options 与 Context]]
|
||||
- [[../6. 工程实践篇/20-性能优化与压测]]
|
||||
Reference in New Issue
Block a user