3、分布式通信基础:RPC原理、序列化协议、网络IO模型
好,咱们进入分布式系统最核心的一块——通信。说白了,分布式系统就是把一堆机器连起来干活,那它们之间怎么说话?这就是本章要聊透的事。
我个人习惯把分布式通信拆成三个层次来看:怎么调用(RPC)、怎么传数据(序列化)、怎么收发包(IO模型)。这三个层次,任何一个出问题,整个系统就崩了。我在项目中踩过的坑,十有八九都跟这三样有关。
3.1 RPC原理:让远程调用像本地一样简单
RPC,全称Remote Procedure Call。名字挺唬人,其实说白了就是——你调一个函数,但这个函数在另一台机器上执行。
你想想看,如果让你写一个分布式应用,最麻烦的是什么?是网络通信的细节:建连接、组包、拆包、处理超时、重试……这些脏活累活,RPC框架全帮你干了。你只需要像调本地方法一样,调一个远程服务就行。
RPC的核心流程:
- 客户端调用本地代理(Stub)
- Stub把方法名、参数序列化成二进制
- 通过网络发送给服务端
- 服务端Stub反序列化,还原请求
- 调用真正的业务逻辑
- 结果再序列化返回给客户端
嗯,这里要注意:RPC不是新东西,它只是个封装。底层还是TCP/UDP那套。我见过不少新手以为RPC是某种神奇协议,其实它就是个"包装纸"。
我曾经在一个项目里,团队自己手写了一套RPC框架。为什么?因为当时用的开源框架太重了,我们只需要一个轻量级的调用。结果呢?序列化协议选错了,性能直接腰斩。所以啊,理解RPC原理,比会用某个框架重要一百倍。
3.2 序列化协议:数据怎么"打包"才高效?
序列化,就是把内存里的对象变成字节流。反序列化,就是把字节流变回对象。听起来简单,但这里面的坑,深着呢。
我给大家对比两个最主流的序列化协议:Protobuf 和 Thrift。
| 特性 | Protobuf | Thrift |
|---|---|---|
| 出身 | ||
| 数据格式 | 二进制,紧凑 | 二进制,支持多种 |
| IDL定义 | .proto文件 | .thrift文件 |
| 语言支持 | 广泛 | 广泛 |
| 性能 | 极高 | 高 |
| 自描述 | 否(需要schema) | 否(需要schema) |
我个人习惯用Protobuf。为什么?因为它生成的二进制数据真的小。举个例子,一个简单的用户信息,JSON可能要200字节,Protobuf可能只要50字节。在分布式系统里,每少传一个字节,可能就省下几毫秒。
来看一个Protobuf的定义示例:
syntax = "proto3";
message User {
int32 id = 1;
string name = 2;
string email = 3;
repeated string tags = 4;
}
编译之后,你就能在代码里直接用了。序列化和反序列化,一行代码搞定。
避坑指南:我曾经在一个高并发场景下,用了Java原生的序列化。结果呢?序列化后的数据里包含了大量的类元信息,体积是Protobuf的5倍。网络带宽直接被打满。从那以后,生产环境我绝不用Java原生序列化。
Thrift也有它的优势。它不光定义了序列化格式,还自带了RPC框架。如果你不想自己拼RPC和序列化,Thrift是个"一站式"方案。但代价是——耦合度高,一旦选定了,后面想换就难了。
3.3 网络IO模型:BIO、NIO、AIO怎么选?
好,数据序列化完了,怎么发出去?这就涉及到网络IO模型了。说白了,就是你的程序怎么跟网卡打交道。
我给大家画个重点:BIO、NIO、AIO,本质区别在于"谁等谁"。
3.3.1 BIO:阻塞IO
BIO,Blocking IO。最原始的方式。你调一个read(),如果数据没到,线程就卡在那,啥也不干,干等。
你想想看,如果每个连接都配一个线程,那1000个连接就是1000个线程。线程多了,上下文切换的开销能把CPU拖死。我见过一个项目,用BIO处理了500个连接,CPU就飙到90%了。为什么?大部分线程都在"等",但操作系统还得伺候它们。
注意:BIO不是一无是处。如果你的连接数很少(比如几十个),而且每个请求处理时间很短,BIO反而简单可靠。别为了用新技术而用新技术。
3.3.2 NIO:非阻塞IO
NIO,Non-blocking IO。核心思想是:一个线程管多个连接。
怎么做到的?靠一个叫Selector的东西。你把所有连接注册到Selector上,然后一个线程去轮询:"哪个连接有数据了?" 有数据就读,没数据就继续轮询下一个。
这就好比一个前台,同时盯着几十个电话。哪个电话响了,就去接哪个。而不是每个电话配一个专人守着。
// Java NIO 核心代码示例
Selector selector = Selector.open();
channel.register(selector, SelectionKey.OP_READ);
while (true) {
selector.select(); // 阻塞,直到有事件发生
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isReadable()) {
// 读取数据
}
}
}
嗯,这里要注意:NIO虽然是非阻塞的,但select()本身是阻塞的。它阻塞在"等待事件发生"这一步。这跟BIO的"阻塞在等待数据"是两码事。
我个人习惯在高并发、长连接的场景下用NIO。比如网关、消息中间件。Netty就是基于NIO的,你想想看,Netty能扛住百万连接,靠的就是NIO这套机制。
3.3.3 AIO:异步IO
AIO,Asynchronous IO。比NIO更进一步。你调一个read(),直接返回,不等待。等数据准备好了,系统会主动通知你:"嘿,数据来了,你来拿吧。"
这就像你点外卖。BIO是你站在店门口等,做好了直接拿走。NIO是你时不时去问一下"好了没"。AIO是你下单后该干嘛干嘛,外卖到了给你打电话。
理论上,AIO是最高效的。但实际中,用得并不多。为什么?因为操作系统对AIO的支持不够完善,而且编程模型复杂。我试过在Linux上用AIO,结果发现底层还是用NIO模拟的。所以,目前主流还是NIO。
一句话总结三种模型:
- BIO:一个连接一个线程,简单但浪费资源
- NIO:一个线程管多个连接,高并发首选
- AIO:完全异步,理想丰满但现实骨感
3.4 三者如何配合?
好,咱们把RPC、序列化、IO模型串起来。一个典型的分布式调用流程是这样的:
- 客户端通过RPC框架发起调用
- 框架把参数用Protobuf序列化
- 通过NIO(比如Netty)发送给服务端
- 服务端用NIO接收数据
- 用Protobuf反序列化
- 调用真正的业务逻辑
- 结果再走一遍序列化+发送
你看,这三个技术是层层嵌套的。任何一个环节慢了,整个调用就慢了。我在做性能调优时,经常要在这三层之间来回排查:是序列化太慢?还是IO模型选错了?还是RPC框架本身有瓶颈?
最后说一句:没有银弹。BIO、NIO、AIO各有适用场景。Protobuf和Thrift也各有优劣。关键是你得理解它们的原理,然后根据你的业务场景做选择。这才是架构师该干的事。