2024-07-09
Speicherverwaltung in DuckDB
Mark Raasveldt
Speicher ist eine wichtige Ressource bei der Verarbeitung großer Datenmengen. Speicher ist eine schnelle Cache-Schicht, die enorme Beschleunigungen der Abfrageverarbeitung liefern kann. Speicher ist aber endlich und teuer, und bei großen Datensätzen reicht er in der Regel nicht, um alle nötigen Datenstrukturen im Cache zu halten. Effektive Speicherverwaltung ist entscheidend für eine hochperformante Query Engine – Speicher muss genutzt werden, um diese hohe Leistung zu liefern, aber wir müssen aufpassen, nicht zu viel Speicher zu verwenden, was zu Out-of-Memory-Fehlern führen oder den unheilvollen OOM Killer dazu bringen kann, den Prozess aus der Existenz zu tilgen.
DuckDB ist so gebaut, dass verfügbarer Speicher effektiv genutzt wird, ohne dass der Speicher ausgeht:
- Die Streaming-Ausführungsengine lässt kleine Datenstücke durch das System fließen, ohne dass ganze Datensätze im Speicher materialisiert werden müssen.
- Daten aus Zwischenergebnissen können vorübergehend auf die Festplatte ausgelagert werden, um Speicherplatz freizugeben, und so komplexe Abfragen zu ermöglichen, die sonst den verfügbaren Speicher überschreiten würden.
- Der Buffer Manager cached so viele Pages wie möglich aus allen angehängten Datenbanken, ohne die vordefinierten Speichergrenzen zu überschreiten.
In diesem Blogpost behandeln wir diese Aspekte der Speicherverwaltung in DuckDB – und geben Beispiele, wo sie zum Einsatz kommen.
Streaming-Ausführung
DuckDB verwendet eine Streaming-Ausführungsengine zur Verarbeitung von Abfragen. Datenquellen wie Tabellen, CSV-Dateien oder Parquet-Dateien werden nie vollständig im Speicher materialisiert. Stattdessen werden Daten stückweise gelesen und verarbeitet. Betrachten Sie zum Beispiel die Ausführung der folgenden Abfrage:
SELECT UserAgent, count(*)FROM 'hits.csv'GROUP BY UserAgent;Statt die gesamte CSV-Datei auf einmal zu lesen, liest DuckDB Daten stückweise aus der CSV-Datei und berechnet die Aggregation inkrementell mit den aus diesen Stücken gelesenen Daten. Das läuft weiter, bis die gesamte CSV-Datei gelesen ist; dann liegt das gesamte Aggregationsergebnis vor.

Im obigen Beispiel zeigen wir nur einen einzelnen Datenstrom. In der Praxis nutzt DuckDB mehrere Datenströme für multithreaded Ausführung – jeder Thread führt seinen eigenen Datenstrom aus. Die Aggregationsergebnisse der verschiedenen Threads werden zum Endergebnis kombiniert.
Streaming-Ausführung ist konzeptionell einfach, aber leistungsfähig und reicht aus, um Larger-than-Memory-Unterstützung für viele einfache Einsatzfälle zu bieten. Streaming-Ausführung ermöglicht Larger-than-Memory-Unterstützung zum Beispiel für:
- Aggregationen, bei denen die Gesamtzahl der Gruppen klein ist
- Lesen von Daten aus einer Datei und Schreiben in eine andere (z. B. CSV lesen und Parquet schreiben)
- Berechnung eines Top-N über die Daten (wenn N klein ist)
Es muss nichts getan werden, um Streaming-Ausführung zu aktivieren – DuckDB verarbeitet Abfragen immer auf diese Weise.
Auslagern von Zwischenergebnissen
Streaming-Ausführung ermöglicht Larger-than-Memory-Verarbeitung für einfache Abfragen, aber in vielen Fällen reicht Streaming-Ausführung allein nicht aus.
Im vorherigen Beispiel funktionierte Larger-than-Memory-Verarbeitung, weil das berechnete Aggregatergebnis sehr klein war – es gibt sehr wenige eindeutige User Agents im Vergleich zur Gesamtzahl der Webanfragen. Dadurch blieb die Aggregate-Hash-Tabelle immer klein und überschritt nie den verfügbaren Speicher.
Streaming-Ausführung reicht nicht, wenn die zur Verarbeitung einer Abfrage nötigen Zwischenergebnisse größer als der Speicher sind. Angenommen, wir gruppieren im vorherigen Beispiel nach der Quell-IP:
SELECT IPNetworkID, count(*)FROM 'hits.csv'GROUP BY IPNetworkID;Da es deutlich mehr eindeutige Quell-IPs gibt, ist die Hash-Tabelle, die wir halten müssen, deutlich größer. Überschreitet die Größe der Aggregate-Hash-Tabelle den Speicher, reicht die Streaming-Ausführungsengine nicht, um Out-of-Memory-Probleme zu verhindern.
Larger-than-Memory-Zwischenergebnisse können in vielen Szenarien entstehen, besonders bei komplexeren Abfragen. Zum Beispiel können folgende Szenarien zu Larger-than-Memory-Zwischenergebnissen führen:
- Eine Aggregation mit vielen eindeutigen Gruppen
- Eine exakte Distinct-Count einer Spalte mit vielen Distinct-Werten
- Der Join zweier Tabellen, die beide größer als der Speicher sind
- Das Sortieren eines Larger-than-Memory-Datensatzes
- Eine komplexe Window-Berechnung über eine Larger-than-Memory-Tabelle
DuckDB behandelt diese Szenarien durch Disk Spilling. Larger-than-Memory-Zwischenergebnisse werden bei Bedarf (teilweise) ins temporäre Verzeichnis auf die Festplatte geschrieben. Disk Spilling ist zwar leistungsfähig, reduziert aber die Performance – weil zusätzliches I/O nötig ist. Deshalb versucht DuckDB, Disk Spilling zu minimieren. Disk Spilling wird adaptiv nur genutzt, wenn die Größe der Zwischenergebnisse das Speicherlimit überschreitet. Selbst dann bleibt so viel Daten wie möglich im Speicher, um die Performance zu maximieren. Die genaue Umsetzung hängt von den Operatoren ab und ist in anderen Blogposts beschrieben (Aggregation, Sortierung).
Die Einstellung memory_limit steuert, wie viel Daten DuckDB im Speicher halten darf. Standardmäßig ist das 80% des physischen RAM Ihres Systems (hat Ihr System z. B. 16 GB RAM, sind das 12,8 GB). Das Speicherlimit lässt sich mit folgendem Befehl ändern:
SET memory_limit = '4GB';Der Ort des temporären Verzeichnisses lässt sich mit der Einstellung temp_directory wählen und ist standardmäßig die verbundene Datenbank mit dem Suffix .tmp (z. B. database.db.tmp) bzw. nur .tmp bei Verbindung zu einer In-Memory-Datenbank. Die maximale Größe des temporären Verzeichnisses lässt sich mit max_temp_directory_size begrenzen; Standard ist 90% des verbleibenden Festplattenspeichers auf dem Laufwerk, auf dem die temporären Dateien liegen. Diese Einstellungen lassen sich so anpassen:
SET temp_directory = '/tmp/duckdb_swap';SET max_temp_directory_size = '100GB';Wird das Speicherlimit überschritten und kann Disk Spilling nicht genutzt werden – weil es explizit deaktiviert ist, das temporäre Verzeichnis das gesetzte Limit überschreitet oder eine Systembeschränkung Disk Spilling für eine gegebene Abfrage verhindert – wird ein Out-of-Memory-Fehler gemeldet und die Abfrage abgebrochen.
Buffer Manager
Eine weitere Kernkomponente der Speicherverwaltung in DuckDB ist der Buffer Manager. Der Buffer Manager ist für das Cachen von Pages aus DuckDBs eigenem persistenten Speicher zuständig. Konzeptionell arbeitet der Buffer Manager ähnlich wie das Auslagern von Zwischenergebnissen. Pages bleiben so weit wie möglich im Speicher und werden verdrängt, wenn Platz für andere Datenstrukturen nötig ist. Der Buffer Manager hält sich an dasselbe Speicherlimit wie alle Zwischendatenstrukturen. Pages im Buffer Manager können freigegeben werden, um Platz für Zwischendatenstrukturen zu schaffen, oder umgekehrt.
Es gibt zwei wesentliche Unterschiede zwischen Buffer Manager und Zwischendatenstrukturen:
- Da der Buffer Manager Pages cached, die bereits auf der Festplatte existieren (in DuckDBs persistentem Speicher), müssen sie beim Verdrängen nicht ins temporäre Verzeichnis geschrieben werden. Werden sie wieder benötigt, können sie direkt aus der angehängten Speicherdatei erneut gelesen werden.
- Abfrage-Zwischenergebnisse haben einen natürlichen Lebenszyklus: Ist die Abfrage fertig, werden die Zwischenergebnisse nicht mehr gebraucht. Pages, die der Buffer Manager aus dem persistenten Speicher cached, sind über Abfragen hinweg nützlich. Deshalb bleiben die vom Buffer Manager gehaltenen Pages gecached, bis entweder die persistente Datenbank geschlossen wird oder Platz für andere Operationen freigemacht werden muss.
Der Performance-Gewinn des Buffer Managers hängt von der Geschwindigkeit des zugrunde liegenden Speichermediums ab. Liegen die Daten auf einer sehr schnellen Festplatte, ist das Lesen schnell und der Gewinn gering. Liegen die Daten auf einem Netzlaufwerk oder werden über HTTP/S3 gelesen, erfordert das Lesen Netzwerkanfragen, und der Gewinn kann sehr groß sein.
Speicherverbrauch profilieren
DuckDB enthält mehrere Werkzeuge zur Profilierung des Speicherverbrauchs.
Die Funktion duckdb_memory() zeigt, welche Systemkomponenten Speicher nutzen. Vom Buffer Manager genutzter Speicher heißt BASE_TABLE, Abfrage-Zwischenergebnisse sind in eigene Gruppen unterteilt.
FROM duckdb_memory();┌──────────────────┬────────────────────┬─────────────────────────┐│ tag │ memory_usage_bytes │ temporary_storage_bytes ││ varchar │ int64 │ int64 │├──────────────────┼────────────────────┼─────────────────────────┤│ BASE_TABLE │ 168558592 │ 0 ││ HASH_TABLE │ 0 │ 0 ││ PARQUET_READER │ 0 │ 0 ││ CSV_READER │ 0 │ 0 ││ ORDER_BY │ 0 │ 0 ││ ART_INDEX │ 0 │ 0 ││ COLUMN_DATA │ 0 │ 0 ││ METADATA │ 0 │ 0 ││ OVERFLOW_STRINGS │ 0 │ 0 ││ IN_MEMORY_TABLE │ 0 │ 0 ││ ALLOCATOR │ 0 │ 0 ││ EXTENSION │ 0 │ 0 │├──────────────────┴────────────────────┴─────────────────────────┤│ 12 rows 3 columns │└─────────────────────────────────────────────────────────────────┘Die Funktion duckdb_temporary_files zeigt den aktuellen Inhalt des temporären Verzeichnisses.
FROM duckdb_temporary_files();┌────────────────────────────────┬───────────┐│ path │ size ││ varchar │ int64 │├────────────────────────────────┼───────────┤│ .tmp/duckdb_temp_storage-0.tmp │ 967049216 │└────────────────────────────────┴───────────┘Fazit
Speicherverwaltung ist entscheidend für eine hochperformante Analytics-Engine. DuckDB ist so gebaut, dass jeder verfügbare Speicher zur Beschleunigung der Abfrageverarbeitung genutzt wird, während Larger-than-Memory-Datensätze durch Auslagern von Zwischenergebnissen elegant behandelt werden. Speicherverwaltung ist weiterhin ein aktives Entwicklungsfeld und hat sich über DuckDB-Versionen hinweg stetig verbessert. Unter anderem arbeiten wir an der Speicherverwaltung für komplexe Abfragen mit mehreren Operatoren und Larger-than-Memory-Zwischenergebnissen.