2024-09-09

Ankündigung: DuckDB 1.1.0

The DuckDB team

Um die neue Version zu installieren, besuchen Sie bitte den Installationsleitfaden. Die Release Notes finden Sie auf der Release-Seite.

Manche Pakete (R, Java) brauchen ein paar Extra-Tage zum Release wegen der Reviews, die in den Release-Pipelines nötig sind.

Wir sind stolz, DuckDB 1.1.0 zu veröffentlichen, unser erstes Release seit wir vor drei Monaten Version 1.0.0 veröffentlicht haben. Dieses Release trägt den Codenamen „Eatoni“ nach der Eatons Spießente (Anas eatoni), einer Gründelente, die nur auf zwei sehr abgelegenen Inselgruppen im südlichen Indischen Ozean vorkommt.

Was ist neu in 1.1.0

Es gab weit zu viele Änderungen, um sie alle im Detail zu besprechen, aber wir möchten mehrere besonders spannende Features hervorheben! Unten eine Zusammenfassung dieser neuen Features mit Beispielen.

Breaking SQL-Änderungen

IEEE-754-Semantik für Division durch Null. Der IEEE-754-Gleitkomma-Standard besagt, dass Division durch Null inf zurückgibt. Zuvor gab DuckDB NULL zurück, wenn durch Null geteilt wurde, auch bei Gleitkommadivision. Ab diesem Release gibt DuckDB stattdessen inf zurück.

SELECT 1 / 0 AS division_by_zero;
┌──────────────────┐
│ division_by_zero │
│ double │
├──────────────────┤
│ inf │
└──────────────────┘

ieee_floating_point_ops kann auf false gesetzt werden, um dieses Verhalten zurückzusetzen:

SET ieee_floating_point_ops = false;
SELECT 1 / 0 AS division_by_zero;
┌──────────────────┐
│ division_by_zero │
│ double │
├──────────────────┤
│ NULL │
└──────────────────┘

Fehler, wenn eine skalare Subquery mehrere Werte zurückgibt. Skalare Subqueries können nur einen einzelnen Wert pro Eingabezeile zurückgeben. Zuvor hat DuckDB das Verhalten von SQLite nachgemacht und eine beliebige Zeile zurückgegeben, wenn mehrere Zeilen zurückkamen. In der Praxis führte dieses Verhalten oft zu Verwirrung. Ab diesem Release wird stattdessen ein Fehler zurückgegeben, passend zum Verhalten von Postgres. Die Subquery kann mit array umwickelt werden, um alle Ergebnisse der Subquery in einer Liste zu sammeln.

SELECT (SELECT unnest(range(10)));
Terminal window
Invalid Input Error:
More than one row returned by a subquery used as an expression -
scalar subqueries can only return a single row.
SELECT array(SELECT unnest(range(10))) AS subquery_result;
┌────────────────────────────────┐
│ subquery_result │
│ int64[] │
├────────────────────────────────┤
│ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] │
└────────────────────────────────┘

Die Einstellung scalar_subquery_error_on_multiple_rows kann auf false gesetzt werden, um dieses Verhalten zurückzusetzen.

SET scalar_subquery_error_on_multiple_rows = false;
SELECT (SELECT unnest(range(10))) AS result;
┌────────┐
│ result │
│ int64 │
├────────┤
│ 0 │
└────────┘

Community Extensions

Kürzlich haben wir Community Extensions eingeführt. Community Extensions erlauben jedem, Extensions für DuckDB zu bauen, die dann von uns gebaut und verteilt werden. Die Liste der Community Extensions ist seitdem gewachsen.

In diesem Release haben wir daran gearbeitet, Community Extensions leichter zu bauen und zu erzeugen. Dieses Release enthält eine neue Methode, Extensions über die C-API zu registrieren, zusätzlich zu vielen Erweiterungen der C-API, die es erlauben, skalare Funktionen, Aggregatfunktionen und eigene Typen zu definieren. Diese Änderungen ermöglichen es, Extensions gegen eine stabile API zu bauen, die kleiner sind und über verschiedene DuckDB-Versionen hinweg funktionieren. Zusätzlich werden diese Änderungen in Zukunft das Bauen von Extensions in anderen Programmiersprachen ermöglichen.

Friendly SQL

Histogram. Diese Version führt die Funktion histogram ein, mit der Histogramme über Spalten eines Datensatzes berechnet werden können. Die Histogram-Funktion funktioniert für Spalten jedes Typs und erlaubt verschiedene Binning-Strategien und eine eigene Zahl von Bins.

FROM histogram(
'https://blobs.duckdb.org/data/ontime.parquet',
UniqueCarrier,
bin_count := 5
);
┌────────────────┬────────┬──────────────────────────────────────────────────────────────────────────────────┐
│ bin │ count │ bar │
│ varchar │ uint64 │ varchar │
├────────────────┼────────┼──────────────────────────────────────────────────────────────────────────────────┤
│ AA │ 677215 │ ██████████████████████████████████████████████████████▏ │
│ DL │ 696931 │ ███████████████████████████████████████████████████████▊ │
│ OO │ 521956 │ █████████████████████████████████████████▊ │
│ UA │ 435757 │ ██████████████████████████████████▉ │
│ WN │ 999114 │ ████████████████████████████████████████████████████████████████████████████████ │
│ (other values) │ 945484 │ ███████████████████████████████████████████████████████████████████████████▋ │
└────────────────┴────────┴──────────────────────────────────────────────────────────────────────────────────┘

SQL-Variablen. Dieses Release führt Unterstützung für Variablen ein, die in SQL definiert werden können. Variablen können einen einzelnen Wert jedes Typs halten – einschließlich verschachtelter Typen wie Listen oder Structs. Variablen können als Literale gesetzt werden oder aus skalaren Subqueries.

Der in Variablen gespeicherte Wert kann mit getvariable gelesen werden. Wird getvariable in einer Abfrage genutzt, wird es während Query-Planung und -Optimierung als Literal behandelt. Das erlaubt es, Variablen an Stellen zu nutzen, an denen wir normalerweise keine Werte aus Tabellen lesen können, zum Beispiel beim Angeben, welche CSV-Dateien gelesen werden sollen:

SET VARIABLE list_of_files = (SELECT LIST(file) FROM csv_files);
SELECT * FROM read_csv(getvariable('list_of_files'), filename := true);
┌───────┬───────────┐
│ a │ filename │
│ int64 │ varchar │
├───────┼───────────┤
│ 42 │ test.csv │
│ 84 │ test2.csv │
└───────┴───────────┘

Entpackte Spalten

Der COLUMNS-Ausdruck erlaubt Nutzern, dynamisches SQL über eine Menge von Spalten zu schreiben, ohne die Spalten explizit im SQL-String auflisten zu müssen. Stattdessen können die Spalten über eine Regex oder mit einer Lambda-Funktion berechnet ausgewählt werden.

Dieses Release erweitert diese Fähigkeit, indem es erlaubt, den COLUMNS-Ausdruck in eine Funktion zu entpacken. Das ist besonders nützlich in Kombination mit verschachtelten Funktionen wie struct_pack oder list_value.

CREATE TABLE many_measurements (
id INTEGER, m1 INTEGER, m2 INTEGER, m3 INTEGER
);
INSERT INTO many_measurements VALUES (1, 10, 100, 20);
SELECT id, struct_pack(*COLUMNS('m\d')) AS measurements
FROM many_measurements;
┌───────┬────────────────────────────────────────────┐
│ id │ measurements │
│ int32 │ struct(m1 integer, m2 integer, m3 integer) │
├───────┼────────────────────────────────────────────┤
│ 1 │ {'m1': 10, 'm2': 100, 'm3': 20} │
└───────┴────────────────────────────────────────────┘

Die Funktionen query und query_table

Die Funktionen query und query_table nehmen ein String-Literal und wandeln es in eine SELECT-Subquery oder eine Tabellenreferenz um. Beachten Sie, dass diese Funktionen nur Literal-Strings entgegennehmen können. Sie sind deshalb nicht so mächtig (oder gefährlich) wie ein generisches eval.

Diese Funktionen sind konzeptuell einfach, ermöglichen aber mächtiges und dynamischeres SQL. Zum Beispiel erlauben sie, einen Tabellennamen als Prepared-Statement-Parameter zu übergeben:

CREATE TABLE my_table (i INTEGER);
INSERT INTO my_table VALUES (42);
PREPARE select_from_table AS SELECT * FROM query_table($1);
EXECUTE select_from_table('my_table');
┌───────┐
│ i │
│ int32 │
├───────┤
│ 42 │
└───────┘

In Kombination mit dem COLUMNS-Ausdruck können wir sehr generische SQL-only-Makros schreiben. Unten zum Beispiel eine eigene Version von SUMMARIZE, die min und max jeder Spalte in einer Tabelle berechnet:

CREATE OR REPLACE MACRO my_summarize(table_name) AS TABLE
SELECT
unnest([*COLUMNS('alias_.*')]) AS column_name,
unnest([*COLUMNS('min_.*')]) AS min_value,
unnest([*COLUMNS('max_.*')]) AS max_value
FROM (
SELECT
any_value(alias(COLUMNS(*))) AS "alias_\0",
min(COLUMNS(*))::VARCHAR AS "min_\0",
max(COLUMNS(*))::VARCHAR AS "max_\0"
FROM query_table(table_name::VARCHAR)
);
SELECT *
FROM my_summarize('https://blobs.duckdb.org/data/ontime.parquet')
LIMIT 3;
┌─────────────┬───────────┬───────────┐
│ column_name │ min_value │ max_value │
│ varchar │ varchar │ varchar │
├─────────────┼───────────┼───────────┤
│ year │ 2017 │ 2017 │
│ quarter │ 1 │ 3 │
│ month │ 1 │ 9 │
└─────────────┴───────────┴───────────┘

Performance

Dynamischer Filter-Pushdown aus Joins

Dieses Release fügt eine sehr coole Optimierung für Joins hinzu: DuckDB erzeugt jetzt automatisch Filter für die größere Tabelle im Join während der Ausführung. Sagen wir, wir joinen zwei Tabellen A und B. A hat 100 Zeilen, B hat eine Million Zeilen. Wir joinen über einen gemeinsamen Schlüssel i. Gäbe es einen Filter auf i, würde DuckDB diesen Filter schon in den Scan pushen und die Kosten zum Abschließen der Abfrage stark reduzieren. Wir filtern jetzt aber nach einer anderen Spalte aus A, nämlich j:

CREATE TABLE A AS
SELECT range AS i, range AS j
FROM range(100);
CREATE TABLE B AS
SELECT t1.range AS i
FROM range(100) t1, range(10_000) t2;
SELECT count(*)
FROM A
JOIN B
USING (i) WHERE j > 90;

DuckDB führt diesen Join aus, indem es eine Hash-Tabelle auf der kleineren Tabelle A baut und diese Hash-Tabelle dann mit dem Inhalt von B probed. DuckDB beobachtet jetzt die Werte von i während des Baus der Hash-Tabelle auf A. Es erzeugt dann einen Min-Max-Range-Filter dieser Werte von i und wendet diesen Filter automatisch auf die Werte von i in B an! So entfernen wir früh (in diesem Fall) 90 % der Daten aus der großen Tabelle, bevor wir überhaupt in die Hash-Tabelle schauen. In diesem Beispiel führt das zu einer etwa 10× besseren Query-Performance. Die Optimierung kann auch in der Ausgabe von EXPLAIN ANALYZE beobachtet werden.

Automatische CTE-Materialisierung

Common Table Expressions (CTE) sind ein bequemer Weg, komplexe Abfragen in handhabbare Stücke zu zerlegen, ohne endloses Verschachteln von Subqueries. Hier ein kleines Beispiel für einen CTE:

WITH my_cte AS (SELECT range AS i FROM range(10))
SELECT i FROM my_cte WHERE i > 5;

Manchmal wird derselbe CTE mehrfach in derselben Abfrage referenziert. Zuvor wurde der CTE überall „kopiert“, wo er vorkam. Das erzeugt ein potenzielles Performance-Problem: Ist das Berechnen des CTE rechenintensiv, wäre es besser, seine Ergebnisse zu cachen („zu materialisieren“), statt das Ergebnis mehrfach an verschiedenen Stellen in derselben Abfrage zu berechnen. Unterschiedliche Filterbedingungen können aber für unterschiedliche Instanziierungen des CTE gelten, was ihre Berechnungskosten drastisch senken könnte. Ein klassisches No-Win-Szenario in Datenbanken. Es war bereits möglich, einen CTE explizit mit dem Schlüsselwort MATERIALIZED als materialisiert zu markieren, das erforderte aber manuelles Eingreifen.

Dieses Release fügt ein Feature hinzu, bei dem DuckDB automatisch entscheidet, ob ein CTE-Ergebnis materialisiert werden soll oder nicht, anhand einer Heuristik. Die Heuristik ist derzeit: Wenn der CTE eine Aggregation durchführt und mehr als einmal abgefragt wird, sollte er materialisiert werden. Wir planen, diese Heuristik in Zukunft zu erweitern.

Paralleles Streaming von Abfragen

DuckDB hat zwei verschiedene Methoden, Ergebnisse zu holen: materialisierte Ergebnisse und Streaming-Ergebnisse. Materialisierte Ergebnisse holen alle Daten, die in einem Ergebnis vorhanden sind, auf einmal und geben sie zurück. Streaming-Ergebnisse erlauben stattdessen, über die Daten in inkrementellen Schritten zu iterieren. Streaming-Ergebnisse sind entscheidend, wenn man mit großen Ergebnismengen arbeitet, weil sie nicht verlangen, dass die gesamte Ergebnismenge in den Speicher passt. In vorherigen Releases war die letzte Streaming-Phase aber auf einen einzelnen Thread beschränkt.

Parallelität ist entscheidend, um gute Query-Performance auf moderner Hardware zu bekommen, und dieses Release fügt Unterstützung für paralleles Streaming von Query-Ergebnissen hinzu. Das System nutzt alle verfügbaren Threads, um einen Query-Ergebnispuffer begrenzter Größe (ein paar Megabyte) zu füllen. Wenn Daten aus dem Ergebnispuffer konsumiert werden, starten die Threads neu und füllen den Puffer wieder. Die Größe des Puffers kann über den Parameter streaming_buffer_size konfiguriert werden.

Unten ein kleiner Benchmark mit ontime.parquet, um die Performance-Vorteile zu veranschaulichen, die mit dem Python-Streaming-Ergebnis-Interface erzielt werden können:

import duckdb
duckdb.sql("SELECT * FROM 'ontime.parquet' WHERE flightnum = 6805;").fetchone()
v1.0 v1.1
1.17 s 0.12 s

Paralleles union_by_name

Der Parameter union_by_name erlaubt die Kombination von – zum Beispiel – CSV-Dateien, die dieselben Spalten haben, aber nicht in derselben Reihenfolge. Dieses Release fügt Unterstützung für Parallelität hinzu, wenn union_by_name genutzt wird. Das verbessert die Leseleistung beim Nutzen von Union-by-Name auf mehreren Dateien deutlich.

Nested-ART-Überarbeitung (Foreign-Key-Ladebeschleunigung)

Wir haben die Index-Insert- und -Delete-Performance für Foreign Keys deutlich verbessert. Normalerweise inlinen wir Row-Identifier direkt in die Baumstruktur. Das ist aber unmöglich für Indexes, die viele Duplikate enthalten, wie es bei Foreign Keys der Fall ist. Stattdessen legen wir jetzt tatsächlich einen weiteren Indexeintrag für jeden Schlüssel an, der selbst ein weiterer „rekursiver“ Indexbaum ist. So können wir gute Insert- und Delete-Performance innerhalb von Indexeinträgen erreichen. Die Performance-Ergebnisse dieser Änderung sind drastisch; betrachten Sie das folgende Beispiel, in dem a 100 Zeilen hat und b eine Million Zeilen, die alle auf a verweisen:

CREATE TABLE a (i INTEGER, PRIMARY KEY (i));
CREATE TABLE b (i INTEGER, FOREIGN KEY (i) REFERENCES a(i));
INSERT INTO a FROM range(100);
INSERT INTO b SELECT a.range FROM range(100) a, range(10_000) b;

In der vorherigen Version dauerte das ca. 10 Sekunden auf einem MacBook. Jetzt dauert es 0,2 Sekunden dank der neuen Indexstruktur, eine ca. 50× Verbesserung!

Verbesserungen bei Window Functions

Window Functions werden in DuckDB viel genutzt, weshalb wir die Performance der Ausführung von Window Functions über große Datensätze kontinuierlich verbessern.

Die Window-Function-Modifier DISTINCT und FILTER können jetzt im Streaming-Modus ausgeführt werden. Streaming-Modus bedeutet, dass die Eingabedaten für den Operator nicht vollständig gesammelt und gepuffert werden müssen, bevor der Operator ausführen kann. Bei großen Zwischenergebnissen kann das einen sehr großen Performance-Einfluss haben. Die folgende Abfrage nutzt zum Beispiel jetzt den Streaming-Window-Operator:

SELECT
sum(DISTINCT i)
FILTER (i % 3 = 0)
OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
FROM range(10) tbl(i);

Wir haben auch Streaming-Modus implementiert für positive lead-Offsets.

Wir können jetzt Filter auf Spalten durch Window Functions pushen, die nach derselben Spalte partitioniert sind. Betrachten Sie zum Beispiel das folgende Szenario:

CREATE TABLE tbl2 AS SELECT range i FROM range(10);
SELECT i
FROM (SELECT i, SUM(i) OVER (PARTITION BY i) FROM tbl)
WHERE i > 5;

Zuvor konnte der Filter auf i nicht in den Scan auf tbl gepusht werden. Wir erkennen jetzt, dass das Pushen dieses Filters „durch“ das Window sicher ist, und der Optimizer tut das. Das kann über EXPLAIN verifiziert werden:

┌─────────────────────────────┐
│┌───────────────────────────┐│
││ Physical Plan ││
│└───────────────────────────┘│
└─────────────────────────────┘
┌─────────────┴─────────────┐
│ WINDOW │
│ ──────────────────── │
│ Projections: │
│ sum(i) OVER (PARTITION BY │
│ i) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ SEQ_SCAN │
│ ──────────────────── │
│ tbl │
│ │
│ Projections: i │
│ │
│ Filters: │
│ i>5 AND i IS NOT NULL │
│ │
│ ~2 Rows │
└───────────────────────────┘

Die blockierende (nicht-streaming) Version des Window-Operators verarbeitet Eingabedaten jetzt parallel. Das reduziert den Footprint des Window-Operators deutlich.

Siehe auch Richards Talk zum Thema auf der DuckCon #5 in Seattle vor ein paar Wochen.

Spatial-Features

GeoParquet

GeoParquet ist ein Erweiterungsformat des allgegenwärtigen Parquet-Formats, das standardisiert, wie Vektorgeometrien und ihre Metadaten in Parquet-Dateien kodiert werden. Damit können geografische Datensätze effizient in Parquet-Dateien gespeichert werden. Wenn die spatial-Extension installiert und geladen ist, wandelt das Lesen aus einer GeoParquet-Datei über DuckDBs normalen Parquet-Reader jetzt Geometriespalten automatisch in den Typ GEOMETRY um, zum Beispiel:

INSTALL spatial;
LOAD spatial;
FROM 'https://blobs.duckdb.org/data/geoparquet-example.parquet'
SELECT GEOMETRY g
LIMIT 10;
┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ g │
│ geometry │
├────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ MULTIPOLYGON (((180 -16.067132663642447, 180 -16.555216566639196, 179.36414266196414 -16.801354076946883, 17… │
│ POLYGON ((33.90371119710453 -0.95, 34.07261999999997 -1.059819999999945, 37.69868999999994 -3.09698999999994… │
│ POLYGON ((-8.665589565454809 27.656425889592356, -8.665124477564191 27.589479071558227, -8.684399786809053 2… │
│ MULTIPOLYGON (((-122.84000000000003 49.000000000000114, -122.97421000000001 49.00253777777778, -124.91024 49… │
│ MULTIPOLYGON (((-122.84000000000003 49.000000000000114, -120 49.000000000000114, -117.03121 49, -116.04818 4… │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

R-Tree

Die Spatial-Extension, die dieses Release begleitet, implementiert auch erste Unterstützung für das Anlegen räumlicher „R-Tree“-Indexes. Ein R-Tree-Index speichert die ungefähren Bounding Boxes jeder Geometrie in einer Spalte in einer zusätzlichen hierarchischen baumartigen Datenstruktur, in der jeder „Knoten“ eine Bounding Box enthält, die alle seine Kindknoten abdeckt. Das macht es wirklich schnell zu prüfen, welche Geometrien eine bestimmte Region of Interest schneiden, weil man viele Kandidaten schnell durch rekursives Absteigen im Baum ausschließen kann.

Unterstützung für Spatial Indexes war ein lange gewünschtes Feature auf der Spatial-Extension-Roadmap, und jetzt, da wir eines haben, öffnen sich eine Menge neuer Einsatzfälle und Richtungen für die Weiterentwicklung. Derzeit werden sie aber nur genutzt, um einfache Abfragen zu beschleunigen, die aus einer Tabelle mit einem Filter selektieren, der eine aus einer fest verdrahteten Menge räumlicher Prädikatfunktionen auf einer indexierten Geometriespalte und einer konstanten Geometrie anwendet. Das macht R-Tree-Indexes nützlich, wenn Sie eine sehr große Tabelle von Geometrien haben, die Sie wiederholt abfragen, aber keinen Full-Table-Scan durchführen wollen, wenn Sie sowieso nur an den Zeilen interessiert sind, deren Geometrien eine bestimmte Region schneiden oder in sie passen. Hier ein Beispiel, in dem wir sehen können, dass der Operator RTREE_INDEX_SCAN genutzt wird:

INSTALL spatial;
LOAD spatial;
-- Create a table with 10_000_000 random points
CREATE TABLE t1 AS SELECT point::GEOMETRY AS geom
FROM st_generatepoints(
{min_x: 0, min_y: 0, max_x: 10_000, max_y: 10_000}::BOX_2D,
10_000_000,
1337
);
-- Create an index on the table
CREATE INDEX my_idx ON t1 USING RTREE (geom);
-- Perform a query with a "spatial predicate" on the indexed geometry
-- column. Note how the second argument in this case,
-- the ST_MakeEnvelope call is a "constant"
SELECT count(*)
FROM t1
WHERE ST_Within(geom, ST_MakeEnvelope(450, 450, 650, 650));
3986

R-Tree-Indexes teilen sich weitgehend denselben Feature-Satz wie DuckDBs eingebauter ART-Index. Sie sind buffer-verwaltet, persistent, lazy von der Platte geladen und unterstützen Inserts, Updates und Deletes auf der Basistabelle. Sie können aber nicht genutzt werden, um Constraints durchzusetzen.

Abschließende Gedanken

Das waren ein paar Highlights – aber es gibt viele weitere Features und Verbesserungen in diesem Release. Die vollständigen Release Notes finden Sie auf GitHub.

Wir möchten uns erneut bei unserer großartigen Community bedanken, die DuckDB nutzt, coole Projekte auf DuckDB baut und DuckDB verbessert, indem sie uns Feedback gibt. Ihre Beiträge bedeuten uns wirklich viel!