Como a recursão SQL realmente funciona?

19

Chegando ao SQL de outras linguagens de programação, a estrutura de uma consulta recursiva parece bastante estranha. Ande por ela passo a passo, e parece desmoronar.

Considere o seguinte exemplo simples:

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;

Vamos passar por isso.

Primeiro, o membro âncora é executado e o conjunto de resultados é colocado em R. Portanto, R é inicializado em {3, 5, 7}.

Em seguida, a execução cai abaixo do UNION ALL e o membro recursivo é executado pela primeira vez. Ele é executado em R (ou seja, no R que atualmente temos em mãos: {3, 5, 7}). Isso resulta em {9, 25, 49}.

O que isso faz com esse novo resultado? Ele anexa {9, 25, 49} ao {3, 5, 7} existente, rotula a união resultante R e continua com a recursão a partir daí? Ou redefine R para ser apenas esse novo resultado {9, 25, 49} e faz toda a união depois?

Nenhuma escolha faz sentido.

Se R agora for {3, 5, 7, 9, 25, 49} e executarmos a próxima iteração da recursão, terminaremos com {9, 25, 49, 81, 625, 2401} e teremos perdeu {3, 5, 7}.

Se R agora é apenas {9, 25, 49}, temos um problema de identificação incorreta. R é entendido como a união do conjunto de resultados do membro âncora e de todos os conjuntos de resultados do membro recursivo subsequentes. Enquanto {9, 25, 49} é apenas um componente de R. Não é o R completo que acumulamos até agora. Portanto, escrever o membro recursivo como selecionar R não faz sentido.


Eu certamente aprecio o que @Max Vernon e @Michael S. detalharam abaixo. Ou seja, que (1) todos os componentes são criados até o limite de recursão ou conjunto nulo e (2) todos os componentes são unidos. É assim que eu entendo a recursão do SQL para realmente funcionar.

Se estivéssemos redesenhando o SQL, talvez aplicássemos uma sintaxe mais clara e explícita, algo como isto:

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;

Mais ou menos como uma prova indutiva em matemática.

O problema com a recursão do SQL como está atualmente é que ele é escrito de uma maneira confusa. A maneira como está escrito diz que cada componente é formado pela seleção de R, mas isso não significa que o R completo que foi (ou, parece ter sido) construído até agora. Significa apenas o componente anterior.

UnLogicGuys
fonte
"Se R agora for {3, 5, 7, 9, 25, 49} e executarmos a próxima iteração da recursão, terminaremos com {9, 25, 49, 81, 625, 2401} e nós ' perdi {3, 5, 7}. " Não vejo como você perde {3,5,7} se funciona assim.
ypercubeᵀᴹ
@ yper-crazyhat-cubeᵀᴹ - Eu estava seguindo a primeira hipótese que propus, a saber, e se o R intermediário fosse um acúmulo de tudo o que havia sido calculado até aquele momento? Então, na próxima iteração do membro recursivo, todo elemento de R é elevado ao quadrado. Assim, {3, 5, 7} se torna {9, 25, 49} e nunca mais temos {3, 5, 7} em R. Em outras palavras, {3, 5, 7} se perde de R.
UnLogicGuys

Respostas:

26

A descrição da BOL de CTEs recursivas descreve a semântica da execução recursiva da seguinte maneira:

  1. Divida a expressão CTE em membros âncora e recursivos.
  2. Execute o (s) membro (s) âncora (s) criando a primeira chamada ou conjunto de resultados base (T0).
  3. Execute os membros recursivos com Ti como entrada e Ti + 1 como saída.
  4. Repita a etapa 3 até que um conjunto vazio seja retornado.
  5. Retorne o conjunto de resultados. Este é um UNION ALL de T0 a Tn.

Portanto, cada nível tem apenas como entrada o nível acima, e não todo o conjunto de resultados acumulado até o momento.

O acima é como funciona logicamente . Atualmente, as CTEs fisicamente recursivas são sempre implementadas com loops aninhados e um spool de pilha no SQL Server. Isso é descrito aqui e aqui e significa que, na prática, cada elemento recursivo está apenas trabalhando com a linha pai do nível anterior, não com o nível inteiro. Mas as várias restrições à sintaxe permitida em CTEs recursivas significam que essa abordagem funciona.

Se você remover o ORDER BYda sua consulta, os resultados serão ordenados da seguinte maneira

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

Isso ocorre porque o plano de execução opera de maneira muito semelhante à seguinte 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: Como acima pelo tempo que o primeiro filho do elemento de fixação 3está sendo processado todas as informações sobre seus irmãos, 5e 7, e seus descendentes, já foi descartada da bobina e não está mais acessível.

NB2: O C # acima possui a mesma semântica geral que o plano de execução, mas o fluxo no plano de execução não é idêntico, pois os operadores trabalham de maneira de exceção em pipeline. Este é um exemplo simplificado para demonstrar a essência da abordagem. Veja os links anteriores para obter mais detalhes sobre o próprio plano.

NB3: O próprio spool de pilha é aparentemente implementado como um índice clusterizado não exclusivo, com a coluna-chave do nível de recursão e os exclusivos adicionados conforme necessário ( origem )

Martin Smith
fonte
6
As consultas recursivas no SQL Server são sempre convertidas de recursão em iteração (com empilhamento) durante a análise. A regra de implementação para iteração é IterateToDepthFirst- Iterate(seed,rcsv)->PhysIterate(seed,rcsv). Apenas para sua informação. Excelente resposta.
Paul White diz GoFundMonica
Aliás, UNION também é permitido em vez de UNION ALL, mas o SQL Server não o faz.
Joshua
5

Este é apenas um palpite (semi) educado e provavelmente está completamente errado. Pergunta interessante, a propósito.

T-SQL é uma linguagem declarativa; talvez um CTE recursivo seja convertido em uma operação no estilo de cursor, em que os resultados do lado esquerdo do UNION ALL sejam anexados a uma tabela temporária; o lado direito do UNION ALL será aplicado aos valores no lado esquerdo.

Então, primeiro inserimos a saída do lado esquerdo do UNION ALL no conjunto de resultados, depois inserimos os resultados do lado direito do UNION ALL aplicados ao lado esquerdo e o inserimos no conjunto de resultados. O lado esquerdo é então substituído pela saída do lado direito e o lado direito é aplicado novamente ao "novo" lado esquerdo. Algo assim:

  1. {3,5,7} -> conjunto de resultados
  2. declarações recursivas aplicadas a {3,5,7}, que é {9,25,49}. {9,25,49} é adicionado ao conjunto de resultados e substitui o lado esquerdo do UNION ALL.
  3. declarações recursivas aplicadas a {9,25,49}, que é {81,625,2401}. {81,625,2401} é adicionado ao conjunto de resultados e substitui o lado esquerdo do UNION ALL.
  4. declarações recursivas aplicadas a {81,625,2401}, que é {6561,390625,5764801}. {6561,390625,5764801} foi adicionado ao conjunto de resultados.
  5. O cursor está completo, pois a próxima iteração resulta na cláusula WHERE retornando false.

Você pode ver esse comportamento no plano de execução do CTE recursivo:

insira a descrição da imagem aqui

Esta é a etapa 1 acima, onde o lado esquerdo do UNION ALL é adicionado à saída:

insira a descrição da imagem aqui

Este é o lado direito do UNION ALL, onde a saída é concatenada para o conjunto de resultados:

insira a descrição da imagem aqui

Max Vernon
fonte
4

A documentação do SQL Server , que menciona T i e T i + 1 , não é nem muito compreensível, nem uma descrição precisa da execução efectiva.

A idéia básica é que a parte recursiva da consulta analise todos os resultados anteriores, mas apenas uma vez .

Pode ser útil ver como outros bancos de dados implementam isso (para obter o mesmo resultado). A documentação do Postgres diz:

Avaliação de consulta recursiva

  1. Avalie o termo não recursivo. Para UNION(mas não UNION ALL), descarte linhas duplicadas. Inclua todas as linhas restantes no resultado da consulta recursiva e também as coloque em uma tabela de trabalho temporária .
  2. Enquanto a mesa de trabalho não estiver vazia, repita estas etapas:
    1. Avalie o termo recursivo, substituindo o conteúdo atual da tabela de trabalho pela auto-referência recursiva. Para UNION(mas não UNION ALL), descarte linhas duplicadas e linhas que duplicam qualquer linha de resultado anterior. Inclua todas as linhas restantes no resultado da consulta recursiva e também as coloque em uma tabela intermediária temporária .
    2. Substitua o conteúdo da tabela de trabalho pelo conteúdo da tabela intermediária e esvazie a tabela intermediária.

Nota
Estritamente falando, esse processo é iteração, não recursão, mas RECURSIVEé a terminologia escolhida pelo comitê de padrões SQL.

A documentação do SQLite sugere uma implementação um pouco diferente e esse algoritmo de uma linha por vez pode ser o mais fácil de entender:

O algoritmo básico para calcular o conteúdo da tabela recursiva é o seguinte:

  1. Execute o initial-selecte adicione os resultados a uma fila.
  2. Enquanto a fila não estiver vazia:
    1. Extraia uma única linha da fila.
    2. Inserir essa única linha na tabela recursiva
    3. Finja que a única linha extraída é a única linha na tabela recursiva e execute o recursive-select, adicionando todos os resultados à fila.

O procedimento básico acima pode ser modificado pelas seguintes regras adicionais:

  • Se um operador UNION conectar o initial-selectcom recursive-select, adicione apenas linhas à fila se nenhuma linha idêntica tiver sido adicionada anteriormente à fila. Linhas repetidas são descartadas antes de serem adicionadas à fila, mesmo que as linhas repetidas já tenham sido extraídas da fila pela etapa de recursão. Se o operador for UNION ALL, todas as linhas geradas por the initial-selecte recursive-selectserão sempre adicionadas à fila, mesmo que sejam repetidas.
    [...]
CL.
fonte
0

Meu conhecimento está especificamente no DB2, mas observar os diagramas de explicação parece o mesmo com o SQL Server.

O plano vem daqui:

Veja-o em Colar o plano

Plano de explicação do SQL Server

O otimizador não literalmente executa uma união para cada consulta recursiva. Ele pega a estrutura da consulta e atribui a primeira parte da união toda a um "membro âncora" e, em seguida, percorre a segunda metade da união toda (chamada de "membro recursivo" recursivamente até atingir as limitações definidas. a recursão estiver concluída, o otimizador une todos os registros.

O otimizador aceita apenas uma sugestão para executar uma operação predefinida.

Michael S.
fonte