Theory · Network · HTTP × Protobuf

HTTP 与 Protobuf:底层原理与对比

一个管「怎么传」,一个管「怎么编码」—— HTTP 版本演进(1.1 / 2 / 3)× Protobuf wire format(TLV / varint / zigzag)× JSON 与 gRPC 的选型对比

HTTP 传输协议

文本报文 → 二进制分帧 → QUIC;队头阻塞的三层来源

Protobuf 序列化

schema 驱动 · tag=(编号<<3)|wire_type · varint/zigzag · 默认值省略

对比与选型

先对齐层次再比较:JSON↔Protobuf,HTTP/1.1↔H2↔H3;gRPC 全链路

这份 deck 解决一个高频混淆点:面试里说「HTTP 和 Protobuf 谁快」其实是跨层比较。HTTP 是应用层传输协议,Protobuf 是序列化格式,gRPC 只是「HTTP/2 + Protobuf」的组合包装。按「HTTP 原理 → Protobuf 编码 → 分层对比 → 选型 → QA」的顺序展开,所有规范细节都对照 RFC 原文和 protobuf.dev 核对过。

Big Picture

先把问题摆正:两者根本不在同一层

HTTP+JSON 与 gRPC 的分层协议栈 两条链路的分层对照:左边是 HTTP/1.1 加 JSON 的文本链路,右边是 gRPC 的 HTTP/2 加 Protobuf 二进制链路,虚线标出真正同层可比的两组:序列化层 JSON 对 Protobuf,传输协议层 HTTP/1.1 对 HTTP/2。 HTTP/1.1 + JSON —— 典型 Web / REST 链路 gRPC = HTTP/2 + Protobuf 链路 业务对象(struct / map) 应用内存里的数据 JSON 文本 自描述 · 字段名写进每一条报文 HTTP/1.1 ASCII 报文 起始行 + 头部 + 空行 + body TCP + IP 可靠字节流 业务对象(struct) 由 .proto 生成强类型代码 Protobuf TLV 字节 字段编号 + 类型 · 没有字段名 HTTP/2 二进制帧 + gRPC LPM 1B 压缩标志 + 4B 长度前缀包装 protobuf TCP(+TLS) / QUIC(UDP) HTTP/3 才走 QUIC 同层可比:编码 同层可比:传输协议 面试常说的「HTTP vs Protobuf」是跨层比较 —— 正确的对齐:JSON ↔ Protobuf(序列化)、HTTP/1.1 ↔ HTTP/2 ↔ HTTP/3(传输)

HTTP:应用层传输协议——定义请求/响应语义(方法、状态码、头部)、连接管理、缓存协商;不关心 body 里是什么字节。

Protobuf序列化格式——定义「内存对象 ↔ 字节流」的双向映射;不关心字节跑在 HTTP、Kafka 还是落盘文件里。

这页是全 deck 的锚点。考官问「为什么不用 HTTP 而用 Protobuf」时,先指出这是层次错位:和 Protobuf 同层对比的是 JSON/XML;和 HTTP/1.1 同层对比的是 HTTP/2、HTTP/3 或 Thrift 等私有 RPC 协议。gRPC = HTTP/2 + Protobuf + LPM 帧,两者是叠加不是二选一。

HTTP · Fundamentals

HTTP 本质:请求-响应模型的文本协议,30 年演进一条线

HTTP 版本演进时间线 1991 年 HTTP/0.9 到 2022 年 HTTP/3 的五个版本时间线,每个版本标注年份与两到三个关键特性。 1991 HTTP/0.9 仅 GET 方法 无状态码、无头部 只传 HTML 1996 HTTP/1.0 MIME 类型 · 状态码 Connection 头(默认非持久) 每请求一条连接 1997 / 1999 HTTP/1.1 持久连接成默认 Host 头 · chunked pipeline 规范有 · 实践无 2015 HTTP/2 二进制分帧(10 种帧) 多路复用 Stream HPACK 头压缩 2022 标准化 HTTP/3 QUIC(UDP) · TLS 1.3 内置 流间独立 · 连接迁移 QPACK · 1-RTT / 0-RTT 语义与方法/状态码:RFC 9110 · HTTP/1.1 报文与连接:RFC 9112 · HTTP/2:RFC 9113 · HTTP/3:RFC 9114 · QUIC:RFC 9000
  • 报文模型不变:请求 = method path version + 头部 + 空行 + body;1.1 是 ASCII 文本(协议本身可读),2/3 把「怎么传」二进制化,但方法、状态码、头部这些语义从未变。
  • 无状态:协议层不记得上一个请求,会话状态靠 Cookie/Token 上移到应用层——这是 HTTP 能水平扩容的前提。
演进主线记三个转折:1.1 解决「每请求一条连接」的浪费(持久连接),2 解决「一条连接上请求排队」(多路复用),3 解决「TCP 本身拖后腿」(QUIC 换掉 TCP)。语义层(RFC 9110)一直稳定,变的只是传输和编码方式——这是 HTTP 生态三十年不倒的原因。

HTTP/1.1 · RFC 9112

HTTP/1.1 底层:持久连接救了浪费,救不了队头阻塞

做对了什么

  • 持久连接成默认:RFC 9112 §9.3 原文「HTTP/1.1 defaults to the use of persistent connections」——一条 TCP 连接串行发多个请求,Connection: close 才主动关。
  • Host 头:一台服务器(一个 IP)托管多个域名 → 虚拟主机与 CDN 的基础。
  • chunked 传输:body 不必先知道 Content-Length,边生成边发;服务器用它定界响应。

为什么快不起来

  • 应用层队头阻塞:一条连接同一时刻只有一个请求在等响应;后发的请求必须排队。
  • pipeline 名存实亡:规范允许(MAY),但要求响应按序返回、且 UA SHOULD NOT 在非幂等方法后 pipeline(§9.3.2)→ 浏览器从未默认启用。
  • 实际解法是并行多开连接:浏览器对同一来源并行约 6 条连接(Chrome/Firefox 均 6)→ 催生域名分片、雪碧图、内联资源这些「1.1 时代的补丁」。
面试要点:说「HTTP/1.1 支持 pipeline」对,但要说全——它要求响应严格按请求序返回,队头请求慢,后面全部干等;非幂等方法(POST)后面 pipeline 还有重试歧义。所以行业用「多开 TCP 连接」绕过去,代价是握手、拥塞窗口、服务端内存全部 ×6。这个绕法正是 HTTP/2 多路复用的动机。
两个易错点:一是「1.1 默认长连接」——对的,keep-alive 不再需要显式声明;二是 pipeline 为何失败——响应必须按序返回,队头没解决反而引入重试安全问题,浏览器干脆不支持。把 6 连接限制和域名分片讲出来,能自然过渡到 HTTP/2 的设计动机。

HTTP/2 · RFC 9113 / RFC 7541

HTTP/2:二进制分帧 + 多路复用,队头阻塞只解决了一半

HTTP/2 多路复用与 TCP 层队头阻塞 一条 TCP 连接上三个流(Stream 1/3/5)的 HEADERS 与 DATA 帧交错传输,下方标注 TCP 层丢包时所有流一起等待的队头阻塞问题。 Client 拆请求 → 帧 Server 帧 → 组装响应 同一条 TCP 连接 · 多个 Stream 的帧交错传输 时间 ↓ S1 HEADERS DATA DATA DATA DATA S3 HEADERS DATA DATA S5 HEADERS DATA DATA TCP 层队头阻塞(HTTP/2 仍在) 任一 TCP 报文段丢失,内核必须等重传交付, 同一连接上所有 Stream 的所有帧一起等待 —— 丢包率高时甚至不如 HTTP/1.1 的 6 条连接。 HTTP/3 的解法:把「流」下沉进 QUIC,单流丢包只重传该流。 帧头固定 9 字节 = 3B 长度 + 1B 类型 + 1B 标志 + 4B 流 ID;标准帧 10 种:DATA/HEADERS/PRIORITY/RST_STREAM/SETTINGS/PUSH_PROMISE/PING/GOAWAY/WINDOW_UPDATE/CONTINUATION HPACK(RFC 7541):静态表 61 条 + 连接级动态表 + Huffman 编码 —— 重复头只需传一个索引;并发流上限由 SETTINGS_MAX_CONCURRENT_STREAMS 协商 浏览器只走 TLS + ALPN「h2」;明文 h2c 规范允许但仅内网/调试使用;Server Push 已被 Chrome 106(2022)移除,实践中废弃
核心逻辑链:1.1 的痛点是排队 → H2 把请求响应切成帧、给每路请求编号(Stream ID),帧可交错发送、两端按编号重组,应用层队头阻塞消失。但要主动说出反面:TCP 只认字节流不认流编号,丢一个报文段,内核必须等重传才能交付后面的数据,所有流一起卡——这就是 H2 并没有「彻底解决」队头阻塞,为下一页 HTTP/3 埋下伏笔。HPACK 记三个部件:静态表 61、动态表、Huffman。

HTTP/3 · RFC 9114 / RFC 9000

HTTP/3:QUIC 重造传输层,把「流」从内核拿回用户态

QUIC(RFC 9000)改了传输层什么

  • UDP 之上用户态实现:绕开内核 TCP 升级周期,协议进化由应用更新驱动。
  • TLS 1.3 内置进握手(RFC 9001):传输与加密一体协商,建连 1-RTT,会话复用可 0-RTT 早期数据(注意:0-RTT 有重放风险,只放幂等请求)。
  • 流间真正独立:每个 Stream 有独立的包号/确认/流控,单流丢包不再阻塞其他流 —— 消灭 TCP 层队头阻塞。
  • Connection ID:连接身份与「源IP+源端口」四元组解耦 → WiFi 切 5G 连接迁移不断线。

HTTP/3(RFC 9114)适配了什么

  • 语义照搬 HTTP:方法、状态码、头部字段与 1.1/2 完全一致,变的只是承载方式。
  • 帧重新设计:QUIC 已提供可靠有序流,HTTP/3 帧不再需要 H2 的流 ID/长度那套。
  • QPACK(RFC 9204)替代 HPACK:H2 的 HPACK 依赖帧按序到达,QUIC 流间乱序会让动态表错位 → QPACK 把动态表更新改为可乱序解码的设计,代价是额外往返。
  • 服务发现:首个连接走 H2/H1,经 Alt-Svc 头或 HTTPS DNS 记录学到 H3 端点。
面试要点:H3 的收益在高丢包/弱网场景最大(流独立 + 更快的握手),在低丢包内网里和 H2 差距很小。Go 生态标准库长期未内置 HTTP/3,实际用 quic-go(quic-go/quic-go)做服务端与客户端。
讲透一个对比就能拿下这页:HPACK 的动态表假设帧严格有序,QUIC 天生乱序送达,所以头压缩必须重做成 QPACK——这是「上层设计受底层特性制约」的绝佳案例。0-RTT 记住安全代价(重放攻击),连接迁移记住本质(Connection ID 把身份与四元组解耦)。

Protobuf · Serialization

Protobuf 本质:把「字段名 → 字段编号」从运行时移到编译期

Protobuf 的 schema 与代码生成流水线 api.proto 文件经 protoc 编译生成多语言代码,Marshal 序列化成 TLV 字节流,反方向 Unmarshal 按字段编号还原结构体。 api.proto(Schema) message User { int64 id = 1; string name = 2; } protoc 编译器 一次编译 · 多语言插件 生成代码 user.pb.go stub + 描述符元数据 Marshal 结构体 → 字节 proto.Marshal() wire bytes TLV 字节流 08 96 01 … Unmarshal 解析端按「字段编号」还原 线上字节只认「字段编号 + wire type」,不含字段名 —— 改字段名零成本,改编号/改 wire type 才是破坏性变更
  • JSON 自描述 vs Proto 靠 schema:JSON 的字段名随每条报文传输,任何端拿到字节就能读懂;Protobuf 字节里只有编号,没有 schema 就是一堆乱码 —— 这既是体积优势,也是可读性劣势。
  • Go 侧落地google.golang.org/protobuf(v2 runtime)+ protoc-gen-go;生成代码带快速路径(直接按编号写 buffer),慢路径走反射元数据。
用一句话立住本质:JSON 把元数据(字段名)放在运行时的报文里,Protobuf 把元数据放在编译期的 schema 里,所以线上每条消息只需付「1~2 字节编号」的成本。强调编号即身份:字段名改动不影响线上,编号改动等于换字段——为兼容性一页做铺垫。

Protobuf · Wire Format

Wire format 总纲:TLV 结构,tag = (编号 << 3) | wire_type

varint 编码的字节级分解 字段 Test1 中 int32 字段 a=150 序列化为三个字节 08 96 01:第一个字节是 tag(字段号 1 加 wire type 0),后两个字节是 Base 128 varint,由各字节低 7 位拼接还原出 150。 message Test1 { int32 a = 1; } · a = 150 → 3 字节:08 96 01 TAG VARINT 字节 1 VARINT 字节 2 0x08 0000 1000 0x96 1001 0110 0x01 0000 0001 tag = (field_number << 3) | wire_type 高 5 位 = 字段号 1 · 低 3 位 = 0(VARINT) 最高位 1 → 后面还有字节 低 7 位 payload = 001 0110 最高位 0 → 编码结束 低 7 位 payload = 000 0001 两组 7 位拼接(低组在前):0000001 0010110 0b10010110 = 150 ✓ VARINT = Base 128:每字节低 7 位承载数据,最高位是「继续位」;0~127 → 1 字节,150 → 2 字节,随数值位数线性增长 字段号 1~15:tag 只要 1 字节 → 把高频字段编号排前面;16~2047 → 2 字节;上限 536,870,911 负数 int32/int64 按符号扩展编码 → 恒占 10 字节(下一页 zigzag 解决)
150→08 96 01 是官方文档的例子,面试可直接手推:150 二进制 10010110 拆成两个 7 位组(低组 0010110、高组 0000001),低组加继续位得 0x96,高组补齐得 0x01;tag 字节 = 1<<3 | 0 = 0x08。tag 的低 3 位是 wire type,六种类型见下一页的表。

Protobuf · Wire Types

六种 wire type:解析端靠它跳过不认识的字段

tag 的低 3 位是 wire type,告诉解析端「这个字段的值怎么读、有多长」。遇到未知字段号时,解析端只需按 wire type 给出的长度跳过整个值——这就是 Protobuf 前向兼容的机制基础。

wire type名称含义用于
0VARINT变长整数int32/64、uint32/64、sint32/64、bool、enum
1I64定长 8 字节fixed64、sfixed64、double
2LEN长度前缀 + 内容string、bytes、嵌套 message、packed repeated
3 / 4SGROUP / EGROUP组开始 / 结束group —— 已废弃(仅 proto2),解析端仍需容忍
5I32定长 4 字节fixed32、sfixed32、float
六种 wire type 里 3/4(SGROUP/EGROUP)已废弃但要知道——解析器靠 wire type 就能跳过不认识的字段,这是前向兼容的机制基础。被跳过的 unknown fields 旧版会丢弃、proto3 起默认保留在未知字段集里,能带出这一点是加分项。

Protobuf · Numbers

数值编码细节:varint 便宜,负数昂贵,zigzag 来救

varint 与它的坑

  • Base 128 变长:小数值极省(0~127 只要 1 字节);业务里大多数 id/计数是小数,平均成本远低于定长。
  • 负数是坑:int32/int64 的负数按 64 位符号扩展编码 → 恒占 10 字节(-1 也是 10 字节)。
  • 解法 sint32/sint64:先做 ZigZag 变换再 varint。
  • 定长类型:fixed64/double 用 I64(8 字节)、fixed32/float 用 I32(4 字节)——数值普遍很大(如时间戳毫秒、金额分)时比 varint 划算,且免变长解码。

ZigZag:把符号位「折」进最低位

  • 公式:正数 p → 2p(偶数),负数 n → 2|n|−1(奇数);解码 (n >> 1) ^ −(n & 1)。
  • 效果:−1 → 1(1 字节),0 → 0,1 → 2,−2 → 3,2 → 4 —— 绝对值小的有符号数都变短。
  • 选型口诀:可能为负的整数用 sint32/sint64;确定非负用 int32/int64uint32/64;大数值用 fixed
  • LEN 类型:string/bytes/嵌套 message = tag + varint(长度) + 原始字节;嵌套消息就是递归的 TLV,长度字段让解析器可以整体跳过。
// -1 在两种类型下的线上体积对比(手推必考)
int32  x = -1;   // varint:FF FF FF FF FF FF FF FF FF 01 → 10 字节
sint32 x = -1;   // ZigZag(-1)=1 → varint:01 → 1 字节(10 倍差距)
bool/enum      // 同 VARINT;enum 的 0 值必须定义为「未知/缺省」语义
最常考的推演题就是「为什么负数 int32 占 10 字节」——因为 varint 编码的是补码按 64 位符号扩展后的无符号数,最高位全是 1。答出 sint 的 ZigZag 公式(p→2p,n→2|n|−1)并给出 −1→1 字节的对比,基本就是满分。fixed vs varint 的选择标准:数值分布普遍大就用定长。

Protobuf · Rules

组合类型与序列化规则:这些「默认行为」都是考点

repeated / map / 字段顺序

  • repeated:同一字段重复出现 tag+payload;数值型在 proto3 默认 packed —— 整段元素装进一个 LEN 载荷,tag 摊薄成一个(解析端必须同时兼容 packed 与非 packed 两种形态)。
  • map<K,V>:线上一律编码为 repeated message,field 1 = key、field 2 = value,迭代顺序不保证;不要依赖 map 的顺序做业务。
  • 字段顺序不保证:规范明确序列化输出顺序是实现细节,parser 必须容忍任意字段顺序 → 同一消息两次序列化字节可能不同,别拿序列化结果做哈希/等值比较。

presence 与未知字段

  • proto3 隐式 presence:标量字段等于默认值(0 / "" / false)时整段不写上 wire —— 副作用:「没设置」和「设置成 0」在线上无法区分。
  • 需要区分就加 optional(显式 presence,官方推荐 basic types 一律加);消息类型字段天生有 presence。
  • 未知字段:解析时遇到没见过的字段号,按 wire type 跳过并保留(proto3 自 3.5 起)→ 新版本消息穿过旧服务再转发不丢数据,这是滚动升级的安全网。
  • oneof:互斥字段共用 presence,线上就是普通 TLV,只是语义层保证只有一个被设置。
高频追问:proto3 为什么去掉 required?——required 让「加字段」变成高风险操作(旧数据缺字段直接解析失败),proto3 全面转向「缺省即默认值」,用 optional 补回显式 presence。这解释了 proto2 → proto3 的整个设计取向:牺牲一点严格性,换取平滑演进。
三个默认行为容易被面试官当陷阱题:packed repeated 的兼容双形态、map 无序、序列化字节不稳定(不能当指纹用)。presence 那组要能说出「0 值消失」的实际事故:bool false、空列表、零金额都不上网,下游无法区分「没给」和「给了默认值」,解法是 optional。

Protobuf · Evolution

兼容性设计:字段编号是线上契约,演进规则是铁律

规则(protobuf.dev 原文要求)

  • 编号 = 身份,永不可改:编号范围 1~536,870,911;19000~19999 是实现保留段,protoc 直接拒绝使用。
  • 加字段:只用全新编号;1~15 的 tag 只要 1 字节,把高频字段放这里。
  • 删字段:编号(和名字)用 reserved 封存,防止后来者复用编号 —— 旧客户端把新数据按旧语义读错,是最隐蔽的线上事故。
  • wire type 不可变:把 int32 改成 int64 可以(同为 VARINT 且符号扩展兼容),改成 string(LEN)就是灾难。

双向兼容的机制根源

  • 前向(新写旧读):旧代码读到未知字段号 → 按 wire type 跳过并保留 → 转发不丢。
  • 后向(旧写新读):新代码读不到旧消息里的新字段 → 取默认值
  • 对比 JSON:加字段通常安全,但改名/改类型没有任何机制保护,全靠 code review 纪律;没有「必须跳过未知字段」的强制约定(解析器行为不一),契约退化成文档。
  • 对比 Avro:Avro 靠 reader/writer schema 双方解析时匹配,Protobuf 靠编号内嵌 —— 前者 schema 更灵活,后者零额外开销。
// 删字段的正确姿势:编号与名字都进 reserved,一箭双雕防复用
message User {
  reserved 2, 15, 9 to 11;          // 旧编号封存
  reserved "email", "phone";        // 旧名字封存(防 JSON 转换层误用)
  int64  id   = 1;
  string name = 3;   // 新字段用全新编号
}
这页是 Protobuf 相对 JSON 最本质的工程优势:它把「向后兼容」从团队纪律升级成编译器和线格式共同强制。reserved 是必考细节——只删字段不 reserve,三个月后有人把编号复用给新类型,旧服务读出新字段按旧类型解释,数据静默损坏。19000~19999 保留段这种数字题也要能答。

Comparison · Bytes

同一消息的字节级对比:JSON 26 B vs Protobuf 11 B

JSON 与 Protobuf 的字节级对比 消息 id=150、name=gopher 的两种编码逐字节对照:JSON 共 26 字节其中 17 字节是字段名与标点开销;Protobuf 共 11 字节其中仅 3 字节是 tag 与长度开销。 HTTP + JSON —— 26 字节(文本 · 自描述) { " i d " : 1 5 0 , " n a m e " : " g o p h e r " } 值(9) 字段名 / 引号 / 括号 / 冒号 = 协议开销(17) 26 B Protobuf —— 11 字节(二进制 TLV · 靠 schema 解释) 08tag f1 96150 低组 01150 高组 12tag f2 06len=6 67g 6Fo 70p 68h 65e 72r 值(8) tag / 长度前缀(3)—— 字段名零传输 11 B(−58%) 体积来源:JSON 的字段名、引号、括号每条消息原样重传;Protobuf 只传 1~2 字节的 tag,且默认值字段整段省略(proto3 隐式 presence)。 解析来源:JSON 要逐字符词法分析、字符串转数值、处理转义;Protobuf 是定界 TLV —— 按 wire type 定长读取或整体跳过,tag 是整数比较。 反例与边界:JSON 开 gzip 后重复字段名压缩率极高,体积差距明显缩小;HTTP 头的 HPACK 压缩不作用于 body —— 选型前用自己的真实数据压测。 图中字节可手推:08=(1<<3)|0 · 96 01=varint(150) · 12=(2<<3)|2 · 06=len("gopher") · 67 6F 70 68 65 72="gopher"
图里每个字节都能手推(08=字段 1 VARINT,96 01=varint 150,12=字段 2 LEN,06=长度 6),面试现场推一遍比背结论有说服力得多。26 对 11 只是无压缩的典型例子,务必主动补上三个边界:gzip 会缩小差距、HPACK 只压 HTTP 头不压 body、真实差距要拿自己的数据测——避免给人「无脑小一半」的错误印象。

Comparison · Matrix

对比总表:同层对齐后再比较

维度HTTP/1.1 + JSON(Web / REST)gRPC = HTTP/2 + Protobuf(微服务 RPC)
层次定位传输协议 + 自描述文本序列化传输协议(H2)+ 二进制序列化(proto)+ 调用约定(LPM/Trailers)
报文格式ASCII 文本,人眼直读二进制帧 + TLV,需 schema/工具解码
消息体积字段名与标点重复传输;gzip 可压缩只传编号 + 值,默认值省略;典型明显更小(见字节级对比)
解析成本逐字符词法/语法分析定界跳读 + 整数 tag 比较,生成代码直写 buffer
契约与类型无强制 schema(OpenAPI 是文档约定);动态灵活.proto 强契约,编译期生成多语言 stub,类型错误编译期暴露
兼容机制加字段基本安全;改名/改类型靠纪律编号机制 + reserved + 未知字段保留,演进规则被规范强制
流式能力1.1 无;H2 有流但无调用级抽象一元 / 服务端流 / 客户端流 / 双向流,第一公民
连接效率1.1 每连接串行(队头阻塞);实践堆 6 连接长连接多路复用,N 路调用并行交错
生态工具浏览器 / curl / CDN / 缓存 / 网关全兼容需运行时与 proto 管理;浏览器走 gRPC-Web + 代理;L7 负载均衡需感知 H2
调试体验抓包即读protoc --decode_raw / Protoscope / grpcurl
典型场景对外 API、低频调用、结构多变、可读性优先内部微服务、高 QPS 低延迟、强契约多语言、流式场景
这张表按「先对齐层次再逐维比」组织,回答任何对比题都可以从中取行。记忆锚点:三个「机制差」(体积、解析、兼容)都源自同一个根因——JSON 把元数据放运行时,Protobuf 把元数据放编译期;两个「生态差」(工具、调试)则是文本自描述的红利。别把表格背死,理解单向因果即可。

gRPC · over HTTP/2

一次 gRPC 调用的全貌:HTTP/2 帧里的 protobuf

gRPC 单次调用的帧时序 客户端到服务端的一次 gRPC 调用时序:请求 HEADERS、携带长度前缀 protobuf 的 DATA 帧、EOS,然后响应 HEADERS、DATA 与携带 grpc-status 的 Trailers。 Client stub · proto.Marshal() HTTP/2 连接(长连接复用) DATA 帧 · LPM 封装 Server handler · proto.Unmarshal() ① HEADERS::method POST · :path /pkg.Svc/Method · content-type application/grpc ② DATA:LPM = 1B 压缩标志 + 4B 大端长度 + protobuf ③ EOS(END_STREAM,请求发完) ④ HEADERS::status 200(先头后体) ⑤ DATA:LPM(响应消息,可多个=流式) ⑥ Trailers:grpc-status: 0(终态元数据) 请求与响应无需互相等待:一条连接上 N 路调用以不同 Stream ID 交错复用;流式 RPC = 同一条流上连续多个 LPM。 Trailers 是 HTTP/1.1 没有的能力(响应结束后再发元数据)—— 反向代理必须支持 HTTP/2 Trailers 才能透传 gRPC。
把六步背下来就能画出来:请求头(:method POST + :path 方法名 + application/grpc)→ DATA 帧装 LPM(1 字节压缩标志 + 4 字节大端长度 + protobuf 载荷)→ EOS → 响应头 → 响应 DATA → Trailers 携带 grpc-status。Trailers 是关键差异化:grpc-status 必须在响应体全部发完之后才能确定,HTTP/1.1 的「先头后体」做不到,这就是 gRPC 必须 HTTP/2 的原因之一。gRPC 快的四件事:长连接复用、多路复用、二进制帧、protobuf 编码。

Decision Guide

选型:不是二选一,是分层组合

HTTP + JSON 适合

  • 对外 API / 浏览器直连:零运行时依赖,curl、devtools、监控、CDN、网关即开即用。
  • 结构多变 / 动态字段:无 schema 编译环节,改字段即改即生效。
  • 可读性与调试优先:日志里直接贴 body。
  • 低频 / 带宽不敏感:体积与解析优势用不上时,别为它引入契约管理成本。

Protobuf + gRPC 适合

  • 内部微服务、高 QPS 低延迟:编解码 + 多路复用的收益随调用量放大。
  • 强契约 + 多语言:.proto 是唯一的真相源,CI 里编译期拦截类型不一致。
  • 流式需求:双向流是原生能力(消息推送、日志采集、大文件分块)。
  • 要接受的成本:schema 仓库与版本管理、gRPC-Web 网关、抓包要解码、L7 负载均衡要感知 H2/Trailers。
常见实践:对外 REST(JSON) + 对内 gRPC(Protobuf),边界处做一次转换;消息队列内部消息体用 Protobuf(Kafka 存 TLV 字节,schema 放 registry)。反模式:拿 Protobuf 存「要人读的」数据(日志、配置导出);用 gRPC 但从来不开流式与多路复用(配了 HTTP/1.1 网关)。
选型题的标准答法是「场景拆分」而不是站队:对外可读兼容优先,对内效率契约优先。主动说出 gRPC 的工程成本(schema 管理、网关/负载均衡要求)会显得有实战经验——性能不是唯一维度,团队规模与运维复杂度同样是决策变量。

Interview QA · 1/2

典型面试 QA(上)

Q1 HTTP 和 Protobuf 是竞争关系吗?

分层gRPC=HTTP/2+Proto

不是。HTTP 是应用层传输协议,Protobuf 是序列化格式,gRPC 是两者的组合。同层对比应是:JSON ↔ Protobuf(编码),HTTP/1.1 ↔ HTTP/2 ↔ HTTP/3(传输)。把层次说对,这道题就已经赢了一半。

Q2 Protobuf 为什么又小又快?

无字段名varint默认值省略TLV 跳读

小:字段编号 1~2 字节替代字段名重复传输;varint 按数值位数变长;proto3 默认值字段整段不上 wire。快:TLV 定界可按 wire type 定长读取或整体跳过,tag 是整数比较;schema 编译期生成代码,无运行时反射(Go 生成代码走快速路径直写 buffer)。

Q3 HTTP/2 彻底解决队头阻塞了吗?

应用层✓TCP 层✗QUIC 流独立

只解决一半。H2 用帧 + Stream 消除了应用层排队,但 TCP 只认字节流:任一报文段丢失,内核必须等重传才能交付其后所有数据,同连接上全部 Stream 一起卡。HTTP/3 把流下沉到 QUIC(用户态、流间独立确认),才在传输层消灭它。

Q4 proto3 默认值不序列化,有什么坑?

0 值消失optionalpresence

标量字段等于默认值(0 / "" / false)时整段不上 wire:「没设置」和「设置成 0」在线上不可区分——bool false、空列表、零金额都会消失。需要区分就必须加 optional(显式 presence,官方推荐 basic types 一律加);消息类型字段天生有 presence,不受影响。

这四题覆盖最高频的追问。Q1 考层次感,Q2 考机制拆解(体积、解析两个维度分开答),Q3 考「解决一半」的辩证表述——能主动说出 TCP 层残余阻塞是加分项,Q4 考实战踩坑。每题的答法都是「先定性、再拆机制、最后给边界」。

Interview QA · 2/2

典型面试 QA(下)

Q5 为什么 int32 存 -1 要 10 字节?怎么办?

符号扩展ZigZagsint32

varint 编码的是补码符号扩展到 64 位后的无符号数,-1 扩展后高位全 1,恒占 10 字节。解法:可能为负的字段用 sint32/sint64——先 ZigZag 变换(p→2p,n→2|n|−1)再 varint,-1 只占 1 字节;确定非负用 int/uint;数值普遍很大用 fixed。

Q6 删字段要注意什么?

reserved编号不复用19000~19999

编号和名字都要进 reserved:只删不封,后人复用编号后,旧客户端会把新字段按旧类型解释,数据静默损坏。同时记住:字段编号上线后永不修改(它是 wire 上的唯一身份);1~15 给高频字段;19000~19999 是实现保留段,protoc 拒绝使用。

Q7 HTTP/1.1 的 pipeline 为什么没被采用?

响应按序重试歧义浏览器未启用

三个原因:响应必须严格按请求序返回,队头阻塞依旧;非幂等方法后 pipeline 出错时无法安全重试(RFC 9112 §9.3.2 明确 SHOULD NOT);收益又被「多开 6 条连接」的简单方案覆盖。结果浏览器从未默认启用,规范保留但事实废弃。

Q8 gRPC 一定比 REST 快吗?

复用+二进制冷连接打平成本另算

不一定。差距来自四个机制(长连接复用、H2 多路复用、二进制帧、protobuf 编码),首次建连与低频调用时差距很小;JSON 开 gzip 后体积可能打平;网关/调试/schema 管理是额外成本。准确说法:高频内网 RPC 场景下端到端开销显著更低。

Q5 是手推题(10 字节 vs 1 字节的对比最有说服力),Q6 考 reserved 的工程动机而非语法,Q7 考「规范存在但事实废弃」的演进视角,Q8 考不站队、讲机制和边界的成熟度。八道 QA 合起来正好覆盖本 deck 的全部主干知识。

References & Related

参考来源 & 相关知识点

参考来源(已逐条核对)

相关知识点(点击跳转 · 待沉淀项以虚线标注)

  • TCP 三次握手与四次挥手 — HTTP/1.1 与 HTTP/2 的传输底座:握手时延、队头阻塞的 TCP 根源、连接生命周期
  • TLS 1.3 与握手优化 待沉淀 — ALPN 协议协商、1-RTT/0-RTT、与 QUIC 握手的融合
  • QUIC 内部机制 待沉淀 — 包号/确认/流控、拥塞控制、连接迁移的实现细节
  • gRPC-Go 内部实现 待沉淀 — HTTP/2 Transport、编解码层与 protobuf-go 快速路径

本 deck 边界:HTTP 传输协议原理 + Protobuf 线格式与对比。不覆盖:TLS/QUIC 内部、gRPC 框架源码、JSON 解析器实现。

键盘操作: 翻页 · T 换主题 · S 演讲者模式 · O 总览。

所有规范细节对照 RFC 9112/9113/9114/9000、RFC 7541、protobuf.dev 与 gRPC 官方协议文档逐条验证;字节示例(08 96 01 / 26B vs 11B)已用程序核算。版本敏感点(Chrome 106 移除 H2 Push、Go 标准库未内置 HTTP/3)在对应页有标注。