从零实现RPC框架之:4协议设计

前言

一提到协议,最先想到的可能是 TCP 协议、UDP 协议等等,这些网络传输协议的实现以及应用层的HTTP协议。

其实rpc协议和http协议都属于应用层协议

可能你会问:“前面你不是说了 HTTP 协议跟 RPC 都属于应用层协议,那有了现成的 HTTP 协议,为啥不直接用,还要为 RPC 设计私有协议呢?”

这还要从 RPC 的作用说起,相对于 HTTP(1.1/1.0) 的用处,RPC 更多的是负责应用间的通信,所以性能要求相对更高。但 HTTP 协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;(当然这一点http2.0已经改为二进制流式)

还有一个更重要的原因是,HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。因此,对于要求高性能的 RPC 来说,HTTP 协议基本很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。

协议的作用

协议的作用:

  1. 协议通过定义规则、格式和语义来约定数据如何在网络间传输。一次成功的 RPC 需要通信的两端都能够按照协议约定进行网络字节流的读写和对象转换。如果两端对使用的协议不能达成一致,就会出现鸡同鸭讲,无法满足远程通信的需求。
  2. 同时 通过包的长度,解决TCP粘包和拆包的问题

如何设计协议?

在设计协议前,我们先梳理下要完成 RPC 通信的时候,在协议里面需要放哪些内容。

img

来看下 市面上的rpc框架采用的协议

RPC 协议的选择

协议是 RPC 的核心,它规范了数据在网络中的传输内容和格式。除必须的请求、响应数据外,通常还会包含额外控制数据,如单次请求的序列化方式、超时时间、压缩方式和鉴权信息等。

协议的内容包含三部分

  • 数据交换格式: 定义 RPC 的请求和响应对象在网络传输中的字节流内容,也叫作序列化方式
  • 协议结构: 定义包含字段列表和各字段语义以及不同字段的排列方式
  • 协议通过定义规则、格式和语义来约定数据如何在网络间传输。一次成功的 RPC 需要通信的两端都能够按照协议约定进行网络字节流的读写和对象转换。如果两端对使用的协议不能达成一致,就会出现鸡同鸭讲,无法满足远程通信的需求。

在这里插入图片描述

RPC 协议的设计需要考虑以下内容:

  • 通用性: 统一的二进制格式,跨语言、跨平台、多传输层协议支持
  • 扩展性: 协议增加字段、升级、支持用户扩展和附加业务元数据
  • 性能:As fast as it can be
  • 穿透性:能够被各种终端设备识别和转发:网关、代理服务器等 通用性和高性能通常无法同时达到,需要协议设计者进行一定的取舍。
HTTP/1.1

比于直接构建于 TCP 传输层的私有 RPC 协议,构建于 HTTP 之上的远程调用解决方案会有更好的通用性,如WebServices 或 REST 架构,使用 HTTP + JSON 可以说是一个事实标准的解决方案。

选择构建在 HTTP 之上,有两个最大的优势:

  • HTTP 的语义和可扩展性能很好的满足 RPC 调用需求。
  • 通用性,HTTP 协议几乎被网络上的所有设备所支持,具有很好的协议穿透性。

但也存在比较明显的问题:

  • 典型的 Request – Response 模型,一个链路上一次只能有一个等待的 Request 请求。会产生 HOL。
  • Human Readable Headers,使用更通用、更易于人类阅读的头部传输格式,但性能相当差
  • 无直接 Server Push 支持,需要使用 Polling Long-Polling 等变通模式

DUBBO

/dev-guide/images/dubbo_protocol_header.jpg

dubbo默认RPC协议是使用dubbo协议。dubbo协议分为报文头(也叫做Header)和报文体(也叫做Payload)。协议头占16个字节,协议体是可变长的,协议体是具体的请求/响应数据。

各个字段的详细介绍:
报文头:

  • Magic - Magic High & Magic Low (2字节)
    标识协议版本号,Dubbo 协议:0xdabb,该字段是一个常量值。

  • Req/Res (1 bit)
    标识是请求或响应。请求: 1; 响应: 0。

  • 2 Way (1 bit)
    仅在 Req/Res 为1(请求)时才有用,标记是否期望从服务器返回值。如果需要来自服务器的返回值,则设置为1。一般应用发送的请求都是1。

  • Event (1 bit)
    标识是否是事件消息,例如,心跳事件。如果这是一个事件,则设置为1。

  • Serialization ID (5 bit)
    标识序列化类型:比如 fastjson 的值为6。

  • Status (8 bits) 1字节
    仅在 Req/Res 为0(响应)时有用,用于标识响应的状态。

    • 20 - OK
    • 30 - CLIENT_TIMEOUT
    • 31 - SERVER_TIMEOUT
    • 40 - BAD_REQUEST
    • 50 - BAD_RESPONSE
    • 60 - SERVICE_NOT_FOUND
    • 70 - SERVICE_ERROR
    • 80 - SERVER_ERROR
    • 90 - CLIENT_ERROR
    • 100 - SERVER_THREADPOOL_EXHAUSTED_ERROR
  • Request ID (64 bits) 8字节
    标识唯一请求。类型为long。该值是一个自增值。每申请一次增加1。

  • Data Length (32 bits) 4字节
    序列化后的内容长度(标识协议体的长度),按字节计数。int类型。

报文体:

  • Variable Part

被特定的序列化类型(由序列化ID标识) 序列化后的内容,每个部分都是一个byte[]或者Byte

如果是请求包 ( Req/Res = 1),则每个部分依次为:

Dubbo version,dubbo协议版本号,比如在2.7.5版本里面,dubbo version是2.0.2
Service name,服务接口名
Service version,服务的group值
Method name,方法名
Method parameter types,参数类型
Method arguments,参数值
Attachments,附录

如果是响应包(Req/Res = 0),则每个部分依次为:
返回值类型(byte),标识从服务器端返回的值类型:
异常:RESPONSE_WITH_EXCEPTION=0
正常响应值: RESPONSE_VALUE=1
返回空值:RESPONSE_NULL_VALUE=2
带附录的异常返回值:RESPONSE_WITH_EXCEPTION_WITH_ATTACHMENTS=3
带附录的正常响应值:RESPONSE_VALUE_WITH_ATTACHMENTS=4
带附录的空值:RESPONSE_NULL_VALUE_WITH_ATTACHMENTS=5
返回值:从服务端返回的响应bytes,如果返回值类型是2或者5,该字段是空
Attachments:当返回值类型是3、4、5时,则在响应包里面添加附录信息,在2.7.5版本里面,附录值只有dubbo协议的版本号,也就是2.0.2。

从协议设计上可以看出,报文体最大不能超过2^31字节,相当于2G大小。

Dubbo协议特性:

  • 连接个数:单连接(默认)
  • 连接方式:长连接(默认)
  • 传输协议:TCP(默认)
  • 传输方式:NIO 异步传输(默认)
  • 序列化:Hessian 二进制序列化(官方文档中也有建议使用kryo)
  • 适用范围:传入传出参数数据包较小(建议小于100K),消费者比提供者个数多,单一消费者无法压满提供者,尽量不要用 dubbo 协议传输大文件或超大字符串。
  • 适用场景:常规远程服务方法调用

针对协议特性,我想到了下面几个问题,我们来看一下dubbo如何解决的。

  1. 为什么dubbo适合传入传出较小的参数数据包?当传输大包的时候会有什么影响?

在官网上也对这个问题进行了分析。官网是从网络负载上分析的,包越大,网络负载越大,每秒传输的请求就越少。
首先dubbo最大可以传输2G的数据,dubbo没有对报文压缩,如果传输大报文,造成网络传输数据量过大,可能会造成网络拥塞;默认消费端发送请求数据后等待服务端返回,大数据包造成网络传输时间长,消费端长时间等待;dubbo最大只能传输2G的数据,过大的包,dubbo无法处理;dubbo是为在微服务环境下快速响应请求的场景设计的,传输大数据包与此设计相违背。

  1. dubbo协议为什么不适合传大包?

因为dubbo协议采用 单一长链接,如果每次请求的数据包大小为500KByte,假设网络为千兆网卡,每条连接最大为7MByte,

单个服务提供者的TPS最大为:128MByte / 500KByte = 262

单个消费者调用单个服务提供者的 TPS(每秒处理事务数)最大为:7MByte / 500KByte = 14。

所以,服务端受网卡限制。不受单挑连接限制。单挑连接受带宽限制

前提:单一长链接

分为服务端和消费端进行分析:

服务端受网卡约束,(会接收多条连接(消费端),单个连接不会成为瓶颈)
1024Mbit=128MByte

单个连接为什么最大7MByte?(56Mbps)

  1. Mbps=Mbit/s即兆比特每秒(1,000,000bit/s),Million bits per second的缩写是一种传输速率单位,指每秒传输的位(比特)数量。

  2. 传输速率是指设备的的数据交换能力,也叫“带宽”,单位是Mbps(兆位/秒),目前主流的集线器带宽主要有10Mbps、54Mbps/100Mbps自适应型、100Mbps和150Mbps四种。

所以,服务端受网卡限制。不受单条连接限制。单条连接受带宽限制。

通俗讲就是服务端接收多个客户端,不受单个连接传输速率限制。千兆网卡1024Mbit(128MByte).
单条连接传输受带宽限制,如果带宽为54Mbps,那么最大每秒传输为54Mbps/8约等于7MByte。

  1. 为什么使用长单连接?

避免每次握手的时间开销。

在Dubbo 文档中也提到了单连接设计的原因:

因为服务的现状大都是服务提供者少,通常只有几台机器,而服务的消费者多,可能整个网站都在访问该服务,比如 Morgan 的提供者只有 6 台提供者,却有上百台消费者,每天有 1.5 亿次调用,如果采用常规的 hessian 服务,服务提供者很容易就被压垮,通过单一连接,保证单一消费者不会压死提供者,长连接,减少连接握手验证等,并使用异步 IO,复用线程池,防止 C10K 问题。

  1. Netty使用异步数据传输,那么dubbo在哪些位置使用了异步?

异步有很多好处:可以使用少量线程处理大量请求,避免客户端等待,减少资源占用。
对于消费端,dubbo使用异步的地方是等待服务端返回值,可以通过参数“async”设置是否异步等待。
对于服务端,Netty收到请求后,将请求交给handler,handler使用异步线程调用最终的服务。异步线程中完成下面几件事:请求报文反序列化,构建Response对象,调用过滤器,访问最终的服务,构造响应报文,响应报文序列化,将返回结果发送到消费端。

GRPC

gRPC是google开源的高性能跨语言的RPC方案。gRPC的设计目标是在任何环境下运行,支持可插拔的负载均衡,跟踪,运行状况检查和身份验证。它不仅支持数据中心内部和跨数据中心的服务调用,它也适用于分布式计算的最后一公里,将设备,移动应用程序和浏览器连接到后端服务。

  • https://grpc.io/
  • https://github.com/grpc/grpc

下面从一个真实的gRPC SayHello请求,查看它在HTTP/2上是怎样实现的。用wireshark抓包:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8QcpjcQX-1651302509573)(http://hengyunabc.github.io/img/wireshark-grpc.png)]

可以看到下面这些Header:

Header: :authority: localhost:50051
Header: :path: /helloworld.Greeter/SayHello
Header: :method: POST
Header: :scheme: http
Header: content-type: application/grpc
Header: user-agent: grpc-java-netty/1.11.0

基于HTTP/2 协议的优点

1. 标准:HTTP/2 是一个公开的标准

Google本身把这个事情想清楚了,它并没有把内部的Stubby开源,而是选择重新做。现在技术越来越开放,私有协议的空间越来越小。

2. 实践检验:HTTP/2 是一个经过实践检验的标准

HTTP/2是先有实践再有标准,这个很重要。很多不成功的标准都是先有一大堆厂商讨论出标准后有实现,导致混乱而不可用,比如CORBA。HTTP/2的前身是Google的SPDY,没有Google的实践和推动,可能都不会有HTTP/2。

3.多端支持性:HTTP/2 天然支持物联网、手机、浏览器

实际上先用上HTTP/2的也是手机和手机浏览器。移动互联网推动了HTTP/2的发展和普及。

4.多语言支持性:基于HTTP/2 多语言的实现容易

只讨论协议本身的实现,不考虑序列化。

  • 每个流行的编程语言都会有成熟的HTTP/2 Client
  • HTTP/2 Client是经过充分测试,可靠的
  • 用Client发送HTTP/2请求的难度远低于用socket发送数据包/解析数据包
5.HTTP/2支持Stream和流控

stream就是http2的一个最小的数据单元 也就是数据包 一个包分为Header frame 和 data frame

在业界,有很多支持stream的方案,比如基于websocket的,或者rsocket。但是这些方案都不是通用的。

HTTP/2里的Stream还可以设置优先级,尽管在rpc里可能用的比较少,但是一些复杂的场景可能会用到。

6.HTTP/2 安全性有保证
  • HTTP/2 天然支持SSL,当然gRPC可以跑在clear text协议(即不加密)上。
  • 很多私有协议的rpc可能自己包装了一层TLS支持,使用起来也非常复杂。开发者是否有足够的安全知识?使用者是否配置对了?运维者是否能正确理解?
  • HTTP/2 在公有网络上的传输上有保证。比如这个CRIME攻击,私有协议很难保证没有这样子的漏洞。
7.HTTP/2 鉴权成熟
  • 从HTTP/1发展起来的鉴权系统已经很成熟了,可以无缝用在HTTP/2上
  • 可以从前端到后端完全打通的鉴权,不需要做任何转换适配

比如传统的rpc dubbo,需要写一个dubbo filter,还要考虑把鉴权相关的信息通过thread local传递进去。rpc协议本身也需要支持。总之,非常复杂。实际上绝大部分公司里的rpc都是没有鉴权的,可以随便调。

基于HTTP/2 的缺点

1. rpc的元数据的传输不够高效

尽管HPAC可以压缩HTTP Header,但是对于rpc来说,确定一个函数调用,可以简化为一个int,只要两端去协商过一次,后面直接查表就可以了,不需要像HPAC那样编码解码。
可以考虑专门对gRPC做一个优化过的HTTP/2解析器,减少一些通用的处理,感觉可以提升性能。

2.HTTP/2 里一次gRPC调用需要解码两次

一次是HEADERS frame,一次是DATA frame。

gRPC选择基于HTTP/2,那么它的性能肯定不会是最顶尖的。但是对于rpc来说中庸的qps可以接受,通用和兼容性才是最重要的事情。

  • 官方的benchmark:https://grpc.io/docs/guides/benchmarking.html
  • https://github.com/hank-whu/rpc-benchmark

NettyRPC中的协议设计

设计协议需要考虑的问题:

在本次造轮子项目中设计协议时借鉴并改进了dubbo协议,可以在此dubbo基础上做一些优化或者增加一些其他的特色,我这里举几个例子:

  1. 对报文体压缩,需要在报文头增加压缩类型字段,好处是减少报文包大小,但是处理报文时增加资源消耗;
    1. 可以直接进行压缩 也可以 接受方再根据压缩方法 进行解压
    2. 暴露方可以直接
  2. Data Length、Request ID字段占了比较大的空间,但是很多时候,这两个字段的值都很小,也就是说有效数字比较少,对于这样的字段可以采用变长整数表示,数值非常小时,只需要使用一个字节来存储,数值稍微大一点可以使用 2 个字节,再大一点就是 3 个字节等等;
  3. 极端情况如果对报文压缩过后 仍然较大 可以采用分片传输 借鉴IP层分片 DF MF字段 以及 片便宜长度。

总包体加入了 解决了

 0     1     2     3     4        5     6     7     8         9          10      11     12  13  14   15 16
 +-----+-----+------+-----+--------+----+----+----+------+-----------+-------+----- --+-----+-----+-------+
 |magic code|version|       full length      |标识 |    RequestId                                         |
 +-----------------------+--------+---------------------+-----------+-----------+-----------+------------+
 |                                                                                                       |
 |                                         body                                                          |
 |                                                                                                       |
 |                                        ... ...                                                        |
 +-------------------------------------------------------------------------------------------------------+
 2B   magic code(魔法数)   1B version(版本)   4B full length(消息长度)    2Bit  messageType(消息类型)
 2bit compress(压缩类型) 4bit codec(序列化类型)    8B    requestId(请求的Id

编码代码如下:

@Slf4j
public class RpcMessageEncoder extends MessageToByteEncoder<RpcMessage> {
    private static final AtomicLong ATOMIC_LONG = new AtomicLong(0);

    @Override
    protected void encode(ChannelHandlerContext ctx, RpcMessage rpcMessage, ByteBuf out) {
        try {
            out.writeBytes(RpcConstants.MAGIC_NUMBER);
            out.writeByte(RpcConstants.VERSION);
            // leave a place to write the value of full length
            //这里挺重要的 先留着 总长度的位置
            out.writerIndex(out.writerIndex() + 4);
            byte messageType = rpcMessage.getMessageType();
            out.writeByte(messageType);
            out.writeByte(rpcMessage.getCodec());
            out.writeByte(CompressTypeEnum.GZIP.getCode());
            out.writeLong(ATOMIC_LONG.getAndIncrement());
            // build full length
            byte[] bodyBytes = null;
            //总长度 先设置为报头长度
            int fullLength = RpcConstants.HEAD_LENGTH;
            // if messageType is not heartbeat message,fullLength = head length + body length
            if (messageType != RpcConstants.HEARTBEAT_REQUEST_TYPE
                    && messageType != RpcConstants.HEARTBEAT_RESPONSE_TYPE) {
                // serialize the object
                String codecName = SerializationTypeEnum.getName(rpcMessage.getCodec());
                log.info("codec name: [{}] ", codecName);
                Serializer serializer = ExtensionLoader.getExtensionLoader(Serializer.class)
                        .getExtension(codecName);
                bodyBytes = serializer.serialize(rpcMessage.getData());
                // compress the bytes
                String compressName = CompressTypeEnum.getName(rpcMessage.getCompress());
                Compress compress = ExtensionLoader.getExtensionLoader(Compress.class)
                        .getExtension(compressName);
                bodyBytes = compress.compress(bodyBytes);
                fullLength += bodyBytes.length;
            }

            if (bodyBytes != null) {
                out.writeBytes(bodyBytes);
            }
            int writeIndex = out.writerIndex();
            out.writerIndex(writeIndex - fullLength + RpcConstants.MAGIC_NUMBER.length + 1);
            out.writeInt(fullLength);
            out.writerIndex(writeIndex);
        } catch (Exception e) {
            log.error("Encode request error!", e);
        }

    }


}

协议头已经占据 2字节

4B = 32位 最大长度 2 32次方 -

消息类型分为:

ping pong req response

0 1 2 3

参考:

https://blog.csdn.net/hengyunabc/article/details/81120904

https://grpc.io/

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值