Vergleich der Serialisierungsformate

Bei der Auswahl des Formats für die Serialisierung von Nachrichten, die in die Warteschlange, das Protokoll oder anderswo geschrieben werden, treten häufig eine Reihe von Fragen auf, die sich irgendwie auf die endgültige Auswahl auswirken. Eines dieser Hauptprobleme ist die Geschwindigkeit der Serialisierung und die Größe der empfangenen Nachricht. Da es für solche Zwecke viele Formate gibt, habe ich beschlossen, einige davon zu testen und die Ergebnisse zu teilen.

Testvorbereitung


Folgende Formate werden getestet:

  1. Java-Serialisierung
  2. Json
  3. Avro
  4. Protobuf
  5. Sparsamkeit (binär, kompakt)
  6. Msgpack


Scala wurde als PL gewählt.
Das Haupttestwerkzeug ist Scalameter .

Die folgenden Parameter werden gemessen und verglichen: die Zeit, die zum Serialisieren und Deserialisieren benötigt wird, und die Größe der resultierenden Dateien.

Benutzerfreundlichkeit, die Möglichkeit der Entwicklung der Schaltung und andere wichtige Parameter in diesem Vergleich werden nicht teilnehmen.

Eingabeerzeugung


Für die Reinheit der Experimente muss zuerst ein Datensatz generiert werden. Das Eingabeformat ist eine CSV-Datei. Daten werden mit einer einfachen "Random.next [...]" für numerische Werte und "UUID.randomUUID ()" für Zeichenfolgenwerte generiert. Die generierten Daten werden mit kantan in eine CSV-Datei geschrieben . Insgesamt wurden 3 Datensätze mit jeweils 100.000 Datensätzen generiert:

  1. Gemischte Daten - 28 mb

    Gemischte Daten
    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. Nur Linien - 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. Nur Zahlen (lang) - 20 mb

    Nur lange
     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 


Jeder Eintrag besteht aus 20 Feldern. Der Wert jedes Feldes ist optional.

Testen


Eigenschaften des PCs, auf dem die Tests durchgeführt wurden, Version von Scala und Java:
PC: 1,8 GHz Intel Core i5-5350U (2 physische Kerne), 8 GB 1600 MHz DDR3, SSD SM0128G
Java-Version: 1.8.0_144-b01; Hotspot: Build 25.144-b01
Scala-Version: 2.12.8

Java-Serialisierung


Gemischte DatenNur sehnt sichNur Saiten
Serialisierung, ms3444,532586,235548,63
Deserialisierung, ms852,62617,652006.41
Größe, mb362486

Json


Gemischte DatenNur sehnt sichNur Saiten
Serialisierung, ms5280,674358.135958,92
Deserialisierung, ms3347,202730.194039,24
Größe, mb5236124

Avro


Die Avro-Schaltung wurde vor dem direkten Testen unterwegs generiert. Hierzu wurde die avro4s- Bibliothek verwendet .
Gemischte DatenNur sehnt sichNur Saiten
Serialisierung, ms2146,721546,952829,31
Deserialisierung, ms692,56535,96944,27
Größe, mb221173

Protobuf


Protobuf-Schema
 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; } 

Um Protobuf3-Klassen zu generieren, wurde das ScalaPB- Plugin verwendet .
Gemischte DatenNur sehnt sichNur Saiten
Serialisierung, ms1169.40865.061856.20
Deserialisierung, ms113,5677,38256,02
Größe, mb221173

Sparsamkeit


Sparsamkeitsschema
 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, } 

Um Scala-ähnliche Sparsamkeitsklassen zu generieren, wurde das Scrooge- Plugin verwendet.
BinärGemischte DatenNur sehnt sichNur Saiten
Serialisierung, ms1274,69877,982168,27
Deserialisierung, ms220,58133,64514,96
Größe, mb371698

KompaktGemischte DatenNur sehnt sichNur Saiten
Serialisierung, ms1294,87900,022199,94
Deserialisierung, ms240,23232,53505.03
Größe, mb311498

Msgpack


Gemischte DatenNur sehnt sichNur Saiten
Serialisierung, ms1142,56791,551974,73
Deserialisierung, ms289,6080,36428,36
Größe, mb219.673

Abschließender Vergleich


Serialisierung

Deserialisierung

Größe

Genauigkeit der Ergebnisse
Wichtig: Die Ergebnisse der Serialisierungs- und Deserialisierungsgeschwindigkeit sind nicht 100% genau. Es liegt ein großer Fehler vor. Trotz der Tatsache, dass die Tests viele Male mit zusätzlichem Aufwärmen der JVM durchgeführt wurden, ist es schwierig, die Ergebnisse als stabil und genau zu bezeichnen. Aus diesem Grund empfehle ich dringend, keine endgültigen Schlussfolgerungen zu einem bestimmten Serialisierungsformat zu ziehen, wobei der Schwerpunkt auf Zeitplänen liegt.


Angesichts der Tatsache, dass die Ergebnisse nicht absolut genau sind, können einige Beobachtungen auf ihrer Grundlage gemacht werden:

  1. Wir haben erneut sichergestellt, dass die Java-Serialisierung langsam und in Bezug auf die Ausgabe nicht die wirtschaftlichste ist. Einer der Hauptgründe für langsames Arbeiten ist der Zugriff auf die Felder von Objekten mithilfe von Reflexion. Übrigens wird auf die Felder zugegriffen und diese nicht in der Reihenfolge aufgezeichnet, in der Sie sie in der Klasse deklariert haben, sondern in lexikografischer Reihenfolge sortiert. Dies ist nur eine interessante Tatsache;
  2. Json ist das einzige Textformat, das in diesem Vergleich dargestellt wird. Warum in json serialisierte Daten viel Platz beanspruchen, liegt auf der Hand - jeder Datensatz wird zusammen mit der Schaltung geschrieben. Dies wirkt sich auch auf die Geschwindigkeit des Schreibens in die Datei aus: Je mehr Bytes Sie schreiben müssen, desto länger dauert es. Vergessen Sie auch nicht, dass für jeden Datensatz ein JSON-Objekt erstellt wird, was auch die Zeit nicht verkürzt.
  3. Bei der Serialisierung eines Objekts analysiert Avro die Schaltung, um weiter zu entscheiden, wie ein bestimmtes Feld verarbeitet werden soll. Dies sind zusätzliche Kosten, die zu einer Verlängerung der gesamten Serialisierungszeit führen.
  4. Im Vergleich zu beispielsweise protobuf und msgpack erfordert Thrift eine größere Speichermenge, um ein Feld zu schreiben, da seine Metainformationen zusammen mit dem Feldwert gespeichert werden. Wenn Sie sich die Ausgabedateien von Thrift ansehen, können Sie auch feststellen, dass nicht ein kleiner Teil des Gesamtvolumens von verschiedenen Bezeichnern des Beginns und des Endes des Datensatzes und der Größe des gesamten Datensatzes als Trennzeichen belegt ist. All dies erhöht sicherlich nur den Zeitaufwand für die Verpackung.
  5. Protobuf packt wie Sparsamkeit Metainformationen, macht sie aber etwas optimierter. Der Unterschied im Algorithmus zum Ein- und Auspacken ermöglicht es diesem Format in einigen Fällen, schneller als in anderen zu arbeiten.
  6. Msgpack arbeitet ziemlich schnell. Ein Grund für die Geschwindigkeit ist die Tatsache, dass keine zusätzlichen Metainformationen serialisiert werden. Dies ist sowohl gut als auch schlecht: gut, weil es wenig Speicherplatz beansprucht und keine zusätzliche Aufnahmezeit erfordert, schlecht, weil im Allgemeinen nichts über die Aufzeichnungsstruktur bekannt ist, also bestimmen, wie dies oder jenes gepackt und entpackt werden soll Für jedes Feld jedes Datensatzes wird ein anderer Wert ausgeführt.

In Bezug auf die Größe der Ausgabedateien sind die Beobachtungen ziemlich eindeutig:

  1. Die kleinste Datei für das numerische Wählen wurde von msgpack erhalten;
  2. Die kleinste Datei für den String-Satz befand sich in der Quelldatei :) Mit Ausnahme der Quelldatei gewann avro mit einem kleinen Vorsprung von msgpack und protobuf;
  3. Die kleinste Datei für den gemischten Satz stammt wieder von msgpack. Die Lücke ist jedoch nicht so auffällig und Avro und Protobuf sind sehr eng;
  4. Die größten Dateien stammten von json. Es muss jedoch eine wichtige Bemerkung gemacht werden: Das JSON-Textformat und der Vergleich mit dem Binärformat (und der Serialisierungsgeschwindigkeit) sind nicht ganz korrekt.
  5. Die größte Datei für das numerische Wählen wurde aus der Standard-Java-Serialisierung erhalten.
  6. Die größte Datei für den String-Satz war Thrift Binary.
  7. Die größte Datei für den gemischten Satz war Thrift Binary. Es folgt die Standard-Java-Serialisierung.

Formatanalyse


Versuchen wir nun, die Ergebnisse anhand des Beispiels der Serialisierung einer 36-stelligen Zeichenfolge (UUID) zu verstehen, ohne Trennzeichen zwischen Datensätzen, verschiedene Bezeichner für den Anfang und das Ende eines Datensatzes zu berücksichtigen - nur das Zeichenfolgenfeld für Datensatz 1, jedoch Parameter wie z. B. Feldtyp und Nummer . Die Berücksichtigung der String-Serialisierung deckt mehrere Aspekte gleichzeitig vollständig ab:

  1. Serialisierung von Zahlen (in diesem Fall die Länge der Zeichenfolge)
  2. String-Serialisierung

Beginnen wir mit avro. Da alle Felder vom Typ "Option" sind, lautet das Schema für solche Felder wie folgt: "union: [" null "," string "]". Wenn Sie dies wissen, können Sie das folgende Ergebnis erhalten:
1 Byte zur Angabe des Datensatztyps (Null oder Zeichenfolge), 1 Byte pro Zeilenlänge (1 Byte, da avro zum Schreiben von Ganzzahlen eine variable Länge verwendet ) und 36 Byte pro Zeile. Gesamt: 38 Bytes.

Betrachten Sie nun msgpack. Msgpack verwendet einen Ansatz ähnlich der variablen Länge , um Ganzzahlen zu schreiben: spec . Versuchen wir zu berechnen, wie viel tatsächlich benötigt wird, um ein Zeichenfolgenfeld zu schreiben: 2 Bytes pro Zeilenlänge (da eine Zeichenfolge> 31 Bytes sind, sind 2 Bytes erforderlich), 36 Bytes pro Daten. Gesamt: 38 Bytes.

Protobuf verwendet auch variable Länge , um Zahlen zu codieren. Zusätzlich zur Länge der Zeichenfolge fügt protobuf jedoch ein weiteres Byte mit der Anzahl und dem Typ des Felds hinzu. Gesamt: 38 Bytes.

Thrift Binary verwendet keine Optimierung, um die Länge der Zeichenfolge zu schreiben, aber anstelle von 1 Byte pro Feldnummer und Typ benötigt Thrift 3. Daher wird das folgende Ergebnis erhalten: 1 Byte pro Feldnummer, 2 Bytes pro Typ, 4 Bytes pro Zeilenlänge, 36 Bytes pro Zeichenfolge. Gesamt: 43 Bytes.

Thrift Compact verwendet im Gegensatz zu Binary den Ansatz variabler Länge , um Ganzzahlen zu schreiben, und verwendet, wenn möglich, zusätzlich einen abgekürzten Feldheaderdatensatz. Basierend darauf erhalten wir: 1 Byte für den Typ und die Nummer des Feldes, 1 Byte für die Länge, 36 Bytes für die Daten. Gesamt: 38 Bytes.

Die Java-Serialisierung benötigte 45 Bytes, um eine Zeichenfolge zu schreiben, von denen 36 Bytes - eine Zeichenfolge, 9 Bytes - 2 Bytes lang und 7 Bytes für einige zusätzliche Informationen, die ich nicht entschlüsseln konnte.

Nur avro, msgpack, protobuf und thrift compact sind übrig. Jedes dieser Formate benötigt 38 Bytes, um utf-8 Zeilen mit einer Länge von 36 Zeichen zu schreiben. Warum hat avro dann beim Packen von 100.000 String-Datensätzen eine geringere Menge erhalten, obwohl ein unkomprimiertes Schema zusammen mit den Daten geschrieben wurde? Avro hat einen kleinen Spielraum gegenüber anderen Formaten und der Grund für diese Lücke ist das Fehlen zusätzlicher 4 Bytes pro Packungslänge des gesamten Datensatzes. Tatsache ist, dass weder msgpack noch protobuf noch thrift ein spezielles Datensatztrennzeichen haben. Damit ich die Datensätze wieder korrekt auspacken konnte, musste ich die genaue Größe jedes Datensatzes kennen. Ohne diese Tatsache hätte msgpack mit hoher Wahrscheinlichkeit eine kleinere Datei.

Bei einem numerischen Datensatz war der Hauptgrund für den Gewinn von msgpack das Fehlen von Schemainformationen in den gepackten Daten und die geringe Dichte der Daten. Thrift und Protobuf benötigen sogar mehr als 1 Byte, um Werte zu leeren, da Informationen über den Typ und die Nummer des Felds gepackt werden müssen. Avro und msgpack benötigen genau 1 Byte, um einen leeren Wert zu schreiben, aber avro speichert, wie bereits erwähnt, die Schaltung mit den Daten.

Msgpack packte auch in einer kleineren Datei und einem gemischten Satz, der ebenfalls spärlich war. Die Gründe dafür sind alle die gleichen Faktoren.

Somit stellt sich heraus, dass die in msgpack gepackten Daten den geringsten Speicherplatz beanspruchen. Dies ist eine faire Aussage - nicht umsonst wurde msgpack als Datenspeicherformat für Tarantool und Aerospike ausgewählt.

Fazit


Nach dem Testen kann ich folgende Schlussfolgerungen ziehen:

  1. Es ist schwierig, stabile Benchmark-Ergebnisse zu erzielen.
  2. Die Auswahl eines Formats ist ein Kompromiss zwischen Serialisierungsgeschwindigkeit und Ausgabegröße. Vergessen Sie dabei nicht so wichtige Parameter wie die Bequemlichkeit der Verwendung des Formats und die Möglichkeit der Weiterentwicklung des Schemas (häufig spielen diese Parameter eine dominierende Rolle).

Den Quellcode finden Sie hier: github

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


All Articles