前言
这本书更像是全面系统的讲解RPC,内容可以连贯起来,从计算机处理器发展到RPC的诞生,后面讲几种常见的RPC组件、通信协议、序列化协议,虽然内容不是很深入,但是对于小白较易理解,便于建立起框架知识,如从Socket到Java NIO 再到 Netty 、RPC等框架来讲解。
挺不错的。算对得起100大洋。下面主要记录一下点,虽然偏理论,点到线到面才能形成自己的思维。
1、初识RPC
主要讲RPC出现是为了解决什么问题,以及RPC的定义概念,概括一个RPC远程调用过程需要经历哪几个阶段?为什么调用本地接口就能实现调用远程的方法?
(1)为什么会出现RPC?RPC是为了解决什么问题 ?正是因为计算机处理器的发展,才会出现RPC。
- 单核单处理器处理器时代:提升CPU内的晶体管进而提升主频提升性能,遵循摩尔定律。
- 多核单处理器处理器时代:通过增加CPU的核心来解决流水线过长,散热功耗问题,AMD和Intel的多核架构不一样,前者是将两个内核做在一个晶元上,通过直连架构整合在硅晶内核中,集成度更高,后者是将内核放在不同的硅晶内核,有独立式缓存设计。
- 多处理器时代:就是多个CPU,每个CPU(处理器)可以最少包含一个或多个核心,可以进行并行操作。多CPU(处理器)之间可以进行数据交换,,并且共享内存、IO通道、控制器和外部设备,通过进程间通信IPC 完成协同工作,什么是IPC?
IPC最早出现在Unix系统,当时只有三种,管道、匿名管道、信号,后来贝尔实验室对Unix的通信方式进行了改进和扩充,形成了SystemV IPC,有消息队列、信号量、共享内存区,然后为了跳出单机IPC的限制,又出现了Socket的通信,逐渐形成了Linux的7种IPC
- 管道:半双工,数据只能单向流动,只能用于具有亲缘关系的进程间。命名管道可以有名字,支持亲缘进程通信
- 消息队列:消息的链接表,克服信号承载量少、只能承载无格式字节流和缓冲器大小受限的缺点
- 信号量:计时器,控制多个进程对共享资源的访问,常用作一种锁机制
- 共享内存:映射一段能被其他进程所访问的内存,进程可以同时访问,是最快的一种IPC,常常和信号量结合使用。
- Socket套接字:不同主机上的进程通信,是RPC实现的基础。
(2)给RPC下一个定义? RPC定义:远程过程调用,概念分为远程过程 + 过程调用 更加直观
- 远程过程:远程方法,业务拆解,分布式部署
- 过程调用:方法调用,传入参数,执行方法,拿到返回值
RPC为分布式系统构建带来了便利,但是分布式系统本身问题也被暴露,通信延迟、地址空间被隔离、局部故障、并发调用顺序问题。
(3)RPC的五大核心部分,发起一个远程调用涉及5部分
- 服务调用方、服务提供方
- 调用端的本地存根、服务端的本地存根:其中本地存根为了解决地址空间隔离问题,使调用远程方法像本地方法一样,作用类型、参数转化,类似两个不同语言人通信需要翻译,也就是将函数方法名、参数 序列化、反序列化 后交给 RPCRuntime
- RPCRuntime:负责数据包的重传、确认、路由、加密等,两端都会有PCRuntime实例。
(4)调用过程可以分为4阶段,服务暴露、服务发现、服务引用、方法调用。
- 服务暴露过程:发生在Provider端,暴露方式可以分为两种,暴露在本地、远程。暴露在本地指的是直接将Provider的地址+端口写死在Consumer中,耦合度严重。暴露在远程指的是把Provider服务暴露在注册中心
- 服务发现过程:分为直连式、注册中心式,和上面的照应。
- 服务引用过程:两端的 RPCRuntime 建立连接,以及在Consumer端创建接口的代理的过程。
- 方法调用过程
- 1、Consumer调用本地接口,将参数类型、参数传递给本端的Consumer本地存根
- 2、Consumer本地存根收到调用,负责将方法、参数等数据封装成能进行网络传输的消息体(序列化:将消息体对象序列化为二进制数据)并将消息体传输给Consumer RPCRuntime 通信者。
- 3、Consumer RPCRuntime 将通过Socket收到的消息发给 Provider RPCRuntime ,然后交给Provider本地存根。
- 4、Provider本地存根反序列化数据,解析出来方法名称、参数等信息
- 5、Provider执行本地方法,并将结果返回给 Provider端本地存根,然后进行序列化,打包成可传输的消息体交给 RPCRuntime。接着就是发给Cosumer端反序列化,拿到结果。至此一次RPC结束。
2、RPC框架
主要讲当今常见的几种RPC框架各自的特点,以及选用一个RPC框架需要考虑的方面?
(1)什么是网络框架?
网络框架是指解决网络通信的某一类问题,框架和类库的区别就是框架内部中的类具有相互协作的功能。RPC框架就是为了不用感知RPC调用实现的细节的工具,就是一个轮子,常见的有dubbo、motan、Thrift、gRPC等。
先以Netty框架为例理解框架,早期必须使用操作系统提供的Socket接口来完成网络通信,Socket库非常复杂,后来Java对Socket通信做了一个封装,在JDK1.4引入了NIO,但是使用起来来时不太方便,因此出现了Netty框架,它是在NIO的基础上进一步封装。开箱即用。
(2)常见的RPC框架及其特点?
以dubbo框架为例,作为一个RPC框架,实现多种方案的组合供开发者选择,阿里巴巴开源的在2019捐献给阿帕奇成为顶级项目,设计了自己的SPI机制提供拓展能力,支持多种注册中心如ZooKeeper、Nacos、Consul、etcd等。
SPI机制是Service Provider Interface 面向接口编程,增加模块之间的可插拔能力,在模块装载的时候不指定是哪个实现,而是利用服务发现机制找对应的实现服务,JDK提供了服务实现查找的工具类ServiceLoader,他会加载META-INF/service/目录下的配置文件,dubbo在此基础上改进,之外还有dubbo的服务治理能力(运维能力)、负载均衡、集群容错、配置粒度如重试次数、超时配置等
- JDK标准的SPI是遍历找拓展点和实例,有的没用的拓展点也会被加载,导致资源浪费
- 修改配置文件中拓展实现的格式,改为k-v型,方便定位
- 增加对SpringIoc 、AOP的支持,拓展点可以通过setter注入到其他拓展点
gRPC是2015有google开源的一个语言中立、平台中立、高性能的框架,采用自研的Protocol Buffers作为序列化的解决方案,传输协议使用的是HTTP2,支持流式通信组合,权限验证机制丰富(SSL\TLS、ALTS)
Thrift是保持语言和平台中立的RPC框架,设计了自己的IDL编译工具,可以将.thrift文件编译成多种高级语言,它的通信传输提供了三种通信数据格式二进制、json、compact,支持多种传输方式阻塞式IO、按块传输的非阻塞IO、文件传输、压缩Zlib传输等
Spring Cloud 提供了RPC的解决方案,也可以算是一个RPC框架,Spring Cloud 项目有很多子项目,
- 第一类是与厂商有关的如Spring Cloud Azure、Alibaba、Amazon等
- 第二类是消息通信相关的项目如Spring Cloud Bus、Stream等
- 第三类是SpringBoot 应用部署、运维的项目如Spring Cloud CLI、Skipper等
- 第四类是分布式、微服务、服务调用以及服务治理相关的项目如Spring Cloud Config、Gateway、Netflix(Eureka、Hystrix、Zuul、Archaius)、Security、Sleuth、Zookeeper、OpenFegin等
(3)选择RPC框架需要考虑?
- 语言和平台中立和服务治理相关指标:因为各个拆分微服务模块未必是使用一种语言来进行开发的,或者是部署在不同的平台。服务注册、路由策略、负载均衡策略等
- 可靠性和稳定性
- 性能
- 设计:对代码的侵入性,低的侵入性可以保证业务系统在重构或者换别的技术栈灵活性更高。如dubbo可以xml接入spring,对应用无任何API侵入,而SpringCloud的注解配置就会对业务系统有一定的入侵。
- 文档
- 开源社区的成熟度
3、RPC框架核心部分
主要讲一个RPC框架需要具备的核心实现组件,是如何完成调用接口进而调用远程方法?如何在网络上将需要数据、参数传递?通信协议?网络IO?序列化?
(1)成熟的RPC网络通信框架发展阶段?那些条件影响RPC的性能?
远程通信方式从原始的操作系统提供的Socket库—>Java封装的ServerSocket—> 网络上比较流行的网络通信框架如Netty、Mina、Grizzly等----> 如今的RPC解决方案,封装度越来越高,越来越成熟性能也几乎越来越高
在RPC框架的底层实现方案选取,线程模型(如常见的Reatctor线程模型)的设计,操作系统支持的I/O模型(BIO、NIO、AIO等)都会影响整个方案的性能。因此需要从轮子的最底层开始分析理解,才会对整个发展过程更加清晰。
(2)代码举栗练习Java ServerSocket API举栗
JDK 原生Socket API、Java NIO API、Netty API等的练习:https://github.com/sichaolong/spring-demo/tree/main/study-netty-demo/src/main/java/henu/soft/example/socket/example_bio
(3)I/O模型概念?Socket I/O 和一般 I/O 的区别?为什么I/O模型的选用影响RPC框架通信性能?
计算机通信的本质就是发送方发送数据,经过一系列传输到接收方。大多数的IPC方法本质就是I/O操作,站在操作系统的角度,I/O操作一般指磁盘操作,而Socket也跟其他IPC方式一样,也离不开I/O。只不过被称为网络I/O,本质就是流数据的读取、写入,基础就是Socket通信。
首先需要知道用户进程和系统进程,系统进程可以执行内存资源分配和进程切换等管理操作,用户进程则是内核之外的程序进程,数据存放的区域叫做用户空间,系统进程数据存放的区域叫做内核空间,用户进程不能直接访问内核空间的数据、资源,需要进行一次复制,只有处于内核态的程序才能访问用户空间、内核空间,这样保证用户进程不能随意的修改系统数据和资源,因此当用户进程需要获取、写入系统数据,则需要上下文切换(系统调用)。
网络传输也是一种系统调用,通过网络Socket传输的数据,首先从内核空间接收远程主机数据即准备数据,然后再从内核空间复制到用户空间供用户进程读取。
所说的I/O模型指的是 机器上进行数据读,写调度的策略模型,如内核进程在准备数据的过程中,用户进程可以选择阻塞占用CPU等待和不阻塞的执行其他任务,可以从两个维度理解
- 阻塞、非阻塞:用户进程发起I/O操作后,是否需要等待内核I/O操作准备数据完成后才能继续运行,针对的对象是I/O的发起者—用户进程
- 同步、异步:在用户进程发起I/O操作后,会由用户态转为核心态,内核进程在执行完准备数据的操作后(从网络复制到内核空间),需要转为用户态,之后用户态在进行复制(内核空间到用户空间),如果是同步I/O,则数据在内核空间—>用户空间的复制过程,用户线程不会立刻收到答复,直至完成数据复制能进行后续操作。如果是异步I/O,则内核线程直接将数据从内核空间—>用户空间复制,内核线程会立刻给用户线程一个答复,说正在处理了。等待复制好了通知用户线程可以进行后续操作。所以同步和异步针对的是—内核进程
在简单阻塞、非阻塞的关系,类似 主角a 去买东西,从告诉老板b 需要一本书开始 到 老板b把书递给a,
- 如果a在原地干等啥也不做就是阻塞,
- 如果期间a有打开手机回了个电话(本来回电话需要拿到书之后再回),期间需要不断看b是否正在把书递给a,说明a是非阻塞。
在简单理解同步、异步的关系,类似a去买东西,从告诉 主角老板 b 需要一本书开始(a和b都不知道书还有没) 到 老板b把书递给a,
- 异步:b立刻去找,并且直接就告诉a说找到后我找到后把书送到你家门口,然后给你打电话,你回去吧(直接给a个状态,但是并不知道结果如何,a获得特殊的答复可以干自己的事情,等待b完成之后将书送给a,然后电话回调通知a,a之后可以直接看书)
- 同步:b立刻去找,但是此时并未给a任何答复,此时并不关注a的状态,直达b获得最终结果反馈给a,a才收到b的一次答复,即a好需要自己将书带回自己家才能看。
(4)I/O模型的种类
参考:https://www.cnblogs.com/myseries/p/10901059.html
Windows下面有五种:选择Select I/O、异步选择WSAsyncSelect I/O 、事件选择WSAEventSelect I/O 、重叠Overlapped I/O、输入输出完成端口I/O模型COmpletion Port
Linux下面有五种:BIO、NIO、非阻塞多路复用Multiplexing IO、信号驱动SingalDriven I/O、异步Asyncchronous I/O
- BIO:用户进程发起IO,内核进入数据准备阶段,此时用户线程一直阻塞直到数据准备完成
- NIO:用户进程发起IO,内核进入数据准备阶段,此时用户线程暂时去做其他事情,并且定期询问数据是否准备好了
- Multiplexing IO :该模型主要是单个进程就可以处理多个网络连接的I/O模型,本质上用户进程还是阻塞的,但是select不会被阻塞,select、poll、epoll会不断的轮询负责的Socket,当某个Socket有数据到达,就通知对应的用户进程。
- SingalDriven I/O:用户进程发起IO,内核进入数据准备阶段,此时内核马上返回答复,在内核完成数据准备阶段之后,通过信号告知用户进程,用户进程不在需要一直轮询,等到接收到信号之后开始将数据内核态复制到用户态即可。
- Asyncchronous I/O:和上面异步情况一致,并且内核线程准备好数据之后,直接复制到用户进程,然后才通知用户进程直接使用数据即可。
(5)Java对 I/O 模型的封装可以分为BIO、NIO、AIO三种。
-
BIO:Blocking I/O的简称,在JDK1.0就已经存在,同步阻塞,当一个java线程在读入或者写入输出流时,在读写动作完成之前,线程会一直阻塞。开始一个客户端的请求被接收器接受之后,在服务端会创建一个线程与该客户端通信,这个线程一般会执行读取、解码、计算、编码等几个步骤。这种处理模式创建线程、处理请求、每次都要销毁线程耗费资源,而且服务端需要一个一直阻塞等待新连接的接收器。因此后来改进增加任务处理线程池。依然存在的问题就是当请求量过大线程数过多,频繁的切换线程带来的成本问题也会暴露出来,它会等待结果阻塞,因此I/O会马上称为瓶颈,当面临十万、百万级连接的时候,传统的BIO模型就显得无力了。
-
NIO:Non-Blocking I/O的简称,在JDK1.4就已经存在,提供Channel、Selector、Buffer等新的抽象,**Java NIO 和 Linux中得非阻塞I/O有一些区别,他更像Linux中的多路复用I/O,**同时支持select、poll模型,在JDK1.5之后又加了对epoll的支持。
- Channel是一个双向的数据读写通道,Channel可以实现读、写同时操作,支持阻塞非阻塞模式。一个客户端对应一个Channel,需要注册进Selector并绑定当前Channel事件处理逻辑
- 事件处理器:包含Channel通道各类事件对应的处理逻辑,读数据、写数据、连接、接收连接事件处理器。
- Buffer:存储数据的容器,Channel可以对Buffer操作,分为直接内存、堆内内存。
- Selector:不断轮询注册在其上的Channel来选择并分发就绪事件,可监控多个Channel,selector的selectorKeys方法会获取Channel的SelectorKey最新就绪的事件状态,由事件处理器来从Buffer读取数据、更新数据、写入数据。
-
AIO:Asynchronous I/O的简称,是JDK1.7之后引入的Java IO 库,是NIO的升级版,提供了异步非阻塞IO,前面说过异步主要针对内核进程处理IO,非阻塞主要针对用户进程处理IO。它是基于事件和回调机制实现的,也就是用户程序发起IO请求的时候,操作系统会通知相应的线程执行后续操作,在Java 中,AIO有两种使用方式:
- 简单的将来式:用Future实现,执行任务的线程并不会阻塞,而是会返回一个Future对象,可以调用get阻塞的拿到线程的结果。CpmpletableFuture支持原来的Future功能外,还支持回调,直接在后面调用whenComplete等方法,而且支持多个CompletableFuture之间的协同合作。
- 回调式:用CompletionHandler作为回调接口,在read、write方法时候。可以传入CmpletionHandler的实现类作为
(6)Reactor、Proactor事件驱动模式
Reactor模式也称为反应器模式,Java NIO就是采用这种模式,最核心的思想就是减少线程的等待,当遇到需要等待的I/O操作时,先释放资源,而是在I/O完成时,在通过事件驱动的方式,在进行接下来的处理。五种重要模式
- Handle:描述符,资源的抽象,可以理解为Socket,可以和操作的I/O事件绑定称为一个个事件
- Synchronous Event Demultiplexer:同步事件分离器,Handler代表的事件会注册到其上,在Linux上值得就是I/O多路复用机制如select、poll、epoll等系统调用。在Java NIO 指的就是Selector.
- Event Handler:事件处理器,多个回调方法构成,提供一个模板或者接口供我们重写自己的逻辑。Java NIO中并没有,这也是Netty 比 Java NIO 更方便的原因。
- Concrete Event Handler:具体的事件处理器,继承、实现上面的事件处理器。
- Initiation DIspatcher:该模式的核心协调者,事件处理器需要注册到其上才能发挥作用。当请求来临,它会通过 Synchronous Event Demultiplexer 将事件分离,然后找响应的 Concrete Event Handler 执行自定义的回调方法处理事件。
Reactor他有三种线程模型
- 单Reactor单线程模型:客户端的连接请求、读写请求由单Reactor线程接收,将相应事件分发给相应的事件处理器,连接、读写事件只有一个线程来处理,问题就是计算、解码、编码的处理会影响后面其他事件的处理,如某一业务执行很耗时,此时客户端有新的连接事件,那么就会被阻塞。在高并发场景,线程负载过高,就会导致请求堆积甚至超时。
- 单Reactor多线程模型:客户端的连接请求、读写请求由单Reactor线程接收,将相应事件分发给相应的事件处理器,连接、读写事件由线程池来处理。虽然充分利用了多核CPU的处理能力但是高并发下所有事件的监听和响应只有单线程又是问题。单Reactor只能同步的处理I/O操作,连接事件往往没有读、写事件频繁。
- 主从Reactor多线程模型:也就是Reactor线程增加至两个,Main Reactor 一个专门处理连接请求,Sub Reactor一个专门处理读、写事件。当然Main Reactor 也可以用线程池进行管理,但也不是越多越好,主要取决于客户端连接是否频繁。
Java中的AIO采用的是Proactor模式,他也是多路复用技术的另外一种常见模式。他也有六个角色,与Reactor的区别就是Proactor注册的并不是就绪事件,而是完成事件
因为Proactor是由内核进程处理I/O操作(由内核准备数据,由内核进程直接将数据从内核缓冲区复制到进程缓冲区),而Reactor是由用户进程处理I/O操作(由内核准备数据,用户进程主动read方法发起系统调用,然后用户进程来将数据从内核缓冲区复制到进程缓冲区)。
-
在Reactor模式下,当接收到read事件,执行事件处理器的时候,会进行用户态—》内核态—》用户态完成I/O复制。此时Reactor模式下的I/O操作还没完成,只是就绪,下一步需要用户进程执行I/O的复制(数据从内核缓冲区复制到进程缓冲区)。
-
而在Proactor模式下,当接收到read事件,执行事件处理器的时候,会进行用户态—》内核态完成I/O复制—》用户态。即之后由内核进程完成I/O的复制操作(数据从内核缓冲区复制到进程缓冲区),也就是内核态完成I/O操作、完成上下文切换之后到用户态,此时I/O已经完成,数据已在用户态。用户进程直接执行逻辑操作即可。
注意:read系统调用,是把数据从内核缓冲区复制到进程缓冲区;而write系统调用,是把数据从进程缓冲区复制到内核缓冲区。这个两个系统调用,都不负责数据在内核缓冲区和磁盘之间的交换。底层的读写交换,是由操作系统kernel内核完成的。
(7) 远程通信实现方案Netty
Netty是一款异步的、事件驱动的、用于构建高性能网络应用程序的框架。简单说就是可以用netty搭建客户端、服务端,建立两端通信实现跨进程通信,netty基于NIO参考Reactor模式设计的,解决了JDK原生存在的问题,比如解决原生的NIO存在的epoll Bug ,并且加入了自己的一些特性,效率提高很多,相较于NIO ByteBuffer,Netty实现了ByteBuf。
目前主流的版本是Netty 4,Netty 5 已经停止维护。优势
- 封装度高API易用,易用性和可拓展性强:简化连接事件的处理,JDK NIO需要自己编写ByteBuffer从Channel读取数据的逻辑,Netty不在需要,对读取的数据做了统一的封装。而且提供了强大的编码、解码器。
- 实现了高性能通信:1、强大的线程模型如EventLoop、EventLoopGroup更加方便开发人员实现Reactor模式,Netty4进一步演化线程模型,如使用串行化设计理念避免多线程并发访问带来的锁竞争和上下文切换CPU的消耗。2、内存池的设计,通过内存池技术循环利用ByteBuf,避免频繁的申请、释放内存,有效减少内存碎片。3、堆外内存使用,TCP接受和发送缓存区使用直接内存代替堆内存,避免了内存复制,提升了I/O读取和写入的性能。4、并发编程优化:环形数组缓冲器无锁编程。5、协议的支持。
- 实现了高可靠通信:1、链路有效监测,虽然长连接相对短连接的性能更高,但是长连接存活判断至关重要,往往需要心跳机制进行链路检测,当发生问题时,可以关闭TCP然后重建,避免发生灾难性问题。有读空闲超时机制、写空闲超时机制,指的是在连续N个周期没有消息可读可写发送心跳包,心跳包发送M个周期如果都没收到心跳回复包,则主动关闭连接,重建。2、内存保护机制,设置ByteBuf的限制大小等等。3、优雅停机,系统退出时,系统进程不会强制退出。否则可能会造成缓冲期请求消息没处理完导致整个客户端的阻塞。实现是在JVM通过注册shutdown hook 拦截退出信号量,然后执行退出操作,比如释放相关模块资源,不在接受新的请求。
Netty核心组件
- Bootstrap、ServerBootstrap:引导类,将各个组件组装,服务端的ServerBootstrap提供bind方法绑定ip、port
- Channel:网络操作抽象类,基本的I/O操作外还有注册、绑定、连接等方法,获取EventLoop等API,生命周期有:注册、未注册、活跃、非活跃、数据可读、读数据完成分别对用几种回调函数。
- ChannelFuture:类似Future、CompletleFuture,支持全异步如
ChannelFuture future = bootstrap.bind(IP,PORT).sync()
。 - EventLoop、EventLoopGroup:前者可以理解为Reactor,每个EventLoop维护一个Selector实例,多个Channel可以注册在一个EventLoop中,在一个EventLoop的生命周期只能和一个Thread绑定,Reactor模式的实现主要和EventLoopGroup有关。可以将后者理解为线程池,在搭建服务端的时候经常会创建两个EventLoopGroup对象,一般命名bossGroup、workerGroup,前者一般只管理一个EventLoop,专门用来处理客户端的连接事件,类似主从Reactor多线程实现方式。
- ChannelHandler:事件发生时的回调处理器,当一个事件发生,会调用响应的重写方法。
- ChannelPipeline:处理ChannelHandler的链,Channel和ChannelPipeline是一一对应的。
(8)远程通信实现方案之Mina
在Netty2和Netty3之间,还存在一个Mina,创建者是一个人,主要解决Netty2不支持文本、UDP/IP协议,提高易用性和可测试性的问题。2020年8月发布2.1.4版本,实现了几种复杂的协议LDAP、Kerberos、DNS、NTP等。他的优势
- 支持协议类型丰富,不仅支持TCP,还支持UDP,而且对UDP做了高层次的抽象,可以把UDP当做面向连接的协议。具体实现就是一个UDP请求会按照客户端地址产生一个新的IoSession,过期时间是1分钟,这样就可以将一个无连接的UDP协议抽象为有连接的协议。
- 支持的传输类型丰富
- 提供安全机制,支持SSL/TLS
- 可定制的线程模型
4、通信协议
通信协议指的是双方都应该遵守的约定规则,协议可以划分为标准协议、自定义协议,在RPC调用过程中,需要传输方法参数等数据,因此需要考虑
- 数据格式:但是网络不可靠,传送的数据包可能丢失需要重传,因此传递的数据需要额外加上一些必要的规则数据。
- 数据编码:选用二进制编码(传输层的TCP)、文本格式编码(早期的HTTP1.1)、二进制和文本混合
因此出现一些标准,ISO七层模型 + 四层模型 => 五层模型,与RPC框架息息相关的网络模型就是应用层、传输层。传输层可以提供流量控制、拥塞机制、失败重传机制等。TCP是传输控制协议,是面向连接的全双工通信。
(1)数据长度的限制MSS和MTU以及粘包、拆包
- MTU: Maximum Transmit Unit,最大传输单元。 由网络接口层(数据链路层)提供给网络层最大一次传输数据的大小;一般 MTU=1500 Byte。假设IP层有 <= 1500 byte 需要发送,只需要一个 IP 包就可以完成发送任务;假设 IP 层有> 1500 byte 数据需要发送,需要分片才能完成发送,分片后的 IP Header ID 相同。
- MSS:Maximum Segment Size 。传输层TCP 提交给 IP 层最大分段大小,不包含 TCP Header 和 TCP Option,只包含 TCP Payload ,MSS 是 TCP 用来限制应用层最大的发送字节数。假设 MTU= 1500 byte,那么 MSS = 1500- 20(IP Header) -20 (TCP Header) = 1460 byte,如果应用层有 2000 byte 发送,那么需要两个切片才可以完成发送,第一个 TCP 切片 = 1460,第二个 TCP 切片 = 540。
在数据链路层,正确帧的大小为 64Bytes — 1518Bytes,不在范围内的则为错误帧,其中帧头、帧尾占
- 2*6Bytes :源MAC、目的MAC
- 2Bytes:Type域
- 4Bytes :CRC校验
因此得到最大传输单元MTU为1500Bytes,数据链路层能收的最大帧数据数据为1500Bytes,
往上面推网络层、传输层,则
- TCP MSS:传输层TCP包大小最大为1500 - 20(IP头部)- 20(TCP头) = 1460Bytes。
- UDP MSS:传输层UDP包大小最大为1500 - 20(IP头部)- 8(UDP头) = 1472Bytes。
应用层传到 TCP 协议的数据,不是以消息报为单位向目的主机发送,而是以字节流的方式发送到下游,这些数据可能被切割和组装成各种数据包,接收端收到这些数据包后没有正确还原原来的消息,因此出现粘包现象。拆包概念类似。
以TCP为例,应用层的数据到达TCP运输层之后,需要重新分片,也就是满了MSS就化作一个TCP包,但是此时一条完整的消息也许不再一个TCP包中,也有可能一个包中有多条数据。而UDP则是面向报文的,按照应用层的定好边界的数据包作为一个UDP包,太大的话需要在IP分批发送。
所以当应用层的传输数据大于1460Bytes,则需要将数据拆分为多个TCP包,等到达目的主然后卸下MAC、IP、TCP头部,就需要将分片重组,经过 TCP 层消息重组变成字节流,为什么说TCP报文段是面向字节流的,UDP包是面向数据报的?。因此就会出现粘包、拆包问题,参考:图解 | 为嘛有 TCP 粘包和拆包
(2)HTTP应用层协议
相较于1991年的HTTP0.9版本,1996发布的HTTP1.0有特性:支持多数据格式,支持超文本传输,支持UTF-8、GB2312编码。请求响应数据格式变化,新增头部信息。Content-Encoding、Content-Length等字段。请求方式的增强,新增了POST、HEAD方式。虽然改进了很多,但是还是优缺点,如1.0存在无法复用的缺点
1997年发布的 HTTP 1.1在基础上加了Keep-Alive字段支持连接复用模式、缓存增强策略、必须传递Host信息、头部信息增强、请求方式再增强PUT、DELETE等、错误状态码新增24个。虽然改进了一些,但是性能上并没有太多的又是,如连接复用模式虽然能共同使用一个连接通道,但是在一个通道同一时刻只能处理一个请求,当前请求没结束的时候,其他请求只能阻塞,这意味不能随意在通道发送和接受内容,这是1.1典型的队头阻塞问题。
2012年发布的 HTTP2,主要突破点,多路复用技术,头部信息压缩技术,采用二进制编码进行传输数据,服务端推送技术。
Google在2015年提出的HTTP 3.0 核心是QUIC(Quick UDP Internet Connections),以往的HTTP版本是基于TCP的,3.0以UDP为基础,能进一步减少TCP三次握手、TLS握手的时间,加快建立连接速度。可靠性方面通过包重传保证消息传输的可靠性。
(3)自定义应用层协议思路
满足系统对协议的特殊要求,重新编排协议头和协议体的数据格式。一般都是咋传输层以上做的,如自定义应用层协议,基于HTTP协议改造等,为了满足:可扩展性、安全性高、传输效率高。
下面以Dubbo协议为例分析,是在TCP传输协议上改造的,采用二进制编码,各个数据位的意义:
- 0-7位 和 8-15位,类似Java字节码中的魔数,判断是不是Dubbo数据包,就是一个固定的数字。
- 16位:Req、Res,代表是请求还是响应,在Dubbo内部,消息是没有方向的。
- 17位:2Way,代表请求需不需要响应,比如Dubbo内部设置了ReadOnly事件,为了通知本客户端即将下线,不需要服务端答复。
- 18位:Event,是否为事件请求,Dubbo内部有心跳事件、只读事件等,一次请求可能是RPC调用或者是事件请求,若为事件请求可以直接调用对应的事件处理器直接处理,加快处理速率。
- 19-23位,序列化实现方案编号,支持Hession、Fastjson、Kryo序列化方案等,告诉服务端,保证服务端能解析出来数据。
- 14-31位:Status,此次请求的状态,如30代表客户端超时,31代表服务端超时。
- 32-95位:ID编号,请求唯一ID
- 96-127位:数据长度,消息体的长度
- 128往后位,消息体数据,如想调用的方法名,方法参数等
设计自定义协议需要考虑方面
- 高扩展性,比如HTTP协议头中可以添加自定义字段
- 良好的兼容性:需要向下兼容
- 高效性:如HTTP1.1的请求头部消息异常庞大,影响效率。
- 安全性
- 可靠性:如何应对丢包等问题
设计步骤
- 设计协议头、协议体
- 设计协议头的必要字段:如版本version、向下兼容upgrad、拓展extension、id、status、data-length等字段
- 确定协议头大的编码方式:二进制便秘吗or文本编码,二者各有特点,二进制编码数据包要小一些,但是可读性差
- 确定各数据位及每个字段的排列顺序。
5、序列化
序列化技术操作的目标就是计算机中的数据,最相关的就是数据的存储和传输,本质就是将数据按照规定的格式重组,在不改变语义的基础上达到传输的目的。通俗讲就是将对象转为特殊格式数据,然后接受方可以将特殊格式数据还原为对象。
通信协议虽然是整个数据包的编排规则,但是他并不关心协议体真实的数据编排规则,交给序列化协议负责。序列化技术可以多个维度划分,其中一个维度是根据数据序列化之后的格式划分的:二进制、文本(JSON、XML)等数据格式。按照技术可以分为:文本格式序列化实现方案,二进制格式的序列化实现方案。
序列化如何保证数据能还原?一种是在序列化过程中添加数据的元数据,可以称为自我描述性序列化方案,如Hession,在Java中定义Integer a = 2
;则Hession会序列化为 I 2
的格式。另外一种是非自我描述性,这种需要依赖额外的外部文件如ProtocolBuffer 就必须要有*.proto
文件才能完成反序列化
-
常见的xml序列化框架有JDK自带的XMLEncoder、XMLDecoder,另外一个是Stuts发展而来的Digester类
-
常见的json序列化框架有Fastjson、Jackson、Gson、JSON-lib等,其中json序列化之后的数据相比xml更小。Fastjson速度快的原因有:获取对象属性值的时候采用ASM库,没有选用传统的反射获取对象属性值,而且只引入ASM框架的部分内容。第二是字符的拼接速度太慢,使用了StringBuilder封装的SerializeWriter,进一步提升性能。第三就是采用ThreadLocal来缓存buf,SerializeWriter内部有个char[] buf属性,每次序列化都要分配空缓存每次序列化后字符串的地址,使用ThreadLocal减少对象分配和GC。第三是采用了一个自己实现的Map存储了Class和Serialize的映射关系,如Arrays.class对应ArraySerializer,避免了ConcurrentHashMap自旋等待效率低,通过equals效率低的问题,他通过重写值比较的逻辑,最会还去掉了transfer逻辑保证并发扩容不会死循环。第四是排序输出,为反序列化准备,正常的map是无序的,但是Fastjson按照key进行了匹配。第五十使用SymbolTable算法关键字缓存,可以避免创建新的字符串对象。
-
常见的二进制序列化方案有Kryo、Hessia。Kryo基于ASM性能很高,缺陷有对类升级兼容性很差,kryo实例的创建很昂贵。Hession支持多种异构语言间的序列化,
序列化框架选用需要考虑
- 通用性
- 性能
- 可拓展性
- 安全性
- 支持数据类型丰富程度
- 可读性
- 开源社区的成熟度和活跃度
6、实现一个简易的RPC
一个RPC框架需要具备远程通信方式、通信协议、序列化方式等组件,需要动态代理实现本地存根等。除了基本的远程调用能力,还需要一定的服务治理能力,比如服务的注册发现的注册中心、保护系统流控作用的负载均衡策略、分析错误的链路追踪以及一些基础的日志记录、配置方式等
将RPC的需求优先级排序,主要分为三步实现:1、实现远程调用相关能力。2、必要的服务治理如注册、发现、负载均衡、路由、容错机制等。3、链路追踪、应用监控、故障告警能力等。
因此可以使用下列组件库完成一个简易的RPC框架:Spring、Netty、log4j、Javassit和Cglib、Zookeeper、curator(最为Zookeeper的客户端实现)、Protostuff。
还可以在最简易的基础上进行拓展升级,升级版RPC框架实现与源码见下期博客:《深入理解RPC框架原理与实现 华钟明》使用Netty、Zookeeper等实现一个简单的RPC框架、自定义注解、SPI机制实践与原理分析