Grpc学习笔记

August 23, 2026

GRPC

rpc是什么?

RPC(Remote Procedure Call)远程过程调用, 一种常见的计算机通信协议. 目标是让远程调用看起来像本地调用一样简单.

RPCRESTful
面向动作/方法(如 getUserInfo())面向资源(如 GET /users/123)
二进制居多(Protobuf、Thrift)体积小、解析极快文本格式居多(JSON、XML)可读性好但体积偏大
微服务内部高性能通信、低延迟分布式系统对外开放 API、前端与后端交互、第三方平台对接

常见 RPC 框架

  • gRPC: 基于 HTTP/2 和 Protocol Buffers,跨语言支持极佳, 目前工业界最主流
  • Apache Thrift: Facebook 开发的高性能跨语言 RPC 框架
  • Apache Dubbo: 阿里巴巴开源, 在 Java 生态微服务体系中广泛应用
  • JSON-RPC / XML-RPC: 基于简单文本格式的轻量级规范

grpc 是什么?

grpc是google开源的高性能、跨语言、通用的 RPC 框架

  • 使用 Protocol Buffers(二进制序列化协议), 充当 IDL(接口定义语言) 定义服务接口与消息结构和数据高校压缩打包.
  • 使用 HTTP2.0 传输协议, 具备多路复用, 头部压缩和全双工双向流特性, 降低连接开销与网络延迟.

特点与优势

  • 高性能和紧凑体积: protobuf 二进制数据远小于 json 解析数据快, 配合 http2 连接复用适合高并发内网通信.
  • 跨语言: .proto
  • 强类型契约:
  • 四种通信模式:
    1. 一问一答(最常用)
    2. 服务端流式: 客户端一次请求, 服务端持续返回数据流 (LLM流式输出、日志推送)
    3. 客户端流式: 客户端持续发送数据流, 服务端处理完毕返回响应 (大文件分片上传)
    4. 双向流式 : 双方简历全双工通道, 彼此随时独立收发数据流 (AI语音对话、多人实时通信)

工作流程

定义契约 → 生成代码 → 服务端实现 → 客户端调用

为什么要用 grpc?

解决了什么痛点

当系统从单体架构拆分为成百上千个微服务时, 服务间频繁的网络调用如果继续依赖传统的 REST + JSON,

  1. JSON 是文本格式, 序列化/反序列化需要做大量的字符串解析,严重消耗 CPU。
    • 使用proto, 体积比 json 小, 二进制序列化速度比 json 快, 降低cpu开销
  2. HTTP1 默认短连接, 每次请求都需要重新握手, 在 HTTP/1.1 中虽然支持 Keep-Alive,但由于单个连接无法并发处理请求,高并发时必须依赖庞大的连接池,面临连接耗尽与频繁建连的开销
    • http2天然支持多路复用, 所有的 RPC 调用以不同 Stream ID 运行在极少数长连接上,避免了连接数激增与频繁握手的情况 (后续有补充)
  3. 解决接口契约与文档不同步
    • 使用 protobuf 直接生成强类型代码, 在编译期发现类型不匹配与字段错误 4.REST 只擅长“一问一答”,做大文件分片上传、实时数据推送、全双工交互时需要拼凑 WebSocket、SSE 等协议
    • 支持四种通信模式

什么时候选它?

  • 业务系统内部微服务集群之间的互相调用,追求极限的吞吐量与极低延迟
  • 多语言技术栈:
  • 低延迟与高吞吐的系统: 广告竞价系统、金融交易、实时推荐、游戏服务器等对延迟 (P99)极其敏感的场景
  • 大模型与多模态流式交互: 模型推理集群内部交互 (如 vLLM、Triton)、实时语音流交互、大文件/传感器数据分片汇聚。

什么时候不选它?

  • 公网开放api, 外部调用者接入成本高(推荐RESTful + json)
  • 浏览器 Web 前端直接调用, 浏览器对 HTTP/2 原生帧控制有限(gRPC-Web 需要网关代理翻译)

从原理的角度分析为什么用 http2 而不用 http1.1?

HTTP/1.1 之所以慢、开销大,不是因为带宽不够, 而是它的数据打包与解析机制限制了单个 TCP 管道的使用效率. HTTP/2 从底层重构了这个传输模型

http1 的缺点

数据使用纯文本字符串传输, 消息之间边界完全依赖:

  • 换行符 \r\n\r\n 来标记 Header 的结束
  • Content-Length 或分块传输标记(Chunked)来确定 Body 的长度 后果: TCP 是一个无边界的连续字节流管道. 如果把请求 A 和请求 B 的文本混在一根管道里同时发送:
GET /user HTTP/1.1...POST /order HTTP/1.1...{"id":1}200 OK...

接收端的解析器根本无法分清哪一段字节属于请求 A,哪一段属于请求 B。 所以, http1 被迫规定: 单条 tcp 连接上, 必须等前一个请求响应彻底读完才能发送下一个请求.

  • 代价1(对头阻塞): 如果请求 A 在服务端查数据库卡了 2 秒,请求 B 必须在后面干等 2 秒。
  • 代价2(连接爆炸): 为了并发, 只能同时建立多个 tcp 连接, 每个连接都要经历「三次握手 + TLS 协商 + 慢启动」, 严重消耗内存与 cpu

http2 的优化

没有改动底层 tcp 协议, 而是在应用层与传输层之间加了一个 二进制分帧层

结构重构

修改成最小单位--帧(Frame). 每个帧都有固定 9 字节(72bit)的头部结构:

字段大小 (Bits)描述
Length24 bits (3B)Payload 长度
Type8 bits (1B)帧类型
Flags8 bits (1B)标志位
R (Reserved)1 bit保留位
Stream ID31 bits【关键】流编号
Payload可变长度实际数据 (二进制)
  • lenth: 支持 2^24-1 约 16MB, 默认上限 16KB (2^14 字节,可通过 SETTINGS 帧调整)
  • Type: 决定当前帧的格式与语义。常见类型:
    • DATA (0x0):携带实际请求/响应体数据
    • HEADERS (0x1):携带 HTTP 请求头/响应头 (HPACK 压缩后)
    • SETTINGS (0x4):协商连接参数配置
  • Flag: 用于微调特定帧类型的行为 (按 bit 位标记) 例如:
    • END_STREAM (0x1):标记当前流的传输已结束(半关闭流)
    • END_HEADERS (0x4):标记头部块已全部发送完毕
  • Stream ID: 实现多路复用的核心,标识当前帧属于哪一个具体的逻辑并发流.
    • 0x0 保留为控制帧(作用于整个 TCP 连接,如 SETTINGS、PING)
    • 客户端发起的流通常使用奇数 ID (1, 3, 5...),服务端发起的流使用偶数 ID(2, 4, 6...,如 Server Push)
  • Payload: 帧携带的实际数据 (二进制). 具体格式由 Type 和 Flags 决定

grpc 数据传输链路

  1. 应用层发起调用: 从内存中的Go对象开始
  2. Protobuf 序列化: Protobuf 根据 .proto 定义,采用 Tag-Length-Value / Varint 算法将数据压成极小的二进制切片
    • 原数据: UserId: 1001
    • 序列化后: 仅占用 3 个字节 (十六进制表示为 08 E9 07)
      • 08: 字段编号 1 与 WireType 类型
      • E9 07:数字 1001 的 Varint 编码
  3. gRPC 协议层封装: gRPC 会在 Protobuf 载荷前加上一个 固定的 5 字节前缀
FieldValue
Compressed (1B)0x00
Message Length (4B)0x00 0x00 0x00 0x03
Protobuf Payload (3B)0x08 0xE9 0x07
  • 第 1 字节 (0x00): 表示未启用 Gzip/Snappy 压缩
  • 第 2~5 字节 (0x00 00 00 03) : 标记后面的 Protobuf 数据正好是 3 字节, 此时数据总长:5 + 3 = 8 字节
  1. HTTP/2 分帧与封装: gRPC 将上述 8 字节数据打包进 HTTP/2 标准帧中,并分配一个新的 Stream ID = 1
    • 发送 HEADERS 帧 (建立 RPC 上下文):
      • :method: POST
      • :scheme: http
      • :path: /UserService/GetUser (服务名/方法名)
      • content-type: application/grpc
      • te: trailers (声明需要接收尾部元数据)
      • (注:这些 Header 会经过 HPACK(头部压缩) 压缩为几个字节)
    • 发送 DATA 帧
      • 帧头部标注 Stream ID = 1, 载荷就是第 3 步封装好的那 8 字节 gRPC Message
  2. 网络传输(TCP/IP)
  3. 服务端接收与逐层解包: 服务端网卡接收到字节流后,自底向上逆向还原
    1. HTTP/2 路由: 服务端解析出 HEADERS 帧中的 :path = /UserService/GetUser,定位到对应的业务处理类和方法;根据 Stream ID = 1 建立会话上下文

    2. 重组 DATA 帧: 提取 Stream ID = 1 携带的数据, 读取前 5 个字节:

      • 确认未压缩
      • 读取长度为 3 字节
      • 精确截取后 3 字节 (08 E9 07)
    3. Protobuf 反序列化: 将 08 E9 07 还原为内存中的 UserRequest{UserId: 1001} 强类型对象。

  4. 执行业务并返回响应
    1. 服务端执行 GetUser 函数,从数据库查出用户名字 Alice。
    2. 将 UserResponse{Name: "Alice"} 同样经过 Protobuf 序列化 → 加 5 字节头 → 封装为 HTTP/2 DATA 帧发回客户端。
    3. 关键补充 (Trailers) : 服务端最后还会发一个带有 END_STREAM 标记的 HEADERS 帧(称为 Trailers), 携带 grpc-status: 0(表示成功, 非 0 则包含错误码和错误信息)和 grpc-message,正式关闭该 Stream。
    4. 客户端解包得到 resp,业务调用完成。

proto2 和 proto3 的区别

维度Proto2Proto3
字段修饰符必须显式标记 required、optional 或 repeated废弃 required 和 optional,普通字段默认为可选;保留 repeated
默认值机制允许通过 [default = 10] 自定义默认值移除自定义默认值,全部采用固定零值(数字为 0,字符串为 "",布尔为 false)
默认值序列化即使是默认值,有时也会被序列化传输零值不进入二进制流(Wire 上直接省略,显著节省带宽)
字段存在性检测 (HasField)所有字段都可以通过 has_field() 明确区分“未设置”与“设置了默认值”基础类型无法直接区分“未设置”还是“设为了零值”(若需区分需使用 optional 关键字或 Wrapper 类型)
枚举(Enum)首值枚举首个值可以任意指定枚举首项必须是 0 (作为该枚举类型的默认零值)
未知字段处理原生保留并透传未识别字段早期丢弃,3.5 版本后重新恢复保留未知字段
JSON 原生映射无标准规范支持内置规范化的 JSON 双向映射规则(Proto 与 JSON 无缝互转)
拓展机制(Extensions)使用 extensions 和 extend 关键字废弃自定义拓展,仅保留 Custom Options(自定义注解)

为什么使用 proto3?

  1. 移除required (最大痛点):

    • Proto2 的必填字段一旦在后续版本中被废弃或删除, 旧版客户端反序列化时会直接报错甚至 Crash
    • Proto3 彻底移除 required, 强制所有字段都是可选的,向前/向后兼容性得到极大提升
  2. 空字段/默认值浪费带宽:

    • 只要字段值是默认零值 (如 int32 = 0、string = ""),序列化时直接完全不写入二进制流 (占 0 字节), 极致压榨网络性能
  3. 缺乏官方规范的 JSON 互转:

    • 微服务架构中, 内部走 gRPC (Protobuf), 对外部 Web/前端往往需要暴露 RESTful JSON, Proto2 缺乏官方的 JSON 转换标准,跨端(Web/App)对接全靠各语言手写转换逻辑且极易出错
    • Proto3 原生内置统一了 Protobuf 与 JSON 的双向映射规则, gRPC-Gateway 等网关组件能无缝实现协议转换, 无需编写转换胶水代码
  4. 降低多语言 SDK 生成与维护复杂度:

    • Proto2 的自定义默认值、复杂的 extensions 和 has_field 状态机使得编译器在支持 Go、Rust、Dart、JavaScript 等新兴语言时实现成本极高
    • Proto3 砍掉冗余语法, 语义更纯粹, 让跨语言生态能快速、轻量地实现
GitHub
LinkedIn
X