QAware Blog

Vergleich binärer Datenformate

Geschrieben von Nicolai Ommer | 21.10.2021

Wir hatten vor Kurzem die Anforderung, mittelgroße Daten (ca. 100 MB bis 1 GB) in einer Datei zu speichern, die beim Anwendungsstart vollständig in den Heap-Speicher eingelesen werden sollte. Daher haben wir verschiedene Dateiformate hinsichtlich ihrer Größe, Lese-/Schreibperformance sowie Handhabung und Flexibilität analysiert:

Der Quellcode für diesen Vergleich ist auf GitHub zu finden.

Technische Unterschiede der Datenformate

Protobuf

Protobuf serialisiert Entitäten als Nachrichten in eine binäre Darstellung. Es gibt keinen Standardweg, eine große Menge von Nachrichten in eine einzelne Datei zu schreiben. Für einige Sprachen wie Java gibt es jedoch Komfortmethoden, um Nachrichten zu lesen und zu schreiben, die durch ihre Länge voneinander getrennt sind. Eine Komprimierung kann manuell über Datenstream-Komprimierung durchgeführt werden.

Avro

Avro ähnelt Protobuf, bietet aber zusätzlich integrierte Unterstützung für das Schreiben mehrerer Entitäten in eine Datei und übernimmt die Trennung selbstständig. Es sind mehrere Komprimierungs-Codecs integriert, die pro Datei eingestellt werden können. Java-Klassen können von Avro auf Basis einer Avro-Schema-Datei generiert werden und werden von den Avro-Reader- und Writer-Implementierungen nativ unterstützt. Alternativ können Daten in einem GenericRecord gespeichert werden. Beide Varianten sind in den folgenden Ergebnissen enthalten.

Parquet

Parquet ist ein spaltenorientiertes Datenformat. Es wird hauptsächlich im Hadoop-Ökosystem eingesetzt, ist aber als separate Bibliothek verfügbar. Da es weiterhin von hadoop-common abhängt, ist der Footprint der Abhängigkeiten deutlich größer als bei allen anderen Formaten. Es unterstützt die Schema-Definitionsdateien von Avro, bietet native Unterstützung für das Speichern mehrerer Entitäten und verfügt über eine integrierte Komprimierung auf Spaltenebene, was die Komprimierung sehr effizient macht. Die von Avro generierten Java-Klassen können ebenso verwendet werden wie der GenericRecord. Zusätzlich ermöglicht Parquet die (De-)Serialisierung ohne Avro. Die Klasse Group demonstriert dies und ist ebenfalls in den Ergebnissen enthalten.

SQLite

SQLite ist eine bekannte, eigenständige SQL-Datenbank, die von vielen Sprachen, einschließlich Java, unterstützt wird. Sie kann im Grunde über jedes Datenbank-Framework mit JDBC verwendet werden. Die gesamte Datenbank mit allen Tabellen wird in einer einzigen Datei gespeichert. Für den Performance-Vergleich wurde eine Datenbank mit einer einzelnen Tabelle verwendet und alle Daten wurden mit einem einzigen Select-Befehl abgerufen.

JSON

ist kein binäres Format und im Hinblick auf die Dateigröße offensichtlich nicht sehr effizient. Es ist jedoch interessant zu sehen, wie es im Vergleich zu den anderen Formaten abschneidet. Hier wurde Yasson verwendet, eine Implementierung von JSON-B. Die Entitäten wurden als zeilengetrennter JSON-Stream (New-line Delimited JSON) geschrieben.

Kryo

Kryo ist ein Framework zur Serialisierung von Objektgraphen. Es kann Java-Objekte direkt serialisieren, unterstützt dadurch allerdings nur Java und kann nicht in anderen Sprachen verwendet werden.

MicroStream

MicroStream ähnelt Kryo. Ein bemerkenswerter Vorteil ist, dass es unveränderliche Datenklassen (wie Lomboks @Valueunterstützt. Es schreibt Daten in einen Ordner, der aus mehreren Dateien besteht, während alle anderen Formate in eine einzelne Datei schreiben.

Performance Measurement

Die Performance der Datenformate wurde analysiert, indem ein zufälliger Datensatz (10.000.000 Einträge) generiert, mit jedem Format in eine Datei geschrieben und anschließend wieder komplett in den Heap eingelesen wurde. Dies wurde zusätzlich für jedes Format mit Gzip-Komprimierung durchgeführt. Die generierten Daten enthalten eine feste Anzahl zufälliger Strings (10.000), um den Effekt der Komprimierung sichtbar und realistischer zu machen.

Für jede Speicherlösung wurden folgende Metriken erfasst:

  • Schreibzeit (write time): Zeit zum Schreiben des gesamten Datensatzes in eine Datei.
  • Lesezeit (read time): Zeit zum Einlesen des gesamten Datensatzes aus einer Datei in den Heap.
  • Dateigröße (file size): Speicherplatzbedarf auf der Festplatte.
  • Heap nach GC (heap after GC): Heap-Speicherbelegung nach dem Laden des gesamten Datensatzes in den Heap (ohne temporäre Daten).

Results

Die Dateigröße pro Format verteilt sich wie folgt:

Diagramm der Dateigröße

Parquet ist signifikant kleiner als die anderen Formate. Der Grund dafür ist, dass Parquet doppelte Strings durch Duplikateliminierung deutlich effizienter speichert. Die zusätzliche Gzip-Komprimierung reduziert die Dateigröße daher kaum noch weiter. Dieser Effekt ist wesentlich geringer, wenn nicht so viele Duplikate vorhanden sind. JSON ist erwartungsgemäß mit Abstand am größten, unterscheidet sich mit Komprimierung in der Größe jedoch relativ wenig von den anderen Formaten.

 

Diagramm der Schreibzeit

Beim Blick auf die Schreibzeiten sind Avro und Kryo ohne Komprimierung bei Weitem am schnellsten. Allerdings nimmt die Komprimierung bei allen Formaten sehr viel zusätzliche Zeit in Anspruch. Das Komprimieren der SQLite-Datenbank dauert sogar mehrere Minuten und wird im Diagramm nicht dargestellt, um dessen Lesbarkeit zu wahren.

 

Diagramm der Lesezeit

Beim Wiedereinlesen der Daten liegt Parquet erneut vorn, solange die Daten direkt auf die von Avro generierten Klassen gemappt werden. Die von Parquet bereitgestellte Beispielgruppe ist sowohl in der Lese-Performance als auch in der Heap-Nutzung sehr inefficient. Die SQLite-Datenbank ist langsamer als die anderen Formate (außer JSON). Wie zu erwarten war, handelt es sich schließlich um eine vollständige Datenbank, die dafür andere Vorteile bietet, eine Überlegung ist sie also wert, wenn der Performance-Overhead akzeptabel ist. Das Lesen und Parsen von JSON dauert 2- bis 3-mal länger als bei den anderen Formaten, was ebenfalls zu erwarten war.

 

Diagramm der Heap-Nutzung

Die Heap-Nutzung hängt natürlich in erster Linie von der Entitätsklasse ab. Die Beispielgruppe speichert Daten redundant und ist daher doppelt so groß wie nötig. Die generische Darstellung von Avro benötigt etwas mehr Speicher, während die generierten Klassen für Avro und Protobuf ähnlich liegen. Die Überraschung ist der Speicherverbrauch von Parquet: Da Parquet doppelte Strings explizit speichert, lädt es auch jeden String nur ein einziges Mal in den Heap. Die anderen Formate allokieren stets neue Strings. Dies ist ein positiver Nebeneffekt des Parquet-Formats, der bei anderen Formaten jedoch durch eigene String-Pools oder String#intern()abgemildert werden könnte.

Die vollständigen Ergebnismetriken sind in den folgenden zwei Tabellen dargestellt:

format write time [s] file size [MB]
parquet 15.326 276.40
parquet.gzip 20.151 275.26
avro 4.219 735.77
avro.gzip 20.776 646.44
proto 32.301 955.01
proto.gzip 53.36 735.77
sqlite 32.296 825.00
sqlite.gzip 523.713 665.55
json 29.169 1948.88
json.gzip 89.793 781.17
kryo 4.218 692.82
kryo.gzip 28.313 596.45
microStream 17.015 962.25
 
format class read time [s] heap usage [MB]
group.parquet SimpleGroup 15.686 9,965.98
group.parquet.gzip SimpleGroup 14.289 9,965.99
gen.parquet SampleDataAvro 6.495 582.86
gen.parquet.gzip SampleDataAvro 6.663 582.86
avro Record 9.356 4,446.56
avro.gzip Record 11.819 4,446.54
gen.avro SampleDataAvro 8.494 3,014.56
gen.avro.gzip SampleDataAvro 12.11 3,014.53
proto SampleDataPb 10.57 3,167.13
proto.gzip SampleDataPb 18.374 3,167.13
sqlite SampleDataAvro 28.578 3,112.41
json SampleDataJson 44.899 3,073.29
json.gzip SampleDataJson 53.56 3,073.30
kryo SampleDataKryo 10.887 3,014.62
kryo.gzip SampleDataKryo 7.819 3,014.62
microStream SampleDataMicroStream 15.338 576.06

Zusammenfassung

Die Analyse hat einige interessante Metriken der verschiedenen Formate gezeigt. Parquet ist ein sehr effizientes Datenformat, bringt jedoch viele Abhängigkeiten und damit zusätzliche Komplexität mit sich. Es ist zudem weniger gebräuchlich und verfügbare Tools sind rar. Avro und Protobuf sind sich sehr ähnlich, aber Avro bietet mehr Funktionen beim Schreiben mehrerer Zeilen und zeigte insgesamt eine bessere Performance bei größeren Datensätzen. SQLite schlägt sich im Vergleich zu den anderen optimierten Binärdatenformaten ziemlich gut, wenn man bedenkt, dass es sich um eine vollständige SQL-Datenbank handelt. Wenn Du in Erwägung ziehst, nur Teile der Daten zu lesen, solltest Du eine solche Datenbank nutzen, um mehr Flexibilität zu gewinnen. Kryo ist sehr effizient und besonders nützlich, wenn auf die Daten nur von einer einzigen Java-Anwendung zugegriffen wird. Der Hauptnachteil ist das Fehlen eines sprachunabhängigen Datenschemas. MicroStream ist ähnlich wie Kryo, aber deutlich langsamer. Auf der anderen Seite kann es sogar unveränderliche Klassen (immutable classes) (de-)serialisieren.

Es gibt kein perfektes Datenformat. Es hängt immer von Ihrem konkreten Anwendungsfall ab, aber dieser Vergleich sollte Ihnen helfen, die richtige Richtung für Ihre individuellen Anforderungen zu finden.

In unserem Fall haben wir zunächst mit Parquet begonnen, sind nach der Analyse jedoch zu SQLite gewechselt. Wir wollten den großen Abhängigkeitsbaum loswerden, der mehrere Bibliotheken mit Sicherheitswarnungen aus der OWASP-Analyse enthielt. Zudem bietet uns die Verwendung einer Datenbank gängigere und mächtigere Werkzeuge zum Lesen und Verknüpfen der Datenentitäten. Für diese Vorteile haben wir die größere Dateigröße in Kauf genommen.

Beachte, dass alle Metriken stark von den tatsächlichen Daten abhängen. Wenn Du das beste Datenformat für deine Anwendungsfall finden möchtest, klone das GitHub-Projekt und führe den Benchmark mit deinem eigenen Datenmodell aus.