2025-06-06
Schnellere Dashboards mit approximativem Mehrspalten-Sortieren
Alex Monahan
Eine animierte Hilbert-Space-Filling-Curve von TimSauder – Own work, CC BY-SA 4.0
Es ist selten, ein Dashboard mit einem einzelnen Chart zu haben. Noch seltener, die Daten nur auf eine Weise abzufragen. Und noch seltener, die Daten ganz ohne Filter abzufragen!
Wenn Queries eine Teilmenge der Gesamtzeilen lesen, bringt Sortieren der Daten beim Laden deutliche Vorteile. Schauen Sie sich bitte den vorherigen Beitrag dieser Serie an, um zu verstehen, wie dieser Ansatz funktioniert, und für praktische Tipps!
Um diese Vorteile in einer größeren Bandbreite realer Fälle zu sehen, müssen wir etwas kreativer werden. Diese fortgeschrittenen Techniken helfen, wenn eine dieser Situationen eintritt:
- Queries filtern auf unterschiedlichen Spalten
- Query-Muster sind nicht perfekt vorhersagbar
- Queries filtern nach Zeit und mindestens einer weiteren Spalte
In diesem Beitrag beschreiben wir mehrere fortgeschrittene Sortierstrategien, vergleichen sie mit einigen Experimenten (Microbenchmarks) und berechnen dann eine Metrik, um ihre Wirksamkeit zu messen.
Die Strategie
Statt präzise nach einer oder einer kleinen Zahl von Spalten zu sortieren, wollen wir näherungsweise nach einer größeren Zahl von Spalten sortieren.
So können Queries mit unterschiedlichen WHERE-Klauseln alle von DuckDBs Min-Max-Indizes (Zone Maps) profitieren.
Dieser Beitrag stellt zwei High-Level-Ansätze mit mehreren Beispielen vor: Sortieren nach Space-Filling Curves und Sortieren nach trunkierten Timestamps.
Space-Filling Curves
Sowohl Morton als auch Hilbert sind Space-Filling-Curve-Algorithmen, die mehrere Spalten zu einer Ordnung kombinieren, die für beide Spalten eine näherungsweise Ordnung erhält. Eine Anwendung von Space-Filling Curves ist die Geospatial Analytics, und das ist eine hilfreiche Illustration.
Enthielte ein Datensatz die Latitude- und Longitude-Koordinaten jedes Cafés auf der Erde (eine Zeile pro Café), wir aber so sortieren wollten, dass Cafés, die physisch nah beieinander liegen, in der Liste nah beieinander stehen, könnten wir eine Space-Filling Curve nutzen. Cafés, die sowohl in Latitude als auch Longitude einigermaßen nah sind, erhalten einen ähnlichen Morton- oder Hilbert-Encoding-Wert. So können wir Queries wie „Finde alle Cafés in dieser rechteckigen Region auf einer Karte“ schnell ausführen. (Eine solche rechteckige Region heißt in der Geospatial-Welt Bounding Box!) Das GIF oben in diesem Beitrag zeigt verschiedene Granularitätsstufen eines Hilbert-Encodings – stellen Sie sich vor, die x-Achse wäre Longitude und die y-Achse Latitude. Die Zickzack-Linie des Hilbert-Algorithmus ist die Liste der Cafés, näherungsweise sortiert.
Sowohl Morton als auch Hilbert arbeiten auf Integers oder Floats, dieser Beitrag skizziert aber einen Weg, sie auch zum Sortieren von VARCHAR-Spalten zu nutzen.
Ein SQL-Macro wandelt die ersten paar Zeichen einer VARCHAR-Spalte als Vorverarbeitungsschritt in einen Integer.
Trunkierte Timestamps
Aktuellere Daten sind tendenziell nützlichere Daten, deshalb filtern Queries häufig auf eine Zeitspalte. Oft filtern Queries aber auf Zeit und auf weitere Spalten.
Sortieren Sie nicht einfach nach der Timestamp-Spalte!
Sie verpassen Performancevorteile.
Timestamps sind so granular, dass die Daten in der Praxis nur nach Timestamp sortiert sind.
Wie viele Zeilen Ihrer Daten wurden genau um 2025-01-01 01:02:03.456789 eingefügt?
Wahrscheinlich nur eine!
Um nach mehreren Spalten plus einer Zeitspalte zu sortieren, sortieren Sie zuerst nach einem trunkierten Timestamp (trunkiert auf den Anfang des Tages, der Woche, des Jahres usw.) und dann nach den anderen Spalten.
Der Datensatz „On-Time Flights“
Haben Sie sich je gefragt, wie wahrscheinlich eine Verspätung ist, wenn Sie zum Fliegen bereitmachen? Wenn Sie in den USA unterwegs sind, können Sie einen Regierungsdatensatz nutzen, um zu sehen, wie pünktlich jede Route ist! Mehr Details zum Datensatz auf der offiziellen Seite, ich habe eine Parquet-Version der Daten von Kaggle genommen. Er umfasst fast 5 Jahre Daten zu US-Flügen. Zusammen sind die Parquet-Dateien etwa 1,1 GB. Lokal ist das recht klein, über eine langsame Remote-Verbindung kann es aber mehr Herausforderung sein. Im Rest des Beitrags führen wir verschiedene Benchmarks aus, um zu zeigen, wie Pruning die für bestimmte Queries nötige Netzwerkmenge reduzieren kann.
Unser erstes Experiment ist ein hypothetischer Use Case, ein Dashboard zu bedienen, auf dem Leute einen einzelnen origin, ein einzelnes dest (Ziel) oder ein origin–dest-Paar erkunden können.
Die Spalten origin und dest sind Drei-Buchstaben-Flughafencodes (wie LAX oder JFK).
Wir nehmen an, sobald Sie einen dieser Filter gewählt haben, wollen Sie alle Zeilen, die matchen, und ein paar beliebige Spalten.
Unser zweites Experiment fügt eine Zeitkomponente hinzu, bei der unsere hypothetischen Nutzer zusätzlich auf die letzten 1, 13 oder 52 Wochen der Daten im Datensatz filtern können.
Experimentelles Design
Wir zeigen ein paar Ansätze, Daten näherungsweise zu sortieren, um den Abruf zu beschleunigen.
Die Kontrollgruppen sind:
random: Sortieren nach einem Hash – der Worst Case!origin: Einspalten-Sort nachoriginorigin-dest: Sortieren nachorigin, dann nachdestination
Unsere alternativen Sortieransätze sind:
zipped_varchar: Sortieren Buchstabe für Buchstabe, abwechselnd zwischenoriginunddestination. – EinoriginvonABCund eindestinationvonXYZwürde z. B. zuAXBYCZ, das dann sortiert wird.morton:originunddestinationin Integers wandeln, dann nach Morton Encoding (Z-Order) ordnenhilbert:originunddestinationin Integers wandeln, dann nach Hilbert Encoding ordnen
Der Algorithmus
zipped_varcharist mit einem SQL-Macro umgesetzt, es werden keine Extensions gebraucht. Er behandelt auch Strings beliebiger Länge.
Die Morton- und Hilbert-Encoding-Funktionen kommen aus der
lindel-DuckDB-Community-Extension, beigetragen von Rusty Conover. Danke an Rusty und die Leute, die dielindel-Rust-Crate gebaut haben, auf der die DuckDB-Extension basiert! Diespatial-Extension enthält auch eine FunktionST_Hilbert, die ähnlich arbeitet. Danke an Max Gabrielsson und die GDAL-Community!
Diese Plots zeigen die Query-Laufzeit beim Abruf aus einer DuckDB-Datei, die auf S3 gehostet wird. Dieselben Techniken lassen sich auch erfolgreich auf das integrierte Data-Lake- und Catalog-Format DuckLake anwenden! DuckLake ist die moderne Evolution des Cloud Data Lakehouse – nehmen Sie sich eine Minute für den Launch-Beitrag, falls Sie ihn noch nicht gelesen haben! DuckLake hat ein zusätzliches Konzept einer Partition, das das Überspringen ganzer Dateien ermöglicht. Um DuckLake voll zu nutzen, partitionieren Sie Ihre Daten zuerst (nach Zeit oder anders) und wenden Sie dann die Techniken in diesem Beitrag beim Laden Ihrer Daten an.
Alle Experimente liefen auf einem M1-MacBook Pro mit DuckDB v1.2.2. Die Tests wurden mit dem DuckDB-Python-Client ausgeführt, Ergebnisse kamen als Apache-Arrow-Tabellen zurück. Zwischen jeder Query wird die DuckDB-Connection geschlossen und neu erzeugt (diese Zeit wird nicht als Teil der Ergebnisse gemessen). Das soll die Erfahrung eines einzelnen Nutzers besser simulieren, der in unserem hypothetischen Use Case auf das Dashboard zugreift.
Aufklappen, um die grundlegenden Sortier-Queries zu sehen
Für die Reproduzierbarkeit hier die sehr standardmäßigen Queries, mit denen die Daten zuerst aus Parquet geladen, zufällig sortiert, nach origin sortiert und nach origin und dann destination sortiert wurden.
Die Parquet-Dateien wurden von Kaggle heruntergeladen.
CREATE TABLE IF NOT EXISTS flights AS FROM './Combined_Flights*.parquet';
-- The hash function is used instead of random-- for consistency across re-runsCREATE TABLE IF NOT EXISTS flights_random AS FROM flights ORDER BY hash(rowid + 42);
CREATE TABLE IF NOT EXISTS flights_origin AS FROM flights ORDER BY origin;
CREATE TABLE IF NOT EXISTS flights_origin_dest AS FROM flights ORDER BY origin, dest;Aufklappen, um die Zipped-`VARCHAR`-Sortier-Queries zu sehen
Als Beispiel für einen Ansatz, der keine Extension braucht, approximiert dieses SQL-Macro grob einen Space-Filling-Curve-Ansatz, nutzt aber alphanumerische Zeichen statt Integers. Das Ergebnis ist ein Datensatz, der nach einer Spalte etwas sortiert ist und nach einer anderen etwas sortiert ist.
CREATE OR REPLACE FUNCTION main.zip_varchar(i, j, num_chars := 6) AS ( -- By default using 6 characters from each string so that -- if data is ASCII, we can fit it all in 12 bytes so that it is stored inline -- rather than requiring a pointer [ list_value(z[1], z[2]) FOR z IN list_zip( substr(i, 1, num_chars).rpad(num_chars, ' ').string_split(''), substr(j, 1, num_chars).rpad(num_chars, ' ').string_split('') ) ].flatten().array_to_string(''));
CREATE TABLE IF NOT EXISTS flights_zipped_varchar AS FROM flights ORDER BY main.zip_varchar(origin, dest, num_chars := 3);Hier ein Beispiel der Ausgabe, die die Funktion zip_varchar erzeugt:
SELECT 'ABC' AS origin, 'XYZ' AS dest, main.zip_varchar(origin, dest, num_chars := 3) AS zipped_varchar;| origin | dest | zipped_varchar |
|---|---|---|
| ABC | XYZ | AXBYCZ |
Aufklappen, um die Morton- und Hilbert-Sortier-Queries zu sehen
Das Ziel einer Space-Filling Curve ist, mehrere Dimensionen (in unserem Fall zwei: origin und destination) auf eine einzelne Dimension abzubilden, aber die Locality der höheren Dimension zwischen Datenpunkten zu erhalten.
Morton- und Hilbert-Encodings sind dafür ausgelegt, Integers oder Floating-Point-Zahlen zu akzeptieren.
In unseren Beispielen wollen wir diese Techniken aber auf VARCHAR-Spalten anwenden.
Strings kodieren tatsächlich eine beträchtliche Datenmenge pro Stringlänge. Zahlen können nur 10 Werte pro Ziffer haben (in unserem Dezimalsystem), ein String kann aber viel mehr haben (alle Kleinbuchstaben, Großbuchstaben oder Symbole). Deshalb können wir nicht sehr lange Strings in Integers kodieren – nur die ersten paar Zeichen. Für unseren Use Case des näherungsweisen Sortierens reicht das trotzdem!
Diese SQL-Funktion kann ein VARCHAR mit ASCII-Zeichen (bis zu 8 Zeichen Länge) in ein UBIGINT wandeln.
Sie splittet das VARCHAR in einzelne Zeichen, berechnet die ASCII-Zahl für dieses Zeichen, wandelt das in Bits, konkateniert die Bits und wandelt dann in ein UBIGINT.
CREATE OR REPLACE FUNCTION main.varchar_to_ubigint(i, num_chars := 8) AS ( -- The maximum number of characters that will fit in a UBIGINT is 8 -- and a UBIGINT is the largest type that the lindel community extension accepts for Hilbert or Morton encoding list_reduce( [ ascii(my_letter)::UTINYINT::BIT::VARCHAR FOR my_letter IN (i[:num_chars]).rpad(num_chars, ' ').string_split('') ], (x, y) -> x || y )::BIT::UBIGINT);Die Funktionen morton_encode und hilbert_encode aus der lindel-Community-Extension können dann in der ORDER BY-Klausel genutzt werden, um nach Morton- oder Hilbert-Encoding zu sortieren.
INSTALL lindel FROM community;LOAD lindel;
CREATE TABLE IF NOT EXISTS flights_morton AS FROM flights ORDER BY morton_encode([ varchar_to_ubigint(origin, num_chars := 3), varchar_to_ubigint(dest, num_chars := 3) ]::UBIGINT[2]);
CREATE TABLE IF NOT EXISTS flights_hilbert AS FROM flights ORDER BY hilbert_encode([ varchar_to_ubigint(origin, num_chars := 3), varchar_to_ubigint(dest, num_chars := 3) ]::UBIGINT[2]);Alternativ kann die spatial-Extension genutzt werden, um ein Hilbert-Encoding auszuführen.
Sie braucht eine Bounding Box, weil das hilft, die Granularität des Encodings für Geospatial-Use-Cases zu bestimmen.
Sie hat ähnlich performt wie der Hilbert-Ansatz in den experimentellen Ergebnissen.
SET VARIABLE bounding_box = ( WITH flights_converted_to_ubigint AS ( FROM flights SELECT *, varchar_to_ubigint(origin, num_chars := 3) AS origin_ubigint, varchar_to_ubigint(dest, num_chars := 3) AS dest_ubigint ) FROM flights_converted_to_ubigint SELECT { min_x: min(origin_ubigint), min_y: min(dest_ubigint), max_x: max(origin_ubigint), max_y: max(dest_ubigint) }::BOX_2D);CREATE OR REPLACE TABLE flights_hilbert_spatial AS FROM flights ORDER BY ST_Hilbert( varchar_to_ubigint(origin, num_chars := 3), varchar_to_ubigint(dest, num_chars := 3), getvariable('bounding_box') );Aufklappen, um die selektiven Lese-Queries zur Performance-Messung zu sehen
Zusammengefasst testet dieser Microbenchmark:
- Das Abfragen von Tabellen, sortiert nach den 6 verschiedenen Ansätzen (3 Kontrolle und 3 fortgeschritten)
- Filtern mit drei Query-Mustern: Filter auf
origin,destinationundorigin/destination - Filtern mit 4 verschiedenen
origin/dest-Paaren:- SFO – LAX
- LAX – SFO
- ORD – LGA
- LGA – ORD
Jede dieser 72 einzigartigen Queries wird 3 Mal wiederholt, insgesamt 216 Queries. Alle sind in den resultierenden Plots enthalten.
FROM ⟨sorted_table⟩SELECT flightdate, airline, origin, dest, deptime, arrtimeWHERE origin = ⟨origin⟩;
FROM ⟨sorted_table⟩SELECT flightdate, airline, origin, dest, deptime, arrtimeWHERE dest = ⟨dest⟩;
FROM ⟨sorted_table⟩SELECT flightdate, airline, origin, dest, deptime, arrtimeWHERE origin = ⟨origin⟩ AND dest = ⟨dest⟩;Experimentelle Ergebnisse
Wir sehen: Beim Filtern nach origin ist der origin-Einspalten-Sort eine volle Größenordnung schneller als zufällig verteilte Daten und braucht nur 1,6 Sekunden statt 16.
Unsere fortgeschrittenen Techniken sind fast so schnell wie der dedizierte Sort nach origin.
Schaut man sich aber den Plot nach destination an, sieht man den Wert der ausgewogeneren Techniken.
Der Ansatz origin_dest, destination einfach an die Liste der sortierten Spalten anzuhängen, verpasst einen großen Teil der Vorteile.
Beim Querying eines spezifischen origin- und destination-Paars sind alle Ansätze deutlich schneller als random.
Zoomt man auf die nicht-zufälligen Techniken, sind die fortgeschritteneren Sortieransätze so schnell oder etwas schneller als Sortieren nach origin oder nach origin und dann destination.
Die Performance ist auch schneller als in den anderen Experimenten, weil weniger Daten gelesen werden müssen (der Filter ist selektiver).
Die Hilbert-Encoding-Methode liefert die konsistenteste Performance über die drei Query-Muster. Sie wäre die beste Wahl, um alle 3 Workloads zu unterstützen.
Approximatives Zeit-Sortieren
Nach einer „approximativen Zeit“ zu sortieren bedeutet, die Zeit auf den nächsten Wert einer bestimmten Zeitgranularität zu trunkieren.
In diesem Experiment laden wir die Daten mit 3 verschiedenen Sortieransätzen.
Wir sortieren zuerst nach einer Trunkierung der Spalte flightdate, trunkiert auf Tag, Monat oder Jahr.
Dann nutzen wir unseren wirksamsten Mehrspalten-Ansatz und sortieren als Nächstes nach dem Hilbert-Encoding von origin und dest.
Bei der Performance-Messung testen wir jedes der zuvor getesteten Query-Muster (Filter auf origin, destination und origin / destination), filtern aber zusätzlich auf drei verschiedene Zeitbereiche: die letzten 1 Woche, 13 Wochen und 52 Wochen.
Aufklappen, um die Queries zum approximativen Zeit-Sortieren zu sehen
CREATE TABLE IF NOT EXISTS flights_hilbert_day AS FROM flights ORDER BY date_trunc('day', flightdate), hilbert_encode([ varchar_to_ubigint(origin, num_chars := 3), varchar_to_ubigint(dest, num_chars := 3) ]::UBIGINT[2]);
CREATE TABLE IF NOT EXISTS flights_hilbert_month AS FROM flights ORDER BY date_trunc('month', flightdate), hilbert_encode([ varchar_to_ubigint(origin, num_chars := 3), varchar_to_ubigint(dest, num_chars := 3) ]::UBIGINT[2]);
CREATE TABLE IF NOT EXISTS flights_hilbert_year AS FROM flights ORDER BY date_trunc('year', flightdate), hilbert_encode([ varchar_to_ubigint(origin, num_chars := 3), varchar_to_ubigint(dest, num_chars := 3) ]::UBIGINT[2]);Aufklappen, um die selektiven Lese-Queries zur Performance-Messung zu sehen
Zusammengefasst testet dieser Microbenchmark:
- Das Abfragen von Tabellen, sortiert mit
flightdatetrunkiert auf drei Granularitätsstufen: Tag, Monat und Jahr, dann nach Hilbert-Encoding. - Filtern auf drei Zeitbereiche: die letzten 1 Woche, 13 Wochen und 52 Wochen
- Filtern auf
origin,destinationundorigin/destination - Filtern mit 4 verschiedenen
origin/dest-Paaren:- SFO – LAX
- LAX – SFO
- ORD – LGA
- LGA – ORD
Jede dieser 108 einzigartigen Queries wird 3 Mal wiederholt, insgesamt 324 Queries.
FROM ⟨sorted_table⟩SELECT flightdate, airline, origin, dest, deptime, arrtimeWHERE origin = ⟨origin⟩ AND flightdate >= ('2022-07-31'::TIMESTAMP – INTERVAL ⟨time_range⟩ WEEKS);
FROM ⟨sorted_table⟩SELECT flightdate, airline, origin, dest, deptime, arrtimeWHERE dest = ⟨dest⟩ AND flightdate >= ('2022-07-31'::TIMESTAMP – INTERVAL ⟨time_range⟩ WEEKS);
FROM ⟨sorted_table⟩SELECT flightdate, airline, origin, dest, deptime, arrtimeWHERE origin = ⟨origin⟩ AND dest = ⟨dest⟩ AND flightdate >= ('2022-07-31'::TIMESTAMP – INTERVAL ⟨time_range⟩ WEEKS);Beim Querying einer Woche Daten für ein spezifisches origin performt das Sortieren auf Tagesebene am besten.
Sortieren nach einer approximativeren Zeit (Monat oder Jahr) performt aber besser, wenn die letzten 13 oder 52 Wochen analysiert werden.
Das liegt daran, dass die approximativeren Zeit-Buckets dem Hilbert-Encoding erlauben, origins wirksamer in unterschiedliche Row Groups zu trennen.
Querying nach Zeit und Destination folgt einem sehr ähnlichen Muster, wobei die ideale Sortierreihenfolge stark davon abhängt, wie weit zurück in der Zeit analysiert wird.
Filtern nach origin und destination zeigt ein ganz anderes Ergebnis: Jahresgranularität ist durchgängig überlegen!
Das liegt daran, dass die Filter origin und destination Row Groups viel wirksamer überspringen, wenn die Timestamp-Ordnung sehr approximativ ist.
Deshalb ist der beste Kompromiss über diese drei Workloads wahrscheinlich die sehr approximative Jahresgranularität. Sehen Sie! Nicht einfach nach Timestamp sortieren!
Tabellenerzeugungszeit
Die Vorab-Investition, um diese Vorteile zu sehen, ist die Zeit, die zum Sortieren der Daten beim Einfügen nötig ist. Typischerweise ist das trotzdem ein guter Trade-off – Daten-Inserts laufen tendenziell im Hintergrund, aber niemand will auf Dashboard-Lade-Spinner starren!
Eine DuckDB-Tabelle aus Parquet-Dateien ohne Sortieren zu erzeugen dauerte etwas über 21 Sekunden. Jeder andere Ansatz kopierte aus der unsortierten DuckDB-Tabelle und erzeugte eine neue Tabelle. Die verschiedenen Sortiermethoden brauchten ähnliche Zeiten (zwischen 48 und 61 Sekunden), wir können also die mit den wirksamsten Ergebnissen wählen, ohne relative Insert-Performance zu berücksichtigen. Es ist aber erwähnenswert, dass die Gesamt-Insert-Performance mit jedem Sortieren um fast 3× langsamer wird.
| Tabellenname | Erzeugungszeit (s) |
|---|---|
from_parquet |
21.4 |
random |
60.2 |
origin |
51.9 |
origin_dest |
48.7 |
zipped_varchar |
58.2 |
morton |
54.6 |
hilbert |
58.5 |
hilbert_day |
58.7 |
hilbert_month |
53.8 |
hilbert_year |
60.2 |
Sortedness messen
Der wirksamste Weg, eine Sortierreihenfolge zu wählen, ist, Ihren Produktions-Workload zu simulieren, wie in den Experimenten oben. Das ist aber nicht immer machbar oder einfach. Stattdessen können wir messen, wie gut der Datensatz auf den interessierenden Spalten sortiert ist. Die Metrik, die wir nutzen, ist „Number of Row Groups per Value“. Die Interpretation: Damit selektive Queries wirksam funktionieren, sollte jeder gefilterte Wert nur in einer kleinen Zahl von Row Groups vorkommen. Kleiner ist besser! Es gibt aber wahrscheinlich abnehmende Erträge, wenn diese Metrik unter der Zahl der Threads liegt, die DuckDB nutzt.
Andere Datenbanksysteme messen eine ähnliche Metrik namens „Clustering Depth“.
Was lässt sich aus diesem Graphen ablesen?
Die zufällige Ordnung verteilt fast jeden Wert über 100 oder mehr Row Groups (die Visualisierung ist bei 100 Row Groups aus Lesbarkeitsgründen abgeschnitten).
Das unterstreicht, wie langsam eine zufällige Ordnung für selektive Queries sein wird!
Sortieren nach origin reduziert die Zahl der Row Groups, über die jedes origin verteilt ist, stark, destinations sind aber weiterhin weit verteilt.
Sortieren nach origin und destination behält die enge Verteilung der origins und verbessert die Metrik für destinations leicht.
Die drei fortgeschrittenen Ansätze (zipped_varchar, morton und hilbert) sind ausgewogener: Sowohl origins als auch destinations kommen nur in einer moderaten Zahl von Row Groups vor.
Sie schneiden in der origin-Metrik schlechter ab als beim direkten Sortieren nach origin, die meisten origins sind aber über weniger Row Groups verteilt als die Kernzahl eines modernen Laptop-Prozessors, deshalb bleibt hohe Performance erhalten.
Das Hilbert-Encoding ist am ausgewogensten, nach dieser Metrik wäre es also ebenfalls der Sieger!
Um diese Metrik zu berechnen, definieren wir mehrere SQL-Macros mit dynamischem SQL und der Tabellenfunktion query.
Aufklappen, um die Macros zur Berechnung von „Number of Row Groups per Value“ zu sehen
-- These are helper functions for writing dynamic SQL-- sq = single quotes-- dq = double quotes-- nq = no quotesCREATE OR REPLACE FUNCTION sq(my_varchar) AS ( '''' || replace(my_varchar,'''', '''''') || '''');CREATE OR REPLACE FUNCTION dq(my_varchar) AS ( '"' || replace(my_varchar,'"', '""') || '"');CREATE OR REPLACE FUNCTION nq(my_varchar) AS ( replace(my_varchar, ';', 'No semicolons are permitted here'));
CREATE OR REPLACE FUNCTION dq_list(my_list) AS ( list_transform(my_list, (i) -> dq(i)));CREATE OR REPLACE FUNCTION nq_list(my_list) AS ( list_transform(my_list, (i) -> nq(i)));
CREATE OR REPLACE FUNCTION dq_concat(my_list, separator) AS ( list_reduce(dq_list(my_list), (x, y) -> x || separator || y));CREATE OR REPLACE FUNCTION nq_concat(my_list, separator) AS ( list_reduce(nq_list(my_list), (x, y) -> x || separator || y));
-- This function produces the "Number of row groups per Value" boxplotCREATE OR REPLACE FUNCTION rowgroup_counts(table_name, column_list) AS TABLE ( FROM query(' WITH by_rowgroup_id AS ( FROM ' || dq(table_name) || ' SELECT ceiling((count(*) OVER ()) / 122_880) AS total_row_groups, floor(rowid / 122_880) AS rowgroup_id, ' || dq_concat(column_list, ',') || ' ), rowgroup_id_counts AS ( FROM by_rowgroup_id SELECT case ' || nq_concat(list_transform(column_list, (i) -> ' when grouping(' || dq(i) || ') = 0 then alias(' || dq(i) || ') '),' ') || ' end AS column_name, coalesce(*columns(* exclude (rowgroup_id, total_row_groups))) AS column_value, first(total_row_groups) AS total_row_groups, count(distinct rowgroup_id) AS rowgroup_id_count GROUP BY GROUPING SETS ( ' || nq_concat(list_transform(dq_list(column_list), (j) -> '(' || j || ')'), ', ') ||' ) ) FROM rowgroup_id_counts SELECT ' || sq(table_name) || ' AS table_name, * ORDER BY column_name '));
-- This is an optional function that can summarize the data-- as an alternative to boxplot chartsCREATE OR REPLACE FUNCTION summarize_rowgroup_counts(table_name, column_list) AS TABLE ( FROM rowgroup_counts(table_name, column_list) SELECT table_name, column_name, total_row_groups, min(rowgroup_id_count) AS min_cluster_depth, avg(rowgroup_id_count) AS avg_cluster_depth, max(rowgroup_id_count) AS max_cluster_depth, map([0.1, 0.25, 0.5, 0.75, 0.9], quantile_cont(rowgroup_id_count, [0.1, 0.25, 0.5, 0.75, 0.9]))::JSON AS quantiles, histogram(rowgroup_id_count, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 32, 64, 128, 256])::JSON AS histograms, GROUP BY ALL ORDER BY ALL);Wir können die Funktion rowgroup_counts dann auf jede Tabelle und jede Spalte aufrufen!
FROM rowgroup_counts('flights_hilbert', ['origin', 'dest']);| table_name | column_name | column_value | total_row_groups | rowgroup_id_count |
|---|---|---|---|---|
| flights_hilbert | dest | PSG | 238 | 2 |
| flights_hilbert | dest | ESC | 238 | 2 |
| flights_hilbert | origin | YUM | 238 | 2 |
| … | … | … | … | … |
Die Spalte rowgroup_id_count misst, in wie vielen Distinct-Row-Groups ein spezifischer Spaltenwert vorkommt, sie ist also ein Indikator, wie viel Arbeit DuckDB tun müsste, um alle zu diesem Wert gehörenden Daten zu holen.
Diese Berechnung nutzt die Pseudo-Spalte
rowidund erfordert, dass Daten in einem einzelnen Batch eingefügt wurden, um perfekt genau zu sein. Sie ist nur eine approximative Metrik, wenn Daten in Batches eingefügt werden.
Fazit
Beim Speichern von Daten in einem spaltenorientierten Dateiformat wie einer DuckDB-Datenbank oder einer Parquet-Datei kann näherungsweises Sortieren nach mehreren Spalten zu schnellen Leseabfragen über eine Vielzahl von Query-Mustern führen. Sortieren mit Hilbert-Encoding lieferte hohe Performance über mehrere Workloads, und Sortieren nach Jahr und dann nach Hilbert performte gut, wenn auch nach Zeit gefiltert wurde.
Dank der Berechnung „Number of Row Groups per Value“ können wir die Sortedness jeder Tabelle nach jeder Spalte messen, und sie war prädiktiv für die experimentelle Performance, die wir beobachtet haben. So können wir mit verschiedenen Sortieransätzen experimentieren und ihre Wirksamkeit schnell vorhersagen, ohne jedes Mal Lese-Workloads zu benchmarken.
Diese Ansätze können die Dashboard-Interaktivität stark beschleunigen, wenn Nutzer unvorhersagbar sind (was wir immer sind!). Sie können auch mit den zusätzlichen Techniken aus dem vorherigen Beitrag kombiniert werden, für weitere Vorteile. Daten sind immer anders, also dürfen wir kreativ sein!
Happy analyzing!