不要将Protobuf用于遥测

2021-01-01 08:51:13

Protobuf无需介绍,但本文认为您不应该将其用于遥测。本文的基本前提是,一个好的遥测库必须轻巧,以免干扰应用程序。与其他格式不同,嵌套的Protobuf消息无法在没有大量缓冲的情况下连续写入流中。这篇文章并没有主张永远不要使用Protobuf,但是与任何现有的实现方式相比,有线格式本身进行的权衡对于轻量级消息发送者来说不太可能起作用。

几个月前,我需要熟悉Protobuf线格式以进行概念验证。深入研究线格式的动机(不仅仅是使用protobuf-java)是库的尺寸,我会需要捆绑代理:

甚至在生成自己的消息类之前,仅依靠库就增加了1.6MB和将近700个类。由于所涉及的部署是无法在运行时假设protobuf-java存在的代理,因此无法避免捆绑额外的1.6MB。由于上述代理实现了ClassLoader隔离以避免干扰应用程序类的加载,即使在运行时存在protobuf-java,这些类也需要由隔离的ClassLoader再次加载。

跟踪程序只需要生成消息,因此不会加载这669个类(不包括您自己生成的类)中的所有类,但其中的许多类都可以装入。自从在DataDog的sketches-java中实现了与库无关的Protobuf序列化以来,我有一个合理的比较点来显示在只写上下文中加载了多少个类:

@OutputTimeUnit(TimeUnit.MICROSECONDS)@BenchmarkMode(Mode.AverageTime)公共类Serialize扩展BuiltSketchState {@Benchmark公共字节[] serialize(){返回草图。序列化()。数组(); } @Benchmark公共字节[] toProto(){返回DDSketchProtoBinding。 toProto(草图)。 toByteArray(); }}

由于我提到了DataDog库并且是DataDog的雇员,因此我明确指出,本博文或此博客的其他地方提出的观点均不代表雇主的观点。

每种方法产生相同的字节,以上基准测试的目的是比较速度。一种方法依赖于一个类,该类由少于150行的代码组成,包括空白,而另一种方法则依赖于大型库。记录在案,上面的手写方法比使用protobuf-java快大约10倍,但这不是本文的重点。通过分别使用-jvmArgsPrepend" -verbose:class"参数运行单个预热迭代的基准测试,将记录加载的类,我将它们记录在单独的文件中。使用时会加载276个更多的类protobuf-java:

如果您在一个完全采用Protobuf的大型组织中工作,我不建议担心1.6MB或数百个已加载的类;这些费用会随着您使用该库提供更多功能而迅速摊销。但是,用于诊断代理的资源预算(告诉您应用程序正在做什么以及其性能如何)应该很小,而且我不确定是否可以制作protobuf-java考虑到上面概述的代理程序的隔离限制,可以适应它。

这意味着我需要编写自己的文件(如果您之前没有做过这样的事情,这比听起来容易得多),所以我不得不阅读编码方面的唯一文档。我无法获得格式化程序我是基于对本文档的初读而编写的,以产生有效的protobuf,因为我已经略读了有关嵌入式消息的部分,并且因为它包含了我永远不会想到的设计决策。当我回头再次阅读时我很惊讶地发现嵌入式消息是长度前缀的,但是长度前缀是varint编码的,这意味着在完成序列化之前,您不知道长度需要多少字节,并且它是递归的。

长度前缀在二进制格式中并不少见:BSON文档以其字节大小为前缀,这意味着子文档在写其长度之前需要递归序列化.BSON通过不压缩文档长度来简化此操作,因此您只需离开长度为4个字节,然后在弹出文档上下文时返回并填充(尽管它可能会增加数据库的很大一部分)。例如,Msgpack确实对嵌入式元素长度(例如,映射和数组)应用前缀压缩但是长度是元素计数,而不是字节数,这使得流序列化变得非常容易。

Protobuf会同时执行这两种操作,因此,如果不做相对昂贵的事情,就无法生成嵌套的Protobuf消息。您必须维护堆栈并在弹出时逐帧复制内容,或者递归地预先计算嵌套元素的序列化长度;就像其他格式一样,不可能简单地将序列化的输出顺序地写入流中。当我发现我编写的流零分配msgpack编解码器比带有嵌套消息的protobuf-java或手写Protobuf编解码器快大约6倍时,我放弃了概念证明。由于我无法删除消息中的嵌套我需要制作,我指责有线格式并继续前进。

真正了解Protobuf的人已经知道了这一点(实际上是在编码手册中写的),并且了解了其他地方从此成本中获得的收益(例如,实现部分反序列化很容易,很容易跳过消息的各个部分),但是很多人似乎不了解线格式所施加的成本模型,如果确实如此,那么Protobuf中的嵌套可能会比野外使用的少得多。

这或多或少地得出了我反对使用Protobuf进行遥测的论点:如果您发现自己生成Protobuf消息的成本很高,那么即使是第三方库也无法为您的应用程序做出错误的权衡;如果您想将遥测数据从应用程序中传输出来,并希望将对应用程序的影响降到最低,即使您实现自己的零分配,微优化编解码器,也不应选择这种格式。 Protobuf。

这是一个很好的机会,可以描述Protobuf 3的有线格式,填补Google编码文档中的一些空白,但是如果您要编写自己的文档,请阅读正式文档。

Protobuf的电汇格式非常简单:它只是一个标记的键/值对的列表。由于读者具有要引用的架构,因此模棱两可是允许的并且是有利的。Protobuf消息的逻辑结构如下,每个标签后跟与该标签关联的一些字节。

正文:: =标记值*标记的值:: =标签值标记:: = varint((field_number<< 3)| wire_type)wire_type :: = VARINT | FIXED_64 | LENGTH_DELIMITED | GROUP_BEGIN | GROUP_END | FIXED_32VARINT :: = 0FIXED_64 :: = 1LENGTH_DELIMITED :: = 2GROUP_BEGIN :: = 3(不推荐使用)GROUP_END :: = 4(不推荐使用)FIXED_32 :: = 5value :: = varint |双| length_delimited | floatlength_delimited :: = varint(N)个字节{N}

每个数据项都有一个标签,其中包含字段编号(在.proto文件中定义)和Protobuf 3中仍在使用的四种导线类型之一。该标签是通过将字段编号向左移动三位并组合而成的导线类型,然后进行varint编码,因此占用的空间更少。由于导线类型只有3位,因此只能有8种导线类型,其中2种已经浪费在组开始和结束标记上(我不知道这些背后的故事)。考虑到FIXED_32 = 5仅剩两种可能的导线类型,我想在决定添加它之前会有一些紧张的会议。

上面的术语是我自己的术语,但请注意,没有重复,重复或消息之类的术语。这是因为它们在此级别上不存在。 message只是一个长度定界符,后跟一些protobuf,之后是LENGTH_DELIMITED标签;模式具有读取原始字节所需的信息。重复字段分为两种:打包的和不打包的。打包的重复字段与消息或电线上的字符串是无法区分的。没有打包的重复字段是具有相同字段编号和每个标签中元素类型的标签值的列表,并且它们不必在消息中是连续的。顺便说一句,编写者不必重复非重复字段,并且由于无法通过导线区分这些字段,因此读者必须对非重复字段取最后一个值(这使我看到的部分反序列化器是非法的)。

FIXED_32对应于原型类型float,fixed32和sfixed32,而FIXED_64对应于double,fixed64和sfixed64。所有其他整数(包括布尔值)都是varint编码的,具有删除前导零以节省空间的基本作用。从最低有效位开始,从整数开始一次取7位,直到没有剩余位为止,除最后一个字节外的所有字节都设置了最高有效位,这使解析器可以通过查找整数来检测整数的结尾未设置MSB的字节。虽然这节省了空间,但是使得保留空间的长度尚不成问题。

图< T,U>可以被编码为重复消息,在字段位置1中使用原型类型T的键,在字段位置2中使用原型类型U的值。即,对于映射中的每个条目,使用具有字段编号的标签地图字段的x和LENGTH_DELIMITED导线类型,后跟与以下相同的protobuf:

Varint可能是Protobuf上最有趣的东西,我偶然发现了一些简单的技巧,可以比我看过的其他Java库更有效地产生它们。这就是protobuf-java的CodedOutputStream的样子(请参阅源代码)

@Override public final void writeUInt64NoTag(long value)引发IOException {if(HAS_UNSAFE_ARRAY_OPERATIONS&& spaceLeft()> = MAX_VARINT_SIZE){while(true){if(((value&〜0x7F L)== 0){UnsafeUtil 。 putByte(缓冲区,位置++,(字节)值);回报; } else {UnsafeUtil。 putByte(buffer,position ++,(byte)(((int)value& 0x7F)| 0x80));值>>> = 7; }}}其他{尝试{while(true){if((value&〜0x7F L)== 0){buffer [position ++] =(byte)value;回报; } else {缓冲区[位置++] =(字节)((((int)value& 0x7F)| 0x80);值>>> = 7; }}} catch(IndexOutOfBoundsException e){抛出新的OutOfSpaceException(String。format(" Pos:%d,limit:%d,len:%d",position,limit,1),e); }}}

忽略所有样板都与检测不安全是否可用有关,这简化为具有数据依赖性的循环:

while(true){if(((value&〜0x7F L)== 0){buffer [position ++] =(byte)value;回报; } else {缓冲区[位置++] =(字节)((((int)value& 0x7F)| 0x80);值>>> = 7; }}

在CPU绑定循环中,数据依赖关系通常很糟糕,例如,如果您有一个相当大的某种整数类型的压缩数组,或者如果您正在编码某种直方图,则实际上可以更有效地编写此代码而无需使用Unsafe,方法是将其打开到计数循环,您可以通过计算前导零的数目并除以7来完成:

Long.numberOfLeadingZeros是一个HotSpot内部函数,可以编译为单条指令-在x86上为lzcnt,在ARM上为clz-确实是非常快。实际上会为此代码发出除法指令。即使这样,也可以通过预先计算长度并查找它们来加快速度。

私有静态最终int [] VAR_INT_LENGTHS =新int [65];静态{for(int i = 0; i< = 64; ++ i){VAR_INT_LENGTHS [i] =(63-i)/ 7; }} int varIntLength(长值){返回VAR_INT_LENGTHS [长整数。 numberOfLeadingZeros(value)]; }

私有void writeVarInt(int offset,long value){int length = varIntLength(value); for(int i = 0; i< length; ++ i){缓冲区[offset + i] =((字节)((value& 0x7F)| 0x80));值>>> = 7; }缓冲区[偏移+ i] =(字节)值; }

我发现对于短varint(标签通常是非常短的varint),其性能类似。但是对于较大的数字,计数循环的性能要好得多。

Protobuf的优势在于其接口定义语言,该语言使不同团队拥有的组件之间的通信变得容易,但是它并不是为提高性能而设计的。生成的Java代码通常还可以,如果有点肿,您可能会发现它可以分配很多,但是必须这样做,因为有线格式。如果延迟时间短或开销低的用例,Protobuf可能不是正确的选择。如果声明接口和生成兼容服务和客户端的能力比性能高,则您可以选择Protobuf。可以通过消除不需要的嵌套来提高性能。我真的不认为Protobuf是遥测的正确选择,因为完美的遥测不会有任何开销,这是不可能的,但是应该采用有利于读者而不是作家的有线格式避免将成本移出主机应用程序。