DDIA-4-数据编码与演化

应用程序在根据需求不断的变化时,往往会对其存储的数据有更改的情况。

当数据格式或者模式发生变化的时候,经常需要对应用程序的代码修改。对于一个大型程序,这并非容易。

  1. 服务器应用需要滚动升级。这样部署新版本的时候,不需要停止服务。
  2. 客户端升级,只能寄希望于用户。很有可能用户永远不会升级。

升级中需要考虑的亮点:

  1. 向前兼容。新代码读老数据。(容易)
  2. 向后兼容。老代码读新数据。(难)

数据编码格式

程序中的数据,至少有两种不同的表示形式

  1. 内存中。
  2. 写入文件或者发送到网络中。

两种形式之间的转换,成为序列化Serialize和反序列化Deserialize。或者编码Encode,解码Decode。

语言特定的格式

比如Java中java.io.Serializable接口;Ruby的Marshal;Python的pickle等。

这类编码的问题:

  1. 编码和语言绑定在一起,无法跨语言使用。
  2. 安全问题。远程执行任意代码。可以恢复成任意类,意味着Decode的过程中需要有能力创建任意的类。
  3. 向前向后兼容问题。
  4. 效率低。比如Java内置序列化。

JSON,XML与它们的二进制遍体

XML被批评主要在于其冗长和不必要的复杂。JSON受欢迎主要是在Web浏览器中的原生支持。CSV格式简单,功能弱一些。

它们都是文本格式的,可读性强。但也有问题

  1. 数字编码有很多模糊的地方。XML和CSV中无法区分数字和数字组成的字符串;JSON不区分整数和浮点数,而且不指定精度。
  2. 对Unicode字符串支持好,但是不支持二进制数据。所以人们使用Base64将二进制数据编码成String来传递。
  3. XML和JSON都有可选的模式支持。数据的正确性解释,取决于模式中的信息,
  4. CSV没有任何模式,需要程序解释。

二进制编码

更紧凑更快的解析格式。可以节省空间和时间。在TB级别的数据时尤其关键。

MessagePack

最最基础的二进制化JSON方式

1
2
3
4
5
{
"userName": "Martin",
"favoriteNumber: 1337,
"interests": ["daydreaming", "hacking"]
}

上图的二进制的结构解读:

  1. 第一个字节0x83,表示接下来是包含三个fields的对象(第四位0x03,高四位0x80)
  2. 第二字节0xa8,表示接下来的字符串,长度为八个字节
  3. 再往下的八个字节时ASCII的字段,userName
  4. 在接下来7个字节前缀0xa6表示后面有六个字节,Martin

结论:
整体编码占用66个字节,比原始的JSON编码(81个)少一些,但是不明显。

下面的方案中只用32个字节就可以完成同样记录的二进制化。

Thrift与Protocol Buffers

它们都是基于相同原理的二进制编码方式。都需要使用模式(Schema)来编码任意数据。

  • 使用Thrift的接口定义语言(IDL)来描述模式:

    1
    2
    3
    4
    5
    struct Person {
    1: required string userName,
    2: optional i64 favoriteNumber,
    3: optional list<string> interests
    }
  • 使用Protocol Buffers的等效模式定义看起来非常相似:

    1
    2
    3
    4
        required string user_name       = 1;
    optional int64 favorite_number = 2;
    repeated string interests = 3;
    }

Thrift和Protocol Buffers都有各自的代码生成工具,并支持各种编程语言。应用程序可以直接使用生成的代码完成Encode,Decode的工作。

Thrift的BinaryProtocal与CompactProtocol

  • BinaryProtocal

    需要59个字节。

  1. 每个字段有一个类型注视(是字符串,整数,列表等),并可以指定长度(字符串长度,列表的count)
  2. 与MessagePack最大的区别是没有字段名。
  • CompactProtocol

    只需要34字节。

  1. 通过将字段类型和标签号打包在一个字节中。
  2. 使用可变长度整数。 1337,不用一个字节中的全部8位都表示数值。使用两个字节编码,每个字节的最高位表示是否还有更多字节。
  3. 可变长度整数意味着,-64到63的数字可以用一个字节表示,-8192到8191之间用两个字节。

Protocol Buffer编码

只有一种编码模式。与CompactProtocol很类似。只用33个字节。

不同之处:

  1. 表示字段位置和类型的一个压缩的字节中的位数分配不同
  2. 表示数字的填充方式不同。例如1337的表示一个是最左,一个是最右。
  3. 表示列表的方式不同,Thrift是有列表type,而protocol buffer是用重复的field tag来表示列表或者数组
  4. 没有end of struct

一个细节,optional字段对于编码encode的结果没有任何影响(二进制中不会体现一个field是optional的)。optional的体现在于在Runtime 时后可以做检查,捕获错误。

字段标签和模式演化

如何保证修改模型时候,既保证向前又保证向后兼容呢?

可以根据之前的例子看出,每个字段的标签号码(1,2,3等),与它的类型整合在一个字节中。由此看出field tag对编码数据的含义至关重要。

  1. 不能随便改字段的标签,会导致现有的编码无效。保证这个原则,实现向后兼容。
  2. 可以添加新的字段,只需要用一个新的标签。如果老代码读到带有新标签的数据,那么会忽略。这样可以向前兼容。
  3. 新增字段不可以是required字段,或者没有默认值的字段,向后兼容。违反的结果,会让老数据无法被新代码读。
  4. 删除字段只可以删除可选字段,老代码读新数据时才不会出检查问题,向前兼容。

数据类型和模式演化

改变数据类型会如何?可能是可以做的,但是风险在于丢失精度或者数据截断。

  1. 例如一个32位整数变成一个64位的整数。新代码可以读老数据,因为可以用0填充缺失的位。如果是老代码读64位的数据,使用32位int存,则会截断。
  2. Protocol Buffer中没有数组或者列表。所以如果把单值的类型变成列表类型的,老代码读新数据,只会留下最后一个值。而新代码没什么问题。

Avro

另一种二进制编码格式。由于Thrift不适合Hadoop的用例,因此Avro在2009年作为Hadoop的子项目启动。

Avro也用模式来制定数据结构的编码。它有两种语言:一种是Avro IDL,用于人工编辑,一种是基于JSON的易于机器读。

基于Avro IDL的模式的例子:

1
2
3
4
5
record Person {
string userName;
union { null, long } favoriteNumber = null;
array<string> interests;
}

其等价JSON格式如下:

1
2
3
4
5
6
7
8
9
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
{"name": "interests", "type": {"type": "array", "items": "string"}
]
}

主要区别:

  1. 模式中没有标签号,是目前见过的所有编码中最紧凑的。
  2. 字节序列中也没有类型信息。只是连在一起的一些列值组成。字符串是长度加UTF-8的字节流,整数使用可变长度编码(与CompactProtocol相同)。但是并没有任何信息表示这些field是什么类型。

如何解析?
需要预先读区模式信息的数据,然后按照模式的顺序,遍历这些字段。

然后直接用模式中的字段信息来决定每个field到底是什么类型。

这意味着,读取数据的代码,必须使用当时写入数据是使用的模式,才可以正确还原数据。如果中间有任何不匹配,都无法解析。

Avro中的写模式和读模式

  • 写模式。encode时,使用的模式,可以将这个模式编译在应用中。
  • 读模式。decode时,应用代码依赖的模式,这个模式可能是在应用程序build过程中基于模式语言动态生成的。

Avro关键思想 ,写模式和读模式不一定需要完全一样,只需要兼容。当数据被Decode的时候,Avro的 Library通过对比查看写模式和读模式之间的差异,把数据从写模式转换成读模式,然后继续decode。Avro规范定义了这种解决方法的工作原理。

假如写模式和读模式的字段顺序不同,也没有关系,如果读的过程中遇到了只有在写模式中出现的字段,那可以忽略。如果在一个字段只在读模式中有,那么可以填充默认值。

Avro模式演化

  1. 只能添加或删除有默认值的字段。
  2. 如果添加一个没有默认值的字段,破坏了向后兼容性
  3. 如果删除一个没有默认值的字段,破坏了向前兼容性。
  4. null值的处理。

关键问题,写模式是什么?读模式如何直到某一个数据是用那个写模式编码的?

这个问题要取决于Avro使用的上下文:

  1. 有很多记录的大文件。
    Avro常见场景。尤其是Hadoop的上下文中。上百万的数据,都是用相同的模式编码。这种情况下,该文件的写入模式可以嵌入到文件的开头。Avro可以制定一个文件格式来做到这一点。
  2. 具有单独写入记录的数据库
    数据库中的记录,可能是在不同的时间点写入的。每个时间点可能使用不同的写入模式。最简单的发难是在每个编码记录的开始处,包含一个版本号,制定着写入模式的版本。在数据库的一个地方存储所有的写入模式列表。这样读取时可以直到数据的写入模式。Espresso就是这样工作的。
  3. 通过网络连接发送记录
    连接双方可以在建立连接时,协商模式的版本,然后在这个连接会话中使用这个模式。这也是Avro RPC的协议原理。

动态生成的模式

Avro的一个重要优点就是不包含任何标签号。

不包含标签号是好理由: 动态生成模式更友好。

关系型数据库,使用二进制方式,把内容转存到一个文件的例子。

  1. 根据关系模型,生成Avro模式。并使用这个模式编码,把数据库内容倒入Avro对象容器文件中。列名对应Avro的field
  2. 如果数据库的关系模型发生变化,则可以更新Avro的模式,使用新的Avro导出数据。不需要关注模式的改变,字段是通过名字来表识的,所以更新后的写模式仍然可以和老的读模式匹配。

相比之下,Thrift和Protocol Buffer都需要手动分配新的标签。而且还需要完成从数据库列名到新标签的映射。

代码生成和动态类型语言

  1. 静态类型语言
    Thrift和Protocol Buffer依赖于代码生成。定义了模式之后,可以跨语言使用模式。对于Java,C++等语言非常有用。可以转换成内存结构,并且支持IDE的类型检查。
  2. 动态语言(Javascript,Ruby,Python),因为没有编译时检查,代码生成没有什么意义。
  3. Avro为静态语言也提供了代码生成的工具,但是它也可以在在不生成代码的情况下使用。如果是一个嵌入了writer模式的对象容器文件,可以简单的使用Avro库打开,并用和JSON文件一样的方式查看。因为文件是字描述的。

模式的优点

  1. 模式语言简单。原理简单,使用更简单,广泛的编程语言支持。
  2. 比JSON,XML,CSV,更紧凑
  3. 模式有文档价值,不需要额外的手工维护的文档

数据流模式

数据库

向数据库中存储内容,就是在给未来的自己发送消息。

这种情况下,向后兼容是非常重要的。否则新代码无法读取存在数据库中以前写入的数据。

需要注意的一点是,老代码读区新代码写入的数据时,更新后又写会数据库,要当心不要丢失新数据格式的原来的信息。

将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。

基于服务的数据流:REST与RPC

Web的方式,基于HTTP。

  1. 使用客户端(浏览器,移动应用,PC应用等)访问服务器数据。
  2. 使用服务器访问另一个服务器的Web服务。这样的应用构建方式最近叫做微服务。

微服务的关键设计目标是,通过使服务可独立部署和演化,让应用程序更易于更改和维护。这样每一个团队可以能够经常发布新的版本,而不必与其他团队协调。因此服务器和客户端之间的数据编码必须在不同版本的API之间兼容。

REST使一种HTTP服务的设计理念。SOAP基于XML,基于Web服务时,API被称为WSDL,支持代码生成。SOAP严重依赖工具,代码生成和IDE支持,与SOAP集成的成本很高。

RESTful的API更简单,格式如OpenAPI,Swagger。
由于互联网上存在广泛的安全威胁,REST的安全生态系统非常强大,从防火墙到OAUTH(身份验证/授权)

远程过程调用RPC的问题

看起来像是在使用本地方法。但是有缺陷需要解决:

  1. 网络请求不可预测。速度方面,网络失败,需要准备重试的逻辑。
  2. 可能超时,没有结果。无法知道发生了什么。
  3. 重试的时候,需要保证调用的方法有幂等性保证。
  4. 序列化时的问题,效率方面,编程语言方面的限制。

RPC发展

这种新一代的RPC框架更加明确的是,远程请求与本地函数调用不同。例如,Finagle和Rest.li 使用futures(promises)来封装可能失败的异步操作。Futures还可以简化需要并行发出多项服务的情况,并将其结果合并。 gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还包括一系列的请求和响应。

使用二进制编码格式的自定义RPC协议可以实现比通用的JSON over REST更好的性能。但是,RESTful API还有其他一些显着的优点:对于实验和调试(只需使用Web浏览器或命令行工具curl,无需任何代码生成或软件安装即可向其请求),它是受支持的所有的主流编程语言和平台,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。由于这些原因,REST似乎是公共API的主要风格。 RPC框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。

RPC的数据编码和演化

假定所有的服务器都会先更新,其次是所有的客户端。只需要在请求上具有向后兼容性,并且对响应具有前向兼容性。

RPC方案中的向前向后兼容的解决方法,可他们在数据编码中使用的方法也有关。

异步消息传递,消息代理

中间件,可以低延迟的响应web请求。

优点:

  1. 如果接收方不可用,可以当作缓冲区,暂存消息,等恢复之后一起消费,实现HA。
  2. 可以自动重新发送消息到崩溃进程。防止丢消息。
  3. 结偶发送方和接收方,双方不需要知道对方的IP
  4. 支持广播,一条消息被多个人接收
  5. 子系统之间结偶

可以使用任何编码方式。只要兼容。

分布式Actor框架

并发编程模型。逻辑封装在Actor中。不直接操作线程。

由于每个Actor一次只能处理一条消息,因此不需要担心线程,每个Actor可以由框架独立调度。

分布式Actor框架中,编程模型被用来跨多节点进行scaling。位于不同节点之间的通讯可以使用encode decode的方式进行网络数据传输。

分布式Actor框架的实质是将消息代理(MQ等)和Actor编程模型集成到一个框架中。

三种流行的分布式Actor框架对于消息encoding的方式:

  • 默认情况下,Akka使用Java的内置序列化,不提供前向或后向兼容性。 但是,你可以用类似Protocol buffer的东西替代它,从而获得滚动升级的能力。
  • Orleans 默认使用不支持滚动升级部署的自定义数据编码格式; 要部署新版本的应用程序,您需要设置一个新的群集,将流量从旧群集迁移到新群集,然后关闭旧群集。 像Akka一样,可以使用自定义序列化插件。
  • 在Erlang OTP中,对记录模式进行更改是非常困难的(尽管系统具有许多为高可用性设计的功能)。 滚动升级是可能的,但需要仔细计划。 一个新的实验性的maps数据类型(2014年在Erlang R17中引入的类似于JSON的结构)可能使得这个数据类型在未来更容易。