《数据密集型应用系统设计》 - 数据编码和演化(一)

91 阅读12分钟

#sjmj 《数据密集型应用系统设计》 - 数据编码和演化(一)

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第二十四天,点击查看活动详情

前言

本章的前半部分编码框架目前在GO领域如鱼得水,并且有不少成熟的产品诞生,如果是GO工作者必然会接触,如果仅仅是了解该领域设计的一些技术架构,这一章可以说是扫盲。

本章节的后半部分讨论的RPC和SOAP,过去指的是基于WebService服务跨语言通信服务以及RPC通信协议,但是WebService这东西现在用的人越来越少,Spring cloud alibaba 的那一套微服务是当前的主流。

为什么HTTP2.0都出来了,RPC还在继续发展呢。因为HTTP2.0 刚刚发布的时候,RPC已经具备相当的发展技术也相对成熟,另外很多项目系统架构已经完全搭建,换回HTTP费力不讨好,同时RPC协议没有特别大的缺陷。应该理性对待 。

章节介绍

从历史的演化的角度来看,虽然过去出现过许多尝试替代HTTP协议的WEB通信框架,但是市场总是需要稳定成熟的架构,这些新兴通信协议最终都默默黯淡在历史的长河之中,比如现在使用的越来越少的 WebService

系统的演进除了数据结构和数据模型本身的演变之外,数据编码和数据之间的交互模式也在不断的进行演变,数据模式和格式改变的时候,通常需要应用程序的对应改变,应用系统的痛点如下:

  • 新版本的部署需要滚动升级(分阶段下线节点然后有序上线),通过这样的方式可以对于大规模升级的系统可以实现不暂停升级。
  • 客户端应用程序需要依赖用户自行进行更新,或者使用强制更新手段强制升级。

这样的应用程序调整不可避免的带来一些问题:前后兼容。

什么是前后兼容?

向后兼容:较新的代码由旧代码编写的数据。

向前兼容:比较旧的数据可以读取新编写的数据。

向后兼容不是难事,因为在原有的基础上扩展。向前兼容比较难,需要对于旧代码忽略新代码的添加。

苏日安现在主流的传输结构是使用JSON,但是在这一章节将会扩展更多的数据编码格式介绍:

  • JSON
  • XML
  • Protocol Buffer
  • Thrift
  • Avro

数据编码格式

数据表现形式无非两种:

  • 内存中数据保存对象,结构体、列表、数组、哈希表和树结构等等,传统的数据结构对于CPU高效访问优化。
  • 数据写入文件通过网络发送,必须要编码为某种字节序列,但是由于一些虚拟字节比如指针的存在所以和内存的表现形式有可能不一样。

术语问题,这里的编码其实就是指的“序列化”,但是序列化在不同的结构中意义不同,所以书中用了编码解释这一概念。

语言特定格式

通常有不少的编程语言支持把内存的对象编码为字节序列码,比如经典的Java.io.Serializable,Ruby的 Marshal,Python的 picle ,还有一些第三方库比如 Kryo

但是语言的特定格式带来下面的一些问题:

  • 编码和特定语言绑定,无法完成不同编程语言互通
  • 恢复数据的时候需要解码并且实例化对应实现类,这产生了序列化攻击问题,比如通过实例化异常对象的方式找到系统的漏洞攻击手段。
  • 简单快速编码在编程语言常常导致前后兼容问题。
  • JAVA的官方序列化极其抵消被人诟病等。

JSON、XML以及二进制

二进制编码

目前系统较为主流的形式是JSON, 而过去XML也流行过一段时间但是后来很快被更为轻便的JSON取代,JSON最早是出现在JS上的一种数据结构,但是后来被广泛采用在不同系统之间的通信格式。

XML和JSON的最大好处是使用字符串进行传输,并且JSON是JS内置的浏览器支持,具备很强的兼容性。

但是XML和JSON也暴露出不少问题:

  • 数字编码问题:JSON中无法区分数字和碰巧是数字的字符串,虽然JSON能识别出数字和字符串,但是无法区分数字的精度,也就是浮点数。
    • 针对浮点数问题,IEEE 754 双精度浮点数在JS上精度不佳。在推特中曾经有一个紧精度丢失的例子,2的53次方,使用了64位的数字去表示推文内容,为了防止这种问题使用了拆分小数位和整数位并且使用字符串代替数字的方式表示,避开了JS程序的问题。
    • JSON和XML对于文本支持较好,可阅读性很强,同时BASE64编码之后可以解除数据传输丢失的风险,但是与此同时也会带来数据大小膨胀问题
    • XML和JSON都有模式可选支持,通常情况下大部分的编程语言可以通用编解码方式,但是对于不使用这两种编码格式的则需要自己编写。
    • CSV没有模式,他只是介于二进制和文本之间的一种特殊状态,每一次数据改动都需要手动改动文件。

下面来讨论二进制编码问题。

二进制编码的优势在于数据体积小并且传输快,但是二进制真的和JSON文本的差异很大么?

在书中的案例中,经过二进制编码的数据仅仅比JSON编码格式缩小了10几个字节,是否意味着在较小文本传输的时候优化编码大小的性价比是很低的?

为了解决二进制编码的性能远不如文本JSON的问题,于是在数据编码和模式上进行了演进。

为了更好地理解二进制编码框架,我们需要了解他们的定位。

二进制编码框架定位

实际上模式框架的设计理解基本和TCP/IP协议面对的问题类似,在差异不同的应用系统之间如何完成统一格式通信,并且在不同应用系统升级之后能以最小的成本完成向前兼容

为了更加透彻的了解Thirft以及一系列数据编码框架的设计定位,我们来看看Thrift的设计思想:

Thrift软件栈分层从下向上分别为:传输层(Transport Layer)、协议层(Protocol Layer)、处理层(Processor Layer)和服务层(Server Layer)。

  • 传输层(Transport Layer):传输层负责直接从网络中读取写入数据,它定义了具体的网络传输协议;比如说TCP/IP传输等。
  • 协议层(Protocol Layer):协议层定义了数据传输格式,负责网络传输数据的序列化反序列化;比如说JSON、XML、二进制数据等。
  • 处理层(Processor Layer):处理层是由具体的IDL(接口描述语言)生成的,封装了具体的底层网络传输序列化方式,并委托给用户实现的Handler进行处理。
  • 服务层(Server Layer):整合上述组件,提供具体的网络线程/IO服务模型,形成最终的服务。

Thrift 和 Protocol Buffer

Apache Thrift 和 Protocol Buffer 基于相同原理二进制编码,而Protocol 最开始由谷歌开发, Thrift 最初由 facbook 开发 后面被Apach 引进并且成为顶级项目。

Thrift是Facebook于2007年开发的跨语言的rpc服框架,提供多语言的编译功能,并提供多种服务器工作模式;用户通过Thrift的IDL(接口定义语言)来描述接口函数及数据类型,然后通过Thrift的编译环境生成各种语言类型的接口文件,用户可以根据自己的需要采用不同的语言开发客户端代码和服务器端代码。

两者的共同点是都需要使用模式进行编码,所谓模式就是指如果通过语法来描述数据结构,需要按照指定的规范。

另外经过模式定义之后两者都可以通过代码生成器生成相关的对象代码,支持多种编程语言,应用代码生成器生成的代码可以完成对应的编码和解码操作。

在Thirft 介绍一句话可以看到它最为基本的限制: To generate the source from a thrift file run

有时候编码框架可能具备多种编码方式。比如Thrift 分为BinaryProtocol和 CompareProtocol。

实际上Thrift 还有DenseProtocol,但是因为只能支持 C++ 所以这里并没有算进去。

首先是传统的BinaryProtocol方式,最终发现需要 59个字节进行编码。

与上面的编码方式类似的是对于字段的内容进行了ASCII编码,区别是在字段名称上的编码方式存在区别,字段名会使用类似Tag的字段给字段名进行分类,这些数字主要用于模式定义。

使用 CompareProtocol ,把相同信息缩减到34个字节完成表示,主要区别是字段类型和标签号打包到单个字节当中,并且用变长整数实现。

Protocol Buffer 则只有一种编码方式,打包格式粗略看上去和 CompareProtocol 比较像,只使用了33个字节表示重复的记录。

这样的区别来自于两个模式对待重复字段的向前和向后兼容的处理方式不太一样,往下看会进行解释。

需要注意前面设置的模式当中可以标记为 requiredoptional ,这种标记对于编码没用任何影响,区别为如果 required 字段没有填充,则会抛出运行时异常。

字段标签和模式演化

了解完格式定义,接着便是编码格式的模式演化。

通常一条编码记录是一组编码字段的拼接,数据格式使用标签号+数据类型(字符串或者整数)对于编码进行注释,同时编码引用不会直接引用字段名称,但是也不能随意的更改字段标签,容易导致编码内容失效。

如果字段没有设置字段值,则编码记录中将会直接忽略

添加字段兼容

为了实现向前兼容性,字段字段名称可以随意更改,标签却不能随意更改。如果旧代码视图读取新代码的数据,如果程序视图读取新代码写入的数据,或者不能识别的标记代码,于是可以通过类型注释通知字段解析器跳过新增内容的解析。

而想要实现向后兼容性,因为新的标记号码总是可以被新代码阅读的,所以通常不会有太大问题,但是有一个细节是新增的字段不能是必填的,这有点类似给数据库新增必填字段,如果旧代码不进行改动则业务整个链路会崩溃。所以保持向后兼容性初始化部署需要塞入默认值或者直接是选填字段。

删除字段兼容

删除字段的向前向后兼容刚好相反,向前兼容通常不会有多少影响,但是向后兼容必须是删除非必填的字段,同时旧的标签号码需要永久废弃,因为使用完全不同的数据类型标签覆盖旧标签号码会导致程序出现奇怪现象。

字段标签改变

如果是字段的删减似乎问题并不会很大,使用标签在引用之间再套一层的方式可以解决这个问题。

但是如果是字段本身改变要如何处理?比如把一个32位的整数转为64位的整数,如果是新结构的代码可以通过填充 0 的方式让数值对齐,但是如果是旧代码读取到新结构的代码,显然会出现位截断的问题。

现在来看 ThriftProtocol Buffer是如何解决这个问题的。

Protocol Buffer:利用字段重复标记(repeated,表示可选之外的第三个选项),用于标记同一个字段标签总是重复的多次出现在记录当中。

通过设置可选字段为重复字段,读取旧代码的新代码可以看到多个元素的列表(前提是元素确实存在),新代码可以挑选符合的值处理。而读取新代码的字段则只允许读取列表的最后一个元素。

这种处理方案有点类似数据库的版本快照,

Thrift:处理方式是使用列表对于字段标签参数化,虽然没有灵活的多版本变化,但是列表可以进行嵌套可以有更多灵活组合。