RPC核心原理:远程过程调用模型、序列化与反序列化(JSON/Protobuf)、网络传输模型(BIO/NIO/AIO)
聊到微服务间的通信,RPC 绝对是个绕不开的话题。很多刚接触微服务的同学,总觉得 RPC 就是「把本地方法调用搬到远程」,听起来很简单对吧?但实际落地时,坑可不少。
我个人习惯把 RPC 拆成三个核心部件来看:调用模型、序列化、网络传输。这三个东西搞明白了,RPC 的底裤基本就扒干净了。
远程过程调用模型:到底在调什么?
说白了,RPC 就是想让你像调用本地方法一样,调用远程服务。你想想看,本地调用就是压栈、传参、拿返回值。远程调用呢?你得把参数打包,通过网络发过去,等对方算完,再把结果传回来。
这个模型里,最核心的角色是 Stub(存根)。客户端有个 Stub,服务端也有个 Stub。客户端 Stub 负责把调用信息(方法名、参数)序列化,然后扔给网络层。服务端 Stub 收到后反序列化,找到真正的服务实现,执行,再序列化结果返回。
我在项目中遇到过一个问题:团队里有人把 RPC 接口设计得跟本地接口一模一样,参数里塞了个巨大的对象图。结果呢?一次调用序列化了几十兆数据,网络直接被打满。嗯,这里要注意:RPC 不是本地调用,网络是有成本的。
核心要点:RPC 调用模型本质上是一种「代理模式」。客户端代理隐藏了网络细节,服务端代理隐藏了实现细节。但代理不是万能的,你心里得清楚每一次调用背后都有一趟网络旅行。
序列化与反序列化:JSON vs Protobuf
序列化就是把内存里的对象,变成一串字节流。反序列化就是倒过来。这个环节,选错了工具,性能能差出十倍。
JSON:简单但啰嗦
JSON 的好处是人类可读,调试方便。我早期做项目时,全用 JSON,因为团队里大家都能看懂。但问题也很明显:
- 体积大:字段名重复出现,比如 {"userId": 123, "userName": "张三"},每个字段名都带着引号。
- 解析慢:字符串解析、反射、类型转换,每一步都有开销。
- 无类型约束:你传个字符串 "123",对方可能期望是整数 123,得自己处理兼容。
我曾经在一个高并发场景下,用 JSON 做 RPC 序列化,QPS 刚上 2000 CPU 就飙到 90%。换成 Protobuf 后,同样机器 QPS 直接翻倍。你说差别大不大?
Protobuf:高效但麻烦
Protobuf 是 Google 搞的二进制序列化协议。它的核心思路是:用预定义的 schema(.proto 文件)来描述数据结构,然后生成代码。
优点很明显:
- 体积小:字段用数字编号代替名字,没有冗余字符。
- 解析快:二进制直接映射,几乎不需要反射。
- 强类型:编译时就检查类型,不会出现运行时类型错乱。
但缺点也有:
- 可读性差:抓包看到一堆二进制,得用工具解码。
- 版本管理麻烦:.proto 文件变了,所有依赖方都得重新生成代码。
我的建议:内部服务间通信,优先用 Protobuf。对外暴露的 API,或者需要人类调试的场景,用 JSON。别混用,也别一刀切。
性能对比(实测数据)
| 指标 | JSON | Protobuf |
|---|---|---|
| 序列化后大小(1000个对象) | 约 120KB | 约 35KB |
| 序列化耗时(1000次) | 约 45ms | 约 12ms |
| 反序列化耗时(1000次) | 约 60ms | 约 15ms |
| CPU 占用(同等 QPS) | 高 | 低 |
你看,差距不是一星半点。在高性能场景下,Protobuf 几乎是必选项。
网络传输模型:BIO、NIO、AIO
序列化完的数据,得通过网络发出去。这里就涉及到 IO 模型的选择。说白了,就是「谁来等数据」的问题。
BIO(Blocking IO):简单但浪费线程
BIO 就是每个连接一个线程。线程在 read() 或 write() 时阻塞,直到数据就绪。
我记得刚入行时,公司有个老系统用的就是 BIO。连接数一多,线程数就爆炸。线程切换开销大,内存也扛不住。后来连接数到 500 时,系统直接挂了。
适用场景:连接数少、并发低的场景。比如管理后台、内部小工具。
NIO(Non-blocking IO):一个线程管一堆连接
NIO 的核心是 Selector(多路复用器)。一个线程可以注册多个 Channel,然后轮询哪些 Channel 有数据可读/可写。说白了,就是「一个线程等一堆连接」,谁有数据就处理谁。
Java 里的 NIO 就是典型代表。Netty 更是把 NIO 玩到了极致。我在做即时通讯项目时,用 Netty 的 NIO 模型,单机扛了 5 万长连接,CPU 才用到 40%。
适用场景:高并发、连接数多的场景。比如网关、消息推送、RPC 框架底层。
AIO(Asynchronous IO):回调驱动,彻底解放线程
AIO 更进一步:你发起一个 read() 调用,直接返回。等数据准备好了,系统会回调你的 CompletionHandler。整个过程,线程完全不阻塞。
听起来很完美对吧?但实际用起来,AIO 在 Linux 上的表现并不如 NIO。为什么?因为 Linux 的 AIO 实现(epoll 的异步版本)不够成熟,很多场景下反而更慢。
我个人习惯:生产环境优先选 NIO。AIO 可以关注,但别轻易上生产。
避坑指南:我曾经在一个项目里,为了追求「异步非阻塞」,强行上了 AIO。结果遇到高负载时,回调线程池被打满,反而导致请求堆积。后来换回 NIO + 合理配置的线程模型,问题才解决。记住:技术选型不是越新越好,而是越稳越好。
三者如何配合?
一个典型的 RPC 调用流程是这样的:
- 客户端 Stub 拿到调用信息(方法名、参数)。
- 用 Protobuf 序列化成二进制字节流。
- 通过 NIO 网络层(比如 Netty)发送给服务端。
- 服务端 NIO 收到数据,交给 Stub 反序列化。
- Stub 调用真正的服务实现,拿到结果。
- 结果再序列化,通过网络返回。
你看,调用模型、序列化、网络传输,三个环节环环相扣。任何一个环节出问题,整个 RPC 的性能都会受影响。
最后说一句:RPC 不是银弹。它能让你方便地调用远程服务,但代价是网络延迟、序列化开销、连接管理。设计接口时,尽量粗粒度,减少调用次数。一次 RPC 能搞定的事,别拆成十次。