2024-12-05
CSV-Dateien: Parquet als ultimatives Speicherformat entthronen – oder doch nicht?
Pedro Holanda
Dateiformate
CSV-Dateien
Daten werden am häufigsten in menschenlesbaren Dateiformaten gespeichert, etwa JSON oder CSV. Diese Formate sind leicht zu handhaben: Jeder mit einem Texteditor kann sie öffnen, ändern und verstehen.
Jahrelang hatten CSV-Dateien den Ruf, langsam und umständlich zu sein. In der Praxis folgt man bei der Arbeit mit einer CSV-Datei in seinem Lieblingsdatenbanksystem diesem Rezept:
- Das Schema manuell entdecken, indem man die Datei im Texteditor öffnet.
- Eine Tabelle mit dem gegebenen Schema anlegen.
- Den Dialekt der Datei manuell herausfinden (z. B. welches Zeichen als Quote dient).
- Die Datei mit einer
COPY-Anweisung und gesetztem Dialekt in die Tabelle laden. - Abfragen beginnen.
Das ist nicht nur mühsam, das Parallelisieren eines CSV-Readers ist auch alles andere als trivial. Die meisten Systeme verarbeiten deshalb single-threaded oder mit einem Two-Pass-Ansatz.
Außerdem sind CSV-Dateien wild: Obwohl RFC-4180 als CSV-Standard existiert, wird er häufig ignoriert. Systeme müssen deshalb robust genug sein, um diese Dateien zu behandeln, als kämen sie direkt aus dem Wilden Westen.
Nicht zuletzt sind CSV-Dateien verschwenderisch: Daten liegen immer als Strings. Numerische Werte wie 1000000000 brauchen 10 Bytes statt 4 Bytes als int32. Und weil das Layout zeilenweise ist, gehen Chancen für leichte spaltenorientierte Kompression verloren.
Parquet-Dateien
Wegen dieser Schwächen haben performanceorientierte Formate wie Parquet in den letzten Jahren stark an Popularität gewonnen. Parquet-Dateien lassen sich nicht mit normalen Texteditoren öffnen, nicht leicht bearbeiten und haben ein starres Schema. Sie speichern Daten aber in Spalten, wenden verschiedene Kompressionstechniken an, partitionieren die Daten in Row Groups, halten Statistiken über diese Row Groups und definieren ihr Schema direkt in der Datei.
Diese Features machen Parquet zu einem Monolithen eines Dateiformats – höchst unflexibel, aber effizient und schnell. Daten aus einer Parquet-Datei zu lesen ist einfach, weil das Schema klar definiert ist. Einen Scanner zu parallelisieren ist geradlinig, weil jeder Thread unabhängig eine Row Group verarbeiten kann. Filter-Pushdown ist ebenfalls einfach, weil jede Row Group statistische Metadaten enthält, und die Dateien sind sehr klein.
Die Schlussfolgerung sollte einfach sein: Bei kleinen Dateien und Bedarf an Flexibilität sind CSV-Dateien in Ordnung. Für Datenanalyse sollte man aber auf Parquet wechseln, oder? Dieser Wechsel ist vielleicht keine harte Anforderung mehr – lesen Sie weiter!
CSV-Dateien in DuckDB lesen
In den letzten Releases hat DuckDB doppelt darauf gesetzt, nicht nur einen einfach zu nutzenden, sondern auch extrem performanten CSV-Scanner zu liefern. Dieser Scanner hat einen eigenen CSV-Sniffer, einen Parallelisierungsalgorithmus, Buffer Manager, Casting-Mechanismen und einen zustandsmaschinenbasierten Parser.
Für die Nutzbarkeit wurde das frühere Paradigma aus manueller Schema-Entdeckung und Tabellenerzeugung geändert. DuckDB nutzt jetzt einen CSV-Sniffer, ähnlich wie in Dataframe-Bibliotheken wie Pandas. CSV-Dateien lassen sich so einfach abfragen wie:
FROM 'path/to/file.csv';Oder Tabellen aus CSV-Dateien anlegen, ohne vorheriges Schema:
CREATE TABLE t AS FROM 'path/to/file.csv';Außerdem wurde der Reader einer der schnellsten CSV-Reader in analytischen Systemen, wie die Ladezeiten der aktuellen Iteration von ClickBench zeigen. In diesem Benchmark werden die Daten aus einer 82 GB großen unkomprimierten CSV-Datei in eine Datenbanktabelle geladen.

CSV und Parquet vergleichen
Nach dem großen Schub bei Nutzbarkeit und Performance des CSV-Readers könnte man fragen: Wie groß ist der tatsächliche Performance-Unterschied beim Laden einer CSV-Datei gegenüber einer Parquet-Datei in eine Tabelle? Und wie unterscheiden sich die Formate, wenn man Queries direkt darauf ausführt?
Um das herauszufinden, führen wir ein paar Beispiele mit CSV- und Parquet-Dateien mit TPC-H-Daten aus. Alle Skripte zur Erzeugung der Benchmarks dieses Beitrags liegen in einem Repository.
Nutzbarkeit
Beim Scannen können sich CSV- und Parquet-Dateien deutlich unterscheiden.
In einfachen Fällen, in denen DuckDB alle Optionen korrekt erkennt, lassen sich Queries auf CSV- und Parquet-Dateien direkt ausführen.
FROM 'path/to/file.csv';FROM 'path/to/file.parquet';Bei wilden, regelbrechenden Arthur-Morgan-artigen CSV-Dateien kann es drastisch anders aussehen. Das zeigt die Zahl der Parameter, die sich für jeden Scanner setzen lassen. Der Parquet-Scanner hat insgesamt sechs Parameter, die das Lesen der Datei ändern. In den meisten Fällen muss der Nutzer keinen davon manuell anpassen.
Der CSV-Reader hängt dagegen davon ab, dass der Sniffer viele verschiedene Konfigurationsoptionen automatisch erkennt. Zum Beispiel: Was ist das Trennzeichen? Wie viele Zeilen sollen oben übersprungen werden? Gibt es Kommentare? Und so weiter. Daraus ergeben sich über 30 Konfigurationsoptionen, die der Nutzer manuell anpassen muss, um die CSV-Datei korrekt zu parsen. Diese Zahl ist nötig, weil es keinen weit akzeptierten Standard gibt. In den meisten Szenarien können Nutzer aber auf den Sniffer vertrauen oder höchstens ein oder zwei Optionen ändern.
Der CSV-Reader hat außerdem ein umfangreiches Fehlerbehandlungssystem und schlägt immer Optionen vor, die man prüfen sollte, wenn etwas schiefgeht.
Als Beispiel für DuckDBs Fehlerberichterstattung betrachten wir folgende CSV-Datei:
Clint Eastwood;94Samuel L. JacksonIn dieser Datei fehlt in der zweiten Zeile der Wert für die zweite Spalte.
Invalid Input Error:CSV Error on Line: 2Original Line: Samuel L. JacksonExpected Number of Columns: 2 Found: 1Possible fixes:* Enable null padding (null_padding=true) to replace missing values with NULL* Enable ignore errors (ignore_errors=true) to skip this row
file = western_actors.csv delimiter = , (Auto-Detected) quote = " (Auto-Detected) escape = " (Auto-Detected) new_line = \n (Auto-Detected) header = false (Auto-Detected) skip_rows = 0 (Auto-Detected) comment = \0 (Auto-Detected) date_format = (Auto-Detected) timestamp_format = (Auto-Detected) null_padding = 0 sample_size = 20480 ignore_errors = false all_varchar = 0DuckDB liefert detaillierte Informationen zu jedem Fehler. Es hebt die Zeile der CSV-Datei hervor, in der das Problem auftrat, zeigt die Originalzeile und schlägt mögliche Fixes vor, etwa die problematische Zeile zu ignorieren oder fehlende Werte mit NULL zu füllen. Es zeigt auch die volle Konfiguration, mit der die Datei gescannt wurde, und gibt an, ob die Optionen automatisch erkannt oder manuell gesetzt wurden.
Fazit: Auch mit den Fortschritten bei der CSV-Nutzung macht die Strenge von Parquet-Dateien sie viel leichter zu handhaben.
Natürlich: Wollen Sie die Datei in einem Texteditor oder Excel öffnen, brauchen Sie die Daten im CSV-Format. Parquet-Dateien haben einige Visualizer, etwa TAD.
Performance
Es gibt vor allem zwei Wege, mit Dateien in DuckDB zu arbeiten:
-
Der Nutzer legt eine DuckDB-Tabelle aus der Datei an und nutzt die Tabelle in künftigen Queries. Das ist ein Ladeprozess, üblich, wenn Sie Daten als DuckDB-Tabellen speichern oder viele Queries darauf ausführen wollen. Für die meisten Datenbanksysteme (z. B. Oracle, SQL Server, PostgreSQL, SQLite, …) sind das außerdem die einzig möglichen Szenarien.
-
Man kann eine Query direkt auf dem Datei-Scanner ausführen, ohne eine Tabelle anzulegen. Das ist nützlich, wenn Speicher und Plattenplatz begrenzt sind oder Queries auf diesen Dateien nur einmal laufen. Typische Datenbanksysteme unterstützen das meist nicht; bei Dataframe-Bibliotheken (z. B. Pandas) ist es üblich.
Für einen fairen Vergleich der Scanner geben wir die Tabellenschemata vorab an, damit die Scanner genau dieselben Datentypen erzeugen. Wir setzen außerdem preserve_insertion_order = false, weil das die Parallelisierung beider Scanner beeinflussen kann, und max_temp_directory_size = '0GB', damit keine Daten auf die Platte gespillt werden und alle Experimente vollständig im Speicher laufen.
Wir nutzen die Standardwriter für CSV und Parquet (mit Standard-Snappy-Kompression) und eine Variante von Parquet mit CODEC 'zstd', COMPRESSION_LEVEL 1, weil das Abfrage-/Ladezeiten beschleunigen kann.
Für alle Experimente nutzen wir einen Apple M1 Max mit 64 GB RAM. Wir nutzen TPC-H Scale Factor 20 und berichten die Medianzeiten aus 5 Läufen.
Tabellen anlegen
Beim Anlegen der Tabelle konzentrieren wir uns auf die Tabelle lineitem.
Nach der Schema-Definition lassen sich beide Dateien mit einer einfachen COPY-Anweisung ohne Extra-Parameter laden. Auch mit definiertem Schema wird der CSV-Sniffer ausgeführt, um den Dialekt zu bestimmen (z. B. Quote-Zeichen, Trennzeichen) und Typen und Namen zuzuordnen.
| Name | Zeit (s) | Größe (GB) |
|---|---|---|
| CSV | 11.76 | 15.95 |
| Parquet Snappy | 5.21 | 3.78 |
| Parquet Zstd | 5.52 | 3.22 |
Die Parquet-Dateien sind eindeutig kleiner. Etwa 5-mal kleiner als die CSV-Datei, der Performance-Unterschied ist aber nicht drastisch.
Der CSV-Scanner ist nur etwa 2-mal langsamer als der Parquet-Scanner. Wichtig: Ein Teil der Kosten dieser Operationen (~1–2 Sekunden) hängt mit dem Einfügen in die DuckDB-Tabelle zusammen, nicht mit dem Scanner selbst.
Das sollte man im Vergleich trotzdem berücksichtigen. In der Praxis ist der reine CSV-Scanner etwa 3-mal langsamer als der Parquet-Scanner – ein erheblicher Unterschied, aber viel kleiner, als man zunächst denken könnte.
Dateien direkt abfragen
Wir führen zwei verschiedene TPC-H-Queries auf unseren Dateien aus.
Query 01. Zuerst führen wir TPC-H Q01 aus. Diese Query arbeitet nur auf der Tabelle lineitem, führt eine Aggregation und Gruppierung mit Filter aus. Sie filtert eine Spalte und projiziert 7 von 16 Spalten aus lineitem.
Die Query belastet also Filter-Pushdown, das der Parquet-Reader unterstützt, der CSV-Reader aber nicht, und Projektions-Pushdown, das beide unterstützen.
SELECT l_returnflag, l_linestatus, sum(l_quantity) AS sum_qty, sum(l_extendedprice) AS sum_base_price, sum(l_extendedprice * (1 - l_discount)) AS sum_disc_price, sum(l_extendedprice * (1 - l_discount) * (1 + l_tax)) AS sum_charge, avg(l_quantity) AS avg_qty, avg(l_extendedprice) AS avg_price, avg(l_discount) AS avg_disc, count(*) AS count_orderFROM lineitemWHERE l_shipdate <= CAST('1996-09-02' AS date)GROUP BY l_returnflag, l_linestatusORDER BY l_returnflag, l_linestatus;| Name | Zeit (s) |
|---|---|
| CSV | 6.72 |
| Parquet Snappy | 0.88 |
| Parquet Zstd | 0.95 |
Diese Query direkt auf der Datei auszuführen, zeigt eine viel größere Performance-Lücke von etwa 7× im Vergleich zum bloßen Laden in die Tabelle. In der Parquet-Datei können wir Row Groups, die nicht zum Filter l_shipdate <= CAST('1996-09-02' AS date) passen, direkt überspringen. Dieser Filter eliminiert etwa 30 % der Daten. Außerdem können wir einzelne Zeilen überspringen, die nicht zum Filter passen. Und weil Parquet spaltenorientiert ist, können wir jede Berechnung auf nicht projizierten Spalten komplett überspringen.
Der CSV-Reader profitiert von diesen Filtern leider nicht. Ohne Partitionen kann er Teile der Daten nicht effizient überspringen. Theoretisch könnte ein CSV-Scanner die Berechnung von Zeilen überspringen, die nicht zum Filter passen; das ist aber derzeit nicht implementiert.
Außerdem überspringt die CSV-Projektion einen Großteil der Berechnung auf einer Spalte (z. B. wird der Wert nicht gecastet oder kopiert), muss den Wert aber trotzdem parsen, um ihn überspringen zu können.
Query 21. Query 21 hängt nicht nur stark von Filter- und Projektions-Pushdown ab, sondern auch erheblich von Join-Ordering auf Basis von Statistiken, um gute Performance zu erreichen. In dieser Query werden vier verschiedene Dateien genutzt und gejoint.
SELECT s_name, count(*) AS numwaitFROM supplier, lineitem l1, orders, nationWHERE s_suppkey = l1.l_suppkey AND o_orderkey = l1.l_orderkey AND o_orderstatus = 'F' AND l1.l_receiptdate > l1.l_commitdate AND EXISTS ( SELECT * FROM lineitem l2 WHERE l2.l_orderkey = l1.l_orderkey AND l2.l_suppkey <> l1.l_suppkey) AND NOT EXISTS ( SELECT * FROM lineitem l3 WHERE l3.l_orderkey = l1.l_orderkey AND l3.l_suppkey <> l1.l_suppkey AND l3.l_receiptdate > l3.l_commitdate) AND s_nationkey = n_nationkey AND n_name = 'SAUDI ARABIA'GROUP BY s_nameORDER BY numwait DESC, s_nameLIMIT 100;| Name | Zeit (s) |
|---|---|
| CSV | 19.95 |
| Parquet Snappy | 2.08 |
| Parquet Zstd | 2.12 |
Diese Query hat jetzt einen Performance-Unterschied von etwa 10×. Wir sehen einen ähnlichen Effekt wie bei Query 01, tragen aber zusätzlich die Kosten der Join-Ordnung ohne statistische Informationen für die CSV-Datei.
Fazit
Zweifellos ist die Performance des CSV-Scannens über die Jahre drastisch gestiegen. Würde man vor ein paar Jahren den Performance-Unterschied beim Tabellenanlegen schätzen, wäre die Antwort wahrscheinlich mindestens eine Größenordnung gewesen.
Das ist großartig, weil sich Daten so aus Legacy-Systemen exportieren lassen, die keine performanceorientierten Dateiformate unterstützen.
Aber lassen Sie sich von superbequemen und schnellen CSV-Readern nicht täuschen. Ihre Daten gehören weiterhin am besten in selbstbeschreibende, spalten-binär komprimierte Formate wie Parquet – oder natürlich das DuckDB-Dateiformat! Sie sind viel kleiner und konsistenter. Außerdem ist das direkte Ausführen von Queries auf Parquet-Dateien wegen effizientem Projektions-/Filter-Pushdown und verfügbaren Statistiken viel vorteilhafter.
Anzumerken ist, dass es eine umfangreiche Literatur zum Indexieren von CSV-Dateien gibt (also zum Aufbau von Statistiken), um künftige Queries zu beschleunigen und Filter-Pushdown zu ermöglichen. DuckDB führt diese Operationen aber noch nicht aus.
Fazit: Parquet bleibt in den meisten Szenarien unangefochtener Champion, aber wir arbeiten weiter daran, die Lücke wo möglich zu schließen.