Как на самом деле работает рекурсия SQL?

19

В отличие от SQL из других языков программирования, структура рекурсивного запроса выглядит довольно странно. Пройдите через это шаг за шагом, и это, кажется, разваливается.

Рассмотрим следующий простой пример:

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;

Давайте пройдемся по нему.

Сначала выполняется элемент привязки, и набор результатов помещается в R. Таким образом, R инициализируется как {3, 5, 7}.

Затем выполнение падает ниже UNION ALL, и рекурсивный член выполняется впервые. Он выполняется на R (то есть на R, который у нас есть на данный момент: {3, 5, 7}). Это приводит к {9, 25, 49}.

Что это делает с этим новым результатом? Добавляет ли он {9, 25, 49} к существующему {3, 5, 7}, маркирует результирующее объединение R, а затем продолжает рекурсию оттуда? Или он переопределяет R как только этот новый результат {9, 25, 49} и делает все объединение позже?

Ни один из вариантов не имеет смысла.

Если R теперь {3, 5, 7, 9, 25, 49} и мы выполним следующую итерацию рекурсии, то получим {9, 25, 49, 81, 625, 2401} и мы потерял {3, 5, 7}.

Если R теперь только {9, 25, 49}, то у нас есть проблема с неправильной маркировкой. Под R понимается объединение результирующего набора якорных членов и всех последующих рекурсивных результирующих наборов членов. Принимая во внимание, что {9, 25, 49} является только компонентом R. Это не полный R, который мы накопили до сих пор. Поэтому записывать рекурсивный член как выбор из R не имеет смысла.


Я, безусловно, ценю то, что @Max Vernon и @Michael S. подробно описали ниже. А именно, что (1) все компоненты создаются до предела рекурсии или нулевого набора, а затем (2) все компоненты объединяются вместе. Вот как я понимаю рекурсию SQL на самом деле работать.

Если бы мы проектировали SQL, возможно, мы бы применили более четкий и явный синтаксис, примерно так:

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;

Вроде как индуктивное доказательство в математике.

Проблема с рекурсией SQL в ее нынешнем виде заключается в том, что она написана в замешательстве. То, как написано, говорит, что каждый компонент формируется путем выбора из R, но это не означает полный R, который был (или, кажется, был) построен до сих пор. Это просто означает предыдущий компонент.

UnLogicGuys
источник
«Если R теперь {3, 5, 7, 9, 25, 49} и мы выполним следующую итерацию рекурсии, то получим {9, 25, 49, 81, 625, 2401} и мы мы потеряли {3, 5, 7}. " Я не понимаю, как вы теряете {3,5,7}, если это так работает.
ypercubeᵀᴹ
@ yper-crazyhat-cubeᵀᴹ - я следовал из первой предложенной мной гипотезы, а именно, что, если промежуточный R является накоплением всего, что было вычислено до этого момента? Затем на следующей итерации рекурсивного члена каждый элемент R возводится в квадрат. Таким образом, {3, 5, 7} становится {9, 25, 49}, и у нас никогда больше не будет {3, 5, 7} в R. Другими словами, {3, 5, 7} потеряно от R.
UnLogicGuys

Ответы:

26

BOL-описание рекурсивных CTE описывает семантику рекурсивного выполнения следующим образом:

  1. Разделите выражение CTE на якорные и рекурсивные члены.
  2. Запустите элемент привязки, создавая первый вызов или базовый набор результатов (T0).
  3. Запустите рекурсивный элемент (ы) с Ti в качестве входа и Ti + 1 в качестве выхода.
  4. Повторяйте шаг 3, пока не будет возвращен пустой набор.
  5. Вернуть набор результатов. Это СОЮЗ ВСЕХ от T0 до Tn.

Таким образом, каждый уровень имеет на входе только уровень выше, а не весь набор результатов, накопленный до сих пор.

Выше, как это работает логически . Физически рекурсивные CTE в настоящее время всегда реализуются с помощью вложенных циклов и буфера стека в SQL Server. Это описано здесь и здесь и означает, что на практике каждый рекурсивный элемент просто работает с родительской строкой с предыдущего уровня, а не со всем уровнем. Но различные ограничения допустимого синтаксиса в рекурсивных CTE означают, что этот подход работает.

Если вы удалите ORDER BYиз запроса, результаты упорядочены следующим образом

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

Это потому, что план выполнения работает очень похоже на следующее 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: Как указано выше, к тому времени, когда первый дочерний элемент привязанного элемента 3обрабатывает всю информацию о своих братьях 5и сестрах, и 7, и их потомках, уже удален из буфера и больше недоступен.

NB2: C # выше имеет ту же общую семантику, что и план выполнения, но поток в плане выполнения не идентичен, так как там операторы работают в конвейерном порядке. Это упрощенный пример, демонстрирующий суть подхода. Смотрите более ранние ссылки для более подробной информации о самом плане.

NB3: сама катушка стека, по-видимому, реализована как неуникальный кластеризованный индекс с ключевым столбцом уровня рекурсии и добавлением уникализаторов по мере необходимости ( источник )

Мартин Смит
источник
6
Рекурсивные запросы в SQL Server всегда преобразуются из рекурсии в итерацию (со стеком) во время синтаксического анализа. Правило реализации для итерации IterateToDepthFirst- Iterate(seed,rcsv)->PhysIterate(seed,rcsv). Просто к вашему сведению. Отличный ответ.
Пол Уайт говорит GoFundMonica
Кстати, UNION также разрешен вместо UNION ALL, но SQL Server этого не сделает.
Джошуа
5

Это всего лишь (полу) образованное предположение, и, вероятно, оно совершенно неверно. Интересный вопрос, кстати.

T-SQL - это декларативный язык; возможно, рекурсивный CTE преобразуется в операцию в виде курсора, в которой результаты с левой стороны UNION ALL добавляются во временную таблицу, а затем правая часть UNION ALL применяется к значениям с левой стороны.

Итак, сначала мы вставляем вывод левой части UNION ALL в набор результатов, затем вставляем результаты правой части UNION ALL, примененной к левой стороне, и вставляем его в набор результатов. Затем левая сторона заменяется выводом с правой стороны, а правая сторона снова применяется к «новой» левой стороне. Что-то вроде этого:

  1. {3,5,7} -> набор результатов
  2. рекурсивные утверждения применяются к {3,5,7}, то есть {9,25,49}. {9,25,49} добавляется в набор результатов и заменяет левую часть UNION ALL.
  3. рекурсивные операторы применяются к {9,25,49}, то есть {81,625,2401}. {81,625,2401} добавляется в набор результатов и заменяет левую часть UNION ALL.
  4. рекурсивные операторы применяются к {81,625,2401}, то есть {6561,390625,5764801}. {6561,390625,5764801} добавляется в набор результатов.
  5. Курсор завершен, поскольку следующая итерация приводит к тому, что предложение WHERE возвращает false.

Вы можете увидеть это поведение в плане выполнения для рекурсивного CTE:

введите описание изображения здесь

Это шаг 1 выше, где левая сторона UNION ALL добавляется к выводу:

введите описание изображения здесь

Это правая часть UNION ALL, где вывод объединяется с набором результатов:

введите описание изображения здесь

Макс Вернон
источник
4

Документация по SQL Server , в которой упоминаются T i и T i + 1 , не очень понятна и не является точным описанием фактической реализации.

Основная идея заключается в том, что рекурсивная часть запроса просматривает все предыдущие результаты, но только один раз .

Может быть полезно посмотреть, как другие базы данных реализуют это (чтобы получить тот же результат). Документация Postgres гласит:

Оценка рекурсивных запросов

  1. Оцените нерекурсивный термин. Для UNION(но не UNION ALL), откажитесь от повторяющихся строк. Включите все оставшиеся строки в результат рекурсивного запроса, а также поместите их во временную рабочую таблицу .
  2. Пока рабочий стол не пуст, повторите эти шаги:
    1. Оцените рекурсивный термин, подставив текущее содержимое рабочей таблицы для рекурсивной самореференции. Для UNION(но не UNION ALL), отбросьте дублирующиеся строки и строки, которые дублируют любую предыдущую строку результата. Включите все оставшиеся строки в результат рекурсивного запроса, а также поместите их во временную промежуточную таблицу .
    2. Замените содержимое рабочей таблицы содержимым промежуточной таблицы, затем очистите промежуточную таблицу.

Примечание.
Строго говоря, этот процесс является итерацией, а не рекурсией, а RECURSIVEявляется терминологией, выбранной комитетом по стандартам SQL.

Документация по SQLite намекает на несколько иную реализацию, и этот алгоритм по одной строке за раз может быть самым простым для понимания:

Основной алгоритм для вычисления содержимого рекурсивной таблицы заключается в следующем:

  1. Запустите initial-selectи добавьте результаты в очередь.
  2. Пока очередь не пуста:
    1. Извлечь одну строку из очереди.
    2. Вставьте эту единственную строку в рекурсивную таблицу
    3. Представьте, что только что извлеченная строка является единственной строкой в ​​рекурсивной таблице, и запустите recursive-select, добавив все результаты в очередь.

Базовая процедура выше может быть изменена следующими дополнительными правилами:

  • Если оператор UNION соединяется initial-selectс оператором recursive-select, добавляйте строки в очередь только в том случае, если ранее не было добавлено ни одной идентичной строки. Повторные строки отбрасываются перед добавлением в очередь, даже если повторные строки уже были извлечены из очереди на этапе рекурсии. Если оператор UNION ALL, то все строки, сгенерированные как initial-selectи recursive-select, всегда добавляются в очередь, даже если они повторяются.
    [...]
CL.
источник
0

Мои знания конкретно в DB2, но взгляд на диаграммы объяснения, похоже, совпадает с SQL Server.

План идет отсюда:

Посмотреть это на Вставить план

План объяснения SQL Server

Оптимизатор не запускает объединение буквально для каждого рекурсивного запроса. Он берет структуру запроса и назначает первую часть объединения all «якорному члену», затем он проходит через вторую половину объединения all (называемую «рекурсивным членом» рекурсивно, пока не достигнет определенных ограничений. После рекурсия завершена, оптимизатор объединяет все записи вместе.

Оптимизатор воспринимает это как предопределенную операцию.

Майкл С.
источник