数据编码与演化

数据编码

为什么需要数据编码

  1. 应用程序一直处在变化中,这些变化可能需要修改存储的数据:新增字段或者记录类型,新的方式呈现现有数据.
  2. 数据库支持数据模式的修改,但是对于大型系统来看,进行滚动升级时会出现新旧版本的应用程序共存的情况.针对客户端的升级,只能寄希望于用户的操作.此时会出现新旧版本的应用代码和新旧版本的数据共存的场景.

由于上面的数据变化,我们需要保持:向后兼容(较新的代码可以读取旧数据)和镶嵌兼容(较旧的代码可以读取较新代码写入的数据).
通过引入数据编码,我们可以在程序演化过程中处理数据模式的变化.

数据编码方式
程序中常用的数据至少分为两种表现形式:

  1. 内存中的数据结构.
  2. 文件或者网络发送过来的一定格式的字节序列.

应用服务持久化数据或者与其他应用程序交互的时候需要将数据在这两种表现形式中转换.
从内存中数据结构转换为某种序列化字节序列的过程我们称为编码,相反的过程被称为解码.
常见的编码方式:

  1. 语言自带的序列化方式.例如:Java有java.io.Serializable,Ruby有Marshal,Python有pickle等.他们都有以下特点:
  1. 使用方便.但是与编程语言深度绑定.不利于跨语言编写的服务直接交换数据.
  2. 为了在相同的对象类型中恢复数据,解码过程需要支持实例化任何类.这会带来安全问题.
  3. 这些库通常存在多个版本,但是它们的主要目标是快速简单的编码数据,这会带来兼容性问题.
  4. 效率比较低.
  1. JSON XML 和二进制变体
    它们都是支持跨语言应用程序交互数据的.其中JSON、XML更是应用广泛.
    JSON、XML都是文本格式的,这提供了很好的可读性.同时JSON也没有XML那么冗长.这使得JSON更受开发者欢迎.
    类似JSON、XML这种文本格式的编码,有以下一些特点:
  1. 数字编码很模糊.XML中无法区分是数字还是刚好是数字的字符串.JSON中不区分是整数类型还是浮点数.这会带来如果传入的整数类型超过了一定值,javaScript将解析到一个不正确的值.
  2. XML和JSON都很好的支持了Unicode字符串,但是不支持二进制字节序列.不过我们可以通过Base64这种算法进行编码,然后当作字符串使用,只是这样做会带来数据体的膨胀和混乱.
  1. 二进制编码
    虽然JSON相对于XML已经精简过了,但是仍然占用空间较多,所以出现了很多支持JSON的二进制编码的方式,例如:MessagePack,BSON,BJSON等.
    对于这类支持JSON的二进制编码,举一个例子:
{
    "userName": "Martin",
    "favoriteNumber": 1337,
    "interests": ["daydreaming", "hacking"]
}

使用MessagePack编码以后:

字节序列:
|83| a8| 75 73 65 72 4e 61 6d 65| a6| 4d 61 72 74 69 6e| ae| 66 61 
76 6f 72 69 74 65 4e 75 6d 62 65 72| cd| 05 39 |a9| 69 6e 74 65 
72 65 73 74 73| 92| ab| 64 61 79 64 72 65 61 6d 69 6e 67 |a7| 68
61 63 6b 6e 67

分解:
83: 8 - object type 3 - 3 entries
a8: a - string type 8 - 8bytes length
75 73 65 72 4e 61 6d 65: userName
a6: a - string type 6 - 6bytes length
4d 61 72 74 69 6e: Martin
ae: a - string type e: 14bytes length
66 61 76 6f 72 69 74 65 4e 75 6d 62 65 72: favoriteNumber
cd: uint16 
05 39: 1337
a9: 9bytes length string
69 6e 74 65 72 65 73 74 73: interests
92: 2 items array
ab: 11bytes length string
64 61 79 64 72 65 61 6d 69 6e 67: daydreaming 
a7: 7bytes length string
68 61 63 6b 6e 67: hacking

可以看出使用MessagePack进行二进制编码仅占用了66字节,比JSON的编码占用空间81字节要小.
使用Thrift进行编码.首相我们需要定义一个使用接口定义语言(IDL)描述的模式.

struct Person {
1: required string userName,
2: optional i64 favoritedNumber,
3: optional list interests
}

Thrift支持三种编码方式:BinaryProtocol、CompactProtocol、DenseProtocol.但是由于DenseProtocol仅支持C++所以我们暂时不考虑它.
首先来看看使用BinaryProtocol编码上面的JSON的结果吧

序列化字节码:(59bytes)
0b |00 01 |00 00 00 06| 4d 61 72 74 69 6e| 0a| 00 02| 
00 00 00 00 00 00 05 39 | 0f | 00 03 | 0b | 00 00 00 02|
00 00 00 0b| 64 61 79 64 72 65 61 6d 69 6e 67| 00 00 00
07| 68 61 63 6b 69 6e 67| 00|
解析:
0b: type11 string
00 01: field tag =1
00 00 00 06: 6bytes string length
4d 61 72 74 69 6e: Martin
0a: type10 i64
00 02: field tag=2
00 00 00 00 00 00 05 39: 1337
0f: type15(list)
00 03: field tag = 3
0b: item type type11 string
00 00 00 02: items count 2
00 00 00 0b: length 11
64 61 79 64 72 65 61 6d 69 6e 67: daydreaming
00 00 00 07: length 7
68 61 63 6b 69 6e 67: hacking
00: end of struct

使用Thrift编码比MessagePack一个很大的区别就是使用了field tag替代了字段名.从而减少了空间占用.
使用Thrift的CompactProtocol进行编码只需要34个字节,它通过将字段类型和标签号写入单个字节中,同时可变长度整数存储整数.
对于整数1337,采用两个字节保存而不是八个.每个字节的最高位用来指示是否还有更多的字节.即,-6463被保存在单个字节中,-81928191保存在两个字节中.以此类推.

Thrift CompactProtocol编码后的字节序列:
18| 06|4d 61 72 74 69 6e| 16| f2 14| 19|28| 0b | 
64 61 79 64 72 65 61 6d 69 6e 67| 07 | 68 61 63 6b 69 6e 67 | 00|

解析:
0001|1000 => 18: field tag=1 type8(string) 06: length 6 4d 61 72 74 69 6e: Martin
0001|0110 => 16: field tag +=1 type6(i64)
1337 => 0110100|111001 从前向后扫描这个二进制序列满足7位则写入,高位放在高地址的方式=>
1|111001|0|0|0110100 => f2 14
0001|1001 => 19: field tag+=1 type9(list)
0010|1000| => 28: 2 items type8(string)
0b: length 11
64 61 79 64 72 65 61 6d 69 6e 67: daydreaming
07: length 7
68 61 63 6b 69 6e 67: hacking
00: end of struct

最后我们来看下ProtoBuffer的编码.
ProtoBuffer与Thrift一样,需要定义数据模式.

message Person {
    required string userName = 1;
    optional int64 favoriteNumber = 2;
    repeated string interests = 3;
}

然后我们来看下使用ProtoBuffer对上面的JSON编码的结果:

字节序列(33字节)
0a|06|4d 61 72 74 69 6e|10|b9 0a|1a|0b|64 61 79 64 72 65 61 6d 69 6e 67|1a |07|68 61 63 6b 69 6e 67
解析:
0000|1010 => 0a => 00001|010: 00001=>field tag=1 010=>type2(string)
06: length 6
4d 61 72 74 69 6e: Martin
0001|0000 => 01 => 00010|000 00010=>field tag=2 000=>type0(varint)
1337=>00010100|111001 高位放在高地址,使用首位标识是否有后续字节=>
|1|0111001| |0|0001010| => b9 0a
0001|1010 => 1a => 00011|010: 00011=>field tag=3 010=>type2(string)
0b: length 11
64 61 79 64 72 65 61 6d 69 6e 67: daydreaming
0001|1010 => 1a => 00011|010: 00011=>field tag=3 010=>type2(string)
07: length 7
68 61 63 6b 69 6e 67: hacking

字段标签和模式演化
Thrift和ProtoBuffer可以随意修改字段的名称,因为在编码过程中并没有使用到.但是针对修改字段标签则需要注意.

  1. 新增字段时需要为optional,并且使用从未使用过的标签号以便向前兼容.
  2. 删除字段的时候,需要保证被删除的字段所有的标签号不再被使用,这样才能支持向后兼容.

数据类型和模式演化
修改字段类型可能会导致字段的值被截取.
对于ProtoBuffer来说,使用了repeated来描述数组或者list,如果修改一个字段的描述符为repeated,则旧代码解析新数据的时候仅会读取到最后一个值.

二进制编码的优势

  1. 编码结构更为紧凑.
  2. 用来编码和解码的模式文件是有价值的.
  3. 可以检查模式修改后的兼容性问题.
  4. 可以通过代码生成的方式解决编解码问题.

数据流模式

数据从一个进程传输到另外一个进程时都需要进行数据的编解码操作.我们前面已经讨论了数据编解码的问题,下面来看看常用的进程间数据流动方式.

基于数据库的数据流
数据库作为数据在进程间流动的中介.写入数据库的进程对数据编码,读取数据库的进程对数据进行解码操作.
这种基于数据库的数据流要注意兼容性问题:

  1. 较新的进程代码可能读取到旧的写入进程写入的数据(向后兼容性)
  2. 较旧的读取进程可能读取到较新进程写入的代码(向前兼容性)

这里有一个需要注意的点:如果将数据库的值转换为应用程序中模型的对象,然后重新编码这些对象,则转换过程中将丢失未知字段的值.
在数据库中进行数据模式改变的时候,需要处理这种兼容性的问题.一种方案是将数据重写(迁移)为新模式.针对数据量不大的场景可以使用.
另外一种就是让新添加的字段支持默认值.这样不需要进行数据重写.因为旧数据被新代码读取的时候会获取到默认值,通过应用程序可以将数据修改为新模式.新数据被旧代码读取的时候会忽略掉这个字段.

基于服务的数据流:REST和RPC
对于需要通过网络进行通信的进程,有很多种通信方式.一般采用C-S的模式.
其中C端并不一定就是用户端,也可以是某一个其他服务.这种情况一般是将一个大型服务按照功能拆分为较小的服务,当一个服务需要另一个服务的某些功能或数据的时候通过请求响应的方式与另外一个服务通信.这种方法被叫做面向服务的体系架构(SOA),现在被叫做微服务架构.

SOA和微服务架构的一个关键目标是,通过服务可独立部署和演化让应用程序更容易修改和维护.

当HTTP被用作与服务通信的底层协议的时候,服务被称为Web服务.
目前有两种流行的Web服务方法:REST和SOAP
RESTT不是一种协议,而是一个基于HTTP原则的设计理念.

使用URL标识资源.
使用HTTP的功能进行缓存控制、身份验证和内容类型协商.

目前REST经常与微服务关联.根据REST原则实现的API被称为RESTful API.
对应的SOAP是一种基于XML的协议,用于发出网络请求.虽然它最常用于HTTP,但是其目的是独立于HTTP的,并避免使用大多数HTTP功能.SOAP Web服务的API使用WSDL来描述.

RPC:远程过程调用.该模型试图使向远程网络服务发出请求看起来与在同一进程中调用编程语言中的函数或者方法相同.
但是RPC与本地方法调用有以下不同点:

  • 本地方法调用是可预测的.但是RPC是不可预测的:请求或者响应可能由于网络问题而丢失,或者远端服务速度慢或者不可用.这些问题在本地方法调用中是不存在的.
  • 本地函数调用要么返回一个结果,要么抛出一个异常,或者永远不返回(死循环或者崩溃了).RPC则有可能会出现超时没有返回结果的情况.
  • 失败重试的RPC可能会导致远端服务被调用多次,因为可能因为执行成功的响应丢了导致重试.这种情况下远端服务的API需要保证幂等性.
  • 本地函数调用消耗的时间是一个可控的范围,RPC的耗时跟随网络波动.
  • 本地函数调用可以使用指针或者引用的方式高效率传递参数,RPC必须进行编解码的操作(这会带来复制的耗时)
  • 客户端和服务端可以使用不同的编程语言实现,RPC使用的编码方式可能会影响对应系统读取到的值.毕竟并不是所有的编程语言都支持同样的数据类型.

上面我们了解的数据编码方式也提供了RPC的框架:Thrift提供了RPC支持,gRPC提供了ProtoBuffer的RPC实现.这些框架更加明确了远程请求和本地调用的不同.例如有些框架提供Futures(Promises)机制来封装可能的异步操作.gRPC提供了流的方式,可以处理一段时间内一系列的请求和响应.

REST VS RPC的优势:

  1. REST更利于实验和调试(可以发送HTTP请求就可以了).RPC需要编写客户端.
  2. 支持所有的主流编程语言和平台.
  3. 提供了一个庞大的工具生态系统.

一般REST提供公共的服务,RPC用于组织内部高效的通信(因为采用了更好的编码方式).

基于消息传递的数据流
基于消息传递的方式是一种异步消息传递系统.客户端的请求以低延迟传递到另一个进程.不同的是不直接使用网络连接发送消息,而是通过一个中间件:消息队列.
与RPC相比有下面的优点:

  • 如果接收方忙,则消息队列可以当作缓冲区使用.
  • 可以防止消息丢失的问题.
  • 增加了中间层,避免了暴漏服务的IP和端口号.
  • 支持一条消息分发给多个订阅者
  • 逻辑上将发送方和接收方完全分开.
    但是有一个比不上RPC的地方:消息传递是单向的.

消息队列对于传递的消息没有任何模式要求,所以在使用的时候需要应用程序自身保证兼容性问题.

分布式Actor框架

Actor模型是用于单个进程中并发的编程模型.业务逻辑被封装在Actor中,而不是直接的处理线程.每个Actor可能包含一些本地状态(不与其他Actor共享),Actor间的通信通过发送和接收异步消息实现.

分布式Actor框架扩展了这个Actor模型.将多个节点的应用程序服务当作Actor处理,这些节点间的通信采用消息队列的方式.

你可能感兴趣的:(数据编码与演化)