当你想要将一些数据存储在文件中或想要通过网络发送时,你经历了一下几个演化阶段:
(1)使用编程语言的内置序列化,如Java序列化、Ruby的marshal,或Python的pickle。
(2)然而,你意识到困在一种编程语言中是很糟糕的,所以转而使用一种广泛支持的、与语言无关的格式,比如Json。
(3)然而,你发现JSON太过冗余,解析太慢,不能区分整数和浮点数,并且你认为自己非常喜欢二进制字符串和Unicode字符串。
(4)然后你会发现人们使用不一致的类型将各种各样的随机字段填充到他们的对象中,这时你非常需要一个schema以及一些documentation。也许你还在使用静态类型的语言,并从schema生成model类。你还会意识到,与JSON相似的二进制文件实际上不是那么紧凑,因为你在一遍又一遍地存储字段名。如果你有一个schema,你可以避免存储对象的字段名,这样可以节省更多字节。
如果你到了第四阶段,你的选择一般会使Thrift,Protocol Buffers或Avro。这三种方法都为Java人员提供了高效的、跨语言的数据序列化(使用schema)和代码生成。
如果schema发生了变化,会发生什么?
现实生活中,数据总是不断变化的。比如添加一个字段。幸运的是,Thrift,Protobuf和Avro都支持模式演化(schema evolution):你可以更改schema,生产者和消费者可以同时使用schema的不同版本,且一切都可以继续工作。在处理大型生产系统时,这是一个非常有价值的特性,因为它允许您在不同的时间独立地更新系统的不同组件,而不用担心兼容性。
这就引出了今天的话题。我想探讨Protocol Buffers、Avro和Thrift如何将数据编码为字节——这有助于解释它们如何处理schema更改。
我将使用的示例是一个描述人的对象。JSON格式如下:
{
"userName": "Martin",
"favouriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}
这个JSON编码可以作为我们的基线。如果我删除所有的空白,它将消耗82字节。
Protocol Buffers
Protocol Buffers的schema如下所示:
message Person {
required string user_name = 1;
optional int64 favourite_number = 2;
repeated string interests = 3;
}
当我们使用这个模式对上面的数据进行编码时,它使用了33个字节,如下所示:
image.png
看看二进制表示是如何一个字节一个字节地结构化的。person记录只是其字段的组合。每个字段以一个字节开始,字节指示其标记号(上面schema中的1,2,3)和字段类型。如果一个字段的第一个字节表明该字段是一个字符串,则后面跟着字符串的字节数,然后是字符串的UTF-8编码。没有数组类型,但是标记号可以多次出现以表示多值字段。
这种编码对模式演化有如下影响:
(1)optional字段、required字段和repeated字段间的编码没有区别(除了标签号可以出现的次数)。这意味着可以将字段从可选更改为重复,反之亦然(如果解析器希望看到一个可选字段,但是在一条记录中多次看到相同的标记号,那么它将丢弃除最后一个值之外的所有标记)。required字段有一个额外的验证检查,因此如果更改它,将面临运行时错误的风险(如果消息的发送方认为它是可选的,但接收方认为它是必需的)。
(2)没有值的optional字段或零值的repeated字段根本不会出现在编码的数据中——带有该标记号的字段根本不存在。因此,从模式中删除这类字段是安全的。但是,永远不能在将来将标记号用于另一个字段,因为可能仍然存储了将该标记用于已删除字段的数据。
(3)可以将字段添加到记录中,只要它有一个新的标记号。如果Protobuf解析器看到模式版本中没有定义的标记号,则无法知道该字段的名称。但是它大致知道它是什么类型,因为字段的第一个字节包含一个3位类型代码。这意味着即使解析器不能准确地解释字段,它也可以计算出需要跳过多少字节才能找到记录中的下一个字段。
这种使用标记号表示每个字段的方法既简单又有效。但我们马上就会看到,这不是唯一的方法。
Avro
Avro模式可以用两种方式编写,一种是JSON格式:
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favouriteNumber", "type": ["null", "long"]},
{"name": "interests", "type": {"type": "array", "items": "string"}}
]
}
或IDL格式:
record Person {
string userName;
union { null, long } favouriteNumber;
array<string> interests;
}
注意,schema中没有标记号!那么它是如何工作的呢?
下面是一个简单的例子,数据编码后的大小为32字节:
字符串只是长度前缀后面跟着UTF-8字节,但是字节流中没有任何东西告诉你这是一个字符串。它也可以是一个可变长度的整数,或者完全是另一个整数。解析这个二进制数据的惟一方法是在schema旁边读取它,模式会告诉您接下来要使用的类型。你需要拥有与数据产生者完全相同的schema版本。如果你使用了错误的schema,解析器将无法理解二进制数据。
Avro如何支持模式演化?虽然需要知道数据所使用的确切模式(生产者的模式),但它不必与使用者期望的模式(读者的模式)相同。实际上,您可以向Avro解析器提供两个不同的模式,它使用解析规则将数据从writer模式转换为reader模式。
这对模式演化有一些有趣的影响:
(1)Avro编码没有指示符表明下一个字段是哪个;它只是按照字段在模式中出现的顺序,对一个字段接着一个字段进行编码。由于解析器无法知道某个字段已被跳过,所以在Avro中不存在可选字段。相反,如果您想省去一个值,可以使用union类型,如上面的union {null, long}。通过使用null类型(它被简单地编码为零字节)创建union,可以实现可选字段。
(2)Union类型非常强大,但是在更改它们时必须小心。如果希望向union中添加类型,首先需要用新模式更新所有读取端,以便它们知道将会发生什么。只有在更新了所有读取器之后,编写器才可能开始将这种新类型放入它们生成的记录中。
(3)您可以按照自己的意愿对记录中的字段进行重新排序。尽管字段是按照声明的顺序编码的,但是解析器根据名称匹配读写schema中的字段,这就是为什么Avro中不需要标记号。
(4)因为字段是由名称匹配的,所以更改字段名称是很棘手的。首先需要更新数据的所有读取端,以使用新的字段名,同时将旧字段名保留为别名。然后可以更新写入端的schema以使用新字段名。
(5)可以在记录中添加新字段,但是需要给它一个默认值(如果字段的类型是有null的union,则为null)。默认值是必要的,这样当使用新模式的读取端解析使用旧模式编写的记录时,会将其填充为null。
(6)相反,如果字段以前具有默认值,则可以从记录中删除该字段(如果可能的话,给所有字段一个默认值)。这样,当使用旧schema的读取端解析用新schema编写的记录时,它就可以填充为默认值。
这就给我们留下了一个问题,即如何知道给定记录的特定schema。最好的解决方案取决于数据使用的环境:
(1)在Hadoop中,您通常拥有包含数百万条记录的大型文件,这些记录都使用相同的模式进行编码。Object container files处理这种情况:它们只在文件的开头包含模schema一次,文件的其余部分可以使用该schema进行解码。
(2)在RPC上下文中,为每个请求和响应发送schema的开销可能太大。但是如果你的RPC框架使用长连接,可以在连接开始时协商schema,将该开销分摊到许多请求上。
(3)如果逐个地在数据库中存储记录,可能会得到在不同时间编写的不同schema版本,因此必须使用每个记录的schema版本对其进行注释。如果存储模式本身的开销太大,则可以使用模式的散列或顺序模式版本号。然后需要一个schema registry,在该注册表中可以查找给定版本号的准确模式定义。
可以这么看:在Protocol Buffers中,记录中的每个字段都被标记,而在Avro中,整个记录,文件或网络连接都被标记为schema版本。
乍一看,Avro的方法似乎更加复杂,因为您需要进行额外的工作来分发schema。然而,我开始认为Avro的方法也有一些明显的优势:
(1)Object container files有非常好的自描述性:嵌入文件中的writer schema包含所有字段名和类型,甚至文档字符串。这意味着您可以直接加载这些文件到交互式工具如Pig,不需要任何配置。
(2)由于Avro模式是JSON,您可以向其中添加自己的元数据,当分发schema时,元数据也会自动被分发。
(3)在任何情况下,schema registry都可能是一件好事,它可以作为文档,帮助您查找和重用数据。因为没有schema就无法解析Avro数据,所以schema registry保证是最新的。
Thrift
thrift是一个比Avro或Protocol buffer大得多的项目,因为它不仅是一个数据序列化库,而且是一整个RPC框架。虽然Avro和Protobuf标准化了单个二进制编码,但是Thrift包含了各种不同的序列化格式(它称之为“协议”)。
事实上,Thrift有两个不同的JSON编码,而且不少于三个不同的二进制编码。
所有编码在Thrift IDL共享相同的模式定义:
struct Person {
1: string userName,
2: optional i64 favouriteNumber,
3: list<string> interests
}
BinaryProtocol encoding非常简单,但也相当浪费(编码我们的示例记录需要59字节):
image.png
CompactProtocol编码在语义上是等效的,但是使用可变长度的整数和位填充来将大小减少到34字节:
image.png
如你所见,Thrift对于模式进化的方法和Protobuf的方法是一样的:每个字段在IDL中手动分配一个标记,标记和字段类型存储在二进制编码中,这使得解析器可以跳过未知字段。
网友评论