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:
- Serialização Java
- Json
- Avro
- Protobuf
- Thrift (binário, compacto)
- 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:
- Dados Mistos - 28 mb
Dados mistosfinal 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
- 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
- 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
Json
Avro
O circuito Avro foi gerado em movimento antes do teste direto. Para isso, foi
utilizada a biblioteca
avro4s .
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 .
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 .
Msgpack
Comparação final



Precisão dos resultadosImportante: 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:
- 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;
- 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;
- 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;
- 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;
- 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;
- 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:
- O menor arquivo para discagem numérica foi obtido no msgpack;
- 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;
- 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;
- 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;
- O maior arquivo para discagem numérica foi obtido da serialização java padrão;
- O maior arquivo para o conjunto de cadeias era o binário econômico;
- 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:
- Serialização de números (neste caso, o comprimento da string)
- 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:
- É difícil obter resultados de benchmark estáveis;
- 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