Comparação de formatos de serialização

Ao escolher o formato de serialização das mensagens que serão gravadas na fila, no log ou em outro local, muitas vezes surgem várias perguntas que afetam de alguma forma a escolha final. Um desses problemas principais é a velocidade da serialização e o tamanho da mensagem recebida. Como existem muitos formatos para esses fins, decidi testar alguns deles e compartilhar os resultados.

Preparação do teste


Os seguintes formatos serão testados:

  1. Serialização Java
  2. Json
  3. Avro
  4. Protobuf
  5. Thrift (binário, compacto)
  6. Msgpack


Scala foi escolhido como o PL.
A principal ferramenta de teste será o Scalameter .

Os seguintes parâmetros serão medidos e comparados: o tempo necessário para serializar e desserializar e o tamanho dos arquivos resultantes.

Facilidade de uso, a possibilidade de evolução do circuito e outros parâmetros importantes nesta comparação não participarão.

Geração de Entrada


Para a pureza das experiências, é necessário primeiro gerar um conjunto de dados. O formato de entrada é um arquivo CSV. Os dados são gerados usando um simples `Random.next [...]` para valores numéricos e `UUID.randomUUID ()` para valores de string. Os dados gerados são gravados em um arquivo csv usando kantan . No total, foram gerados 3 conjuntos de dados de 100 mil registros cada:

  1. Dados Mistos - 28 mb

    Dados mistos
    final case class MixedData( f1: Option[String], f2: Option[Double], f3: Option[Long], f4: Option[Int], f5: Option[String], f6: Option[Double], f7: Option[Long], f8: Option[Int], f9: Option[Int], f10: Option[Long], f11: Option[Float], f12: Option[Double], f13: Option[String], f14: Option[String], f15: Option[Long], f16: Option[Int], f17: Option[Int], f18: Option[String], f19: Option[String], f20: Option[String], ) extends Data 

  2. Somente linhas - 71 mb

    Onlystrings
     final case class OnlyStrings( f1: Option[String], f2: Option[String], f3: Option[String], f4: Option[String], f5: Option[String], f6: Option[String], f7: Option[String], f8: Option[String], f9: Option[String], f10: Option[String], f11: Option[String], f12: Option[String], f13: Option[String], f14: Option[String], f15: Option[String], f16: Option[String], f17: Option[String], f18: Option[String], f19: Option[String], f20: Option[String], ) extends Data 

  3. Apenas números (longos) - 20 mb

    Apenas
     final case class OnlyLongs( f1: Option[Long], f2: Option[Long], f3: Option[Long], f4: Option[Long], f5: Option[Long], f6: Option[Long], f7: Option[Long], f8: Option[Long], f9: Option[Long], f10: Option[Long], f11: Option[Long], f12: Option[Long], f13: Option[Long], f14: Option[Long], f15: Option[Long], f16: Option[Long], f17: Option[Long], f18: Option[Long], f19: Option[Long], f20: Option[Long], ) extends Data 


Cada entrada consiste em 20 campos. O valor de cada campo é opcional.

Teste


Características do PC no qual o teste foi realizado, versão do scala e java:
PC: 1,8 GHz Intel Core i5-5350U (2 núcleos físicos), 8 GB 1600 MHz DDR3, SSD SM0128G
Versão Java: 1.8.0_144-b01; Ponto de acesso: build 25.144-b01
Versão Scala: 2.12.8

Serialização Java


Dados mistosApenas anseiaApenas strings
Serialização, ms3444.532586,235548.63
Desserialização, ms852,62617,652006.41
Tamanho, mb36.2486

Json


Dados mistosApenas anseiaApenas strings
Serialização, ms5280.674358.135958.92
Desserialização, ms3347,202730.194039.24
Tamanho, mb5236.124

Avro


O circuito Avro foi gerado em movimento antes do teste direto. Para isso, foi utilizada a biblioteca avro4s .
Dados mistosApenas anseiaApenas strings
Serialização, ms2146.721546,952829,31
Desserialização, ms692,56535,96944,27
Tamanho, mb221173

Protobuf


Esquema Protobuf
 syntax = "proto3"; package protoBenchmark; option java_package = "protobufBenchmark"; option java_outer_classname = "data"; message MixedData { string f1 = 1; double f2 = 2; sint64 f3 = 3; sint32 f4 = 4; string f5 = 5; double f6 = 6; sint64 f7 = 7; sint32 f8 = 8; sint32 f9 = 9; sint64 f10 = 10; double f11 = 11; double f12 = 12; string f13 = 13; string f14 = 14; sint64 f15 = 15; sint32 f16 = 16; sint32 f17 = 17; string f18 = 18; string f19 = 19; string f20 = 20; } message OnlyStrings { string f1 = 1; string f2 = 2; string f3 = 3; string f4 = 4; string f5 = 5; string f6 = 6; string f7 = 7; string f8 = 8; string f9 = 9; string f10 = 10; string f11 = 11; string f12 = 12; string f13 = 13; string f14 = 14; string f15 = 15; string f16 = 16; string f17 = 17; string f18 = 18; string f19 = 19; string f20 = 20; } message OnlyLongs { sint64 f1 = 1; sint64 f2 = 2; sint64 f3 = 3; sint64 f4 = 4; sint64 f5 = 5; sint64 f6 = 6; sint64 f7 = 7; sint64 f8 = 8; sint64 f9 = 9; sint64 f10 = 10; sint64 f11 = 11; sint64 f12 = 12; sint64 f13 = 13; sint64 f14 = 14; sint64 f15 = 15; sint64 f16 = 16; sint64 f17 = 17; sint64 f18 = 18; sint64 f19 = 19; sint64 f20 = 20; } 

Para gerar classes protobuf3, foi utilizado o plugin ScalaPB .
Dados mistosApenas anseiaApenas strings
Serialização, ms1169,40865,061856,20
Desserialização, ms113,5677,38256,02
Tamanho, mb221173

Thrift


Esquema de economia
 namespace java thriftBenchmark.java #@namespace scala thriftBenchmark.scala typedef i32 int typedef i64 long struct MixedData { 1:optional string f1, 2:optional double f2, 3:optional long f3, 4:optional int f4, 5:optional string f5, 6:optional double f6, 7:optional long f7, 8:optional int f8, 9:optional int f9, 10:optional long f10, 11:optional double f11, 12:optional double f12, 13:optional string f13, 14:optional string f14, 15:optional long f15, 16:optional int f16, 17:optional int f17, 18:optional string f18, 19:optional string f19, 20:optional string f20, } struct OnlyStrings { 1:optional string f1, 2:optional string f2, 3:optional string f3, 4:optional string f4, 5:optional string f5, 6:optional string f6, 7:optional string f7, 8:optional string f8, 9:optional string f9, 10:optional string f10, 11:optional string f11, 12:optional string f12, 13:optional string f13, 14:optional string f14, 15:optional string f15, 16:optional string f16, 17:optional string f17, 18:optional string f18, 19:optional string f19, 20:optional string f20, } struct OnlyLongs { 1:optional long f1, 2:optional long f2, 3:optional long f3, 4:optional long f4, 5:optional long f5, 6:optional long f6, 7:optional long f7, 8:optional long f8, 9:optional long f9, 10:optional long f10, 11:optional long f11, 12:optional long f12, 13:optional long f13, 14:optional long f14, 15:optional long f15, 16:optional long f16, 17:optional long f17, 18:optional long f18, 19:optional long f19, 20:optional long f20, } 

Para gerar classes de economia semelhantes a scala, foi usado o plug-in Scrooge .
BinárioDados mistosApenas anseiaApenas strings
Serialização, ms1274,69877,982168.27
Desserialização, ms220,58133,64514,96
Tamanho, mb37.1698

CompactDados mistosApenas anseiaApenas strings
Serialização, ms1294,87900,022199.94
Desserialização, ms240,23232,53505,03
Tamanho, mb311498

Msgpack


Dados mistosApenas anseiaApenas strings
Serialização, ms1142,56791,551974.73
Desserialização, ms289,6080,36428,36
Tamanho, mb219,673

Comparação final


serialização

desserialização

tamanho

Precisão dos resultados
Importante: os resultados da velocidade de serialização e desserialização não são 100% precisos. Há um grande erro. Apesar de os testes terem sido executados muitas vezes com o aquecimento adicional da JVM, é difícil chamar os resultados de estáveis ​​e precisos. É por isso que eu recomendo não tirar conclusões finais sobre um formato de serialização específico, com foco nos cronogramas.


Dado o fato de os resultados não serem absolutamente precisos, algumas observações ainda podem ser feitas com base em:

  1. Mais uma vez, garantimos que a serialização java seja lenta e não a mais econômica em termos de saída. Uma das principais razões para o trabalho lento é acessar os campos de objetos usando reflexão. A propósito, os campos são acessados ​​e gravados posteriormente, não na ordem em que você os declarou na classe, mas classificados em ordem lexicográfica. Este é apenas um fato interessante;
  2. Json é o único formato de texto apresentado nesta comparação. Por que os dados serializados no json ocupam muito espaço é óbvio - cada registro é gravado junto com o circuito. Isso também afeta a velocidade de gravação no arquivo: quanto mais bytes você precisar gravar, mais tempo levará. Além disso, não esqueça que um objeto json é criado para cada registro, o que também não reduz o tempo;
  3. Ao serializar um objeto, o Avro analisa o circuito para decidir melhor como processar um campo específico. Esses são custos adicionais, levando a um aumento no tempo total de serialização;
  4. O Thrift, em comparação com, por exemplo, protobuf e msgpack, requer uma quantidade maior de memória para gravar um campo, pois suas informações meta são salvas junto com o valor do campo. Além disso, se você observar os arquivos de saída do thrift, poderá ver que nem uma pequena fração do volume total é ocupada por vários identificadores do início e do fim do registro e o tamanho de todo o registro como separador. Tudo isso certamente aumenta apenas o tempo gasto na embalagem;
  5. O protobuf, como a economia, compacta as informações meta, mas o torna um pouco mais otimizado. Além disso, a diferença no algoritmo de empacotar e descompactar permite que esse formato, em alguns casos, funcione mais rápido que outros;
  6. Msgpack funciona muito rápido. Uma razão para a velocidade é o fato de que nenhuma meta informação adicional é serializada. Isso é bom e ruim: bom porque ocupa pouco espaço em disco e não requer tempo adicional de gravação, ruim porque geralmente não se sabe nada sobre a estrutura de gravação, determinando como empacotar e descompactar isso ou aquilo um valor diferente é executado para cada campo de cada registro.

Quanto aos tamanhos dos arquivos de saída, as observações são bastante inequívocas:

  1. O menor arquivo para discagem numérica foi obtido no msgpack;
  2. O menor arquivo para o conjunto de strings estava no arquivo de origem :) Com exceção do arquivo de origem, o avro venceu por uma pequena margem de msgpack e protobuf;
  3. O menor arquivo para o conjunto misto veio novamente do msgpack. No entanto, a diferença não é tão perceptível e o avro e o protobuf estão muito próximos;
  4. Os maiores arquivos vieram de json. No entanto, uma observação importante deve ser feita - o formato de texto json e compará-lo com o binário em volume (e em termos de velocidade de serialização) não está totalmente correto;
  5. O maior arquivo para discagem numérica foi obtido da serialização java padrão;
  6. O maior arquivo para o conjunto de cadeias era o binário econômico;
  7. O maior arquivo para o conjunto misto era o binário econômico. A seguir, vem a serialização java padrão.

Análise de Formato


Agora, vamos tentar entender os resultados com o exemplo de serializar uma seqüência de 36 caracteres (UUID) sem levar em conta os separadores entre registros, vários identificadores do início e do final de um registro - apenas registre um campo de string 1, mas levando em consideração parâmetros como, por exemplo, tipo e número de campos . A consideração da serialização de strings abrange completamente vários aspectos ao mesmo tempo:

  1. Serialização de números (neste caso, o comprimento da string)
  2. Serialização de string

Vamos começar com o avro. Como todos os campos são do tipo `Option ', o esquema para esses campos será o seguinte:` union: [“null”, “string”] `. Sabendo disso, você pode obter o seguinte resultado:
1 byte para indicar o tipo de registro (nulo ou string), 1 byte por comprimento de linha (1 byte porque o avro usa comprimento variável para escrever números inteiros) e 36 bytes por linha. Total: 38 bytes.

Agora considere o msgpack. Msgpack usa uma abordagem semelhante ao comprimento variável para escrever números inteiros: spec . Vamos tentar calcular quanto é realmente necessário para escrever um campo de string: 2 bytes por comprimento de linha (desde que uma string> 31 bytes, serão necessários 2 bytes), 36 bytes por dados. Total: 38 bytes.

O Protobuf também usa comprimento variável para codificar números. No entanto, além do comprimento da string, o protobuf adiciona outro byte com o número e o tipo de campo. Total: 38 bytes.

O binário Thrift não usa nenhuma otimização para escrever o comprimento da string, mas em vez de 1 byte por número e tipo de campo, o thrift leva 3. Portanto, o resultado é obtido: 1 byte por número de campo, 2 bytes por tipo, 4 bytes por comprimento de linha, 36 bytes por string. Total: 43 bytes.

O Thrift compact , diferentemente do binário, usa a abordagem de comprimento variável para escrever números inteiros e, se possível, usa adicionalmente um registro de cabeçalho de campo abreviado. Com base nisso, obtemos: 1 byte para o tipo e número do campo, 1 byte para o comprimento, 36 bytes para os dados. Total: 38 bytes.

A serialização Java levou 45 bytes para escrever uma string, dos quais 36 bytes - uma string, 9 bytes - 2 bytes de comprimento e 7 bytes para algumas informações adicionais, que não pude descriptografar.

Apenas avro, msgpack, protobuf e thrift compact são deixados. Cada um desses formatos exigirá 38 bytes para escrever utf-8 linhas com 36 caracteres. Por que, então, ao compactar registros de 100k strings, o avro obteve uma quantidade menor, embora um esquema não compactado tenha sido escrito junto com os dados? O Avro possui uma pequena margem de outros formatos e o motivo dessa lacuna é a falta de 4 bytes adicionais por comprimento de embalagem de todo o registro. O fato é que nem o msgpack, nem o protobuf, nem o thrift têm um separador de registros especial. Portanto, para descompactar corretamente os registros, eu precisava saber o tamanho exato de cada registro. Se não fosse por esse fato, com alta probabilidade, o msgpack teria um arquivo menor.

Para um conjunto de dados numéricos, o principal motivo pelo qual o msgpack ganhou foi a falta de informações do esquema nos dados compactados e os dados eram escassos. O Thrift e o protobuf levam até mais de 1 byte para valores vazios devido à necessidade de compactar informações sobre o tipo e o número do campo. O Avro e o msgpack requerem exatamente 1 byte para escrever um valor vazio, mas o avro, como já mencionado, salva o circuito com os dados.

Msgpack também empacotou em um arquivo menor e em um conjunto misto, que também era escasso. As razões para isso são os mesmos fatores.

Portanto, os dados compactados no msgpack ocupam menos espaço. Esta é uma afirmação justa - não foi à toa que o msgpack foi escolhido como o formato de armazenamento de dados para tarantool e aerospike.

Conclusão


Após o teste, posso tirar as seguintes conclusões:

  1. É difícil obter resultados de benchmark estáveis;
  2. Escolher um formato é uma troca entre velocidade de serialização e tamanho da saída. Ao mesmo tempo, não se esqueça de parâmetros tão importantes quanto a conveniência de usar o formato e a possibilidade de evolução do esquema (geralmente esses parâmetros desempenham um papel dominante).

O código fonte pode ser encontrado aqui: github

Source: https://habr.com/ru/post/pt458026/


All Articles