Java序列化与FlatBuffers
#java #serialization #flatbuffers

previous post中,我使用JSON作为基线分析了协议缓冲区格式。在这篇文章中,我将分析FlatFuffers并将其与先前研究的格式进行比较。

FlatBuffers也在2014年在Apache 2.0许可下在Google内部创建。它的开发是为了满足视频游戏和移动应用程序世界内的特定需求,其中资源受到更大的限制。

喜欢协议缓冲区,它依赖于具有类似语法的数据格式,并生成人类可读的二进制内容。它具有对multiple languages的支持,并允许添加和贬低字段而不会破坏兼容性。

flatbuffers的主要区别在于它实现了零拷贝 delelialization:它不需要创建对象或保留新的内存区域来解析信息,因为它始终与二进制信息中的信息一起使用在内存或磁盘区域内。 代表要求信息的对象不包含该信息,但是知道如何在其 get 方法称为 的情况下解决该值。

在Java的特定情况下,这不是严格正确的,因为对于字符串,您必须实例化其内部结构所需的char[]。但是,只有在调用字符串类型属性的getter时才有必要。 只有被访问的信息才能进行。


IDL和代码生成

带有架构的文件可以是this one

namespace com.jerolba.xbuffers.flat;

enum OrganizationType : byte { FOO, BAR, BAZ }

table Attribute {
    id: string;
    quantity: byte; 
    amount: byte;
    size: short;
    percent: double;
    active: bool;
}

table Organization {
    name: string;
    category: string;
    type: OrganizationType;
    country: string;
    attributes: [Attribute];
}

table Organizations {
    organizations: [Organization];
}

root_type Organizations;

要生成所有Java类,您需要安装编译器flatc并使用一些参数引用IDL文件的位置以及生成文件的目标路径:

 flatc -j -o /opt/src/main/java/  /opt/src/main/resources/organizations.fbs

与协议缓冲区一样,我更喜欢直接使用Docker与准备执行命令的图像:

docker run --rm -v $(pwd)/src:/opt/src neomantra/flatbuffers flatc -j -o /opt/src/main/java/  /opt/src/main/resources/organizations.fbs

序列化

这是Flatbuffers最糟糕的部分:它很复杂,而且不是自动的。序列化操作需要手动编码过程,您可以逐步描述如何填充二进制缓冲区。

当您将数据结构的元素添加到缓冲区中时,它会返回偏移量或 pointer ,这是包含它们的数据结构中用作参考的值。全部以递归方式。

如果我们将数据结构视为树,则必须做 a postorder traversal

该过程非常脆弱,很容易犯错,因此有必要用单位测试覆盖此部分

从Pojos开始序列化信息所需的代码看起来像this

var organizations = dataFactory.getOrganizations(400_000)
FlatBufferBuilder builder = new FlatBufferBuilder();

int[] orgsArr = new int[organizations.size()];
int contOrgs = 0;
for (Org org : organizations) {
  int[] attributes = new int[org.attributes().size()];
  int contAttr = 0;
  for (Attr attr : org.attributes()) {
    int idOffset = builder.createString(attr.id());
    attributes[contAttr++] = Attribute.createAttribute(builder, 
        idOffset, attr.quantity(), attr.amount(), attr.size(), 
        attr.percent(), attr.active());
  }
  int attrsOffset = Organization.createAttributesVector(builder, attributes);

  int nameOffset = builder.createString(org.name());
  int categoryOffset = builder.createString(org.category());
  byte type = (byte) org.type().ordinal();
  int countryOffset = builder.createString(org.country());
  orgsArr[contOrgs++] = Organization.createOrganization(builder, 
    nameOffset, categoryOffset, type, countryOffset, attrsOffset)
}
int organizationsOffset = Organizations.createOrganizationsVector(builder, orgsArr);
int root_table = Organizations.createOrganizations(builder, organizationsOffset);
builder.finish(root_table);

try (var os = new FileOutputStream("/tmp/flatbuffer.json")) {
  InputStream sizedInputStream = builder.sizedInputStream();
  sizedInputStream.transferTo(os);
}

您可以看到,这是一个非常丑陋的代码,您可以轻松犯错。

  • 序列化时间:5 639 MS
  • File size: 1 284 MB
  • 压缩文件大小:530 MB
  • 需要内存:内部创建一个ByteBuffer,该ByteBuffer逐两个生长。要适合1.2 GB的数据,您需要分配2 GB的内存,除非最初将其设置为1.2 GB,如果您事先知道其大小。
  • 库大小(FlatBuffers-Java-1.12.0.jar):64 873字节
  • 生成类的大小:9 080字节

避免

避免比序列化更简单,唯一的困难在于实例化包含二进制序列化信息的ByteBuffer

根据要求,我们可以将所有内容带入内存或使用a memory-mapped file

一旦进行估算(或更确切地说读取文件),您使用的对象实际上并不包含信息,它们只是一个知道如何按需找到它的代理。因此,如果未访问某些数据,它并不是真正的值得注意的...另一方面,每次您访问相同的值时,您都可以将其化。

将整个文件读为内存

如果可用的内存允许,并且您将强烈使用数据,那么您更有可能想要read everything in memory

try (RandomAccessFile file = new RandomAccessFile("/tmp/organizations.flatbuffers", "r")) {
  FileChannel inChannel = file.getChannel();
  ByteBuffer buffer = ByteBuffer.allocate((int) inChannel.size());
  inChannel.read(buffer);
  inChannel.close();
  buffer.flip();
  Organizations organizations = Organizations.getRootAsOrganizations(buffer);
  Organization organization = organizations.organizations(0);
  String name = organization.name();
  for (int i=0; i < organization.attributesLength(); i++){
    String attrId = organization.attributes(i).id();
  ......
  • 访问某些属性的挑选时间:640 ms
  • 访问所有属性的挑选时间:2 184 MS
  • 需要内存:将整个文件加载到内存中,1 284 MB

将文件映射到内存中

java FileChannel系统抽象所有信息是否直接在内存中,还是从磁盘as needed中读取。

try (RandomAccessFile file = new RandomAccessFile("/tmp/organizations.flatbuffers", "r")) {
  FileChannel inChannel = file.getChannel();
  MappedByteBuffer buffer = inChannel.map(MapMode.READ_ONLY, 0, inChannel.size());
  buffer.load();
  Organizations organizations = Organizations.getRootAsOrganizations(buffer);
    .....
  inChannel.close();
}
  • 访问某些属性的挑选时间:306 MS
  • 访问所有属性的挑选时间:2044 MS
  • 需要内存:因为它仅创建文件读取对象和临时缓冲区,所以我不知道如何测量它,我认为它可以忽略不计。

我惊讶的是,使用内存映射的文件花费的时间略少一些。大概在我多次运行基准测试时,操作系统已将文件缓存在内存中。如果我对测试很严格,我应该更改过程,但这足以获得一个想法。


分析和印象

json 协议缓冲区 flatbuffer
序列化时间 11 718 ms 5 823 MS 5 639 MS
文件大小 2 457 MB 1 044 MB 1 284 MB
GZ文件大小 525 MB 448 MB 530 MB
内存序列化 n/a 1,29 GB 1,3 GB -2 GB
避免时间 20 410 MS 4 535 MS 306-2 184 MS
内存挑战 2 193 MB 2 710 MB 0-1 284 MB
jar库大小 1 910 kb 1 636 kb 64 kb
生成类的大小 n/a 40 kb 9 kb

从数据以及通过使用格式来看的内容,我们可以得出结论:

  • 当避免时间和记忆消耗很重要时,强烈建议使用FlatBuffers。这解释了为什么它在视频游戏和移动应用程序的世界中如此广泛使用。它的序列化API非常微妙,容易出现错误。
  • 在flatbuffers中,在像我这样的示例中,有很多数据,在序列化过程中,配置缓冲区大小接近最终结果很重要,否则,库将具有为了连续扩展缓冲区,花费更多的内存和时间。
  • 在flatbuffers中,与生成的类别一起序列化和应对序列化所需的jar依赖性是荒谬的小。。
  • 在JSON和协议缓冲区中,您需要对所有信息进行访问的一部分,在Flatbuffers 中,您可以随机访问任何元素,而无需穿越和解析其之前的所有信息
  • 协议缓冲区所占的空间比FlatBuffers少,因为每个数据结构的开销(消息/表)较小,尤其是因为协议缓冲以最小字节的值序列的标量,压缩信息。
  • 对于标量值,flatbuffers 不支持null 。如果不存在一个值,则采用相应原始的默认值(0、0.0或false)。但是字符串可以是null
  • 如果您需要表示标量的null值,则可以定义结构:struct NullableInt32 { i:int32 }。这为序列化代码增加了更多的复杂性,并消耗了更多字节。
  • 如果您遵循序列化算法,则将在序列化文件中重复出现所有标准化字符串(国家和类别)。

迭代序列化实施

用flatbuffers实施序列化确实很难,但另一方面,如果您了解其内部的工作原理,那么此问题可能会成为有利于它的巨大优势。

每次您做这样的事情:

int idOffset = builder.createString(attr.id());

您正在添加到缓冲区一个字符数组,并在包含它的对象中使用一种指针或偏移。

如果在序列化中重复相同的字符串,我可以每次出现指针吗?

如果在序列化中,您知道将多次重复相同的字符串,您可以重复使用其偏移而不会破坏序列化的内部表示

每次我们使用Map<String, Integer>序列序列化并每次添加字符串时,都会有一个小更改,我们将拥有一些like this

var organizations = dataFactory.getOrganizations(400_000)
FlatBufferBuilder builder = new FlatBufferBuilder();
Map<String, Integer> strOffsets = new HashMap<>();   //<-- offsets cache

int[] orgsArr = new int[organizations.size()];
int contOrgs = 0;
for (Org org : organizations) {
  int[] attributes = new int[org.attributes().size()];
  int contAttr = 0;
  for (Attr attr : org.attributes()) {
    int idOffset = strOffsets.computeIfAbsent(attr.id(), builder::createString);  // <--
    attributes[contAttr++] = Attribute.createAttribute(builder, 
        idOffset, attr.quantity(), attr.amount(), attr.size(),
        attr.percent(), attr.active());
  }
  int attrsOffset = Organization.createAttributesVector(builder, attributes);

  int nameOffset = strOffsets.computeIfAbsent(org.name(), builder::createString);  // <--
  int categoryOffset = strOffsets.computeIfAbsent(org.category(), builder::createString);  // <--
  byte type = (byte) org.type().ordinal();
  int countryOffset = strOffsets.computeIfAbsent(org.country(), builder::createString);  // <--
  orgsArr[contOrgs++] = Organization.createOrganization(builder, nameOffset,
      categoryOffset, type, countryOffset, attrsOffset);
}
int organizationsOffset = Organizations.createOrganizationsVector(builder, orgsArr);
int root_table = Organizations.createOrganizations(builder, organizationsOffset);
builder.finish(root_table);

try (var os = new FileOutputStream("/tmp/flatbuffer.json")) {
  InputStream sizedInputStream = builder.sizedInputStream();
  sizedInputStream.transferTo(os);
}
  • 序列化时间:3 803 MS
  • File size: 600 MB
  • 压缩文件大小:414 MB
  • 需要内存:为了存储600 MB,它将使用ByteBuffer,如果您不预先配置,则需要1 GB。

在这个综合示例中,文件大小的减少达到50%以上,尽管不得不不断查找地图,但序列化所需的时间显着较低。

由于格式不变,因此避难所保留相同的属性,并且读取代码不会更改。如果您选择将整个文件读取到内存中,则将获得相同的内存空间,而如果将其读取为映射文件,则除了较低的I/O使用外,更改将不会引人注目。

如果我们将此优化添加到比较表中,则有:

json 协议缓冲区 flatbuffer flatbuffers v2
序列化时间 11 718 ms 5 823 MS 5 639 MS 3 803 MS
文件大小 2 457 MB 1 044 MB 1 284 MB 600 MB
GZ文件大小 525 MB 448 MB 530 MB 414 MB
内存序列化 n/a 1,29 GB 1,3 GB -2 GB 0.6 GB -1 GB
避免时间 20 410 MS 4 535 MS 306-2 184 MS 202-1 876 MS
内存挑战 2 193 MB 2 710 MB 0-1 284 MB 0-600 MB

我们实际上在序列化时,通过利用我们对数据有一些了解的事实来压缩信息。我们正在手动创建一个compression dictionary


再次迭代这个主意

可以将这种弦压缩或归一化的过程应用于您序列化的数据结构吗?

如果组织之间重复属性值的元组怎么办?

我们可以重复使用先前序列化的元组的指针,以从另一个组织的序列化中引用。我们只需要创建一个映射,其中关键是atter类,值是其第一次出现的偏移:Map<Attr, Integer> attrOffsets = new HashMap<>()

我为本文创建的示例是合成的,并且使用的值是随机的。但是在我的真实情况下,与示例类似的数据结构,数据更重复,并且数据重用更高

在我们的真实用例中,我们从MongoDB集合中加载信息,数字是:

  • MongoDB中的记录占据737 MB(与WiredTiger在磁盘上压缩,以510 MB的身份进行)
  • 如果我们在没有优化的情况下使用flatbuffers序列相同的记录,它将消耗390 MB
  • 如果我们通过标准化字符串进一步优化,则需要186 MB
  • 如果我们将Attr实例归一化,则文件需要45 MB
  • 如果我们用GZIP压缩文件,则结果文件仅为25 MB

MongoDB Wiredtigler vs FlatBuffers optimizations

结论

知道您使用的技术,它们的优势和缺点以及它们所基于的原则如何使您可以优化它们的使用并将其推开超过所告诉您通常的用例。

当数据量较高并且资源受到限制时,了解数据的样子也很重要。您如何构建数据以使其更轻?您如何构建数据以使其更快地处理?

在我们的情况下,这些优化使我们能够推迟复杂的体系结构更改。在在线服务中,我们无需花60秒即可加载一批信息,而是可以减少到3-4秒。

如果数据量成为真正的大数据,我建议您使用这些优化已经内置的格式,例如字典或将数据分组到列中。