LLM 面试备考
首页/分布式与可靠性

通信:RPC、HTTP 与 gRPC(RPC, HTTP & gRPC)

所属:01-fundamentals / 07-distributed-systems
面试权重:★★
来源:通用分布式知识,System Design 101(ByteByteGo)/ MIT 6.5840(分布式系统)/ gRPC 官方文档等公开资料;本仓库两本参考书基本未覆盖,标渠道来源、未做一手考证。唯一从参考书引的一处:MCP 的两类传输见《深入理解 AI Agent:设计原理与工程实践》第 4 章
相关知识点:timeout-retry-idempotency.md(超时 / 重试 / 幂等的完整展开)、queue-pubsub.md(异步投递与背压)、state-machine.md(调用背后的任务状态)、../04-agent-architecture/mcp.md(MCP 协议本身)、../04-agent-architecture/tool-use.md(模型侧的工具调用格式)、../../02-interview/design/05-routing-gateway.md(网关层的重试 / 熔断 / 降级)

一句话#

跨进程通信的第一课是打破「远程调用和本地调用一样」这个幻觉 —— 网络不是本地:延迟非零、会部分失败、结果不确定;HTTP 的演进(1.1 → 2 → 3)一直在解决「怎么在一根连接上并排跑很多请求而不互相堵」,而 gRPC 们用 IDL 把契约搬到编译期。工程上真正值钱的两条纪律是:deadline 沿调用链逐级递减,重试只对幂等调用。

为什么重要(面试角度)#

  • 它是一道「送分也送命」的题。 谁都会说「gRPC 比 REST 快」;能说清为什么(HTTP/2 多路复用 + Protobuf 二进制 + IDL 代码生成)、以及它没解决什么(网络照样会失败),才是分层点。
  • 「超时 ≠ 没执行」是最高频的失分点。 面试官问「下游超时了怎么办」,答「重试一下」的人直接出局 —— 正确动作是先查再定,因为超时只说明没收到响应,不说明操作没发生。
  • deadline 递减是一道能当场推的题。 上游给 3 秒、你给下游 3 秒,就会出现「上游已放弃、下游还在跑」的白烧与重试放大 —— 这个说法比背定义更能证明你设计过链路。
  • 它接得上 Agent。 MCP 的本地 / 远程两类传输、模型 API 的 HTTP + SSE 流式、工具跨进程调用时的幂等键与超时预算,全都在这篇的射程内。
  • 诚实划界:这层我没有在生产里搭过 RPC 链路;真正做过的是 QueWI 网关那一层的模型调用重试 / 熔断 / 降级(见 ../../02-interview/design/05-routing-gateway.md)。别的用「这套账我会这么算」的口径讲。

核心内容#

1. RPC 的透明性幻觉:网络不是本地#

**RPC(Remote Procedure Call,远程过程调用)**的目标是让「调一个远程服务」写起来像「调一个本地函数」:result = getUser(id)。这个便利是真的,但「透明」是幻觉。 本地调用和远程调用至少有四处本质区别:

维度 本地调用 远程调用
延迟 纳秒级,且稳定 毫秒级起,抖动大(排队、重传、GC)
失败模式 要么返回、要么抛异常 —— 结果确定 部分失败:可能发出去了没回来,结果不确定
参数传递 可以传指针 / 引用(共享内存) 只能按值序列化,指针没有意义
执行语义 顺序、同步、恰好一次 可能乱序、并发、重放(重复执行)

把远程调用当本地调用写,会在网络出错时暴露「不可见的假设」:你以为「抛异常 = 没发生」,但远程世界里超时既不等于成功也不等于失败(下节展开)。

一段值得记住的历史:Waldo 等人 1994 年的论文《A Note on Distributed Computing》系统性地否定了「把分布当作实现细节、对调用方透明」的思路,指出本地与远程在延迟、内存访问、并发、部分失败上根本不同,必须显式建模。另有「分布式计算的谬误」(Deutsch 等提出)那一串常被引用:网络可靠、延迟为零、带宽无限、拓扑不变、只有一个管理员、传输免费、网络同质……注意这是公开的经典论断、未做一手考证,面试引的时候说清是「业界共识/经典论文」,别说成你自己的发现。

2. 超时 ≠ 没执行:部分失败(partial failure)#

一次远程调用发出去后,可能落在四种状态,而你只收到「超时」这一种信号:

实际情况 你收到的 正确反应
请求没到达服务端 超时 可以安全重试(前提是幂等或带幂等键)
请求到了、执行了、响应丢了 超时 不能盲目重试 —— 会写两次
请求到了、还在执行(慢) 超时 盲目重试会放大负载
请求到了、执行了、响应正常 正常返回 ——

核心结论:超时只说明「我没在预算内收到响应」,不说明「操作没发生」。 所以对不幂等的操作(付款、下单、创建资源),超时后的正确动作是先查询已持久化的结果、核对操作标识,确认没做再重试 —— 这就是「超时 ≠ 没执行」。

这和仓库里 Agent 运行时那篇的结论是同一件事:「没做完」和「做完了但确认丢了」是两种失败,处理完全不同(见 ../06-agent-infra/10-agent-runtime.md §6)。重试的完整机制见 timeout-retry-idempotency.md。

3. HTTP 的演进:1.1 → 2 → 3,一直在解「队头阻塞」#

HTTP/1.1 —— 一个问题一次:一根连接在某一时刻只处理一个请求的响应(有 pipelining,但实践里基本不用,因为一个慢响应会把后面全堵住)。为了并发,浏览器/客户端只能开多条连接、甚至域名分片。另外,每个请求都带一套纯文本 header(cookie、UA 等),重复开销不小。

  • 队头阻塞(Head-of-Line Blocking, HOL):排在前面的响应卡住,后面的响应即便已就绪也只能等。HTTP/1.1 有应用层的 HOL。

HTTP/2 —— 二进制分帧 + 多路复用:把请求/响应切成帧,多个流(stream)在一条 TCP 连接上交错传输,互不阻塞;header 用 HPACK 压缩(省去重复字段);可选服务端推送(现多已弃用)。

  • 它解决了应用层的 HOL,但没解决传输层的。 HTTP/2 仍跑在 TCP 上,而 TCP 要求字节有序交付 —— 一个 TCP 丢包会让后面所有流一起等重传,这就是 TCP 层队头阻塞。

HTTP/3 —— 改跑在 QUIC 上(基于 UDP,内建 TLS 1.3)。QUIC 在流这一层做可靠传输:一个流丢包只堵它自己,不牵连别的流,从根上绕开 TCP 层 HOL;握手也更快(1-RTT,重连可 0-RTT)。代价:UDP 有时被中间设备拦截,生态与调试工具相对新。

一张表记住:

传输 多路复用 队头阻塞
HTTP/1.1 TCP 靠开多条连接 应用层有
HTTP/2 TCP 一条连接多流 应用层无,TCP 层有
HTTP/3 QUIC over UDP 一条连接多流 按流隔离,基本消除

4. REST vs RPC vs gRPC#

先纠一个常见对立:REST 和 RPC 不是「新旧」之分,是两种风格。

  • REST:面向资源。URL 是名词(/users/42),用 HTTP 动词(GET / POST / PUT / DELETE)+ 状态码表达操作,无状态。吃现成的 HTTP 生态,人可读、易调试;但没有强契约(要靠 OpenAPI 等约定),动作语义别扭时(POST /users/42/activate)就不那么「REST」了。
  • RPC(泛称):面向过程 / 方法。就是「调一个远程方法」:activateUser(42)。可以是 HTTP 上的 JSON-RPC,也可以是 gRPC。动作表达自然,但不如 REST 那样复用 HTTP 语义。
  • gRPC:Google 的 RPC 框架,跑在 HTTP/2 上,用 Protocol Buffers 作 IDL。带来三样东西:强类型 + 代码生成(客户端/服务端从 .proto 生成)、二进制紧凑、四种流式调用。代价:二进制不可人肉读、浏览器原生用不了(要 grpc-web 或代理)、schema 演进要守规矩。
维度 REST gRPC
风格 面向资源 面向方法(RPC)
契约 弱(惯例 / OpenAPI) 强(.proto IDL)
编码 JSON(文本) Protobuf(二进制)
传输 HTTP/1.1 或 2 HTTP/2
流式 靠 SSE / chunked 原生四类
浏览器 直接可用 需 grpc-web / 代理
可读性 高,抓包即懂 低,需工具解码

选型判据:服务间、内部、高吞吐、要强类型契约 → gRPC;面向浏览器 / 第三方、要可读可调试、动作简单 → REST(或 HTTP + JSON)。

5. gRPC 的四类调用#

gRPC 把「谁在流」分成四种,对应不同的 stream 关键字组合:

类型 请求 响应 典型用途
一元(unary) 1 1 普通方法调用,最常用
服务端流(server streaming) 1 多 订阅、日志尾随、进度 / 增量结果
客户端流(client streaming) 多 1 分批上传、聚合上报
双向流(bidi streaming) 多 多 实时对话、双向推送

要能说清的两点:① 流式都建立在 HTTP/2 的多路复用上,多个流共享一条连接;② 服务端流对 Agent 场景很贴切 —— 模型边生成边下发、工具边执行边报进度,本质就是「一个请求、一串响应」。

6. deadline(超时预算)必须沿调用链递减#

先说清两个词的区别:

  • timeout(超时)是相对的:每次尝试给一段固定的时间,重试会重新计时。
  • deadline(截止时间)是绝对的:一个全局的时间上限,随请求一路传下去,不因重试重置。

纪律:上游的预算必须比下游的更大,且要逐级递减。

设上游给你 3 s,你要调一个下游:

  • 错:给下游 3 s。→ 上游在第 3 秒放弃了,下游可能还在跑到第 3 秒 —— 白烧资源,而且上游若重试,下游会被重复触发。
  • 对:给下游 ≤ 2.5 s,留出返回路径与本跳处理的余量。上游 3 s 的账拆成:本跳开销 + 下游预算 + 回来的时间。

不递减的两个后果,正是面试想听的:

  1. 浪费:上游已放弃、下游还在跑,做的全是无用功。
  2. 重试放大(retry amplification):每一层的超时若都触发重试,每一层的重试都会把负载乘一遍 —— 一次上游重试,会放大成下游的重试风暴。越往下游,重试越要克制。

为什么传 deadline 比传 timeout 好:deadline 是绝对的,跨层不会各自重新计时,天然保证「下游不会比上游活得久」。gRPC 原生支持在元数据里传播 deadline。重试与退避的完整展开见 timeout-retry-idempotency.md。

7. 重试只对幂等调用:幂等键放哪#

铁律:只重试幂等操作。 读、查询天然幂等;写操作要想重试,得让它幂等 —— 办法是幂等键(idempotency key):客户端为这次操作生成一个唯一 ID,随请求发出;服务端记录这个 ID 和结果,重复收到同一 ID 时直接返回上次的结果,而不是再执行一次。

幂等键放 header 还是 body?

  • 放 header:是传输层元数据,对任意方法(含 GET / DELETE)都统一适用,也便于网关 / 中间件统一处理;这是常见约定(如支付类 API 用 Idempotency-Key header)。
  • 放 body:适合没有 header 概念、或要求强类型的 RPC —— 把 key 放进请求消息、由 IDL 定义。
  • 关键不在位置,在语义:这个 key 必须参与请求身份,且服务端要把它持久化(连同结果),否则重启后就失效了。

还要配套的两件事:

  1. 只重试可重试的错误:超时、连接重置、503 之类可重试;400 / 422 这类参数错误重试一百次也一样,别重试。
  2. 退避 + 抖动(backoff + jitter):固定间隔重试会让失败的下游收到同步的一波重试(惊群 / 重试风暴),加随机抖动把重试打散。

8. 序列化:Protobuf vs JSON 的取舍#

维度 Protobuf JSON
形式 二进制 文本
契约 有 schema(IDL),编译期检查 无 schema(或松散的 JSON Schema)
体积 更紧凑(用字段号而非字段名) 更大(字段名 + 文本编码)
解析 更快(二进制解码) 相对慢(文本解析)
可读性 低,需工具 高,人肉可读
浏览器 / 通用性 需代码生成 Universal,任何语言 / curl 都能用

schema 演进是 Protobuf 的隐藏考点:靠字段号兼容 —— 新增字段用新号、设为可选;绝不重用或改号(老数据会解错);删字段要标 reserved。JSON 侧没有这套强约束,演化更容易,也更容易悄悄破线。

判据:内部服务间、量大、要强契约 → Protobuf;对外 API、要可读可调试、工具通用 → JSON。「哪个更好」没有绝对答案,看的是契约强度与可读性的取舍。

9. 接回 Agent:MCP 传输、模型 API 与工具调用的跨进程账#

  • MCP 的两类传输。 参考书(《AI Agent》第 4 章)的口径:本地传输用 stdio(标准输入输出),远程传输用 Streamable HTTP(早期的 SSE 方案已弃用)。面试提醒:别去背具体的传输名 —— MCP 规范在演进,记住形态就好:本地 = 起一个子进程、走 stdin/stdout 管道;远程 = 走可流式 HTTP。协议本身(host / client / server、三种原语)见 ../04-agent-architecture/mcp.md。
  • 模型 API 是 HTTP + SSE。 流式输出走的是 SSE(Server-Sent Events,服务器推送事件):一条长连接上,服务器单向持续推送增量事件。它本质是「HTTP/1.1 上的一个长响应」;换成 HTTP/2 则落在一条流上。「一个请求、一串响应」这个模式和 gRPC 的服务端流是同一个形状。
  • 工具跨进程调用,两笔账照旧。 工具如果是远程服务(而非本地函数),那么:① deadline 要传下去 —— 主 Agent 这一轮的预算必须拆给工具,别让它比主流程活得久;② 不幂等的工具要带幂等键 —— 工具超时重调时,别把「下单」「发邮件」这类副作用做两次。这两条与 §2、§6 是同一套纪律,只是换了场景。
  • 工具调用的调用格式在模型侧(call id 配对、并行调用前提)见 ../04-agent-architecture/tool-use.md 与 ../04-agent-architecture/agent-react.md;跨进程的可恢复性见 ../06-agent-infra/10-agent-runtime.md §6;网关层的重试 / 熔断 / 降级见 ../../02-interview/design/05-routing-gateway.md。

常见面试问法#

Q:为什么不建议把远程调用当作本地函数调用来写? A:因为「透明」是幻觉,本地和远程在四个维度上根本不同:延迟(纳秒 vs 毫秒且有抖动)、失败模式(本地要么返回要么抛异常、结果确定;远程是部分失败、结果不确定)、参数传递(本地能传指针 / 引用,远程只能按值序列化)、执行语义(本地顺序同步恰好一次,远程可能乱序、并发、重放)。把这些当成本地,网络一出错就会踩中「不可见的假设」—— 尤其是超时不等于没执行。这会引出经典论文《A Note on Distributed Computing》(Waldo 等,1994)的结论:分布不能对调用方透明,必须显式建模。

Q:下游调用超时了,你会重试吗? A:先看它幂不幂等,而且不要直接重试。 超时只说明没在预算内收到响应,可能是「没到达」「执行了但响应丢了」「还在执行」三种状态,你分不清。对不幂等的写操作(付款、下单、创建资源),直接重试会做两次;正确动作是先查询已持久化的结果、核对操作标识,确认没做再重试。只读 / 幂等的操作可以重试,但要配 退避 + 抖动,并且只重试可重试的错误(超时 / 5xx,不要重试 4xx)。细节见 timeout-retry-idempotency.md。

Q:HTTP/1.1、HTTP/2、HTTP/3 的差别? A:主线是一路在解队头阻塞。HTTP/1.1 一根连接一次一个请求,慢响应会堵住后面(应用层 HOL),只能靠多连接 / 域名分片绕过,且每次重复文本 header。HTTP/2 用二进制分帧 + 多路复用让多个流跑在一条 TCP 连接上,HOL 在应用层消失 —— 但它还在 TCP 上,一个丢包会让所有流一起等重传(TCP 层 HOL)。HTTP/3 改跑 QUIC(基于 UDP),按流做可靠传输,一个流丢包不牵连别的流,握手也更快;代价是 UDP 可能被中间设备拦、生态较新。

Q:gRPC 和 REST 怎么选? A:看契约强度和面向谁。 服务间、内部、高吞吐、要强类型契约和流式 → gRPC(HTTP/2 + Protobuf IDL + 代码生成,四类调用)。面向浏览器 / 第三方、要可读可调试、动作不复杂 → REST(吃现成 HTTP 生态、JSON 好读好抓包)。注意 REST 和 RPC 是两种风格、不是新旧;也别把「gRPC 比 REST 快」当成无条件的 —— 差别来自多路复用 + 二进制编码 + 代码生成,而在小报文、低并发下未必显著。

Q:为什么要传 deadline 而不是各层各设 timeout? A:因为 timeout 是相对的、重试会重新计时,各层独立设会出现「上游 3 秒放弃、下游 3 秒还在跑」—— 白烧资源,而且上游重试会把下游重复触发,形成 重试放大。deadline 是绝对的,跟着请求一路传下去、不因重试重置,天然保证下游不会比上游活得久;工程上让下游预算严格小于上游(如上游 3 s → 下游 ≤ 2.5 s),给返回路径留余量。gRPC 原生支持在元数据里传播 deadline。

易错点#

易错说法 问题 应该说
「远程调用和本地调用差不多,包一层就行」 透明性幻觉 延迟、部分失败、参数按值、执行语义四点都不同,分布必须显式建模
「下游超时了就重试」 把「不确定」当「没发生」 超时 ≠ 没执行;先查持久化结果与操作标识,不幂等不给重试
「gRPC 一定比 REST 快」 无条件化 优势来自多路复用 + 二进制 + 代码生成;小报文低并发下未必显著
「HTTP/2 没有队头阻塞了」 只看到应用层 HTTP/2 消除了应用层 HOL,但TCP 层仍有(丢包堵所有流);HTTP/3 走 QUIC 才按流隔离
「REST 是新版 RPC」 混淆风格与代际 REST 面向资源、RPC 面向方法,是两种风格,不是新旧
「超时时间各层各自设就行」 缺 deadline 传播 各设会造成「上游放弃、下游还在跑」+ 重试放大;要用绝对 deadline 逐级递减
「重试只要多试几次总能成功」 忽略幂等与放大 只重试幂等调用、只重试可重试错误,并配退避 + 抖动
「幂等键放 header 还是 body 无所谓」 只纠结位置 位置可议,关键在服务端要持久化 key 与结果,否则重启即失效
「Protobuf 只是更小更快」 漏了 schema 演进 靠字段号兼容:新增用新号、绝不改号 / 重用、删字段标 reserved
「MCP 的传输类型要背下来」 背会过期的细节 记住形态:本地 = 子进程 stdio,远程 = 可流式 HTTP(早期 SSE 方案已弃用);规范在演进
把本篇讲成「我们线上就是这么搭的」 触碰仓库硬规矩 这是公开资料的系统知识;真正做过的是 QueWI 网关那层,用「这套账我会这么算」口径讲

自测#

1. 你调一个「下单」接口,客户端超时了。你能确定订单没创建吗?该怎么办?

答案

不能确定。超时只说明「没在预算内收到响应」,不说明「操作没发生」。

请求可能处于四种状态之一,而你只看到「超时」这一种信号:没到达 / 执行了但响应丢了 / 还在执行 / 其实成功了。下单是不幂等的,直接重试会创建两笔订单。

正确顺序是「先查,再决定」:

  1. 查询已持久化的结果,核对操作标识(如客户端生成的 order id)—— 订单在,就接续,不重试。
  2. 查不到,再判断能不能重试:要么该操作本身幂等,要么目标系统支持幂等键 / 操作标识 / 补偿。
  3. 若一开始就设计好:客户端为这次下单生成幂等键随请求发出,服务端持久化「key → 结果」,重试时同 key 直接返回上次结果 —— 从根上避免重复下单。

别忘了配套纪律:只重试可重试的错误(超时 / 5xx),配退避 + 抖动打散重试。这与 Agent 运行时的结论一致:「没做完」和「做完了但确认丢了」是两种失败(见 ../06-agent-infra/10-agent-runtime.md §6)。

2. 一条调用链 A → B → C,budget 是 A 收到请求后 3 秒内必须返回。B、C 各自该怎么设超时?不这么做会怎样?

答案

用绝对的 deadline 逐级递减,不用各层相对 timeout。

  • A 的总预算 3 s:留给本跳处理 + 调 B + 返回的时间。
  • B 拿到的 deadline 应严格小于 A 的,比如 2.5 s —— 留出 A↔B 的网络往返和 B 自己的处理时间。
  • C 再小一档,比如 2.0 s,留出 B↔C 的往返和 B 的处理。

关键是「上游预算 > 下游预算」 —— 这样下游不会比上游活得久。

不这么做的两个后果(面试要主动说):

  1. 浪费:A 在第 3 秒放弃了,C 可能还在跑到第 3 秒甚至更久 —— 白烧资源。
  2. 重试放大:每层超时都触发重试的话,每一层的重试把负载再乘一遍,越往下游越危险。所以越靠近下游,重试越要克制。

为什么用 deadline 不用 timeout:timeout 是相对的、重试会重新计时;deadline 是绝对的、跟着请求传、不重置。gRPC 原生在元数据里传播 deadline。

3. 面试官说:「我们用 gRPC,所以不会遇到队头阻塞。」这句话对吗?

答案

要分开看,不能一句话说对或错。

  • gRPC 跑在 HTTP/2 上,HTTP/2 用二进制分帧 + 多路复用,多个流跑在一条 TCP 连接上 —— 应用层的队头阻塞确实消除了。
  • 但 HTTP/2 仍然跑在 TCP 上,TCP 要求字节有序交付:一个 TCP 丢包会让这条连接上所有流一起等重传。这就是传输层的队头阻塞,gRPC 照样会遇到。
  • 真正从根上解的是 HTTP/3(QUIC):按流做可靠传输,一个流丢包不牵连别的流。它基于 UDP、内建 TLS 1.3,握手更快;代价是 UDP 可能被中间设备拦、生态较新。

所以准确说法是:「gRPC(HTTP/2)消除了应用层 HOL,但TCP 层 HOL 仍在」—— 把「gRPC」和「没有队头阻塞」直接划等号,是漏了传输层这一半。

4. 你要在两个内部服务之间传结构化数据,量很大、字段还会慢慢加。选 Protobuf 还是 JSON?加字段时要注意什么?

答案

内部服务间、量大、要强契约 → Protobuf 更合适。 理由:二进制更紧凑(用字段号而非字段名)、解析更快、有 .proto 作 IDL,客户端 / 服务端代码生成、编译期检查,字段演化有明确规则。JSON 的优势是人可读、工具通用,更适合对外 API 与调试,但量大时体积和解析成本更高,且契约靠约定、容易悄悄破线。

加字段时(schema 演进)的规矩:

  • 新增字段用新的字段号,并设成可选 —— 老客户端不认也不报错。
  • 绝不重用或修改已有字段号 —— 老数据会解错。
  • 删字段要标 reserved(保留号与名),防止以后有人复用。
  • 需要改语义时,新增字段而不是改旧字段的含义。

要补一句边界:Protobuf 不是无脑「更好」,它对调试不友好(二进制要工具解码)、浏览器侧要代理;选型看契约强度与可读性的取舍。