Wie funktioniert die SQL-Rekursion?

19

Wenn man von anderen Programmiersprachen zu SQL kommt, sieht die Struktur einer rekursiven Abfrage ziemlich seltsam aus. Gehen Sie Schritt für Schritt durch, und es scheint auseinander zu fallen.

Betrachten Sie das folgende einfache Beispiel:

CREATE TABLE #NUMS
(N BIGINT);

INSERT INTO #NUMS
VALUES (3), (5), (7);

WITH R AS
(
    SELECT N FROM #NUMS
    UNION ALL
    SELECT N*N AS N FROM R WHERE N*N < 10000000
)
SELECT N FROM R ORDER BY N;

Lass uns durchgehen.

Zuerst wird das Ankerelement ausgeführt und die Ergebnismenge in R abgelegt. Daher wird R auf {3, 5, 7} initialisiert.

Dann fällt die Ausführung unter UNION ALL und das rekursive Element wird zum ersten Mal ausgeführt. Es wird auf R ausgeführt (d. H. Auf dem R, das wir derzeit in der Hand haben: {3, 5, 7}). Dies führt zu {9, 25, 49}.

Was macht es mit diesem neuen Ergebnis? Wird {9, 25, 49} an die vorhandene {3, 5, 7} angehängt, die resultierende Vereinigung R bezeichnet und die Rekursion von dort fortgesetzt? Oder definiert es R neu, nur dieses neue Ergebnis zu sein {9, 25, 49} und die gesamte Vereinigung später?

Weder Wahl macht Sinn.

Wenn R jetzt {3, 5, 7, 9, 25, 49} ist und wir die nächste Iteration der Rekursion ausführen, erhalten wir {9, 25, 49, 81, 625, 2401} hat {3, 5, 7} verloren.

Wenn R jetzt nur {9, 25, 49} ist, haben wir ein Problem mit der falschen Bezeichnung. Unter R versteht man die Vereinigung der Ankerelement-Ergebnismenge und aller nachfolgenden rekursiven Element-Ergebnismengen. Wobei {9, 25, 49} nur eine Komponente von R ist. Es ist nicht das vollständige R, das wir bisher angehäuft haben. Daher macht es keinen Sinn, das rekursive Element so zu schreiben, dass es aus R auswählt.


Ich weiß auf jeden Fall zu schätzen, was @Max Vernon und @Michael S. unten beschrieben haben. Das heißt, dass (1) alle Komponenten bis zur Rekursionsgrenze oder Nullmenge erstellt werden und dann (2) alle Komponenten zusammen unioniert werden. So verstehe ich, dass die SQL-Rekursion tatsächlich funktioniert.

Wenn wir SQL neu entwerfen würden, würden wir möglicherweise eine klarere und explizitere Syntax durchsetzen, etwa so:

WITH R AS
(
    SELECT   N
    INTO     R[0]
    FROM     #NUMS
    UNION ALL
    SELECT   N*N AS N
    INTO     R[K+1]
    FROM     R[K]
    WHERE    N*N < 10000000
)
SELECT N FROM R ORDER BY N;

Irgendwie ein induktiver Beweis in der Mathematik.

Das Problem mit der aktuellen SQL-Rekursion ist, dass sie verwirrend geschrieben ist. Die Art und Weise, wie es geschrieben steht, besagt, dass jede Komponente durch Auswahl von R gebildet wird, aber es bedeutet nicht, dass das gesamte R, das bisher konstruiert wurde (oder zu sein scheint). Es bedeutet nur die vorherige Komponente.

UnLogicGuys
quelle
"Wenn R jetzt {3, 5, 7, 9, 25, 49} ist und wir die nächste Iteration der Rekursion ausführen, erhalten wir {9, 25, 49, 81, 625, 2401} und wir ' habe {3, 5, 7} verloren. " Ich verstehe nicht, wie Sie {3,5,7} verlieren, wenn es so funktioniert.
ypercubeᵀᴹ
@ yper-crazyhat-cubeᵀᴹ - Ich knüpfte an die erste von mir vorgeschlagene Hypothese an, nämlich: Was ist, wenn das Zwischen-R eine Akkumulation von allem ist, was bis zu diesem Zeitpunkt berechnet wurde? Dann wird bei der nächsten Iteration des rekursiven Elements jedes Element von R quadriert. Somit wird {3, 5, 7} zu {9, 25, 49} und wir haben nie wieder {3, 5, 7} in R. Mit anderen Worten, {3, 5, 7} geht von R.
UnLogicGuys verloren

Antworten:

26

Die BOL-Beschreibung von rekursiven CTEs beschreibt die Semantik der rekursiven Ausführung wie folgt:

  1. Teilen Sie den CTE-Ausdruck in Anker- und rekursive Member auf.
  2. Führen Sie die Ankerelemente aus, die die erste Aufruf- oder Basisergebnismenge (T0) erstellen.
  3. Führen Sie die rekursiven Elemente mit Ti als Eingabe und Ti + 1 als Ausgabe aus.
  4. Wiederholen Sie Schritt 3, bis ein leerer Satz zurückgegeben wird.
  5. Gibt die Ergebnismenge zurück. Dies ist eine UNION ALL von T0 bis Tn.

Jede Ebene hat also nur die Ebene über der gesamten bisher gesammelten Ergebnismenge als Eingabe.

Das Obige ist, wie es logisch funktioniert . Physikalisch rekursive CTEs werden derzeit in SQL Server immer mit verschachtelten Schleifen und einem Stack-Spool implementiert. Dies wird hier und hier beschrieben und bedeutet, dass in der Praxis jedes rekursive Element nur mit der übergeordneten Zeile der vorherigen Ebene und nicht mit der gesamten Ebene arbeitet. Aufgrund der verschiedenen Einschränkungen der zulässigen Syntax in rekursiven CTEs funktioniert dieser Ansatz jedoch.

Wenn Sie das ORDER BYaus Ihrer Abfrage entfernen, werden die Ergebnisse wie folgt sortiert

+---------+
|    N    |
+---------+
|       3 |
|       5 |
|       7 |
|      49 |
|    2401 |
| 5764801 |
|      25 |
|     625 |
|  390625 |
|       9 |
|      81 |
|    6561 |
+---------+

Dies liegt daran, dass der Ausführungsplan dem folgenden sehr ähnlich ist C#

using System;
using System.Collections.Generic;
using System.Diagnostics;

public class Program
{
    private static readonly Stack<dynamic> StackSpool = new Stack<dynamic>();

    private static void Main(string[] args)
    {
        //temp table #NUMS
        var nums = new[] { 3, 5, 7 };

        //Anchor member
        foreach (var number in nums)
            AddToStackSpoolAndEmit(number, 0);

        //Recursive part
        ProcessStackSpool();

        Console.WriteLine("Finished");
        Console.ReadLine();
    }

    private static void AddToStackSpoolAndEmit(long number, int recursionLevel)
    {
        StackSpool.Push(new { N = number, RecursionLevel = recursionLevel });
        Console.WriteLine(number);
    }

    private static void ProcessStackSpool()
    {
        //recursion base case
        if (StackSpool.Count == 0)
            return;

        var row = StackSpool.Pop();

        int thisLevel = row.RecursionLevel + 1;
        long thisN = row.N * row.N;

        Debug.Assert(thisLevel <= 100, "max recursion level exceeded");

        if (thisN < 10000000)
            AddToStackSpoolAndEmit(thisN, thisLevel);

        ProcessStackSpool();
    }
}

NB1: Wie oben durch die Zeit , das erste Kind von Ankerelement 3wird alle Informationen über seine Geschwister verarbeitet werden, 5und 7, und ihre Nachkommen, bereits von der Spule verworfen worden und ist nicht mehr zugänglich.

NB2: Das obige C # hat die gleiche allgemeine Semantik wie der Ausführungsplan, aber der Ablauf im Ausführungsplan ist nicht identisch, da dort die Operatoren in einer Pipeline-Exektionsweise arbeiten. Dies ist ein vereinfachtes Beispiel, um den Kern des Ansatzes zu veranschaulichen. Weitere Informationen zum Plan selbst finden Sie unter den früheren Links.

Anmerkung 3: Die Stapelspule selbst ist anscheinend als nicht eindeutiger Clustered-Index implementiert, wobei die Schlüsselspalte der Rekursionsebene und die Uniqueifiers nach Bedarf hinzugefügt werden ( Quelle ).

Martin Smith
quelle
6
Rekursive Abfragen in SQL Server werden beim Parsen immer von Rekursion zu Iteration (mit Stacking) konvertiert. Die Implementierungsregel für die Iteration lautet IterateToDepthFirst- Iterate(seed,rcsv)->PhysIterate(seed,rcsv). Nur zur Info. Hervorragende Antwort.
Paul White sagt GoFundMonica
Übrigens ist auch UNION anstelle von UNION ALL zulässig, SQL Server jedoch nicht.
Joshua
5

Dies ist nur eine (halb) begründete Vermutung und wahrscheinlich völlig falsch. Interessante Frage übrigens.

T-SQL ist eine deklarative Sprache. Vielleicht wird ein rekursiver CTE in eine Cursor-artige Operation übersetzt, bei der die Ergebnisse von der linken Seite von UNION ALL an eine temporäre Tabelle angehängt werden. Dann wird die rechte Seite von UNION ALL auf die Werte auf der linken Seite angewendet.

Wir fügen also zuerst die Ausgabe der linken Seite von UNION ALL in die Ergebnismenge ein, dann die Ergebnisse der rechten Seite von UNION ALL, die auf die linke Seite angewendet wurden, und fügen diese in die Ergebnismenge ein. Die linke Seite wird dann durch die Ausgabe von der rechten Seite ersetzt, und die rechte Seite wird erneut auf die "neue" linke Seite angewendet. Etwas wie das:

  1. {3,5,7} -> Ergebnismenge
  2. rekursive Anweisungen werden auf {3,5,7} angewendet, d. h. {9,25,49}. {9,25,49} wird zur Ergebnismenge hinzugefügt und ersetzt die linke Seite von UNION ALL.
  3. rekursive Anweisungen wurden auf {9,25,49} angewendet, also auf {81,625,2401}. {81,625,2401} wird zur Ergebnismenge hinzugefügt und ersetzt die linke Seite von UNION ALL.
  4. Rekursive Anweisungen wurden auf {81,625,2401} angewendet, also auf {6561,390625,5764801}. {6561,390625,5764801} wurde zur Ergebnismenge hinzugefügt.
  5. Der Cursor ist vollständig, da bei der nächsten Iteration die WHERE-Klausel false zurückgibt.

Sie können dieses Verhalten im Ausführungsplan für den rekursiven CTE sehen:

Bildbeschreibung hier eingeben

Dies ist Schritt 1 oben, in dem die linke Seite von UNION ALL zur Ausgabe hinzugefügt wird:

Bildbeschreibung hier eingeben

Dies ist die rechte Seite von UNION ALL, auf der die Ausgabe mit der Ergebnismenge verknüpft ist:

Bildbeschreibung hier eingeben

Max Vernon
quelle
4

Die SQL Server-Dokumentation , in der T i und T i + 1 erwähnt werden , ist weder sehr verständlich noch enthält sie eine genaue Beschreibung der tatsächlichen Implementierung.

Die Grundidee ist, dass der rekursive Teil der Abfrage alle vorherigen Ergebnisse betrachtet, jedoch nur einmal .

Es kann hilfreich sein zu sehen, wie andere Datenbanken dies implementieren (um das gleiche Ergebnis zu erzielen ). In der Postgres-Dokumentation heißt es:

Auswertung rekursiver Abfragen

  1. Bewerten Sie den nicht rekursiven Term. Verwerfen Sie für UNION(aber nicht UNION ALL) doppelte Zeilen. Beziehen Sie alle verbleibenden Zeilen in das Ergebnis der rekursiven Abfrage ein und platzieren Sie sie auch in einer temporären Arbeitstabelle .
  2. Solange der Arbeitstisch nicht leer ist, wiederholen Sie diese Schritte:
    1. Bewerten Sie den rekursiven Term und ersetzen Sie die rekursive Selbstreferenz durch den aktuellen Inhalt der Arbeitstabelle. Verwerfen Sie für UNION(aber nicht UNION ALL) doppelte Zeilen und Zeilen, die alle vorherigen Ergebniszeilen duplizieren. Beziehen Sie alle verbleibenden Zeilen in das Ergebnis der rekursiven Abfrage ein und platzieren Sie sie auch in einer temporären Zwischentabelle .
    2. Ersetzen Sie den Inhalt der Arbeitstabelle durch den Inhalt der Zwischentabelle und leeren Sie die Zwischentabelle.

Hinweis
Streng genommen handelt es sich bei diesem Prozess nicht um eine Iteration, sondern RECURSIVEum die vom SQL-Standardisierungsausschuss gewählte Terminologie.

In der SQLite-Dokumentation wird auf eine etwas andere Implementierung hingewiesen, und dieser Algorithmus für eine einzelne Zeile ist möglicherweise am einfachsten zu verstehen:

Der grundlegende Algorithmus zum Berechnen des Inhalts der rekursiven Tabelle lautet wie folgt:

  1. Führen Sie das aus, initial-selectund fügen Sie die Ergebnisse einer Warteschlange hinzu.
  2. Während die Warteschlange nicht leer ist:
    1. Extrahieren Sie eine einzelne Zeile aus der Warteschlange.
    2. Fügen Sie diese einzelne Zeile in die rekursive Tabelle ein
    3. Stellen Sie sich vor, dass die gerade extrahierte einzelne Zeile die einzige Zeile in der rekursiven Tabelle ist, und führen Sie die aus recursive-select, und fügen Sie alle Ergebnisse zur Warteschlange hinzu.

Das oben beschriebene grundlegende Verfahren kann durch die folgenden zusätzlichen Regeln geändert werden:

  • Wenn ein UNION-Operator die initial-selectmit verbindet recursive-select, werden der Warteschlange nur Zeilen hinzugefügt, wenn der Warteschlange zuvor keine identische Zeile hinzugefügt wurde. Wiederholte Zeilen werden verworfen, bevor sie der Warteschlange hinzugefügt werden, selbst wenn die wiederholten Zeilen bereits durch den Rekursionsschritt aus der Warteschlange extrahiert wurden. Wenn der Operator UNION ALL ist, werden alle Zeilen, die sowohl vom initial-selectals auch vom generiert recursive-selectwerden, immer zur Warteschlange hinzugefügt, auch wenn es sich um Wiederholungen handelt.
    […]
CL.
quelle
0

Ich kenne mich speziell mit DB2 aus, aber der Blick auf die EXPLAIN-Diagramme scheint mit SQL Server identisch zu sein.

Der Plan kommt von hier:

Zeigen Sie es an, indem Sie den Plan einfügen

SQL Server-Erklärungsplan

Das Optimierungsprogramm führt nicht buchstäblich für jede rekursive Abfrage eine Union all aus. Es nimmt die Struktur der Abfrage an und weist den ersten Teil der Union all einem "Ankermitglied" zu. Anschließend durchläuft es die zweite Hälfte der Union all ("rekursives Mitglied" genannt) rekursiv, bis die festgelegten Einschränkungen erreicht sind Wenn die Rekursion abgeschlossen ist, fügt der Optimierer alle Datensätze zusammen.

Das Optimierungsprogramm versteht es lediglich als Vorschlag, eine vordefinierte Operation durchzuführen.

Michael S.
quelle