技术揭秘 | 如何设计 RQData 通讯协议

2021-03-30 14:05:39 +08:00
 thinkingmind

前面我们介绍了 RQData 如何用 Asyncio 来提高服务端性能,这次我们介绍一下 RQData 的通讯协议,通过对比 RQData 的通讯协议和一些常用程序的通讯协议,看看如何设计一个合适的通讯协议。

互联网上两台计算机进行通讯的基础是网络传输协议——数据在网络上以字节流的形式从一台计算机传输到另一台计算机,如果没有双方约定好的协议,就无法将数据流重塑成我们需要的数据结构。这就像是一个家长拆了一个用积木搭成的城堡,然后把一个一个积木递给小孩子,小孩子根据记忆用这些积木把城堡再重新搭起来。上述过程就是通讯协议涉及的内容,也是我们在讨论通讯协议所需要关心的事。

背景知识

讲解通讯协议之前,我们先介绍一下计算机是如何存储数据的。

计算机最小的存储单位是比特( bit ),一个比特有两种状态——1 和 0 。计算机里大部分部件是用晶体管组成的,晶体管有导通和截止两种状态,在数字电路里分别表示 1 和 0 。八个比特是一个字节( byte ),一个字节是大多数计算机语言能处理数据的最小单位 。1 个字节能表示的无符号整型范围是 0-255 ( 2 的 8 次方),如果大于 255 就需要用 2 个字节来表示,这时 2 个字节就有了先后排序的选择:假如 2 个字节的多位数中,低位放在较小的位置上,高位放在较大的位置上,称为小端排序,反之是大端排序。比如说 65280 需要用 11111111 和 00000000 表示,如果表示为 1111111100000000 就是小端排序,0000000011111111 就是大端排序。网络传输一般都是用大端排序,所以大端排序也被称为网络序。

程序中的对象需要转变为有意义的字节序列(类似于城堡到积木的过程)才能在网络中传输,之后字节序列被接收端反转成为一个相同的对象(类似于积木到城堡的过程),对象转为字节序列的这个过程称为序列化,也被称为编码,字节序列转变回对象的过程称为反序列化,也常被称为解码。

统一解决序列化 /反序列化以及数据传输问题的工具就是通讯协议。因此,我们在关注通讯协议的时候主要关注两个方面:

接下来我们就来看看几个常用的软件是怎么处理这两方面内容的。

Redis

Redis 是一个应用非常广泛的内存数据库,是现在最流行的键值对存储数据库之一。Redis 有 5 种基本的数据结构,分别是字符串、列表、哈希表、无序集合和有序集合。接下来我们从序列化协议和传输协议两方面来分析一下 Redis 是如何传输这些数据结构的。

序列化协议

Redis 的通讯协议设计上是可阅读的,它通过 CRLF (也就是 word 中的回车符,字符串表示为 “\r\n”)来分隔不同的参数。Redis 的序列化协议是自己定制的,通过第一个字符来表明返回的类型,序列化的数据结构支持以下类型:

接下来具体介绍一下上述数据结构类型。

01 字符串

一个简单的字符串回复是 “+OK\r\n”,单行字符串仅表示状态,比如说一个键值成功返回 OK 的设置就会使用单行字符串。如果字符串复杂或含有 \r\n 字符,Redis 则会通过多行字符串回复,一个多行字符串有如下结构 “ $6\r\nfoobar\r\n ”,一个 $ 后面跟着一个数字来表示字符串的长度,数字和字符串的内容以 CRLF 分割,最后仍旧以一个 CRLF 结束。空字符串长度为 0,表示为 “$0\r\n\r\n”。此外,当多行字符串长度为 -1,且只有一个 CRLF 时,特殊地表示为 NULL (无值),格式是 “$-1\r\n”

02 错误

如果出现错误,返回的第一个字符是 -, 接着的一行内容是错误的描述, 通常用空格区分错误类型和错误描述, 比如 “-WRONGTYPE Operation against a key holding the wrong kind of value”, 表示请求的键值对应的类型不是期望的类型。

03 整形数字

返回类型是数字时,第一个字符是:,以 CRLF 结尾,以字符串表示而非二进制。比如 “:0\r\n”“:512\r\n” 都是整数回复。整形 0 和 1 也被广泛地用于表示逻辑真和逻辑假,比如说判断一个键值存不存在,就通过返回“ 0 ”表示不存在,返回“ 1 ”表示存在。

04 数组

返回类型为数组时,第一个字符是 *,紧接着是数组的长度 n,接下来就是 n 的具体内容 ——n 个上述提到的多个类型的数据。一个包含整形和字符串的 2 个元素的数组例子如下(多行结尾省略 CRLF ):

*2
:1
$6
foobar

在 Python 里,这个返回可以组合成一个列表 [1, “foobar”]

特殊地,哈希表也是通过数组返回的,按照键和值的顺序依次返回,比如下面这个 Python 简单字典会进行这样的序列化处理:

{  
 “foo”: “apple”,
 “bar”: “orange”,
}

序列化↓

*4
$3
foo
$5
apple
$3
bar
$6
origin

注:客户端根据请求的类型来区分返回的类型,协议本身并没有区分是数组还是字典的功能。

Redis 客户端发出去的命令都是数组,第一个数组元素表示命令,其余的元素表示参数,请求的一般形式如下:

*<参数数量> CR LF
$<参数 1 的字节数量> CR LF
<参数 1 的数据> CR LF
...
$<参数 N 的字节数量> CR LF
<参数 N 的数据> CR LF

如简单的 GET 请求会被序列化为

*2
$3
GET
$3
foo

回复会根据不同的命令返回不同的数据类型,客户端按照上面的格式解析返回结果。

传输协议

Redis 的连接是基于 TCP 长连接的,客户端在建立连接后会一直保持连接状态,直到主动关闭。Redis 使用“请求 —— 应答”模型,当一个请求发送出去之后客户端会等待,直到被接受到服务端响应内容之后,才会发送下一个请求。服务端也一样,接受请求并处理完成后,才会响应下一个请求。Redis 也支持管道,客户端可以把多个请求一次性发给服务端,服务端把这些请求都处理完之后,把内容一起发给客户端,所以管道技术最显著的优势是提高了 Redis 服务的性能。从宏观上来看,即使使用了管道技术,也是遵循一个请求一个响应按序执行的模式。Redis 连接之间传输的内容支持压缩,压缩算法使用了 LZF 算法,是一个开源无专利的高效流压缩算法。Redis 的认证比较简单,如果服务端设置了需要认证的话(默认关闭),客户端需要在执行其它命令前需要执行 AUTH 命令,AUTH 命令发送一个密码给服务端,服务端校验密码,通过就可以执行其它命令了。

总结

Redis 的协议是非常容易阅读的,而且实现序列化和反序列化也很简单,返回的每一个字段都有类型和长度描述,分割符也都是 CRLF 。比起 JSON,Redis 在实现反序列化的时候不需要去读每一个字段的内容来猜测它的类型,得以相对容易地做出一个高性能的解码器。Redis 通讯协议的缺点就是表达能力有点弱,只有字符串、整形数字、数组这几个通用的类型,因此在我们想要表达一个小数的时候,不得不只能使用字符串,然后通过 atof 这样的字符串转浮点数的方法来间接实现,如果是更复杂的结构,就会比较难实现。

MySQL

MySQL 是最广泛使用的开源关系型数据库管理系统,由于性能高、成本低、可靠性好等优点,已经成为最流行的开源数据库之一。MySQL 不光在中小型网站中被频繁使用,在大型网站中也逐渐成为主要的数据库应用程序。

序列化协议

MySQL 传输的数据都都使用了一个统一的包格式:4 byte 包头 + N byte 数据。

表头包含了

消息体用于存放报文的具体数据,长度由消息头中的长度值决定,长度值用 3 个 byte 表示整型数据,使用小端字节序编码。表头所能表示的最大内容是 ( 2 ^24 )- 1 Bytes 长度,也就是 16M – 1 Bytes,如果需要表示的长度大于或等于这个数,就分为两次发送,如果内容刚好是 16M -1 Bytes,则下一次包的长度就是 0 。上述操作始于一个历史原因,MySQL 设计之初没有考虑到内容有 16MB 这么大,从而没有预留字段来标志这个包的数据是不完整的,所以只能把达到或超过 16MB 这一最大长度的包的内容分开发送,直到包内容小于这个最大的长度 ,再把这些数据拼接起来组成一个完整的包,这一系列操作也给解析器带来了额外的复杂度。

数据包中的数据内容有 2 种基本数据类型:整型和字符串。

01 整型

整型值的编码有两种,定长整型和带长度编码的整型。

定长整型编码时将整型数字放到一个固定个数的字节序列中,以小端字节序来保存。有从 1byte 到 8byte 这 8 个类型,定长的整型能表示的最大值是固定的,不能扩展。

带长度编码的整型通常用 1 、3 、4 、9 个字节来表示整数,它会根据数字的大小自动选择合适的字节数量来表示,如果 1 个字节存不在,就会存在 3 个字节里,他的编码规则如下:

02 字符串

字符串会按照是不是以 NULL 结尾分为下面几种:

客户端请求时,内容的第一个字节表明请求的命令,后面的字节来表示参数。常用的数据的增删改查都是 COM_QUERY 命令,后面跟着的是一个用上面提到 E 方式编码的 SQL 语句 。比如一个简单的 select * from foo 查询语句完整的包编码后结果是:

0x00 00 11           表示数据长度是 18,SQL 字符串 17 + 1 字节命令 = 18
0x00                  序列号是 0
0x03                  COM_QUERY 命令
0x73 65 6c 65 63 74 20 2a 20 66 72 6f 6d 20 66 6f 6f      select * from foo 的二进制编码

服务端返回的包比起请求包会复杂一点,主要包括以下四类:

  1. 成功报告包
  2. 错误消息包
  3. 数据结束包
  4. 结果集合包

我们分别讨论一下。

01 成功报告包

对数据的更改这种操作服务端会向客户端返回一个 OK 包,用来表示命令成功执行。一个 OK 包的长度要大于 7,而且第一个字节必须是 0x00 。

02 错误消息包

如果命令出错了,服务端会返回一个错误包,它的数据格式如下:

第一个字节固定是 0xff, 接下来是小端字节序(表示错误码)、固定的字符 # 和 5 个字符长度(表示当前 SQL 执行的状态),最后是错误消息的字符串,也是用上面提到的 E 方式编码的。

03 数据结束包( EOF 包)

EOF 包是表示数据结束了,它有 5 个字节的内容,第一个字节固定是 0xfe,剩下 4 个字节是两个固定 2 字节长度编码的整型,用小端字节序,分别表示警告的数量和服务端的状态信息。

04 结果集合包

结果集合包是几种包的统称。一些查询语句需要返回具体的表内容,返回结果一般会包含字段信息、数据集合、表示数据终止的 EOF 。字段集合里面定义数据的具体内容,数据集合包含了对应字段集合定义好的相同数量和类型的数据,再分别解析成具体的数据。

传输协议

MySQL 的连接也是 TCP 长连接,和 Redis 不同,MySQL 的在建立连接之前有一个认证过程,其基本流程是:

  1. 连接建立时先验证用户的 IP 地址是否可以连接,可以连接时进入下一步用协商认证阶段,否则直接断开。
  2. 服务器发送握手包给客户端,包含了一些 MySQL 服务信息和一个随机字符串。
  3. 客户端用随机字符串和用户输入的密码生成一个 token 发给服务器。
  4. 服务器也用随机字符串和用户保存的密码生成一个 token,比对和上一次客户端发过来的 token 是否相同,相同返回成功报告包,否则返回错误报告包。

MySQL 在处理请求的时候也是根据请求响应模型,将请求按序一个一个处理。

MySQL 连接支持两种压缩算法,zlib 和 zstd 。zlib 是目前被使用最广泛的一种流压缩算法;而 zstd 是 Facebook 开源的一个压缩算法,在保持高压缩率的同时,压缩和解压缩速度都比 zlib 快很多。服务端和客户端在连接的第一步握手的时候会协商压缩算法。

总结

MySQL 的协议相对于 Reids 来说比较复杂,它定义了很多状态,需要客户端根据状态去使用不同的函数处理。SQL 查询语句所能表达的内容非常丰富,返回的内容会根据查询条件的不同而不同,所以解析器写起来也复杂。消息内容内容也是编码成了不可读的二进制,抓到了网络包也需要做一些解析工作才能看的懂。但是因为关系型数据库管理系统本身比较复杂,所以通讯协议必然也会跟着复杂,这是无可厚非的。

gRPC 和 Protocol Buffers

gRPC 是谷歌开源的一个简单易用支持多语言的 RPC 框架,基于 protocol buffers 序列化协议开发,支持多种语言,是使用非常广泛的一个开源通信框架。

序列化协议

Protocol buffers 定义了一个 IDL ( Interface description language 接口描述语言),有自己的生成器,根据用户定义好的内容来生成编码和解码的目标代码,程序直接导入就可以进行解析。

一个简单的 Protocol buffers 的定义内容如下:

message Foo {
  int32  foo = 1;
  string bar = 2;
 }

编码后,每个字段从逻辑上分为三个部分:

<tag> <type> [<length>] <data>

最前面的是 TAG,每一个字段后面都有一个 TAG (如上面的“1”和“2”) ,不能重复,序列化的时候会把这个 TAG 也加上,反序列化的时候就可以根据这个 TAG 来给对应的字段填充内容;

第二个是字段的类型;第三个是字段长度,长度是可选的,如果是字符串或者字节序列则会有这个字段标示长度。

Tag,type 和 length 都是用整型表示。编码整型的时候,Protocol Buffers 使用了 VarInts 算法,和 MySQL 的带长度编码的整型类似,VarInts 也用不固定长度的字节数表示整型,具体表示方式如下:

   0 ~ 2^07 - 1 0xxxxxxx
2^07 ~ 2^14 - 1 1xxxxxxx 0xxxxxxx
2^14 ~ 2^21 - 1 1xxxxxxx 1xxxxxxx 0xxxxxxx
2^21 ~ 2^28 - 1 1xxxxxxx 1xxxxxxx 1xxxxxxx 0xxxxxxx
2^28 ~ 2^35 - 1 1xxxxxxx 1xxxxxxx 1xxxxxxx 1xxxxxxx 0xxxxxxx

在解码的时候,每一个字节的第一个比特如果是 1 的话,就说明没有结束,可以继续读取下一个字节,直到遇到第一个比特是 0 的字节为止。最后把这些字节最高位删掉,组合成的数字就是真实的整型数字大小。

Protocol buffers 定义了 4 种类型的数据:

其中重复字段可以和 Redis 数组一样用来表示哈希表。和其它协议一样,Protocol Buffers 所使用的字节序也是小端字节序。

连接协议

gRPC 使用了 HTTP/2 协议来传输数据,HTTP/2 协议是 HTTP/1.1 协议的升级版,2015 年正式发表,保留了 HTTP/1.1 的大部分语义后,它额外添加了:

HTTP/2 连接维护了一个动态表,动态表最初是空的,当每一个 header 被解释并且动态表还有空间时,它就会被存储在表中,后面的请求就会使用动态表来减少头部信息的传输量。HTTP/2 也支持客户端发送多个请求,服务端响应请求无需遵循先入先出的原则,只要有一个请求准备好了就可以返回响应,所以 HTTP 请求的响应速度会快很多。HTTP/2 引入了服务端推送,即服务端向客户端发送比客户端所请求的更多的数据,这允许服务器直接提供浏览器渲染页面所需的资源,而无须浏览器在收到、解析页面后再提起一轮请求,节约了加载时间。和 HTTP/1.1 一样,HTTP/2 也是无状态的连接,所以认证信息都是在头部储存,每一个请求都认证此头部,gRPC 提供了基本的 HTTP 认证方式,用户也可以基于 HTTP 头部信息实现自己的认证方式。

总结

gRPC 没有像其它协议一样使用 TCP 直接连接,而是考虑了更高层的 HTTP 协议 。为了性能考量,它采用了更新版本的 HTTP/2 协议。gRPC 使用 Protocol Buffers 序列化协议,Protocol Buffers 和其它协议不同的地方,在于它需要事先定义好消息内容,然后通过自动代码生成工具来生成序列化和反序列化代码。这种方式少了一些灵活性,更多是基于性能方面的考量。

RQData

RQData 量化交易中最基础的数据工具,它提供了一个“傻瓜式”的数据获取方案,用户可以轻易地在本地使用 Ricequant 存储在云端的金融数据。这个获取数据的过程也需要思考:如何设计序列化协议和连接协议才能使数据的获取尽可能快而且兼具灵活性。接下来我们一一分析。

序列化协议

RQData 的数据多且杂,灵活性是必须要考虑的——一开始考虑序列化协议的时候就把 Prototol Buffers 这样的需要预先定义好结构且自动生成代码的序列化工具给否决了。最初的选择是 JSON,但是 JSON 的速度比较慢,而且支持的类型也不够丰富,所以最后我们选定了一个比较新的序列化协议 MessagePack,这个序列化协议满足我们的需求。

MessagePack 支持的类型比较多,主要有以下几个:

  1. 整型数字
  2. 空值,表示不存在
  3. 逻辑真和逻辑假
  4. 浮点数表示的小数
  5. 字符串
  6. 字节数组
  7. 列表
  8. 哈希表
  9. 扩展类型,自己定义

MessagePack 通过每一个元素字节序列的第一个字节来区分不同的类型,-128 到-1 表示系统定义的类型,对于 0 到 127,用户可以定义自己的扩展类型,但需要自己编写编码和解码代码。基本数据组织成:

+--------+---------------+-------------------+
|  类型   |长度信息(可选)|   数据内容(可选)  |
+--------+---------------+-------------------+

整型和浮点没有长度信息,不同大小的数字按照类型字段来区分具体需要多少字节存储,每一种数字类型对应的数据内容大小都是固定的,最大支持 8 个字节的数字大小,多字节按字节序大端编码。逻辑值和空值没有长度信息和数据内容,靠类型就可以推断出具体的值。其它类型的数据都既有长度信息又有数据内容。需要额外注意的是,哈希表的数据内容是长度信息的两倍,因为每个哈希表元素有两段数据,数据内容按照键和值的顺序按序编码。

传输协议

RQData 是基于 TCP 的长连接通讯协议,请求和返回协议相同,协议头如下:

--------------------------------------------------------------
    2 bytes    |1 byte |1 byte |            4 bytes              |
--------------------------------------------------------------
|   Msg Type   |   ST  |   CM  |           Body Length           |
--------------------------------------------------------------

Msg Type: 消息类型
ST: Serialization Type, 序列化方式
CM: Compression Method, 压缩方式
Body Length: 包体长度,不包含头

RQData 的协议头一共占 8 个字节,前 2 个字节是消息的类型,现共有 8 种消息类型:

  1. 握手消息,用户建立简介的客户端于服务端协商
  2. 请求消息
  3. 独立返回消息,返回仅需读取这一条就好
  4. 错误消息
  5. 流式消息开始,无内容,表明一个返回流的开始
  6. 流式消息结束,无内容,表示一个返回流的结束
  7. 普通流式消息
  8. 表结构的流式消息

前 4 种消息和前面提到的一问一答式通讯协议基本类似,后面类型的消息实现了数据的流式传输,比如在获取每日的行情数据时得以按照交易日逐日返回行情数据、客户端收到每日的数据得以解析而不必等到所有数据返回、服务端得以按日准备数据进而减轻压力。流式的内容有两种:一种是普通数据,按照正常的序列化方式返回;一种是表结构的数据,为了减少传输数据量而设计。在表结构数据流中,流的第一条内容数据是表的字段列表,后面的流数据都是表的具体内容,在反序列化的时候,按字段列表和流的内容组装成一个字典,数据传输完毕后把这些字典按序组合成一个大的列表返回,这样在数据传输的时候,就只需要传输一次字段列表,大大减少了数据的传输量。RQData 的传输协议中也包含序列化协议字段和压缩方式字段,目前支持 JSON 和 MessagePack 两种序列化方式和 zlib 和 Brotli 两种压缩方式。Brotli 是现代 HTTP 协议所推荐的压缩算法,针对文本的压缩率和压缩速度都比传统的 HTTP 压缩算法快很多。

总结

RQData 使用了 MessagePack 作为序列化协议,虽然牺牲了可读性,但是能实现高效、灵活的数据传输功能,且性能方面较 JSON 有较大提升。在设计传输协议的时候,我们充分考虑了 RQData 传输数据量大的问题,加入了区分流的头和尾的标志包,用来切割数据流来重塑数据结构。综合了以上考虑而设计的序列化协议和传输协议,使得 RQData 数据的获取快速、灵活、准确,得以为用户呈现满意的数据调取效果。

得益于此,RQData 的调取速度非常快,例如:

[调取单股票 10 年日线数据耗时 50 毫秒左右]

[调取全市场股票 10 年日线数据耗时 50 秒左右]

[调取单股票 10 年分钟线数据耗时 2.5 秒左右]

以上结果为互联网访问情况测得。而如果将 RQData 进行本地化部署,同样的情况下速度将会更快:

[调取单股票 10 年日线数据耗时 13 毫秒左右]

[调取全市场股票 10 年日线数据耗时 9 秒左右]

[调取单股票 10 年分钟线数据耗时 1.7 秒左右]

数据对比

*以上数据为米筐内部测试结果,实际结果以用户在本地网络和硬件条件等环境下的调取速度为准。

无论是否本地部署,RQData 的调取速度都是非常优秀的。

诚然,调取速度并不都取决于协议设计,RQData 的后台架构和独到的数据存储方式也是高性能的保障。同时,协议也并不都是为了高速,对不同语言的兼容性、内存的占用、带宽的要求等都是 RQData 的协议需要考量的内容。

您可以联系米筐量化王老师微信 RicequantCS 进行产品试用,在米筐官网进入“投资研究”功能体验本地化部署的访问速度。

717 次点击
所在节点    推广
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/766487

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX