vlambda博客
学习文章列表

Avro、Protobuf和Thrift中的模式演变

马丁-克莱普曼于2012年12月5日发表。

你有一些数据,你想存储在一个文件中或通过网络发送。你可能会发现自己经历了几个阶段的演变。

  1. 使用你的编程语言的内置序列化,例如 Java serialization, Ruby的 marshal或 Python 的 pickle. 或者你甚至可以发明你自己的格式。

  2. 然后你意识到被锁定在一种编程语言中是很糟糕的,所以你转而使用一种被广泛支持的、与语言无关的格式,如JSON(如果你喜欢像1999年那样狂欢,也可以使用XML)。

  3. 然后你决定JSON太冗长了,解析起来太慢了,你对它不区分整数和浮点感到恼火,并且认为你很喜欢二进制字符串和Unicode字符串。所以你发明了某种二进制格式,有点像JSON,但又是二进制(123456).

  4. 然后你发现人们把各种随机的字段塞进他们的对象中,使用不一致的类型,而你很想有一个模式和一些文档,非常感谢。也许你还在使用一种静态类型的编程语言,并想从模式中生成模型类。你也意识到你的二进制JSON-lookalike实际上并不那么紧凑,因为你仍然在重复存储字段名;嘿,如果你有一个模式,你可以避免存储对象的字段名,你可以节省一些字节

一旦你到了第四阶段,你的选择通常是 ThriftProtocol Buffers或 Avro。所有这三个都提供了高效的、跨语言的、使用模式的数据序列化,并为Java生成代码。

已经有很多关于它们的比较文章然而,许多文章忽略了一个乍看起来很平凡的细节,但实际上是至关重要的。如果模式发生变化会怎样?

在现实生活中,数据总是在不断变化。当你认为你已经敲定了一个模式的时候,有人会想出一个没有预料到的用例,并希望 "只是快速添加一个字段"。幸运的是,Thrift、Protobuf和Avro都支持模式演进:你可以改变模式,你可以让生产者和消费者同时使用不同版本的模式,而且都能继续工作。当你处理一个大的生产系统时,这是一个非常有价值的功能,因为它允许你在不同的时间独立地更新系统的不同组件,而不用担心兼容性问题。

这把我们带到了今天文章的主题。我想探讨一下Protocol Buffers、Avro和Thrift实际上是如何将数据编码成字节的--这也将有助于解释它们各自如何处理模式变化。每个框架的设计选择都很有趣,通过比较,我认为你可以成为一个更好的工程师(通过一点点)。

我将使用的例子是一个描述一个人的小对象。在JSON中我将这样写。

{
"userName": "Martin",
"favouriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}

这个JSON编码可以作为我们的基线。如果我去掉所有的空白,它消耗了82个字节。

Protobuf

人物对象的Protobuf模式可能看起来像这样。

message Person {
required string user_name = 1;
optional int64 favourite_number = 2;
repeated string interests = 3;
}

当我们 encode上面的数据使用这种模式时,它使用了33个字节,如下所示。

Avro、Protobuf和Thrift中的模式演变

准确地看一下二进制表示法的结构,逐个字节地看。这个人的记录只是其字段的连接。每个字段以一个字节开始,表示它的标签号(上述模式中的数字1、2、3),以及字段的类型。如果一个字段的第一个字节表明该字段是一个字符串,那么它后面是该字符串的字节数,然后是该字符串的UTF-8编码。如果第一个字节表明该字段是一个整数,那么接下来是一个可变长度的数字编码。没有数组类型,但一个标签号可以出现多次,以代表一个多值字段。

这种编码对模式的进化有影响。

  • 可选字段、必填字段和重复字段之间的编码没有区别(除了标签号可以出现的次数)。这意味着你可以将一个字段从可选字段改为重复字段,反之亦然(如果解析器期待一个可选字段,但在一条记录中多次看到相同的标签号,它就会丢弃除最后一个值以外的所有字段)。required有一个额外的验证检查,所以如果你改变它,你会有运行时错误的风险(如果消息的发送者认为它是可选的,但接收者认为它是必需的)。

  • 一个没有值的可选字段,或者一个值为零的重复字段,根本不会出现在编码数据中--带有该标签号的字段根本不存在。因此,从模式中删除这类字段是安全的。然而,你决不能在将来为另一个字段重复使用标签号,因为你可能仍然有存储的数据,这些数据在你删除的字段中使用了该标签。

  • 你可以向你的记录添加一个字段,只要给它一个新的标签号。如果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;
}

请注意,在模式中没有标签号!在模式中没有标签号。那么,它是如何工作的呢?

下面是同一个例子的数据 encoded只用了32个字节。

Avro、Protobuf和Thrift中的模式演变


字符串只是一个长度前缀,后面是UTF-8字节,但字节流中没有任何东西告诉你它是一个字符串。它也可能是一个变长的整数,或者完全是其他的东西。你能解析这个二进制数据的唯一方法是通过与模式一起阅读,而模式告诉你接下来应该期待什么类型。你需要拥有与所用数据的编写者完全相同的模式版本。如果你有错误的模式,解析器将不能对二进制数据进行首尾呼应。

那么,Avro是如何支持模式演变的呢?好吧,尽管你需要知道写入数据的确切模式(写入者的模式),但这并不一定与消费者所期望的模式(读者的模式)相同。实际上,你可以给Avro分析器提供两种不同的模式,它用 resolution rules来将数据从写模式翻译成读模式。

这对模式的进化有一些有趣的影响。

  • Avro编码没有一个指示器来说明哪个字段是下一个;它只是按照它们在模式中出现的顺序,对一个又一个字段进行编码。因为解析器没有办法知道一个字段被跳过,所以在Avro中没有可选字段这种东西。相反,如果你想撇开一个值,你可以使用一个联合类型,比如上面的union { null, long }。这被编码为一个字节,告诉解析器要使用哪种可能的联合类型,然后是值本身。通过使用null类型的Union(简单地编码为零字节),你可以让一个字段变得可有可无。

  • Union类型很强大,但在改变它们时,你必须小心。如果你想给Union添加一个类型,你首先需要用新的模式更新所有的读者,这样他们就知道该怎么做了。只有当所有的读者都被更新后,写作者才可以开始把这个新的类型放在他们生成的记录中。

  • 你可以随心所欲地重新排列记录中的字段。尽管字段是按照它们被声明的顺序进行编码的,但解析器是按照名字来匹配读写器模式中的字段的,这就是为什么在Avro中不需要标签号。

  • 因为字段是按名称匹配的,所以改变字段的名称是很棘手的。你需要首先更新数据的所有读者以使用新的字段名,同时保留旧的名称作为别名(因为名称匹配使用来自读者模式的别名)。然后,你可以更新写作者的模式以使用新的字段名。

  • 你可以在一条记录中添加一个字段,只要你给它一个默认值(例如,如果字段的类型是与null联合的,则为null)。默认值是必要的,这样当使用新模式的读者解析用旧模式写的记录时(因此缺少字段),它就可以填入默认值来代替。

  • 相反,你可以从一条记录中删除一个字段,只要它以前有一个默认值。(这是一个很好的理由,如果可能的话,让你的所有字段都有默认值。)这样,当使用模式的读者解析用模式写的记录时,它就可以返回到默认值。

这就给我们留下了一个问题,就是要知道某条记录是用什么模式写的。最好的解决方案取决于你的数据被使用的环境。

  • 在Hadoop中,你通常会有包含数百万条记录的大文件,这些记录都是用同一个模式编码的。 Object container files处理这种情况:他们只是在文件的开头包括一次模式,文件的其余部分就可以用该模式进行解码。

  • 在RPC上下文中,在每个请求和响应中发送模式的开销可能太大。但是,如果你的RPC框架使用长寿命的连接,它可以在连接开始时协商一次模式,并在许多请求中分摊开销。

  • 如果你在数据库中逐一存储记录,最终可能会出现在不同时间编写的不同模式版本,因此你必须在每条记录上注释其模式版本。如果存储模式本身的开销太大,你可以使用一个 hash的模式,或者一个连续的模式版本号。然后你需要一个 schema registry在这里,你可以为一个给定的版本号查找准确的模式定义。

一种看法是:在Protocol Buffers中,记录中的每个字段都被标记,而在Avro中,整个记录、文件或网络连接都被标记为模式版本。

乍一看,Avro的方法似乎有更大的复杂性,因为你需要付出额外的努力来分配模式。然而,我开始认为Avro的方法也有一些明显的优势。

  • 对象容器文件是很好的自我描述:文件中嵌入的作者模式包含了所有的字段名和类型,甚至还有文档字符串(如果模式的作者费心写了一些)。这意味着你可以将这些文件直接加载到交互式工具中,如 Pig等交互式工具中,而且无需任何配置就能正常工作。

  • 由于Avro模式是JSON格式,你可以在其中添加你自己的元数据,例如,描述一个字段的应用级语义。当你分发模式时,这些元数据也会自动分发。

  • 模式注册表在任何情况下都可能是一件好事,它可以作为 documentation并帮助你找到和重用数据。而且因为没有模式,你根本无法解析Avro数据,所以模式注册表可以保证是最新的。当然,你也可以建立一个protobuf模式注册表,但由于它不是操作所必需的,所以它最终将是在尽力而为的基础上。

Thrift

Thrift是一个比Avro或Protocol Buffers更大的项目,因为它不仅仅是一个数据序列化库,也是一个完整的RPC框架。它也有一些不同的文化:Avro和Protobuf标准化了一个单一的二进制编码,而Thrift embraces有各种不同的序列化格式(它称之为 "协议")。

事实上,Thrift有两种不同的JSON编码,以及不少于三种不同的二进制编码。(然而,其中一种二进制编码,DenseProtocol,是只支持C++的实现的;由于我们对跨语言的序列化感兴趣,我将专注于其他两种编码)。

所有的编码都有相同的模式定义,在Thrift IDL中。

struct Person {
1: string userName,
2: optional i64 favouriteNumber,
3: list<string> interests
}

BinaryProtocol的编码非常直接,但也相当浪费(它需要59个字节来编码我们的示例记录)。

Avro、Protobuf和Thrift中的模式演变

CompactProtocol编码在语义上是等同的,但它使用可变长度的整数和比特打包,将大小减少到34字节。

正如你所看到的,Thrift的模式演化方法与Protobuf的相同:每个字段在IDL中被手动分配一个标签,标签和字段类型被存储在二进制编码中,这使得解析器可以跳过未知字段。Thrift定义了一个明确的列表类型,而不是Protobuf的重复字段方法,但除此之外,两者非常相似。

就哲学而言,这些库是非常不同的。Thrift倾向于 "一站式服务 "的风格,给你一个完整的RPC框架和许多选择,而Protocol Buffers和Avro似乎更倾向于遵循一种 “do one thing and do it well”风格。



来源:

https://www.toutiao.com/article/7078084133943001631/?log_from=f72a76d35dc64_1648180420342

“IT大咖说”欢迎广大技术人员投稿,投稿邮箱:[email protected]





来都来了,走啥走,留个言呗~





 IT大咖说  |  关于版权 

感谢您对IT大咖说的热心支持!



  • 相关推荐


  • 推荐文章