HTTP协议

对HTTP协议的详解先不放到这里,移到了另外一章,看到这篇的默认认为了解 HTTP 协议

RPC协议

为什么要有RPC协议

RPC,全称 Remote Procedure Call,远程过程调用,是一种允许程序调用另一个地址空间(通常是在网络中的另一台计算机上)的程序的协议。它使得程序能够像调用本地函数一样调用远程函数,而无需显式编码这些远程交互的细节。

但这个名字起的一点都不好,过分强调了和LPC(本地过程调用)的对比。没有突出出来 RPC 本身涉及到的一些技术特点。

我非常同意上述观点,跟他妈的鲁棒性那个翻译似的

什么意思

假设你有两台电脑:

  • 本地电脑 A:你写的程序运行在这里,想获取用户信息;
  • 远程电脑 B:数据库在这,有一个 getUserInfo(userId) 函数能查用户信息。

如果没有 RPC,你要实现 “调用 B 的函数”,得自己做这些事:

  1. 手动写网络通信代码,比如用 Socket,把 userId 打包成字节流;
  2. 处理网络传输,比如 TCP 连接、IP 地址、端口,把数据发给 B;
  3. B 收到数据后,手动解析字节流,拿到 userId,调用本地的 getUserInfo;
  4. B 把返回的用户信息再打包成字节流,发回给 A;
  5. A 收到后,手动解析字节流,拿到用户信息。

整个过程你要操心各种事情,数据怎么序列化怎么反序列化两主机网络如何握手怎么处理超时怎么粘包拆包….这些都是和 查用户信息无关的底层细节。

RPC 就是帮你把上面这些底层细节全封装了,你在 A 电脑的代码里,只需要像调用本地函数一样写就可以

Dubbo!

那么在上述内容中说,其实,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的思想。

客户端在不知道调用细节的情况下,调用存在于远程计算机上的某个对象,就像调用本地应用程序中的对象一样,即允许像调用本地服务一样调用远程服务。

image-20251225160634823

而且,从微服务的角度来考虑,RPC 的大量使用也是必然的,因为当我们的系统访问量增大、业务增多时,我们会发现一台单机运行此系统已经无法承受,必然会去进行微服务化的相关内容,而服务之间的通讯手段,RPC就必然会大显身手

常见的 RPC 框架

回到本质,RPC 是一种协议,那么一个协议内容再多,终究只是一套规范,需要有人遵循这套规范来进行实现

gRPC

gRPC 是跨语言的高性能 RPC 框架,基于 HTTP/2 + Protobuf(二进制序列化),Google 主导,Java 生态支持完善,适合跨语言通信、高性能场景。

基于 Protobuf 定义服务接口,可生成 Java、Go、Python 等多语言客户端 / 服务端代码,是 Go 项目下的最多的需求

而且 gRPC 性能很好,基于 HTTP/2 + Protobuf,支持简单 RPC、服务端流、客户端流、双向流四种调用方式。

你可以理解 ProtoBuf 是一种更加灵活、高效的数据格式,与 XML、JSON 类似,在一些高性能且对响应速度有要求的数据传输场景非常适用。

我们知道使用 XML、JSON 时,数据文本格式更容易阅读,但进行数据交换时,设备就需要耗费大量的 CPU 在编码解码等 I/O 动作上,自然会影响整个传输速率。而 Protocol Buffers 会先把结构化数据编码成紧凑的二进制再传输,字段名根本不进报文,只留一个数字标签,所以它的报文小、编解码也快

原生异步,Java 端提供异步 API,适配高并发场景,微服务使用可靠,而且对 Spring Boot 的支持也相当好

gRPC 的详细内容单开一节讲,因为它是所有 RPC 框架里最容易让人概念混乱的一个——它同时踩在“RPC 框架”和“HTTP/2 协议”两个概念上。放在这里怕把主线带偏,见后面的《认清楚 gRPC》。

Dubbo

Dubbo 是专为 Java 打造的高性能、高扩展性的分布式服务框架,对 Java 开发者好感度拉满。

它原生 Java 友好,实际上,是 Java RPC 远程调用中用的最多的实践方案,基于 Java 接口定义服务,无缝集成 Spring Boot 和 Spring Cloud Alibaba

而且 Dubbo 自带多协议支持,默认推荐 dubbo 私有二进制协议(性能最优),也支持 HTTP、gRPC、Hessian 等

Dubbo 全链路能力极强,内置负载均衡、服务降级、熔断、限流、链路追踪,微服务涉及到的管理,容灾,高性能,高可靠的相关内容,基本全涉及到了

本内容的所有案例都基于 Dubbo,但是会使用其中最简单最清晰的内容

Thrift

这位更是老资历

Thrift是Facebook开源项目,其是一个跨语言的服务开发框架。用户只需在进行二开即可,对底层的RPC通讯透明。

理解RPC

只要涉及到网络通信,必然涉及到网络协议,应用层也是一样。在应用层最标准和常用的就是HTTP协议,但是很多性能要求较高的场景各大企业内部也会自定义的 RPC 协议,HTTP相当于普通话,而RPC就相当于是一个方言。

RPC可以分为两部分:用户调用接口 + 具体网络协议。前者为开发者需要关心的,后者需要由框架来实现。

实际上,这两部分其实包括的是完整的 RPC 五个逻辑层

层次 职责 技术关键点
User (客户端) 发起逻辑调用。 对远程细节完全无感。
Client Stub (客户端存根) 核心环节:序列化 (Marshaling)。 将函数名、参数类型、参数值封装成网络包。
RPC Runtime 传输管理。 负责建立连接、处理超时重试、请求/响应映射(Request ID)。
Server Stub (服务端存根) 反序列化 (Unmarshaling)。 解析网络包,通过反射或分发器找到对应的本地实现类并执行。
Service (服务端实现) 执行具体业务逻辑。 将结果交给 Server Stub。

用户调用接口

这是一套展示给用户的契约与抽象

在 RPC 的世界里,开发者操作的是“存根(Stub)”,而 RPC 强制要求先定义“契约”(IDL,如 Protobuf)。这解决了分布式系统中最头疼的沟通成本问题。

举个例子,我们定义一个函数,我们希望函数如果输入为“Hello World”的话,输出给一个“OK”,那么这个函数是个本地调用。如果一个远程服务收到“Hello World”可以给我们返回一个“OK”,那么这是一个远程调用。

所以说,用户会关心调用哪个远程方法、传什么参数、拿什么返回值,所以,我们会和服务约定好远程调用的函数名,因此,我们的用户接口就是:输入、输出、远程函数名

这是 Dubbo 中 用户调用接口 的核心 —— 所有通信双方(服务提供方、消费方)都依赖这个接口,有着上面的契约型

比如,使用 Dubbo,client 端的代码就会长这样

1
2
3
4
5
6
7
8
9
10
11
// 单独抽离的接口(通常打jar包,提供方/消费方都依赖)
// 对应你提到的“用户自定义请求/回复结构+远程函数名”
public interface UserService {
// 远程函数名:getUserById
// 输入:Long id(对应EchoRequest)
// 输出:UserInfo(对应EchoResponse)
UserInfo getUserById(Long id);

// 调用远程函数名为Echo
String echo(String message);
}

而且开发者只需要实现接口的业务逻辑,Dubbo 通过注解@DubboService标记 “这是一个远程服务”,无需写任何网络代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 服务提供方:实现接口(纯业务逻辑,无网络代码)
@DubboService(version = "1.0.0", group = "user-center") // Dubbo注解:暴露为远程服务
public class UserServiceImpl implements UserService {
@Override
public UserInfo getUserById(Long id) {
// 本地业务逻辑:查数据库/调本地方法等
return new UserInfo(id, "张三", 25);
}

@Override
public String echo(String message) {
// 对应你示例中的“输入Hello World,输出OK”
if ("Hello World".equals(message)) {
return "OK";
}
return "Unknown";
}
}

消费方通过@DubboReference注入远程接口,调用方法时完全感知不到 “远程”,和你平常调用本地Math.abs()这种方法没有区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 服务消费方:调用远程接口(无任何网络代码)
@RestController
public class UserController {
// Dubbo注入远程服务代理
@DubboReference(version = "1.0.0", group = "user-center")
private UserService userService;

@GetMapping("/user/{id}")
public UserInfo getUser(@PathVariable Long id) {
// 【用户视角的远程调用】和本地调用一模一样
// 只关心:调用echo方法,传"Hello World",拿返回值
String echoResult = userService.echo("Hello World");
System.out.println(echoResult); // 输出:OK

// 调用getUserById方法,传id,拿UserInfo
return userService.getUserById(id);
}
}

可以看到,RPC 是有很好的语义对齐的,本地调用是同步的,RPC 框架通过封装,让远程调用在语义上也接近同步,尽管底层是复杂的异步 IO

这里“语义上同步”和“实现上异步”的区分很关键,别混了:

  • 语义上同步:用户写下 userInfo = userService.getUserById(1L),这行代码执行完,变量里就有值了。用户的心智模型和本地调用完全一致。
  • 实现上异步:框架内部绝不会真的阻塞一个线程去死等网络回包(那样线程资源会被瞬间打爆)。实际发生的是——请求异步发出,框架登记一个 Request ID → Future 的映射,把 Future 交给用户;用户读 Future 时,如果结果还没回来,才把当前线程挂起等待(或者干脆用回调 / CompletableFuture 的异步风格,一步都不阻塞)。

所以 Dubbo 才会同时提供同步调用和异步调用两套 API,gRPC 才会既有 BlockingStub 又有 FutureStub / 异步 StreamObserver。BlockingStub 那个 “Blocking” 不是“网络实现是阻塞的”,而是“框架给你把 Future 包成了阻塞的样子”。

这个区分在排查性能问题时特别重要:看到线程池被打满、线程全堆在 get() 上,问题往往不在“RPC 是阻塞的”,而在超时设得太长、或者下游确实慢,线程只是受害者。

具体网络协议

这部分就是对应框架的有自己的具体实现了,是 RPC 希望用户不用关心的底层内容,把开发者要发出和接收的内容以某种应用层协议打包进行网络收发。

这里可以和HTTP进行一个明显的对比,以 Dubbo 中的私有二进制协议(默认) 为 RPC 实现的例子:

协议类型 结构特点 RPC 协议(Dubbo默认) HTTP 协议
核心结构 固定 / 自定义 自定义二进制结构(灵活、高性能) 固定结构(请求行 + 请求头 + 请求体)
传输层 - TCP(长连接,多路复用) TCP(1.1 起默认持久连接,请求串行)
序列化 - 默认 Hessian2(二进制,小而快) 默认 JSON(文本,大而慢)

表里 HTTP 那一格我做了微调:说 HTTP 是“短连接为主”不太准,HTTP/1.1 起默认就是持久连接,它真正的问题是一条连接上的请求串行执行,要靠开多个连接顶并发。这一点在后面的《传输效率与序列化》里会专门展开。

一般 RPC 协议的实现,其具体结构的核心大部分是 协议头 + 协议体,目的是让接收方能快速解析出 “要调用的服务、消息长度、内容” 等重要信息,而且不少 RPC 协议的实现都会在协议头里放上不用解析协议体就能拿到的核心信息——这样接收方读完固定长度的头,就知道接下来该怎么处理了

拿 Dubbo 默认协议举个例子,它的协议头是固定 16 字节,每一段都有明确用途:

字段 字节数 作用
魔数(Magic) 2 固定值 0xdabb,用来快速判断“这是不是一个 Dubbo 协议的包”
标志位(Flag) 1 标记请求还是响应、单向还是双向、以及用哪种序列化方式
状态(Status) 1 响应里的调用状态
请求 ID(Request ID) 8 一次请求的唯一编号,响应靠它找回调方
数据长度(Data Length) 4 协议体的长度,用来切包

这里的请求 ID 值得单独说一下,因为它是“多路复用”的命门。 你想,一条 TCP 长连接上同时跑了 100 个请求,服务端乱序返回,客户端怎么知道第 7 个回来的包是给哪次调用的?

答案就是给每个请求发一个 ID,客户端在本地维护一张 Request ID → 等待中的 Future 的映射表。响应回来时,拿头里的 ID 一查表,就把这次响应交还给了对应的调用方。所以从用户视角看是“同步阻塞”的调用,底层其实是“异步发出 + 靠 ID 匹配回调 + 用 Future 假装阻塞”。

这就是上面那个五个逻辑层表里 RPC Runtime 那行说的 “请求/响应映射(Request ID)”。别小看这 8 个字节,没有它,长连接上就一次只能有一个请求在飞,性能直接退回到 HTTP/1.1 那种队头阻塞的水平。

而协议体是 序列化后的业务数据,Protobuf 和 Hessian2 是两种用到的很多的序列化协议体方式

而 RPC 也存在三大核心问题。也就是我们想要实现用户接口,需要怎么做?最重要需要支持以下三个功能:

  • 定位要调用的服务
    • RPC 一般是通过服务注册中心来实现的,而 HTTP 就是经典 URL 实现,通过 URL 直接指定服务地址
  • 把完整的消息切下来
    • 也就是常说的 “粘包 / 拆包” 问题,如何准确读取完整的一条请求 / 响应。RPC 使用协议头中的 Data Length 字段,它代表数据长度;而 HTTP 就是请求头中的 Content-Length 字段实现这功能
  • 让消息向前 / 向后兼容
    • 不同 RPC 协议的实现会有不同的版本控制逻辑,而 HTTP 协议需要开发者在 HTTP Body 中自行处理

“粘包 / 拆包”这个名字起得其实也挺误导的,跟 RPC 那个名字一个毛病——听起来像是 TCP 把两个包粘在一起了,好像 TCP 出了 bug。但 TCP 根本没错,它本来就是一个字节流(stream)协议,而不是消息(message)协议:它只保证字节按顺序、不丢不重地送到,从来不承诺“你调用几次 send,对面就恰好 read 几次”。

所以“粘包 / 拆包”的本质是:

  • 发送方视角:我发了 3 条消息;
  • 接收方视角:TCP 给的是一个字节队列,一次 read 可能读到 1.5 条,也可能读到 2 条。“一条消息从哪开始、到哪结束”这件事,TCP 完全不管,必须由应用层协议自己定义。

而这个定义方式,就是上面反复出现的那个长度字段。这就是为什么所有 RPC 协议头里都必然有一个 Data Length,也是为什么 HTTP 除了 Content-Length 还得有个 Transfer-Encoding: chunked(因为流式响应开始的时候,总长度还不知道,只能边发边报每块的长度)。

换句话说:所有应用层协议的“切包”机制,都是在补 TCP 不提供消息边界这个洞。 这个洞是 TCP 的设计选择,不是缺陷。

所以说,RPC 与 HTTP 协议是天然互通的,要不然 Feign 怎么做到的使用 HTTP 实现轻量 RPC,因为,大家都会需要类似的结构去组装一条完整的用户请求,而第三部分的 body 只要框架支持

因此开发者完全可以根据自己的业务需求进行选型,下面会对 HTTP 和 RPC 的互通做出进一步的解释。

这里先拿 Dubbo 举个例子:

  • RPC 框架之间互通:协议层更换就行,只要协议层 / 序列化层兼容(比如都用 HTTP+JSON),就能互通。
  • RPC 与 HTTP 互通:协议层替换为 HTTP,序列化层替换为 JSON,业务层的UserService接口完全不变;也就是 RPC 中的一些定制化内容使用 HTTP 协议的内容。

RPC调用流程

要让网络通信细节对使用者透明,我们就需要对通信细节进行封装,我们先看一下一个RPC调用的流程都会涉及到哪些通信细节,就根据我网上找到的图来说吧

image-20251225170536236
  1. Client 封装请求:客户端像调用本地方法一样发起请求,那么它就需要把要调用的方法、参数等信息打包好。
  2. 消息编码:把请求信息(比如对象、参数)转换成能在网络上传输的格式,比如二进制、JSON。
  3. Client Stub 发送请求:客户端存根(Stub,相当于客户端的 “代理”)把编码后的请求通过网络发送给服务端。
  4. Server Stub 接收请求:服务端存根(服务端的 “代理”)从网络中接收到客户端发来的请求。
  5. 消息解码:把网络传输的格式还原成服务端能识别的方法、参数信息。
  6. 反射调用本地方法:服务端存根根据解码后的信息,通过反射(感觉更像是预设的映射)调用服务端本地的实际方法。
  7. 封装响应:服务端本地方法执行完成后,把结果(返回值)打包成响应信息。
  8. 消息编码:把响应信息转换成网络传输格式。
  9. Server Stub 发送响应:服务端存根把编码后的响应通过网络发回客户端。
  10. Client Stub 接收响应:客户端存根从网络中接收到服务端的响应。
  11. 消息解码:把响应的网络格式还原成客户端能识别的结果。
  12. Client 得到结果:客户端拿到最终的调用结果,就像调用本地方法一样完成整个流程。

那么,RPC协议的目标就算要把上面 2 - 11 的步骤给封装起来,让用户不用关心这些内容,完成对远程方法的调用

那么,这个过程中肯定会涉及到一些额外的问题,例如如何做到透明化远程服务调用,如何对消息进行编码和解码等,这个不同协议对此实现的不一样的,下面再说

一个 RPC 特有的、被严重低估的问题:超时了,到底谁的问题

上面这个流程里,有一个步骤藏着一个分布式系统里最根本的麻烦,值得单独拎出来说:第 9 步“Server Stub 发送响应”。

假设客户端设了 3 秒超时,3 秒到了没等到响应,客户端报超时——但服务端那边到底发生了什么? 有三种可能,而客户端从协议层面完全无法区分:

  1. 请求压根没到服务端(网络丢了);
  2. 请求到了,服务端正在执行,只是还没跑完;
  3. 服务端执行完了,业务其实成功了,只是响应在回来的路上丢了。

这就是 RPC 和本地调用最本质的区别:本地调用要么成功要么失败,没有 不知道 这个状态;而远程调用天然多出了一个第三态——结果未知。

这个“未知态”带来的直接后果是:重试变得非常危险。

如果第 3 种情况发生了(业务其实成功,只是响应丢了),客户端一重试,这笔业务就被执行了两次。如果这是个“扣款 100 元”“创建订单”的接口,那就是生产事故。

所以 RPC 世界里有一条铁律:

只有幂等的接口才能安全地自动重试。

这里“幂等”的意思和我们开发时候涉及到的一样,都是 执行一次和执行多次,结果一样。查询天然幂等,可以放心重试;写入则必须靠业务手段保证:

  • 业务唯一键 / 幂等键:请求带一个全局唯一的 requestId 或业务单号,服务端记录已处理过的键,重复请求直接返回上次结果;
  • 数据库唯一索引:让重复插入直接失败;
  • 状态机:比如订单只允许从“待支付”流转到“已支付”,重复调用会被状态判断挡掉。

而且还有一个更阴险的内容——重试风暴:服务端只是变慢(不是挂了),大量请求超时被重试,重试的请求又加重了服务端负载,于是更慢、更多超时、更多重试,一个本来能自愈的慢查询被重试放大成了雪崩。

这就是为什么成熟的 RPC 框架一定会给你这几样东西:

  • 重试次数上限 + 重试间隔退避;
  • 熔断:发现某个下游错误率超阈值,直接快速失败,不再往上堆请求;
  • 限流:把入口流量卡住,保护下游;
  • 超时:而且是每一层都要设,不能只有最外层设(这正好对应下面 gRPC 那节会讲的 Deadline 传播)。

RPC 把网络细节对用户透明了,但它的代价是 把分布式的不确定性也一起透明掉了。用户代码看起来像本地调用,于是很容易忘记了“它可能会超时、可能会执行两次、可能结果未知”。框架能帮你屏蔽序列化和网络传输,但屏蔽不了业务上的幂等性设计——这部分永远得自己清楚。

RPC 协议的组成

这部分聚焦于 RPC 有什么,也即是 RPC 框架从系统到用户都有哪些层次

上面说了RPC协议跑通的流程,那么,这些流程中的每一部分,肯定对应着协议中的不同层级

RPC 框架的层级是 “分层封装” 的 —— 越上层越贴近用户,越下层越贴近网络 或 系统,每个层级解决一类问题:

  • 用户代码层

    这部分是用户直接写的代码,是 RPC 的 出入口,在这部分,客户端写调用远程方法的逻辑就像调用本地函数一样,或者两者差不多,服务端写远程方法的实际实现和写本地业务逻辑无差。所以,在这层级核心是 对用户透明:用户不用关心远程调用的细节,只需要按框架约定写代码。

    例如,Dubbo 中这样定义服务接口,服务端实现不需要特别的处理,只需要加上一个注解就可以

    1
    2
    3
    4
    5
    6
    // 服务接口(用户定义,对应RPC的“业务契约”)
    public interface UserService {
    UserDTO getUserById(String userId);
    }

    // 数据传输对象DTO省略
  • 接口定义层(IDL)

    这部分解决 客户端和服务端如何约定请求 与 响应的结构 的问题,是 RPC 的 契约层

    客户端要传什么参数,服务端要返回什么结果,都要在这里定义,而这里是保证双方能理解对方的数据的关键,刚才说到的用户接口和前后兼容问题,都是IDL层来解决的

  • 数据组织层

    这部分解决 请求和响应的数据用什么格式存储 的问题,是 IDL 层的落地格式,把 IDL 定义的结构转换成具体的二进制或者文本等格式(比如 Protobuf 的二进制格式、JSON 的文本格式)

    这一层和下面的一层,在Dubbo中都说以配置文件的方式就可以进行控制

  • 压缩层

    这层很好理解,就是对数据组织层生成的内容进行压缩,减少网络传输的字节数。比较常用的就是 lz4 和 gzip 算法

  • 协议层

    这层就是贴近网络底层的协议了,它解决 网络中传输的数据包要怎么打包 的问题,例如给数据加上 “协议头”(包含 “数据长度、协议类型、压缩方式” 等元信息),让服务端能识别 这是什么协议的包、怎么解析。TRPC 就是腾讯自研的 RPC 协议

    而 Dubbo 支持多种 RPC 协议,通过 protocol.name 配置

  • 通信层

    这部分就是解决数据如何通过网络发送出去的问题,RPC其实也是基于底层网络协议(TCP/HTTP)实现数据的发送 与接收。

    这部分就是实现RPC与HTTP协议互通的关键,可以理解为把 RPC 的协议包 伪装 成 HTTP 请求,服务端收到后按 HTTP 解析出 Body,再按 RPC 协议解析,相当于 用 HTTP 的壳子装 RPC 的内容。在下面说明好理解一些

    RPC 的各种实现最重要的就是屏蔽这些传输细节

再看下图

img

从左到右,是用户接触的最多到最少的层次。

中间那列是具体的网络协议,而各RPC能互通,就是因为大家实现了对方的“语言”,因此可以协议互通。

而RPC作为和HTTP并列的层次,第二列和第三列理论上是可以两两结合的,只需要第二列的具体RPC协议在发送时,把HTTP相关的内容进行特化,不要按照自己的协议去发,而按照HTTP需要的形式去发,就可以实现RPC与HTTP互通。

其中,Dubbo 是可以支持 protobuf 自定义协议的,也就是说,Dubbo 可以把用户编写的 Protobuf 的 IDL,转换成Dubbo框架自己的 Java 生成代码,配置Dubbo的 Protobuf 代码生成插件后,在 maven 编译的时候,就会生成 Dubbo 框架对应的代码,所以说,如果你使用 protobuf,中间 client 和 service 还会有一个生成代码的过程

如何实现层级解耦

RPC 框架的实现都很注重层次之间的解耦,层级之间做什么不会互相干扰,Dubbo 核心通过 SPI 机制 实现这一点,不少人认为这是 Dubbo 的灵魂设计,就以 Dubbo 作为完全的例子来说了

SPI 是 Service Provider Interface,是 Dubbo 实现 “插件化扩展” 的基础 , 每个层级(序列化 / 压缩 / 协议)都定义了标准接口,扩展新实现时只需新增插件,无需修改框架核心代码。

假如,我们希望新增一种 LZ4 压缩算法

Dubbo 中横向增加压缩算法无需改现有代码,只需要新定义即可,对应的上面的设计理念

  • 定义对应的标准接口,这只是一个对Dubbo压缩层标准接口的简单的展示

    1
    2
    3
    4
    public interface Compressor {
    byte[] compress(byte[] data);
    byte[] decompress(byte[] data);
    }

    因为框架的协议层在处理报文的时候,只调用 compressor.compress(data),它根本不关心底层是 Gzip、Snappy 还是你要新增的 LZ4。

  • 实现 LZ4 压缩插件

    1
    2
    3
    4
    public class Lz4Compressor implements Compressor {
    public byte[] compress(byte[] data) { /* LZ4 压缩逻辑 */ }
    public byte[] decompress(byte[] data) { /* LZ4 解压逻辑 */ }
    }
  • 然后注册进去

    1
    lz4=com.yourcompany.project.Lz4Compressor

    然后业务代码直接使用或者配置类配置就行,这是一个自适应调用的过程,Dubbo 会通过 ExtensionLoader 扫描这些配置文件,将 lz4 这个 Key 和你的类关联起来。当 RPC URL 中携带 compressor=lz4 参数时,框架会自动实例化并注入你的 Lz4Compressor。

那么,Dubbo 是这样解耦实现各个层级的,通过提供不同的接口,什么意思,在任何解耦的系统中,核心框架只认识接口,不认识实现。在 Dubbo 中,这些层级都被抽象为一个个的接口。

层级 Dubbo 标准接口 扩展示例(新增无需改核心代码)
序列化层 Serialization Protobuf/Thrift/JSON/Hessian2
压缩层 Compressor Gzip/LZ4/Snappy/None
协议层 Protocol Dubbo/HTTP/Thrift/TRPC
通信层 Transporter Netty4/TCP/HTTP2

而切换 Server 类型,也只需要在Dubbo 中只需修改配置即可,可以说,这种解耦和方便配置是很多框架的核心设计,RPC 协议的各种实现也把这些内容视为了重要的设计理念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1. 使用 Dubbo 原生 Server(基于 TCP)
dubbo:
protocol:
name: dubbo
port: 20880

# 2. 切换为 HTTP Server(基于 HTTP)
dubbo:
protocol:
name: http
port: 8080

# 3. 切换为 Thrift Server(兼容 Thrift 协议)
dubbo:
protocol:
name: thrift
port: 20881

透明化远程服务调用

怎么封装通信细节才能让用户像以本地调用方式调用远程服务呢?还是接着上面,拿 Dubbo 作为 RPC 协议的实现框架作为例子来说吧,其中Dubbo 实现 “透明化远程服务调用” 的核心逻辑,还是通过上面说的生成代码(Stub / 代理)+ 框架自动封装,实现的伪装

Dubbo 会基于服务接口(或 IDL)生成 客户端 Stub 代理类,用户调用这个代理类的方法时,实际是触发远程调用逻辑,但用户感知上和调用本地方法完全一致。

  • 例如,用户定义的 EchoService 接口,Dubbo 生成 EchoServiceDubboStub 代理类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 用户眼中的“本地接口”
    public interface EchoService {
    EchoResponse echo(EchoRequest request);
    }

    // Dubbo生成的代理类(用户无感知)
    public class EchoServiceDubboStub implements EchoService {
    private DubboInvoker invoker; // 框架的远程调用器

    @Override
    public EchoResponse echo(EchoRequest request) {
    // 代理类偷偷把“本地方法调用”转成远程调用
    return invoker.invoke(
    new RpcInvocation("echo", new Object[]{request})
    ).recreate();
    }
    }

    那么,如果需要使用,只需通过 @DubboReference 注入 EchoService,调用 echo() 就像调用本地方法,完全不知道背后是远程请求。

而框架层对远程调用的细节更是封装到了极致,Dubbo 框架在代理类的背后,自动完成 序列化、协议打包、网络传输、响应解析 等所有远程细节,用户无需写一行相关代码。

具体封装的细节包括:

  • 序列化 / 反序列化:自动把请求 和 响应对象转成二进制,比如 Protobuf/Hessian2;
  • 压缩 / 解压:自动对大请求大响应做压缩,比如 Gzip/LZ4;
  • 协议封装:自动给数据加 “Dubbo 协议头”,包含接口名、方法名、版本等元信息;
  • 网络通信:自动用 Netty 建立长连接,发送 / 接收数据。

而服务发现与地址的透明就需要注册中心来实现了,以前是 ZooKeeper,现在是 Nacos 更多一点,这些注册中心会自动注册服务,自动完成服务地址的发现与管理,完全不用关心服务端部署在哪里

那么方法写在了哪里更不需要复杂的思索,因为服务端只需通过 @DubboService 注解标记实现类,Dubbo 会自动把这个类 暴露为远程服务,用户不用手动实现这部分

如何对消息进行编码和解码

对消息进行编码解码的本质上就是为了让跨进程跨语言的对象能在网络上传输,并在对方进程中还原成可用的对象

首先回顾一下序列化相关内容

  • 序列化就是将数据结构或对象转换成二进制串的过程,也就是编码的过程。
  • 反序列化就是将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程,也就是解码

为什么需要序列化?转换为二进制串后才方便进行网络传输,为什么需要反序列化?将二进制转换为对象才方便进行后续处理

这部分就是 RPC 的兼容性,而还是像上述那样,Dubbo 把 编码 解码封装成了可插拔的组件,不详细说明

说一下一些 RPC 框架中常用的序列化方案

序列化方案 特点
Hessian2(默认) 轻量、跨语言(Java/Go 等)、性能中等,Dubbo 默认选它是因为够用且生态成熟
Protobuf 体积小、编解码快、跨语言(全语言支持),需要先写 IDL
JSON 可读性强、跨语言,但体积大、性能差
Thrift 自带 IDL、跨语言、性能优
Kryo Java 专用,性能极好、体积小,但不能跨语言,且对类结构变化敏感
Avro 自带 Schema,大数据生态(Hadoop / Kafka)用得多,schema 演进能力强
JDK 原生 什么都不用引,但体积大、速度慢,还有反序列化安全风险,生产环境不要用

这里有个选型时的现实规律:序列化方案的取舍,核心在“跨语言”和“性能”之间。

  • 只在 JVM 内部通信 → 可以上 Kryo 这类 Java 专用方案,性能最好;
  • 要跨语言(Java 服务调 Go 服务)→ 只能在 Protobuf / Thrift / JSON 这个范围里选;
  • 要可读、方便排查 → JSON,代价是性能;
  • 要兼顾 → Protobuf,代价是必须维护 IDL 和代码生成流程。

不同序列化方案的核心差异,在于 “怎么把对象转成二进制” 的规则,因为转过去,就能转回来

Protobuf 是 “二进制紧凑编码”,比如把EchoRequest(message="hello")转成二进制

  • 给每个字段分配数字标签(比如message字段的标签是 1)

  • 用 “标签 + 类型 + 值” 的紧凑格式存储:

    比如message="hello"会被编码成 08 05 68 65 6C 6C 6F,其中08是标签 + 类型,05是字符串长度,后面是 “hello” 的 ASCII 码。

  • 标签就是字段的“身份证”,字段名完全不进报文。 这就是为什么 Protobuf 报文能比 JSON 小一大截——JSON 每次都要老老实实写 {"message":"hello"},把 message 这 7 个字符重复传一千万遍。

  • 而且这里有个关键推论:Protobuf 的兼容性,是靠“字段号”而不是“字段名”来维持的。 所以字段名随便改都没事(只要改完重新生成代码),但字段号一旦上线就绝对不能改、不能复用——改了字段号,等于把字段的身份证换了,对面的旧代码会把它认成另一个人。

img

JSON 不多说,人类可读,编码解码慢,体积大

那么这部分也和上面说的版本兼容有关系,比如服务端新增了字段,旧客户端没这个字段怎么办?选支持 字段兼容 的序列化方案(比如 Protobuf/Thrift),会自动忽略未知字段。 Protobuf/Thrift 更是很好的支持跨语言,不同语言用同一份 IDL 生成代码即可

这里顺便把前后兼容这件事说清楚,因为原文只提了一句,但它其实是 IDL 存在的最大理由之一:

变更类型 兼容性 说明
新增字段 向前 / 向后都兼容 旧代码读到未知字段号会自动跳过,不报错
删除字段 兼容(有条件) 旧代码还会发这个字段,新代码忽略即可;但字段号必须保留不用,否则将来复用会串数据
改字段名 兼容 因为报文里只有字段号,没有字段名
改字段号 不兼容 等于换了字段身份,双方语义对不上
改字段类型 大概率不兼容 比如 int32 改 string,字节解释方式都变了

所以“IDL 保证兼容”不是自动发生的,它是靠一套纪律换来的:字段号只增不改不复用。 这也解释了为什么很多团队会用一个中心化的 proto 仓库来管这些文件——因为这份纪律一旦破,线上就是静默的数据错乱,比直接报错还难查。

而 HTTP 这边为什么说兼容性要“自行处理”?因为 JSON 没有字段号这个概念,它是按字段名直接映射的。你改个字段名、或者把 age 从数字改成字符串,接收方只会在运行时解析失败或者拿到一个莫名其妙的值。编译期没人拦你,因为根本没有编译期。

RPC 对比 HTTP ——并非既生瑜何生亮

协议设计:RPC 与 HTTP 的本质区别

“既然有了 HTTP,为什么还要 RPC 协议?” 这是一个深层的技术考量,他们的选择并非简单的谁优谁劣,而是设计哲学与应用场景权衡的结果。直到今日,我依旧无限感慨 RPC 还是太牛逼了

首先,无论是微服务还是分布式服务,他们都面临着服务间的远程调用的问题,而 HTTP 和 RPC 都是作为服务间的远程调用的方式,但是他们负责的各个部分,还是很不一样的,这就要从协议的设计说起

  • RPC (Action-Oriented): 它的协议设计的核心思想是“像调用本地函数一样调用远程函数”。你关注的是做什么。这种方式对开发者非常友好,屏蔽了底层网络细节。

  • HTTP/REST (Resource-Oriented): 它的核心是状态转移。你关注的是资源是什么。通过标准的 HTTP 动词(GET, POST, PUT, DELETE)对特定的 URL(资源)进行操作。

RPC 倾向于远程操作的业务导向,HTTP 倾向于资源操作的业务导向

而且,RPC 的设计目标是 服务间高效、透明的远程调用,它核心面向的是后端服务,HTTP 的设计目标是通用的跨系统数据交互,是面向 Web 倾向性更大,如浏览器→服务器、前端→后端

所以说,光用 RPC 不用 HTTP 不太可行,只用 HTTP 不用 RPC 还很不方便

本质区别一目了然

但在讲本质区别之前,必须先分清楚两个不同层面的问题,否则后面会一路绕不出来。

层面 问题 RPC 与 HTTP 的关系
语义层(怎么建模接口) 我是把接口看成“一个动作”,还是“一个资源”? 两种对立的风格,Action-Oriented vs Resource-Oriented
事实层(报文实际长什么样) 字节在网线上到底是什么协议? 可能是同一个东西,gRPC 就是跑在 HTTP/2 上的

把这两层混在一起,就会得出“RPC 和 HTTP 是对立的、只能二选一”这个错误结论。 实际上:

  • 你可以用 HTTP 做 RPC(Feign、gRPC 都是这么干的);
  • 你也可以在私有二进制协议上做资源式建模(虽然没人这么闲);
  • 而 gRPC 的情况最特殊:语义上它是彻底的 RPC,事实层上它又是百分百的 HTTP/2。

所以 既然有了 HTTP,为什么还要 RPC 协议 这个问题的准确提法应该是:

既然有了 HTTP,为什么还要自研一套 RPC 传输协议?

答案就是上面那些——自定义二进制协议能省掉通用语义的开销,能做得更薄更快。

而 gRPC 给出的答案是第三种可能

  • 不自研了,直接用 HTTP/2,把开销交给 HPACK 和二进制帧去消化,换回通用性和中间件兼容性。

这才是“既生瑜何生亮”这句话真正的落点:自研 RPC 协议和 HTTP 是两条路线的竞争;而 gRPC 选择了站在 HTTP/2 里面。 换句话说,gRPC 不站在 HTTP 的对面,它站在 HTTP 的里面。

传输效率与序列化

在高性能场景下,这也是考量的关键点。

虽然,两者底层通讯都是基于socket,都可以实现远程调用,都可以实现服务调用服务,简单对比一下

维度 RPC (以 gRPC/Dubbo 为例) HTTP (通常指 REST/JSON)
序列化 通常使用 Protobuf 或 Thrift(二进制格式),体积小,解析极快。 通常使用 JSON/XML(文本格式),包含大量冗余标签,解析开销大。
协议层 现代 RPC 多基于 HTTP/2(多路复用、头部压缩),甚至自定义 TCP 协议。 传统 REST 多基于 HTTP/1.1,而 HTTP/1.1 有应用层队头阻塞(一个连接上的请求必须串行等待)
连接管理 通常维持长连接,减少握手开销。 HTTP/1.1 默认 Keep-alive(持久连接),但一条连接上的请求执行是串行的,实际要靠开多个连接(常说的 6 个并发)来顶并发

HTTP 是短连接 这个流行说法其实是个简化,而且容易推导出错误结论。严格来说:

  • HTTP/1.0:默认短连接,一个请求一个 TCP 连接,用完就关。这时候“短连接”是准确的。
  • HTTP/1.1:默认就是持久连接(Keep-alive),而且从 HTTP/1.1 起就已经不推荐短连接了。所以 HTTP 核心是短连接 这句话对 HTTP/1.1 来说是错的。
  • HTTP/2:只有一条 TCP 连接,上面跑多个 Stream,多路复用,这就是长连接。

那 HTTP/1.1 的问题到底出在哪?不是连接短,而是它的 一条连接 = 一个请求在进行。同一连接上,前一个请求的响应没回来,后一个请求就得排队等着——也就是 应用层的队头阻塞(Head-of-Line Blocking)。浏览器之所以还要开 6 个并发连接,就是为了绕开这个限制。

而 HTTP/2 的 Stream 才真正解决了这个问题:一个连接上多个请求真正并发,互不阻塞。

这里必须补一个经常被混为一谈的点——HTTP/2 也有队头阻塞,但和 HTTP/1.1 的不是一个东西:

阻塞发生的位置 后果 谁解决了它
HTTP/1.1 HTTP 应用层 同一连接上请求串行,必须排队 HTTP/2 的 Stream 多路复用
HTTP/2 TCP 传输层 一个 TCP 包丢了,后面所有 Stream 都得等重传(因为 TCP 不保证有序到达,只保证有序交付给应用层) HTTP/3(改用基于 UDP 的 QUIC)

所以“HTTP/2 多路复用”并不是免费的午餐:它把应用层的队头阻塞解决了,但把问题的下限推到了 TCP 层。这也是 HTTP/3 一定要换掉 TCP 的使用了 UDP 的 QUIC 的原因。

实际上,传输效率受到很多因素的影响,两者协议头开销也是不同的

  • RPC:协议头极简,不是必要信息不放
  • HTTP:协议头冗余,即使是 HTTP 2(二进制帧),也保留了大部分 HTTP 语义头

其中需要注意一些内容:

  • RPC 协议头极简对 Dubbo 私有协议这类自定义二进制协议是成立的;但对 gRPC 不成立,因为 gRPC 用的就是 HTTP/2 的头部。
  • HTTP 协议头冗余 在 HTTP/1.1 时代是事实,在 HTTP/2 时代基本被 HPACK 消掉了。HTTP/2 的头部压缩配合动态表,第一次请求把 :method、user-agent、authorization 这些头发全,之后同一个连接上的请求只需要发一个索引号引用动态表里的条目,重复的头部实测能压到几个字节。

结论:在都是 HTTP/2 的前提下,协议头的开销差异基本可以忽略不计。

那两者的性能差异到底来自哪?

主要来自序列化那一步:Protobuf 的二进制紧凑编码 vs JSON 的文本编码,这个差距是实打实、且量级明显的(报文体积通常有几倍差距,编解码的 CPU 开销也差得远)。

拆开来看,RPC 高效真正成立的部分是:

  • 序列化更紧凑:二进制 vs 文本,这是硬差距;
  • 少了一层文本解析:JSON 要先做词法解析才能得到结构,Protobuf 直接按标签读值,CPU 开销差得明显;
  • 协议头更薄(仅限自研二进制协议):省掉 HTTP 的通用语义头——但到了 HTTP/2 + HPACK 时代,这条优势已经很微弱了。

这一点在选型时很关键: 如果你的瓶颈判断是“因为 HTTP 头大”,那换成 gRPC 的收益会低于预期;真正的收益在“二进制序列化 + 强契约”这两处。

但 RPC 的高效不是我硬要说它在技术上比 HTTP 厉害,这样的设计是有场景严格限制和一定代价的,这也正好是后面要说的。

因为 RPC 面向 已知的服务间调用,可以预先通过 IDL 约定数据格式、接口定义,省去 HTTP 中通用语义头、弱类型兼容的开销。而 HTTP 要兼容浏览器、网关等通用场景,无法放弃通用性换性能。

契约与耦合度

HTTP 与 RPC 之间,对类型的要求是不同的,这直接影响到大型团队协作和代码质量的控制。

首先,RPC是强契约的,因为RPC 的强契约是通过 IDL接口定义语言+ 编译时生成代码 实现的,这个耦合度虽然很高,但是是完全可以控制的,而且编译时就能拦截各种问题,不仅如此,生成的代码自带序列化反序列化,几乎不会出现各种运行时的异常,代码质量是比较高的,协作成本是一个前期高,后期低的过程

而 HTTP 是弱契约的,实际上,经常 HTTP 的契约实现是离不开接口文档的,这东西以前就是个纸,直到现在的 OpenAPI,实际上也只是一个字面上的参考,方便协作的,你不写完全不能怎么样,而双方是松散适配的,耦合度肯定低,我不信你没出现过文档更新不及时,客户端调用旧字段的问题))

HTTP 弱契约也严格代表代码质量严格依靠人工,双方靠 约定的 JSON 结构 交互,编译时候几乎没有检查,这是运行时适配,协作成本是一个前期低,后期高的过程

所以说,在不同的情况下,要分清上述的内容

服务治理

在微服务规模扩大时,治理能力的差异会显现。

  • RPC 框架: 像 Dubbo 或 gRPC 往往自带服务发现、负载均衡、熔断、限流、重试策略等功能。这些功能深度集成在框架内,能够跟随着框架一起组合使用,性能和稳定性更可控。
  • HTTP 生态: 协议本身不包含治理功能。通常需要通过外部工具链(如 Nginx、API Gateway、Service Mesh/Envoy)来实现治理逻辑。

这个其实没啥好说的,微服务会自带各种服务高可靠高性能的套件,这是 RPC 框架的特性能够实现的

内敛还是开放

很明显,两者的定位不同,使用场景不一致的情况下,可以探讨一下内敛还是开放的问题

  • RPC: 适合微服务内部、高并发、低延迟的场景。如果你的服务都在内网,且由同一技术团队维护,RPC 是首选。
  • HTTP: 适合对公众开放、浏览器端调用、跨语言/平台的场景。它是互联网的“通用语言”,几乎任何设备、任何语言都能轻松发起 HTTP 请求。

不过网上经常说的那个 内网用 RPC、外网用 HTTP 这个说法虽然好记,但作为结论太粗了,因为它只回答了 对内还是对外。真实选型至少要看四个维度:

维度 偏向 RPC(Dubbo / gRPC) 偏向 REST / JSON
调用方 都是自己人写的后端服务 有浏览器、有第三方、有移动端
性能诉求 高频调用、低延迟敏感、报文体积敏感 调用频率低,或者瓶颈根本不在网络上
团队与流程 能接受维护 IDL / proto 仓库、能接受代码生成流程 想要“改完就上”、不想引入生成步骤
生态与工具链 已有注册中心、配置中心、链路追踪等基础设施 现有网关、监控、调试工具都是 HTTP 系的

再加一条我认为很重要的经验判断:

如果你的团队规模还小、服务数量还少,REST/JSON 往往是性价比更高的选择——因为 RPC 的收益(性能、强契约)是规模效应,规模不够时感知不到;而它的成本是立刻就要付的。

反过来,当服务数量涨到几十个、调用关系变成一张网、跨语言需求出现、接口变更开始频繁打架的时候,强契约和代码生成的收益会突然变得非常明显。

而 gRPC 在这张图里的特殊位置是:它是唯一同时能拥有 跨语言 和 强契约 ,而且还不用你自建传输协议栈的方案。 这就是它这几年在云原生和跨语言场景里横着走的原因。

选型不是站队

把这一章收个尾:

  1. RPC 和 HTTP 的差异,本质是 专用协议 和 通用协议 的权衡,不是先进与落后。专用协议用通用性换性能,通用协议用性能换普适性。
  2. 它们的报文结构是同构的——都是协议头 + 消息体,都要解决“定位服务、切分消息、前后兼容”这三件事。区别只是实现方式和抽象层级。
  3. 性能差距的主要来源是序列化,不是协议头。在都是 HTTP/2 的前提下,协议头开销的差异基本可以忽略。
  4. “HTTP 是短连接”是个需要修正的说法:HTTP/1.1 默认持久连接,问题在于应用层队头阻塞;HTTP/2 已经解决了它,代价是把队头阻塞推到了 TCP 层(这也是 HTTP/3 出现的理由)。
  5. 强契约不是免费的:它换来编译期检查和跨语言能力,代价是 IDL 维护、代码生成流程和更高的调试门槛。
  6. 最要紧的是别把两层问题搅在一起:语义层上 RPC 和 REST 是对立的两种风格;事实层上 gRPC 恰恰就运行在 HTTP/2 之上。

认清楚 gRPC

gRPC 经常让人混乱,就是因为它同时踩在两个概念上。

一方面,它是 RPC 框架。

你定义服务和方法,生成客户端和服务端代码,然后像调用方法一样调用远程服务。

另一方面,它基于 HTTP/2 传输。

准确的说法是:gRPC 是跑在 HTTP/2 上面的一个 RPC 协议。它没有另外造一套传输层,它就是 HTTP/2 的一个用法,只不过这套用法被定义得非常严格,严格到已经可以称为一个独立的协议——它有正式的规范文档,这份规范就是 gRPC 的标准,各语言实现(grpc-java / grpc-go / grpc-python / grpc-rs…)都是照着这份规范做的。

为什么偏偏是 HTTP/2

既然要讲为什么它有这个双重身份,就得回答一个问题:RPC 框架那么多,为什么 gRPC 偏偏挑了 HTTP/2 来做传输?

答案在上一章的对比里其实已经埋好了:传统 RPC 协议高效,但它高效的方式是私有化——自己定二进制协议头、自己维护长连接、自己做请求响应匹配。代价就是只有懂这套私有协议的客户端才能连上,跨语言支持要靠一个个客户端库去堆,而且中间件(代理、网关、负载均衡)完全不认识你。

gRPC 的选择是:把传输层的事交给一个已经被全世界实现过、被所有中间件认识的标准协议。

而 HTTP/2 恰好把传统 RPC 想要的那几样东西都原生给出来了:

RPC 想要的能力 传统私有 RPC 协议怎么做 HTTP/2 怎么给
长连接、多路复用 自己维护 TCP 长连接 + 自研 Request ID 匹配 原生 Stream(流),一条连接上并发跑多个请求
二进制帧、解析快 自定义二进制协议头 原生二进制帧(Frame),不用再解析文本
头部压缩 自研精简协议头 HPACK 头部压缩 + 动态表
流式传输 自己设计流控和分片语义 原生 Stream 语义 + Flow Control

那 HTTP/2 缺什么?缺的是应用层的语义,没有“我要调用哪个方法”、“这次调用的参数是什么类型”、“这次调用是成功还是失败”,这些东西。

所以 gRPC 的工作就是,站在 HTTP/2 上,把应用层语义补齐。 HTTP/2 负责怎么把字节可靠地、并发地、双向地送到对面,gRPC 负责这些字节代表哪次方法调用、结果是什么

gRPC 到底长什么样:它就是一次 HTTP 请求

一次 gRPC 调用,拆开看就是一次 HTTP/2 请求。

假设你的 proto 里定义了 UserService.GetUser,客户端发起调用时,真实的 HTTP/2 请求大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
HEADERS (stream 1)
:method = POST
:scheme = http
:path = /user.UserService/GetUser
content-type = application/grpc+proto
te = trailers
grpc-timeout = 3000m

DATA (stream 1)
[1 字节压缩标志][4 字节大端长度][Protobuf 编码后的 GetUserRequest]

HEADERS (stream 1) ← 这就是 HTTP Trailer
grpc-status = 0
grpc-message =

对着这几行,gRPC 规范里的几个关键约定就全出来了:

  • 方法名塞在 :path 里,格式固定是 /包名.服务名/方法名。这就回答了 RPC 怎么定位要调用的服务 这个问题,UserService.GetUser 这个方法,就是 :path = /user.UserService/GetUser。IDL 里定义的服务名和方法名,直接变成了 URL 路径。
  • 统一用 POST。不管你是查询还是写入,gRPC 一律 POST。别问为什么不按 REST 的语义来,因为 gRPC 的语义单位是 方法调用”,不是“资源”,GET 那套资源语义在这里根本不适用;
  • content-type 指认协议版本和编码。application/grpc+proto 表示 这是 gRPC,消息体是 protobuf。而且content-type 里那个后缀是可以换的,application/grpc+json 就表示消息体是 JSON,application/grpc-web+proto 就是给浏览器用的 gRPC-Web。Protobuf 是默认选项。
  • grpc-timeout 是超时,单位有严格的格式,3000m 是 3000 毫秒。

两个容易被忽略、但能说明问题的细节

第一个:Length-Prefixed-Message。

上面 DATA 帧里的 [1 字节压缩标志][4 字节大端长度] 就是它。这是 gRPC 在 HTTP/2 之上又加的一层自己的封装,含义是:

字节 含义
第 1 字节 压缩标志(0 = 未压缩,1 = 用 grpc-encoding 指定算法压缩)
接下来 4 字节 消息长度,大端字节序
之后 N 字节 真正的消息内容

为什么要加这个?因为 HTTP/2 的 DATA 帧只保证字节流,不保证“一帧就是一条消息”。一条消息可能跨好几个 DATA 帧,一个 DATA 帧里也可能塞着好几条消息。所以 gRPC 必须自己带上长度字段,接收方才能准确地切出完整的一条消息。

是不是很熟?这不就是上面说的“把完整的消息切下来”——RPC 三大核心问题里的第二个。

HTTP/1.1 靠 Content-Length,Dubbo 靠协议头里的 Data Length,gRPC 靠 Length-Prefixed-Message。换了三个壳子,同一个问题,同一个解法。

第二个:Trailers 和 grpc-status。这是纯正 gRPC 自己的发明。

一般 HTTP 请求怎么表示“这次失败了”?用 HTTP 状态码,404、500。但 gRPC 不能用这一套,因为有一个时序问题:服务端在执行过程中错误发生的那一刻,HTTP 响应头早就发出去了。 比如你是个服务端流式接口,前 100 条数据已经正常发给客户端了,第 101 条时数据库炸了——这时候你没法回头去把 :status 改成 500,那份 HEADERS 帧早在第一条数据之前就发出去了。

所以 gRPC 把最终状态放在了 Trailer 里:grpc-status = 0 表示成功,非 0 就是那套 gRPC 状态码(1 = CANCELLED,4 = DEADLINE_EXCEEDED,14 = UNAVAILABLE…)。也就是说 gRPC 的状态是双层的:HTTP/2 的 :status 只表示“HTTP 传输这一层通不通”,比如 200 代表“这条 HTTP 响应本身正常结束了”;而业务上的成功失败,一律看 grpc-status。

这就是为什么规范里要求客户端带上 te: trailers 这个头,它在声明 我支持 Trailer,你把状态放这儿就行。这也顺带解释了为什么浏览器做不了原生 gRPC:因为 Trailer 是 HTTP/2 的东西,浏览器的 fetch API 里根本拿不到它。所以浏览器要用 gRPC,必须靠 gRPC-Web 这类中转方案,把 Trailer 折叠成响应体里的一个特殊帧。

四种调用方式:它其实是在用 HTTP/2 的流

gRPC 的四种调用方式,是它和普通 REST 拉开差距的地方,也是 HTTP/2 真正被用起来的地方:

类型 语义 对应 HTTP/2 的形状
一元(Unary) 一请求一响应,最常见 一组 HEADERS + DATA,再来一组 Trailer
服务端流 一次请求,服务端持续推多条响应 请求 DATA 结束后,响应方向持续发 DATA,直到 Trailer
客户端流 客户端持续上传,服务端最后回一次 请求方向持续发 DATA,服务端只回一次结果
双向流 两边随时互发,完全异步 一个 Stream 上双向 DATA 帧交织

关键点在于:这四种流之所以是“一等公民”,不是 gRPC 自己实现了流控和分片,而是 HTTP/2 本来就长这样。 HTTP/2 的一个 Stream 本身就是双向的、可以持续发 DATA 帧的、带流量控制的通道。gRPC 只是给这个通道规定了消息格式(Length-Prefixed-Message)和结束标志(Trailer 里的 grpc-status)。

你用 HTTP/1.1 想做服务端流,得靠 SSE(Server-Sent Events)或 chunked 拼凑;想做双向流,基本没戏。而 HTTP/2 上,双向流是白送的。

所以回到最开始那句话:gRPC 的流式能力,本质是 HTTP/2 的能力。这就是它“基于 HTTP/2 传输”这句话的真实分量,而不是一句修辞。

和 REST 摆一起看

把两者并排,差别就很干脆:

维度 gRPC REST / JSON
操作单位 方法(GetUser) 资源(/users/1)
HTTP 动词 一律 POST GET / POST / PUT / DELETE
路由 :path = /包.服务/方法 URL 路径 + 动词
成功判定 grpc-status(Trailer) HTTP :status 状态码
契约 .proto 强契约,编译期生成代码 OpenAPI 文档,运行时靠约定
消息体 默认 Protobuf 二进制 通常 JSON 文本
浏览器直连 不支持,需 gRPC-Web / Connect 原生支持
调试 得用 grpcurl、专用工具 curl 一把梭

最后一行那个“调试”差别是真实存在的痛点,而且经常被低估:JSON 接口出问题,你浏览器一开、curl 一敲,报文一目了然;gRPC 出问题,你得先找到 .proto、装好 grpcurl、再想清楚怎么拼消息。强契约和二进制带来的收益,是用“可观测性和随手调试的便利”换来的。 这笔账要提前算,别上线了才发现自己团队连抓包都抓不动。

别把它当成 HTTP 的对手

所以这篇标题里那个“既生瑜何生亮”的感慨,用在 HTTP 和自研 RPC 协议之间是成立的——它们确实是两条路线。但用在 HTTP 和 gRPC 之间就不成立了:

gRPC 不是一个和 HTTP 竞争的东西,它是 HTTP/2 上的一个约定。 当你写 gRPC over HTTP/2 这句话的时候,重点是那个 over。

看清楚这一点,很多原本绕人的问题就都通了:

  • 为什么 gRPC 能穿网关、能被 Service Mesh 接管?因为它就是 HTTP/2,Envoy 这类 L7 代理天生能解析。
  • 为什么 gRPC 长连接不适合套四层负载均衡?因为一条连接上的所有请求都走同一个后端,负载均衡会失效(详见下面「几件绕不开的工程事」)。
  • 为什么它跨语言这么好?因为它不靠某个语言的私有协议栈,靠的是 .proto 生成代码 + 这个所有语言都实现的 HTTP/2 约定。
  • 为什么它会有“明明在传数据却报 UNAVAILABLE”这种怪现象?因为 UNAVAILABLE 是 gRPC 状态码,不是 HTTP 状态码,把两层的错误混在一起看了。

下面这一节就专门说这些工程上的事——它们没人写进“gRPC 是什么”的教程里,但每一个你都会撞上。

gRPC 的强契约:一次完整的调用长什么样

上面讲的是“线上跑的是什么”,这里补上“你写的时候是什么样”。因为 gRPC 的强契约,是通过 .proto + 代码生成这条流水线落地的,这条流水线也是它跨语言能力的真正来源。

一份最小可用的 .proto:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
syntax = "proto3";

package user;

// 服务定义:这就是“方法名”的来源,最终变成 /user.UserService/GetUser
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
// 流式声明方式:加 stream 关键字
rpc ListUsers (ListUsersRequest) returns (stream UserResponse);
}

message GetUserRequest {
int64 user_id = 1; // 这个 1 是字段号,不是值
}

message UserResponse {
int64 user_id = 1;
string name = 2;
string email = 3;
}

然后用 protoc 加对应语言的插件,生成客户端和服务端代码:

1
2
3
4
5
# Java
protoc --java_out=./src/main/java --grpc-java_out=./src/main/java user.proto

# Go
protoc --go_out=. --go-grpc_out=. user.proto

生成物里最关键的两样东西,正好对应前面说的“用户调用接口”和“Stub”:

1
2
3
4
5
// 客户端:Stub 就是 Client Stub
UserServiceGrpc.UserServiceBlockingStub stub =
UserServiceGrpc.newBlockingStub(channel);
UserResponse resp = stub.getUser(
GetUserRequest.newBuilder().setUserId(1L).build());
1
2
3
4
5
6
7
8
9
10
11
12
13
// 服务端:继承生成的基类,只写业务逻辑
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
@Override
public void getUser(GetUserRequest req,
StreamObserver<UserResponse> respObserver) {
// 纯业务逻辑,没有一行网络代码
respObserver.onNext(UserResponse.newBuilder()
.setUserId(req.getUserId())
.setName("张三")
.build());
respObserver.onCompleted();
}
}

对着这段代码看看,是不是和前面 Dubbo 的 @DubboReference / @DubboService 完全同一套逻辑?

  • UserServiceBlockingStub 就是 Client Stub,stub.getUser(...) 就是“像调用本地方法一样调用远程方法”;
  • UserServiceImplBase 就是服务端骨架,getUser 就是被反射 / 分发到的那段业务代码;
  • .proto 就是那份契约(IDL);
  • StreamObserver 那个有点别扭的回调写法,是因为 gRPC 原生就是异步 + 流的模型,BlockingStub 只是框架给你套的一层“假装同步”的壳子。

所以 gRPC 一点都不神秘,它就是一张 .proto 契约 + 一条代码生成流水线 + 一套跑在 HTTP/2 上的调用约定。 前面 Dubbo 那一整套 Stub、序列化、协议打包、网络传输,在这里一样不少,只是换成了跨语言的实现方式。区别在于:Dubbo 的契约是 Java 接口,只有 JVM 系语言能用;gRPC 的契约是 .proto 文件,谁都能拿它生成代码。跨语言能力不是来自 HTTP/2,是来自 IDL。

几件绕不开的事

这部分是用了才知道的坑,教程里基本不写,但每一个都是线上事故的来源。

1. UNAVAILABLE 不代表服务挂了。

gRPC 最基础的几个状态码,和 HTTP 状态码完全是两套东西:

状态码 值 含义 什么时候会出现
OK 0 成功 正常返回
DEADLINE_EXCEEDED 4 超时 调用方设的 Deadline 到了
RESOURCE_EXHAUSTED 8 资源耗尽 触发限流、超出配额
UNIMPLEMENTED 12 方法未实现 客户端和服务端版本不一致
UNAVAILABLE 14 服务不可用 连不上、连接被断、后端没就绪

UNAVAILABLE 是最容易被误读的一个——它经常不是“服务宕了”,而是“这条 HTTP/2 长连接被中间设备掐了”“后端在滚动发布”“代理配了空闲超时”。排查方向应该是连接层,而不是先去看服务端日志。

2. Deadline 会传播,这是 gRPC 里最漂亮的设计之一。

调用方设置 2s 的超时,这个时间预算会被编进 grpc-timeout 头,随着调用链一路传下去,下游每个环节看到的都是剩余时间,而不是各自重置一遍超时。

1
stub.withDeadlineAfter(2, TimeUnit.SECONDS).getUser(req);

意义很大:A → B → C 三层调用,A 给 2 秒,B 花掉 0.5 秒,那么 C 拿到的预算是 1.5 秒。任何一个环节超时,整条链路上的调用都会被取消(取消会沿流传播),不会出现“上游早就返回超时了,下游还在傻傻地跑、还在占着数据库连接”这种经典浪费。这一点比很多 HTTP 调用链要靠 Sleep + 重试硬扛要优雅得多。

3. gRPC 长连接和四层负载均衡天生犯冲。

这个问题非常实在,而且很多人是在流量涨起来之后才发现的。

gRPC 客户端默认会和后端建立一个长期存在的 HTTP/2 连接,然后所有请求都在这条连接上多路复用。如果你前面挡的是四层负载均衡(L4,只看 IP 和端口),它在建连时就把这条连接分配给了某个后端——结果就是这个客户端的所有请求,永远都打到那一台机器上。

客户端越多,这个偏差越随机;客户端越少(比如就几个服务节点),偏差越离谱:可能出现 3 个后端里有 2 个在摸鱼。

正确做法有两条:

  • 客户端负载均衡:让客户端自己拿到全部后端地址列表,按策略(round_robin 等)把请求分散到不同后端。gRPC 原生支持这套——客户端侧有两个角色,名字解析(Name Resolution)负责把服务名解析成后端地址列表,负载均衡策略负责从列表里挑一个。
  • 用 L7 代理:Envoy、Nginx(支持 gRPC 的版本)这类能理解 HTTP/2 的代理,它可以在流(Stream)级别做负载均衡,而不是连接级别。

关键区别就是:L4 只能按连接分,L7 才能按请求 / 流分。 gRPC 的多路复用把“连接”和“请求”解耦了,那负载均衡也必须跟着解耦到流这一层,否则就是刻舟求剑。

4. 浏览器直连是个先天缺口。

前面提过:浏览器拿不到 HTTP/2 的 Trailer,所以原生 gRPC 在浏览器里跑不了。这不是实现问题,是浏览器 API 的限制。

业界的解法有三条路:

方案 做法 代价
gRPC-Web 用一个代理把 gRPC 转成浏览器能吃的格式,Trailer 被折叠进响应体的最后一个帧 早期不支持客户端流和双向流,能力被削
grpc-gateway 在 gRPC 服务前面加一层反向代理,把 REST / JSON 请求翻译成 gRPC 调用 多一层翻译,需要额外维护 proto 到 REST 的映射注解
Connect 一套兼容 gRPC 语义、但同时支持 HTTP/1.1 和 JSON 的协议 相对新,生态还在长

结论很朴素:如果你的调用方包含浏览器,那 gRPC 就不能是唯一入口,你必然还要有一条 REST / JSON 的路。 这是因为 gRPC 它的设计目标从一开始就是服务端到服务端。

5. 元数据(Metadata)才是放鉴权信息的地方。

gRPC 没有 HTTP 那种 随便加 Header 的自由用法,它把这套东西规范成了 Metadata,本质就是 HTTP/2 的头部,键值对,分两种:

  • 普通 Metadata:ASCII 键值,用来传 authorization、trace-id、tenant-id 这类信息;
  • 二进制 Metadata:键名以 -bin 结尾,值可以做 base64 编码的二进制,用来传证书、签名之类的数据。

所以 JWT 鉴权在 gRPC 里的标准做法是:把 token 放进 authorization Metadata,由拦截器(Interceptor)统一注入和校验。gRPC 的拦截器等价于 HTTP 世界里的 Filter / Middleware,这是做统一日志、链路追踪、鉴权、限流的标准位置。

6. 服务端流不等于“推送”。

这个误解挺常见。服务端流只是说“一次请求,服务端可以持续发多条响应”,它仍然是由客户端发起的那次调用的生命周期内的传输。客户端不发起调用,服务端没法主动联系它。它不能替代消息队列,也不能替代真正的推送通道。 要做那种事,请老老实实上 MQ 或者 WebSocket。

小结

把这一节收一下:

  1. gRPC 的两份身份不矛盾:它是 RPC 框架(.proto 契约 + 代码生成 + Stub),也是 HTTP/2 上的一个协议约定(/包.服务/方法 路径、Length-Prefixed-Message、Trailers + grpc-status)。前一半让它用起来像本地调用,后一半让它能穿过整个现代网络基础设施。
  2. 它不是 HTTP 的对立面,它是 HTTP/2 的一个用法。gRPC over HTTP/2 里的 over 才是重点。
  3. 它的四种流、多路复用、头部压缩,都是 HTTP/2 给的;它自己加的,是应用层语义和那套严格的状态码 / 元数据规则。
  4. “HTTP/2 + Protobuf”是个便于入门的简化,因为 Protobuf 只是默认编解码,可以换成 JSON;而 HTTP/2 之外,gRPC 还有自己的一层规则,那一层才是它区别于“随便用 HTTP/2 发 protobuf”的地方。
  5. 它的短板也很清楚:浏览器不支持、调试不方便、四层负载均衡会踩坑、长连接会带来连接管理的复杂度。这些不是 bug,是这套设计选择的必然代价。

所以选不选 gRPC,从来不是“它快不快”,而是“你的场景是不是服务间调用、你的团队能不能接受强契约和二进制带来的工具链成本”。它解决的是服务与服务之间的问题,这是它的主场,也是它的边界。

参考资料: