Comparación de formatos de serialización

Al elegir el formato de serialización de los mensajes que se escribirán en la cola, el registro o en otro lugar, a menudo surgen una serie de preguntas que de alguna manera afectan la elección final. Una de estas cuestiones clave es la velocidad de serialización y el tamaño del mensaje recibido. Como hay muchos formatos para tales propósitos, decidí probar algunos de ellos y compartir los resultados.

Preparación de la prueba


Se probarán los siguientes formatos:

  1. Serialización Java
  2. Json
  3. Avro
  4. Protobuf
  5. Ahorro (binario, compacto)
  6. Msgpack


Scala fue elegido como el PL.
La principal herramienta de prueba será Scalameter .

Se medirán y compararán los siguientes parámetros: el tiempo necesario para serializar y deserializar, y el tamaño de los archivos resultantes.

La facilidad de uso, la posibilidad de evolución del circuito y otros parámetros importantes en esta comparación no participarán.

Generación de entrada


Para la pureza de los experimentos, primero se debe generar un conjunto de datos. El formato de entrada es un archivo CSV. Los datos se generan utilizando un simple `Random.next [...]` para valores numéricos y `UUID.randomUUID ()` para la cadena. Los datos generados se escriben en un archivo csv usando kantan . En total, se generaron 3 conjuntos de datos de 100k registros cada uno:

  1. Datos mixtos - 28 mb

    Datos mixtos
    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. Solo líneas - 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. Solo números (largos) - 20 mb

    Onlylongs
     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 consta de 20 campos. El valor de cada campo es opcional.

Prueba


Características de la PC en la que se realizaron las pruebas, versión de scala y java:
PC: 1.8 GHz Intel Core i5-5350U (2 núcleos físicos), 8 GB 1600 MHz DDR3, SSD SM0128G
Versión de Java: 1.8.0_144-b01; Punto de acceso: compilación 25.144-b01
Versión Scala: 2.12.8

Serialización Java


Datos mixtosSolo anhelaSolo cuerdas
Serialización, ms3444,532586,235548,63
Deserialización, ms852,62617,652006.41
Tamaño, mb362486

Json


Datos mixtosSolo anhelaSolo cuerdas
Serialización, ms5280.674358,135958,92
Deserialización, ms3347,202730,194039,24
Tamaño, mb5236124

Avro


El circuito Avro se generó sobre la marcha antes de las pruebas directas. Para esto, se utilizó la biblioteca avro4s .
Datos mixtosSolo anhelaSolo cuerdas
Serialización, ms2146,721546.952829,31
Deserialización, ms692,56535,96944,27
Tamaño, 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 generar clases protobuf3, se utilizó el complemento ScalaPB .
Datos mixtosSolo anhelaSolo cuerdas
Serialización, ms1169,40865.061856.20
Deserialización, ms113,5677,38256,02
Tamaño, mb221173

Ahorro


Esquema de ahorro
 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 generar clases de ahorro de escala, se utilizó el complemento Scrooge .
BinarioDatos mixtosSolo anhelaSolo cuerdas
Serialización, ms1274,69877,982168,27
Deserialización, ms220,58133,64514,96
Tamaño, mb371698

CompactoDatos mixtosSolo anhelaSolo cuerdas
Serialización, ms1294.87900,022199,94
Deserialización, ms240,23232,53505,03
Tamaño, mb311498

Msgpack


Datos mixtosSolo anhelaSolo cuerdas
Serialización, ms1142,56791,551974,73
Deserialización, ms289,6080,36428,36
Tamaño, mb219.673

Comparación final


serialización

deserialización

tamaño

Precisión de los resultados
Importante: los resultados de la velocidad de serialización y deserialización no son 100% precisos. Hay un gran error A pesar del hecho de que las pruebas se ejecutaron muchas veces con un calentamiento adicional de JVM, es difícil llamar a los resultados estables y precisos. Es por eso que recomiendo no hacer conclusiones finales con respecto a un formato de serialización particular, centrándome en los plazos.


Dado el hecho de que los resultados no son absolutamente precisos, todavía se pueden hacer algunas observaciones sobre la base de:

  1. Una vez más, nos aseguramos de que la serialización de Java sea lenta y no la más económica en términos de salida. Una de las razones principales para el trabajo lento es acceder a los campos de objetos utilizando la reflexión. Por cierto, se accede a los campos y se registran más, no en el orden en que los declaró en la clase, sino que se ordenan en orden lexicográfico. Esto es solo un hecho interesante;
  2. Json es el único formato de texto presentado en esta comparación. Es obvio por qué los datos serializados en json ocupan mucho espacio: cada registro se escribe junto con el circuito. Esto también afecta la velocidad de escritura en el archivo: cuantos más bytes necesite para escribir, más tiempo llevará. Además, no olvide que se crea un objeto json para cada registro, lo que tampoco reduce el tiempo;
  3. Al serializar un objeto, Avro analiza el circuito para decidir aún más cómo procesar un campo en particular. Estos son costos adicionales que conducen a un aumento en el tiempo total de serialización;
  4. Thrift, en comparación con, por ejemplo, protobuf y msgpack, requiere una mayor cantidad de memoria para escribir un campo, ya que su metainformación se guarda junto con el valor del campo. Además, si observa los archivos de salida de thrift, puede ver que no una pequeña fracción del volumen total está ocupada por varios identificadores del principio y el final del registro y el tamaño de todo el registro como separador. Todo esto ciertamente solo aumenta el tiempo dedicado al empaque;
  5. Protobuf, como el ahorro, empaqueta la metainformación, pero la optimiza un poco. Además, la diferencia en el algoritmo de empaquetado y desempaquetado permite que este formato en algunos casos funcione más rápido que otros;
  6. Msgpack funciona bastante rápido. Una razón de la velocidad es el hecho de que no se serializa ninguna metainformación adicional. Esto es bueno y malo: bueno porque ocupa poco espacio en disco y no requiere tiempo de grabación adicional, malo porque en general no se sabe nada sobre la estructura de grabación, por lo tanto, determinar cómo empacar y desempacar Se realiza un valor diferente para cada campo de cada registro.

En cuanto a los tamaños de los archivos de salida, las observaciones son bastante inequívocas:

  1. El archivo más pequeño para marcación numérica se obtuvo de msgpack;
  2. El archivo más pequeño para el conjunto de cadenas resultó estar en el archivo fuente :) A excepción del archivo fuente, avro ganó por un pequeño margen de msgpack y protobuf;
  3. El archivo más pequeño para el conjunto mixto nuevamente vino de msgpack. Sin embargo, la brecha no es tan notable y avro y protobuf están muy cerca;
  4. Los archivos más grandes vinieron de json. Sin embargo, debe hacerse una observación importante: el formato de texto json y su comparación con el binario en volumen (y en términos de velocidad de serialización) no es del todo correcto;
  5. El archivo más grande para la marcación numérica se obtuvo de la serialización estándar de Java;
  6. El archivo más grande para el conjunto de cadenas era binario de segunda mano;
  7. El archivo más grande para el conjunto mixto era binario de segunda mano. A continuación viene la serialización estándar de Java.

Análisis de formato


Ahora intentemos comprender los resultados con el ejemplo de serialización de una cadena de 36 caracteres (UUID) sin tener en cuenta los separadores entre registros, varios identificadores del principio y el final de un registro: solo registre 1 campo de cadena, pero teniendo en cuenta parámetros como, por ejemplo, el tipo y número de campo . La consideración de la serialización de cadenas cubre completamente varios aspectos a la vez:

  1. Serialización de números (en este caso, la longitud de la cadena)
  2. Serialización de cadenas

Comencemos con avro. Como todos los campos son del tipo `Opción`, el esquema para dichos campos será el siguiente:` unión: ["nulo", "cadena"] `. Sabiendo esto, puede obtener el siguiente resultado:
1 byte para indicar el tipo de registro (nulo o cadena), 1 byte por longitud de línea (1 byte porque avro usa longitud variable para escribir enteros) y 36 bytes por línea. Total: 38 bytes.

Ahora considere msgpack. Msgpack utiliza un enfoque similar a la longitud variable para escribir enteros: spec . Tratemos de calcular cuánto se necesita para escribir un campo de cadena: 2 bytes por longitud de línea (dado que una cadena> 31 bytes, entonces se requerirán 2 bytes), 36 bytes por datos. Total: 38 bytes.

Protobuf también usa longitud variable para codificar números. Sin embargo, además de la longitud de la cadena, protobuf agrega otro byte con el número y tipo de campo. Total: 38 bytes.

Thrift binary no utiliza ninguna optimización para escribir la longitud de la cadena, pero en lugar de 1 byte por número de campo y tipo, thrift toma 3. Por lo tanto, se obtiene el siguiente resultado: 1 byte por número de campo, 2 bytes por tipo, 4 bytes por longitud de línea, 36 bytes por cuerda Total: 43 bytes.

Thrift compact , a diferencia del binario, utiliza el enfoque de longitud variable para escribir enteros y, si es posible, utiliza adicionalmente un registro de encabezado de campo abreviado. En base a esto, obtenemos: 1 byte para el tipo y número del campo, 1 byte para la longitud, 36 bytes para los datos. Total: 38 bytes.

La serialización de Java tomó 45 bytes para escribir una cadena, de los cuales 36 bytes - una cadena, 9 bytes - 2 bytes de longitud y 7 bytes para obtener información adicional, que no pude descifrar.

Solo quedan avro, msgpack, protobuf y thrift compact. Cada uno de estos formatos requerirá 38 bytes para escribir líneas utf-8 de 36 caracteres de longitud. ¿Por qué, entonces, al empaquetar 100k registros de cadena avro obtuvo una cantidad menor, aunque se escribió un esquema sin comprimir junto con los datos? Avro tiene un pequeño margen con respecto a otros formatos y la razón de esta brecha es la falta de 4 bytes adicionales por longitud de empaque del registro completo. El hecho es que ni msgpack, ni protobuf, ni thrift tienen un separador de registro especial. Por lo tanto, para poder descomprimir correctamente los registros, necesitaba saber el tamaño exacto de cada registro. Si no fuera por este hecho, entonces, con alta probabilidad, msgpack tendría un archivo más pequeño.

Para un conjunto de datos numéricos, la razón principal por la que ganó msgpack fue la falta de información de esquema en los datos empaquetados y que los datos eran escasos. Thrift y protobuf incluso llevarán más de 1 byte a valores vacíos debido a la necesidad de empaquetar información sobre el tipo y el número del campo. Avro y msgpack requieren exactamente 1 byte para escribir un valor vacío, pero avro, como ya se mencionó, guarda el circuito con los datos.

Msgpack también se empacó en un archivo más pequeño y un conjunto mixto, que también era escaso. Las razones para esto son todos los mismos factores.

Por lo tanto, resulta que los datos empaquetados en msgpack ocupan el menor espacio. Esta es una declaración justa: no en vano se eligió msgpack como formato de almacenamiento de datos para tarantool y aerospike.

Conclusión


Después de la prueba, puedo sacar las siguientes conclusiones:

  1. Es difícil obtener resultados de referencia estables;
  2. Elegir un formato es una compensación entre la velocidad de serialización y el tamaño de salida. Al mismo tiempo, no se olvide de parámetros tan importantes como la conveniencia de usar el formato y la posibilidad de la evolución del esquema (a menudo, estos parámetros juegan un papel dominante).

El código fuente se puede encontrar aquí: github

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


All Articles