协议栈

协议栈:不同的服务在性能上适用不同协议进行传输。对比对接异构第三方服务时,通常会选择HTTP/Restful等公有协议;对于内部不同模块之间的服务调用,往往会选择性能较高的二进制私有协议。

1、关键技术点分析

    大部分服务框架都支持多协议,但是多协议却不是必须的。如果公司内部使用,则可能根据业务需要决定支持的具体协议,如果是开源的通用分布式服务框架,考虑到通用性往往会提供一些默认的协议(HTTP、WebService)。

    1.1、是否必须支持多协议

        答案是否定的,尽管从功能上来说分布式服务框架默认支持的协议种类越多,功能越强大。但是,分布式服务架构与ESB(企业服务总线)最大的差异就是他不负责异构系统的对接,这意味着所有使用分布式服务框架服务化的业务都需要遵守服务化框架的规范,包括通信协议,服务配置等。

        需要指出的是分布式服务框架需要具备通过扩展的方式支持多协议的能力,协议栈应该作为一个扩展点开放出来。

    1.2、公有协议还是私有协议

        大部分分布式服务框架/RPC框架内部都采用私有的协议进行内部通信,但是,SOA理论又告诉我们应该使用标准的WEB Service提供服务。

        SOA服务化的目标就是重用IT系统已有资产、实现异构系统之间的灵活互通。重用已有的异构系统,实现不同系统之间的调用,最佳的做法就是使用标准的公有协议,其中WEB Service最合理。

        服务提供者通过WSDL(网络服务描述语言,Web Services Description Language是一门基于 XML 的语言,用于描述 Web Services 以及如何对它们进行访问)向注册中心提供服务接口描述,注册中心通过UDDI(Universal Description Discovery and Integration即统一描述、发现和集成协议)发布服务提供者的服务,消费者检索到服务提供者服务消息之后,通过标准的SOAP协议调用服务提供者,实现服务远程调用。相比Web Service,HTTP等公有协议缺少服务描述文件、服务注册中心和服务订阅发布机制,不太适合SOA服务化的标准协议。

        分布式服务框架主要是为了解决内部服务化之后业务接口跨进程通信问题,吞吐量、时延等性能指标是关键。在性能方面,私有协议往往会根据业务的具体需求进行针对性的优化,因此性能更优。

        以Web Service公有协议为例,它的性能存在如下缺陷:

            1)、SOAP消息使用XML进行序列化,相比于PB等二进制序列化框架,性能低一大截。

            2)、SOAP通常用HTTP1.0/1.1不支持双向全双工通信,而且一般使用短连接通信,性能比较差。

        如果没有特殊需求,分布式服务框架默认使用性能更高、扩展性更好的私有协议。

    1.3、继承开源还是自研

        分两种场景,对于私有协议通常是自研的,因为开源的RPC框架等协议栈与序列化框架,甚至是功能都耦合在一块,想剥离难度很大。另外就是开源方案的功能满足度、灵活性等很难满足业务需求,往往要做二次优化。

        对于公有协议建议如下:

            1)、如果业界有比较成熟的开源框架,在性能、功能、可靠性等方面能够满足需求,则优先采用集成开源框架的方案。

            2)、如果使用的功能不多,或者集成开源框架不能完全满足需求,改造工作量太大,也可以考虑基于通信框架(基于Netty)自研。

2、功能设计

    公有协议栈往往采用集成开源的方案,集成起来比较简单,只需要做简单Facade即可,下面主要介绍自研的私有协议栈。

    2.1、功能描述

        私有协议承载了业务内部各模块之间的消息交互和服务调用,它的主要功能如下:

            1)、定义了私有协议的信息模式和消息定义。

            2)、支持服务提供者和消费者之间采用点对点长连接通信。

            3)、基于java NIO通信框架,提供高性能的异步通信能力。

            4)、提供可扩展的编解码框架,支持多种序列化格式。

            5)、握手和安全认证机制。

            6)、链路的高可用性。

    2.2、通信模型

        私有协议栈通信模式如图1-1所示:
      
                                  图1-1    私有协议栈通信模式

        服务提供者和消费者之间采用单链路、长连接的方式进行网络通信,链路创建流程如下:

            1)、客户端发送握手请求消息,携带节点ID等有效身份认证信息。

            2)、服务端对握手请求消息进行合法性校验,包括节点ID有效性校验、节点重复登录校验和IP地址合法性校验,校验通过后,返回登录成功的握手应答消息。

            3)、链路建立成功后,客户端发送业务消息。

            4)、链路建立成功后,服务端发送心跳消息。

            5)、链路建立成功后,客户端发送心跳消息。

            6)、链路建立成功后,服务端发送业务消息。

            7)、服务端退出时,服务端关闭连接,客户端感知对方关闭连接后,被动关闭客户端连接。

       需要指出的是,服务消费者和服务提供者双方连接建立成功之后,就可以进行双工通信。无论是客户端还是服务端都是发送消息给对方,通信方式可以使TWO WAY也可以是ONE WAY。

        双方的心跳采用ping-pong的机制,当链路处于空闲状态时,客户端主动发送ping消息给服务端,服务端接收到消息后发送应答消息pong给客户端,如果客户端连续发送N条ping消息都没有接收到服务端的pong消息,说明链路已经挂死或者对方处于异常状态,客户端主动关闭连接,间隔周期T后发起重连操作,直到重连成功。

    2.3、协议消息定义

        通常协议栈的消息模型分为两部分,消息头和消息体。消息头存放协议公共字段和用户扩展字段,消息体则用于承载消息内容。以HTTP为例,请求消息头允许客户端向服务端传递请求的附加信息以及客户端自身的信息,通常消息头关键字有Accept、Authorization、Host等。

        私有协议的消息模型与公有协议类似,它也包含消息头和消息体两部分,如下表1-1所示:

名称 类型 长度 描述
header Header 变长 消息头定义
body byte[] 变长 字节数组:对于请求消息,它是方法的参数;对于相应消息,它是返回值
        消息头通常包含服务调用相关的公共参数,参数定义如下表1-2所示:
名称 类型 长度 描述
crcCode int 32

协议栈校验码,有三部分组成

1)0xAFBA:固定长度,表明该消息是私有协议消息,2个字节

2)主版本号:1~255,1个字节

3)次版本号:1~255,1个字节

crcCode=0xAFBA+主版本号+次版本号

length int 32 消息长度(包括消息头和消息体)
type byte 8

0:业务请求消息

1:业务相应消息

2:业务ONE WAY 消息(及时请求又是相应)

3:握手请求消息

4:握手应答消息

5:心跳请求消息

6:心跳应答消息

priority byte 8 消息优先级0~255
interfacename string 变长 接口名
methodName string 变长 方法名
attachment Map<String,String> 变长 可选字段用于扩展消息头

    消息头中最有一个字段attachment是一个Map,用于协议栈自身、用户协议扩展使用,例如可以设置traceID表示全流程业务跟踪的消息ID。消息体是个byte数组,它的设置存在如下四个场景。

    1)、请求消息:服务提供者接口请求参数类型编码+请求参数值编码。

    2)、应答消息:服务提供者返回给消费者的应答消息编码。

    3)、心跳消息:不需要设置。

    4)、握手应答消息:应答结果编码。

    2.4、协议栈消息序列化支持的字段类型

        协议栈可以由不同的序列化框架承载,不管使用那种序列化方式,承载在它之上的都是业务消息,业务消息与协议、序列化框架无关。因此,只需要在协议栈层面约束支持的数据结构类型,防止用户使用不支持的数据结构导致编码或者解码失败。

字段类型 备注说明
Boolean 包括它的包装类型Integer
byte 包括它的包装类型Byte
int 对应于C/C++的int32
char 包括它的包装类型Character
short 对应于C/C++的int16
long 对应于C/C++的int64
float 包括它的包装类型Float
double 包括它的包装类型Double
string 对应于C/C++的String
list 支持各种List的实现
array 支持各种数组的实现
map 支持Map的嵌套和泛型
set 支持Set的嵌套和泛型

    2.5、协议消息的序列化和反序列化

        消息的序列分为两部分,消息头的序列化和消息体的序列化,两者采用的机制不一样。原因是协议栈可以由不同的序列化框架承载,表示序列化格式的字段在消息头中定义,因此我们首先需要对消息头做通用解码,获取序列化格式,然后根据类型再调用解码器对消息体做解码。如果消息头的序列化不是通用的,我们就无法对其做反序列化。

    消息头的编码规范如下:

    √ crcCode:java.nio.ByteBuffer.putInt(int value),如果采用其他缓冲区实现,必须与其等价。

    √ length:java.nio.ByteBuffer.putInt(int value),如果采用其他缓冲区实现,必须与其等价。

    √ type:java.nio.ByteBuffer.put(byte b),如果采用其他缓冲区实现,必须与其等价。

    √ priority:java.nio.ByteBuffer.put(byte b),如果采用其他缓冲区实现,必须与其等价。

    √ interfaceName:将接口名编码为byte数组,然后先编码长度,在编码内容。

    √ methodName:将方法名编码为byte数组,然后先编码长度,在编码内容。

    √ attachment:它的编码规则为——如果attachment长度为0,表示没有附件,则将长度设置为0,java.nio.ByteBuffer.
putInt(0);如果大于0,说明附件需要编码,具体编码如下:
        · 首先对附件的个数进行编码,java.nio.ByteBuffer.putInt(attachment.size())。
        · 然后对Key进行编码,先编码长度,再将它转换成byte数组之后编码内容,具体代码如下:

String key = null;
String value = null;
for(Map.Entry<String,Object> param : attachment.entrySet()){
  key = param.getKey();
  buffer.writeString(key);
  value = param.getValue();
  buffer.writeString(value);
}
  key = null;
  value = null;

    √ body的编码:通过服务发布时指定的序列化格式将其序列化为byte数组,然后调用java.nio.ByteBuffer.put(byte [] src)将其写入ByteBuffer缓冲区中。

    由于整个消息的长度必须等全部字段都编码完之后才能确认,所以最后需要更新消息头的length字段,将其重新写入ByteBuffer中。

    协议消息的反序列化(解码)规范如下:

        √ crcCode:通过java.nio.ByteBuffer.getInt()获取校验码字段,其他缓存区需要与其等价。

        √ length:通过java.nio.ByteBuffer.getInt()获取协议消息的长度,其他缓存区需要与其等价。

        √ type:通过java.nio.ByteBuffer.get()获取消息类型,其他缓存区需要与其等价。

        √ priority:通过java.nio.ByteBuffer.get()获取消息优先级,其他缓存区需要与其等价。

        √ interfaceName:通过java.nio.ByteBuffer.getInt()获取接口名称长度,然后再根据长度获取内容byte[],然后根据字节数组构造String类型的接口名。

        √ methodName:通过java.nio.ByteBuffer.getInt()获取方法名称长度,然后再根据长度获取内容byte[],然后根据字节数组构造String类型的方法名。

        √ attachment:它的解码规则为——首先创建一个新的attachment对象,调用java.nio.ByteBuffer.getInt()获取附件的长度,如果为0,说明附件为空,解码结束,继续解码消息体;如果非空,则根据长度通过for循环进行解码。

 String key = null;
 String value = null;
 for(int i = 0; i < size; i++){
   key = buffer.readString();
   value = buffer.readString();
   this.attachment.put(key,value);
 }
 key = null;
 value = null;

        √ body:根据消息类型,按照指定的编码规则,对消息体进行解码。

    2.6、链路创建

        协议栈包括服务端和客户端,对于上层应用程序而言,不需要刻意区分到底是客户端还是服务端,在分布式组网环境中,一个节点可能即是服务端也是客户端,这个依据具体的用户场景而定。

        客户端主动发起链接流程。

        考虑到安全,链路建立需要通过基于IP地址或者号段的黑白名单安全认证机制,作为样例,本协议使用基于IP地址的安全认证,如果有多个IP通过都号分隔。在实际商用项目中,安全认证机制更加严格,例如通过密钥对用户名和密码进行安全认证。

        客户端与服务端链路建立成功之后,由客户端发送握手请求消息,握手请求消息的定义如下。

            √ 服务头的type字段值为3。

            √ 可选附件个数为0。

            √ 消息体为空。

        服务端接收到客户端的握手请求之后,如果IP校验通过,返回握手成功应答消息给客户端,应用层链路建立成功。握手应答消息定义如下:

            √ 消息头的type字段值为4。

            √ 可选附件个数为0。

            √ 消息体为byte类型的结果,0:认证成功; -1:认证失败。

        链路建立成功之后,客户端和服务端就可以互相发送业务消息了。

    2.7、链路关闭

        由于采用长连接通信,在正常的业务运行期间,双方通过心跳和业务消息维持链路,任何一方都不需要主动关闭链路。

        但是在以下情况下,客户端和服务端需要关闭连接:

            1)、当对方宕机或者重启时,会主动关闭链路,另一方读取到操作系统的通知信号,得知对方的REST链路,需要关闭连接,释放自身的句柄等资源。由于采用TCP全双工通信,通信双方都需要关闭链路,释放资源。

            2)、消息读写过程中,发生了I/O异常,需要主动关闭链路。

            3)、心跳消息读写过程中发生了I/O异常,需要主动关闭链路。

            4)、心跳超时需要关闭连接。

            5)、当发生编码异常等不可恢复错误时,需要主动关闭连接。

3、可靠性设计

    分布式服务框架可能会运行在非常恶劣的网络环境中,网络超时、闪断、对方进程僵死或者处理缓慢等情况都有可能发生。为了保证在此情况下,服务能够正常运行和自动恢复,需要底层的协议栈支持高HA。

    3.1、客户端连接超时

        在传统的同步阻塞编程模式下,客户端socket发起网络连接,往往需要制定连接超时时间,这样做的目的主要有两个:

            1)、在同步阻塞I/O模式中,连接操作是同步阻塞的,如果不设置超时时间,客户端I/O线程可能会被长时间阻塞,这会导致系统可用I/O线程数减少。

            2)、业务层需要:大多数系统都会对业务流程执行时间限制,例如Web交互类的响应时间要小于3s。客户端设置连接超时时间是为了实现业务的超时。

        对于NIO的SocketChannel,在非阻塞模式下,它会直接返回连接结果,如果没有连接成功,也没有发生I/O异常,则需要将SocketChannel注册到Selector上监听连接结果。所以,异步连接的超时无法在API层面直接设置,而是需要用户自定义定时器来主动监测。

        Netty在创建NIO客户端时,支持设置连接超时参数。Netty的客户端连接超时参数与其他常用的TCP参数一起配置,使用起来非常方便,上层用户不用关心底层的超时实现机制。这既满足了用户的个性化需求,又实现了故障的分层隔离。

    3.2、客户端重连机制

        客户端通过监听器监听链路状态,如果链路中断,等待INTERVAL时间后,由客户端发起重连操作,如果重连失败,间隔INTERVAL后再次发起重连,知道重连成功。

        为了保证服务端能够有充足的时间释放句柄资源,在首次断连时客户端需要等待INTERVAL时间之后再发起重连,而不是失败后就立即重连。

        为了保证句柄资源能够及时释放,无论什么场景下的重连失败,客户端都必须保证自身的资源被及时释放,包括但不限于SocketChannel、Socket等。

        重连失败后,需要打印异常堆栈信息,方便后续的问题定位。为了防止服务端正常下线,客户端通常会设置重连次数限制,防止客户端无限重连无畏的损耗资源。

    3.3、客户端重复握手保护

        当客户端握手成功后,在链路处于正常状态下,不允许客户端重复握手,以防止客户端在异常状态下反复重连导致句柄资源耗尽。

        服务端接收到客户端的请求消息之后,首先对IP地址进行合法性校验,如果校验成功,在缓存地址中查看客户端是否已经登录,如果已经登录,则拒绝重复登录,返回错误码-1,同时关闭TCP链路,并在服务端的日志中打印握手失败的原因。

        客户端接受到握手失败的应答消息之后,关闭客户端的TCP连接,等待INTERVALUE时间之后,再次发起TCP连接,知道认证成功。

        为了防止由服务服务端到客户端对链路状态理解不一致导致客户端握手失败的问题,当服务端连续N次心跳超时之后需要主动关闭链路,清空该客户端的地址缓存信息,以保证后续该客户端可以重连成功,防止被重复登录保护机制拒绝掉。

    3.4、客户缓存重发

        无论客户端还是服务端,当发生链路中断之后,在链路恢复之前,缓存在消息队列中待发送的消息不能丢失,等链路恢复之后,重新发送这些消息,保证链路中断期间消息不能丢失。

        考虑到内存溢出的风险,建议消息队列设置上限,当达到上限之后,应该拒绝继续向该队列添加新的消息。

    3.5、心跳机制

        在凌晨等业务低谷期时段,如果发生网络闪断、连接被Hang住等网络问题时,由于没有业务消息,应用进程很难被发现。到了白天业务高峰期的时候会出现大量网络通信失败,严重的会导致一段时间进程内无法处理业务消息。为了解决这个问题,在网络空闲时采用心跳机制来检测链路的互通性,一旦发现网络故障,立即关闭链路,主动重连。

4、安全性设计

    为了保证这个集群环境的安全,内部长连接采用基于IP地址的安全认证机制,服务端对握手请求消息的IP地址进行合法性校验:如果在白名单之内,则校验通过;否则失败。

    如果需要将服务开放给第三方非信任域的消费者,需要采用更加严格的安全认证机制。例如基于密钥和AES加密的用户名+密码认证机制,也可以采用SSL/TSL安全传输。

    协议栈的安全原理如图1-2所示:

  
                                           图1-2  协议栈的安全原理

    对第三方开发的分布式服务框架的服务调用存在三种场景:

        1)、在企业内网,开放给内部其他模块调用的服务,通常不需要进行安全认证和SSL/TSL传输。

        2)、在企业内网,被外部其他模块调用的服务,往往需要利用IP黑名单,握手登录等方式进行安全认证,认证通过后双方使用普通的Socket进行通信,如果失败,拒绝客户端访问。

        3)、开放给企业外部第三方应用访问的服务,往往需要监听公网IP(通常是防火墙的IP地址),由于对第三方服务调用者的监管存在诸多困难,或者无法有效监管,这些第三方应用实际是非授信的。为了有效应对安全风险,对于敏感的服务往往需要通过SSL/TSL进行安全传输。

5、最佳实践——协议的前向兼容性

    实际工作总结:随着业务的不断发展,功能不断增强,对分布式框架的需求也越来越多,例如通过隐式传参让协议消息头携带业务消息上下文发送给服务端。

    对于分布式服务框架,功能也在不断的增加,例如支持不同的序列化方式,需要在消息头中新增一个标识序列化方式的字段。如果协议栈没有良好的可扩展性和兼容性设计,就会遇到比较棘手的问题。

    服务上线之后,来自不同业务团队的消费者会依赖某类服务提供者,假如服务提供者升级了协议栈,协议栈不支持前向兼容,消费者发送老版本的消息服务端不能识别,站在服务提供者角度,希望所有依赖此服务的消费者都升级。但是消费者可能来自公司不同的业务线、公司外的第三方合作伙伴、手机终端用户等,让所有的消费者同时全量升级显然不现实。

    因此协议栈的前向兼容性和可扩展性非常重要,在2.3节我们设计消息模型的时候,已经考虑到了协议的前向兼容性,核心设计思想有两个:

        1)、消息头第一个字段中携带协议的版本号,用来标识消息协议版本。

        2)、消息头最后一个字段是Map类型的扩展字段,用于服务框架自身或者用户扩展信息头。

    如果涉及到大的不兼容的修改,建议升级版本号;如果是新增部分特性,建议通过Map扩展的方式进行,不要升级协议版本。

总结:最后总结一下通信框架、序列化/反序列化和协议栈的关系:协议描述了分布式服务框架的通信契约,序列化和反序列化框架用于协议消息对象和二进制数组之间的相互转换,通信框架在技术上承载协议,协议要落地,需要依赖通信框架提供的基础通信能力。

猜你喜欢

转载自blog.csdn.net/zhengzhaoyang122/article/details/80927296
今日推荐