2023-05-26
Korrelierte Subqueries in SQL
Mark Raasveldt
Subqueries in SQL sind eine mächtige Abstraktion: Einfache Abfragen werden zu zusammensetzbaren Bausteinen. Komplexe Probleme lassen sich in kleinere Teile zerlegen; große, komplexe Abfragen werden dadurch leichter zu schreiben, zu verstehen und zu warten.
DuckDB nutzt einen modernen Optimizer zur Dekorrelation von Subqueries, sodass Subqueries sehr effizient ausgeführt werden. Nutzer können Subqueries frei verwenden, um ausdrucksstarke Abfragen zu bauen, ohne sie manuell in Joins umschreiben zu müssen. Mehr dazu im Abschnitt Leistung.
Arten von Subqueries
SQL-Subqueries gibt es in zwei Hauptformen: Subqueries als Ausdrücke und Subqueries als Tabellen. Als Ausdrücke können sie in den Klauseln SELECT oder WHERE stehen. Als Tabellen können sie in der Klausel FROM stehen. In diesem Beitrag konzentrieren wir uns auf Subqueries als Ausdrücke. Ein späterer Beitrag behandelt Subqueries als Tabellen.
Subqueries als Ausdrücke gibt es in drei Formen.
- Skalare Subqueries
EXISTSIN/ANY/ALL
Alle können korreliert oder unkorreliert sein. Eine unkorrelierte Subquery ist unabhängig von der äußeren Abfrage. Eine korrelierte Subquery enthält Ausdrücke aus der äußeren Abfrage. Korrelierte Subqueries lassen sich als parametrisierte Subqueries verstehen.
Unkorrelierte skalare Subqueries
Unkorrelierte skalare Subqueries können nur einen einzelnen Wert zurückgeben. Dieser konstante Wert wird dann in der Abfrage eingesetzt. Warum das nützlich ist: Stellen Sie sich vor, wir wollen alle kürzesten Flüge in unserem Datensatz. Die kürzeste Flugstrecke liefert:
SELECT min(distance)FROM ontime;| min(distance) |
|---|
| 31.0 |
Diesen Abstand könnten wir manuell in die Klausel WHERE einsetzen, um alle Flüge auf dieser Strecke zu holen.
SELECT uniquecarrier, origincityname, destcityname, flightdateFROM ontimeWHERE distance = 31.0;| uniquecarrier | origincityname | destcityname | flightdate |
|---|---|---|---|
| AS | Petersburg, AK | Wrangell, AK | 2017-01-15 |
| AS | Wrangell, AK | Petersburg, AK | 2017-01-15 |
| AS | Petersburg, AK | Wrangell, AK | 2017-01-16 |
Dafür müssten wir die Konstante aber fest in die Abfrage schreiben. Nutzen wir die erste Abfrage als Subquery, berechnen wir die minimale Distanz als Teil der Abfrage.
SELECT uniquecarrier, origincityname, destcityname, flightdateFROM ontimeWHERE distance = ( SELECT min(distance) FROM ontime);Korrelierte skalare Subqueries
Unkorrelierte Subqueries sind mächtig, haben aber eine harte Grenze: Es darf nur ein einzelner Wert zurückkommen. Oft wollen wir die Abfrage parametrisieren, damit pro Zeile andere Werte zurückkommen.
Angenommen, wir wollen die kürzesten Flüge je Carrier finden. Den kürzesten Flug für einen bestimmten Carrier liefert die folgende parametrisierte Abfrage:
PREPARE min_distance_per_carrier ASSELECT min(distance)FROM ontimeWHERE uniquecarrier = ?;Das Prepared Statement liefert die minimale Distanz für einen bestimmten Carrier.
EXECUTE min_distance_per_carrier('UA');| min(distance) |
|---|
| 67.0 |
Wollen wir diese parametrisierte Abfrage als Subquery nutzen, brauchen wir eine korrelierte Subquery. Korrelierte Subqueries erlauben parametrisierte Abfragen als skalare Subqueries, indem sie Spalten aus der äußeren Abfrage referenzieren. Die Menge der kürzesten Flüge je Carrier erhalten wir so:
SELECT uniquecarrier, origincityname, destcityname, flightdate, distanceFROM ontime AS ontime_outerWHERE distance = ( SELECT min(distance) FROM ontime WHERE uniquecarrier = ontime_outer.uniquecarrier);| uniquecarrier | origincityname | destcityname | flightdate | distance |
|---|---|---|---|---|
| AS | Wrangell, AK | Petersburg, AK | 2017-01-01 | 31.0 |
| NK | Fort Lauderdale, FL | Orlando, FL | 2017-01-01 | 177.0 |
| VX | Las Vegas, NV | Los Angeles, CA | 2017-01-01 | 236.0 |
Beachten Sie, wie die Spalte der äußeren Relation (ontime_outer) innerhalb der Abfrage genutzt wird. Das macht die Subquery zur korrelierten Subquery. Die Spalte der äußeren Relation (ontime_outer.uniquecarrier) ist ein Parameter der Subquery. Logisch wird die Subquery einmal für jede Zeile in ontime ausgeführt; der Spaltenwert dieser Zeile wird als Parameter eingesetzt.
Um deutlicher zu machen, dass die korrelierte Subquery im Kern eine parametrisierte Abfrage ist, können wir mit DuckDBs Macros ein skalares Macro anlegen, das die Abfrage enthält.
CREATE MACRO min_distance_per_carrier(param) AS ( SELECT min(distance) FROM ontime WHERE uniquecarrier = param);Das Macro können wir in der ursprünglichen Abfrage wie eine Funktion nutzen.
SELECT uniquecarrier, origincityname, destcityname, flightdate, distanceFROM ontime AS ontime_outerWHERE distance = min_distance_per_carrier(ontime_outer.uniquecarrier);Das ergibt dasselbe wie die korrelierte Subquery in der Abfrage, ist aber sauberer, weil sich die Abfrage wirksamer in mehrere Teile zerlegen lässt.
EXISTS
EXISTS prüft, ob eine gegebene Subquery Ergebnisse hat. Als korrelierte Subquery ist das mächtig. Zum Beispiel, wenn wir den letzten Flug auf jeder Strecke holen wollen.
Alle Flüge auf einer gegebenen Strecke nach einem bestimmten Datum liefert:
PREPARE flights_after_date ASSELECT uniquecarrier, origincityname, destcityname, flightdate, distanceFROM ontimeWHERE origin = ? AND dest = ? AND flightdate > ?;EXECUTE flights_after_date('LAX', 'JFK', DATE '2017-05-01');| uniquecarrier | origincityname | destcityname | flightdate | distance |
|---|---|---|---|---|
| AA | Los Angeles, CA | New York, NY | 2017-08-01 | 2475.0 |
| AA | Los Angeles, CA | New York, NY | 2017-08-02 | 2475.0 |
| AA | Los Angeles, CA | New York, NY | 2017-08-03 | 2475.0 |
Für den letzten Flug auf einer Strecke brauchen wir Flüge, für die kein späterer Flug existiert.
SELECT uniquecarrier, origincityname, destcityname, flightdate, distanceFROM ontime AS ontime_outerWHERE NOT EXISTS ( SELECT uniquecarrier, origincityname, destcityname, flightdate, distance FROM ontime WHERE origin = ontime_outer.origin AND dest = ontime_outer.dest AND flightdate > ontime_outer.flightdate);| uniquecarrier | origincityname | destcityname | flightdate | distance |
|---|---|---|---|---|
| AA | Daytona Beach, FL | Charlotte, NC | 2017-02-27 | 416.0 |
| EV | Abilene, TX | Dallas/Fort Worth, TX | 2017-02-15 | 158.0 |
| EV | Dallas/Fort Worth, TX | Durango, CO | 2017-02-13 | 674.0 |
IN / ANY / ALL
IN prüft, ob ein gegebener Wert im Ergebnis der Subquery vorkommt. Alle Carrier mit mehr als 250 000 Flügen im Datensatz liefert zum Beispiel:
SELECT uniquecarrierFROM ontimeGROUP BY uniquecarrierHAVING count(*) > 250000;Über eine Klausel IN holen wir dann alle Flüge dieser Carrier.
SELECT *FROM ontimeWHERE uniquecarrier IN ( SELECT uniquecarrier FROM ontime GROUP BY uniquecarrier HAVING count(*) > 250000);Eine korrelierte Subquery ist hier nützlich, wenn wir nicht die Gesamtzahl der Flüge je Carrier zählen wollen, sondern die Gesamtzahl auf der gegebenen Strecke. Alle Flüge von Carriern mit mindestens 1000 Flügen auf einer gegebenen Strecke liefert:
SELECT *FROM ontime AS ontime_outerWHERE uniquecarrier IN ( SELECT uniquecarrier FROM ontime WHERE ontime.origin = ontime_outer.origin AND ontime.dest = ontime_outer.dest GROUP BY uniquecarrier HAVING count(*) > 1000);ANY und ALL verallgemeinern IN. IN prüft, ob der Wert in der von der Subquery gelieferten Menge vorkommt. Das entspricht = ANY(...). Die Operatoren ANY und ALL erlauben andere Vergleichsoperatoren (etwa >, <, <>). Die obige Abfrage lässt sich so nach ANY umschreiben.
SELECT *FROM ontime AS ontime_outerWHERE uniquecarrier = ANY ( SELECT uniquecarrier FROM ontime WHERE ontime.origin = ontime_outer.origin AND ontime.dest = ontime_outer.dest GROUP BY uniquecarrier HAVING count(*) > 1000);Leistung
Skalare Subqueries werden logisch einmal ausgeführt, korrelierte Subqueries logisch einmal pro Zeile. Natürlich denkt man daher, korrelierte Subqueries seien sehr teuer und aus Leistungsgründen zu vermeiden.
In vielen SQL-Systemen stimmt das – in DuckDB nicht. In DuckDB werden Subqueries immer dekorreliert. DuckDB nutzt einen modernen Algorithmus zur Dekorrelation, beschrieben im Paper Unnesting Arbitrary Queries. Alle Subqueries können dekorreliert und als eine einzige, deutlich effizientere Abfrage ausgeführt werden.
In DuckDB bedeutet Korrelation keine Leistungsverschlechterung.
Schauen wir uns den Query-Plan der korrelierten skalaren Subquery mit EXPLAIN an: Die Abfrage wurde in eine Hash-Aggregation und einen Hash-Join verwandelt. Damit läuft sie sehr effizient.
EXPLAIN SELECT uniquecarrier, origincityname, destcityname, flightdate, distanceFROM ontime AS ontime_outerWHERE distance = ( SELECT min(distance) FROM ontime WHERE uniquecarrier = ontime_outer.uniquecarrier);┌───────────────────────────┐│ HASH_JOIN ││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││ uniquecarrier = ││ uniquecarrier ├──────────────┐└─────────────┬─────────────┘ │┌─────────────┴─────────────┐┌─────────────┴─────────────┐│ SEQ_SCAN ││ HASH_GROUP_BY ││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││ ontime ││ uniquecarrier │└───────────────────────────┘│ min(distance) │ └─────────────┬─────────────┘ ┌─────────────┴─────────────┐ │ SEQ_SCAN │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ ontime │ └───────────────────────────┘Den drastischen Leistungsunterschied der Dekorrelation sieht man, wenn man die Laufzeit dieser Abfrage in DuckDB mit Postgres und SQLite vergleicht. Auf dem ontime-Datensatz für 2017 mit etwa ~4 Millionen Zeilen ergeben sich:
| DuckDB | Postgres | SQLite |
|---|---|---|
| 0.06 s | >48 hours | >48 hours |
Da Postgres und SQLite die Subquery nicht dekorrelieren, wird sie nicht nur logisch, sondern tatsächlich einmal pro Zeile ausgeführt. Die Subquery läuft in diesen Systemen also 4 Millionen Mal – das dauert enorm.
In diesem Fall lässt sich die Abfrage manuell dekorrelieren:
SELECT ontime.uniquecarrier, origincityname, destcityname, flightdate, distanceFROM ontimeJOIN ( SELECT uniquecarrier, min(distance) AS min_distance FROM ontime GROUP BY uniquecarrier ) AS subquery ON ontime.uniquecarrier = subquery.uniquecarrier AND distance = min_distance;Nach manueller Dekorrelation verbessert sich die Leistung von SQLite und Postgres deutlich. Beide Systeme bleiben aber über 30× langsamer als DuckDB.
| DuckDB | Postgres | SQLite |
|---|---|---|
| 0.06 s | 1.98 s | 2.81 s |
Bestimmte Subqueries lassen sich durch Umschreiben des SQL manuell dekorrelieren – aber nicht immer. Wie im Paper Unnesting Arbitrary Queries beschrieben, braucht man zum Dekorrelieren beliebiger Abfragen spezielle Join-Typen, die in SQL nicht vorkommen.
In DuckDB erzeugt das System diese speziellen Join-Typen automatisch, um alle Subqueries zu dekorrelieren. DuckDB kann Subqueries, die nicht dekorreliert sind, gar nicht ausführen. Alle Subqueries werden vor der Ausführung dekorreliert.
Fazit
Subqueries sind ein sehr mächtiges Werkzeug: Beliebige Abfragen werden zu Ad-hoc-Funktionen. Zusammen mit DuckDBs starker Dekorrelation laufen sie extrem effizient – zuvor unhandhabbare Abfragen werden nicht nur möglich, sondern schnell.