Recuperando n linhas por grupo

88

Geralmente, preciso selecionar um número de linhas de cada grupo em um conjunto de resultados.

Por exemplo, convém listar os 'n' valores mais altos ou mais baixos de pedidos recentes por cliente.

Em casos mais complexos, o número de linhas a serem listadas pode variar por grupo (definido por um atributo do registro de agrupamento / pai). Esta parte é definitivamente opcional / para crédito extra e não pretende dissuadir as pessoas de responder.

Quais são as principais opções para resolver esses tipos de problemas no SQL Server 2005 e posterior? Quais são as principais vantagens e desvantagens de cada método?

Exemplos do AdventureWorks (para maior clareza, opcional)

  1. Liste as cinco datas e IDs de transações recentes mais recentes da TransactionHistorytabela, para cada produto que começa com uma letra de M a R. inclusive.
  2. O mesmo novamente, mas com nlinhas de histórico por produto, onde né cinco vezes o DaysToManufactureatributo Produto.
  3. O mesmo se aplica ao caso especial em que é necessária exatamente uma linha do histórico por produto (a entrada mais recente única de TransactionDatetie-break) TransactionID.
Paul White
fonte

Respostas:

70

Vamos começar com o cenário básico.

Se eu quiser obter algum número de linhas de uma tabela, tenho duas opções principais: funções de classificação; ou TOP.

Primeiro, vamos considerar todo o conjunto de Production.TransactionHistorypara um particular ProductID:

SELECT h.TransactionID, h.ProductID, h.TransactionDate
FROM Production.TransactionHistory h
WHERE h.ProductID = 800;

Isso retorna 418 linhas, e o plano mostra que ele verifica todas as linhas da tabela procurando por isso - uma Verificação de Índice em Cluster irrestrita, com um Predicado para fornecer o filtro. 797 lê aqui, o que é feio.

Digitalização cara com predicado 'Residual'

Então, vamos ser justos e criar um índice que seria mais útil. Nossas condições exigem uma correspondência de igualdade em ProductID, seguida de uma pesquisa pela mais recente por TransactionDate. Precisamos do TransactionIDtambém retornou, então vamos ir com: CREATE INDEX ix_FindingMostRecent ON Production.TransactionHistory (ProductID, TransactionDate) INCLUDE (TransactionID);.

Feito isso, nosso plano muda significativamente e reduz as leituras para apenas 3. Portanto, já estamos melhorando as coisas em mais de 250x ...

Plano melhorado

Agora que nivelamos o campo de jogo, vejamos as principais opções - funções de classificação e TOP.

WITH Numbered AS
(
SELECT h.TransactionID, h.ProductID, h.TransactionDate, ROW_NUMBER() OVER (ORDER BY TransactionDate DESC) AS RowNum
FROM Production.TransactionHistory h
WHERE h.ProductID = 800
)
SELECT TransactionID, ProductID, TransactionDate
FROM Numbered
WHERE RowNum <= 5;

SELECT TOP (5) h.TransactionID, h.ProductID, h.TransactionDate
FROM Production.TransactionHistory h
WHERE h.ProductID = 800
ORDER BY TransactionDate DESC;

Dois planos - TOP \ RowNum básico

Você notará que a segunda TOPconsulta ( ) é muito mais simples que a primeira, tanto na consulta quanto no plano. Mas, de maneira muito significativa, os dois usam TOPpara limitar o número de linhas realmente retiradas do índice. Os custos são apenas estimativas e merecem ser ignorados, mas você pode ver muita semelhança nos dois planos, com a ROW_NUMBER()versão fazendo uma pequena quantidade de trabalho extra para atribuir números e filtrar adequadamente, e as duas consultas acabam fazendo apenas 2 leituras para fazer trabalho deles. O Query Optimizer certamente reconhece a idéia de filtrar um ROW_NUMBER()campo, percebendo que pode usar um operador Top para ignorar linhas que não serão necessárias. Ambas as consultas são boas o suficiente - TOPnão é tão melhor que vale a pena alterar o código, mas é mais simples e provavelmente mais claro para iniciantes.

Portanto, isso funciona em um único produto. Mas precisamos considerar o que acontece se precisarmos fazer isso em vários produtos.

O programador iterativo irá considerar a idéia de fazer um loop entre os produtos de interesse e chamar essa consulta várias vezes, e podemos realmente escrever uma consulta neste formulário - não usando cursores, mas usando APPLY. Estou usando OUTER APPLY, imaginando que podemos devolver o produto com NULL, se não houver transações para ele.

SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM 
Production.Product p
OUTER APPLY (
    SELECT TOP (5) h.TransactionID, h.ProductID, h.TransactionDate
    FROM Production.TransactionHistory h
    WHERE h.ProductID = p.ProductID
    ORDER BY TransactionDate DESC
) t
WHERE p.Name >= 'M' AND p.Name < 'S';

O plano para isso é o método dos programadores iterativos - Nested Loop, executando uma operação Top e Seek (as duas leituras que tínhamos antes) para cada Produto. Isso fornece 4 leituras contra o produto e 360 ​​contra TransactionHistory.

APLICAR plano

Usando ROW_NUMBER(), o método é usar PARTITION BYna OVERcláusula, para reiniciar a numeração de cada Produto. Isso pode ser filtrado como antes. O plano acaba sendo bem diferente. As leituras lógicas são cerca de 15% mais baixas no TransactionHistory, com uma Verificação de Índice completa para obter as linhas.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY h.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5;

Plano ROW_NUMBER

Significativamente, porém, esse plano possui um caro operador de classificação. A junção de mesclagem não parece manter a ordem das linhas no TransactionHistory; os dados devem ser utilizados para poder encontrar os números de rown. É menos leituras, mas esse tipo de bloqueio pode parecer doloroso. Usando APPLY, o Nested Loop retornará as primeiras linhas muito rapidamente, depois de apenas algumas leituras, mas com uma Classificação, ROW_NUMBER()retornará as linhas somente após a conclusão da maior parte do trabalho.

Curiosamente, se a ROW_NUMBER()consulta usar em INNER JOINvez de LEFT JOIN, um plano diferente será exibido.

ROW_NUMBER () com INNER JOIN

Este plano usa um loop aninhado, assim como com APPLY. Mas como não há operador Top, ele puxa todas as transações para cada produto e usa muito mais leituras do que antes - 492 leituras no TransactionHistory. Não há uma boa razão para não escolher a opção Mesclar associação aqui, então acho que o plano foi considerado 'bom o suficiente'. Ainda - não bloqueia, o que é bom - apenas não tão bom quanto APPLY.

A PARTITION BYcoluna que eu usei ROW_NUMBER()era h.ProductIDnos dois casos, porque eu queria dar ao QO a opção de produzir o valor RowNum antes de ingressar na tabela Product. Se eu usar p.ProductID, vemos o mesmo plano de forma que a INNER JOINvariação.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY p.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5;

Mas o operador Join diz 'Join Exterior Esquerdo' em vez de 'Inner Join'. O número de leituras ainda está abaixo de 500 leituras na tabela TransactionHistory.

PARTITION BY em p.ProductID em vez de h.ProductID

Enfim - de volta à pergunta em questão ...

Respondemos à pergunta 1 , com duas opções para você escolher. Pessoalmente, gosto da APPLYopção.

Para estender isso para usar um número variável ( pergunta 2 ), o 5justo precisa ser alterado de acordo. Ah, e eu adicionei outro índice, para que houvesse um índice Production.Product.Nameque incluísse a DaysToManufacturecoluna.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, p.DaysToManufacture, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY h.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5 * DaysToManufacture;

SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM 
Production.Product p
OUTER APPLY (
    SELECT TOP (5 * p.DaysToManufacture) h.TransactionID, h.ProductID, h.TransactionDate
    FROM Production.TransactionHistory h
    WHERE h.ProductID = p.ProductID
    ORDER BY TransactionDate DESC
) t
WHERE p.Name >= 'M' AND p.Name < 'S';

E os dois planos são quase idênticos ao que eram antes!

Linhas variáveis

Novamente, ignore os custos estimados - mas ainda gosto do cenário TOP, pois é muito mais simples e o plano não tem operador de bloqueio. As leituras são menos no TransactionHistory por causa do alto número de zeros DaysToManufacture, mas na vida real, duvido que escolheríamos essa coluna. ;)

Uma maneira de evitar o bloqueio é criar um plano que lide com o ROW_NUMBER()bit à direita (no plano) da junção. Podemos convencer que isso aconteça fazendo a associação fora da CTE.

WITH Numbered AS
(
SELECT h.TransactionID, h.ProductID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY TransactionDate DESC) AS RowNum
FROM Production.TransactionHistory h
)
SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM Production.Product p
LEFT JOIN Numbered t ON t.ProductID = p.ProductID
    AND t.RowNum <= 5 * p.DaysToManufacture
WHERE p.Name >= 'M' AND p.Name < 'S';

O plano aqui parece mais simples - não está bloqueando, mas há um perigo oculto.

Participando fora da CTE

Observe o escalar de computação que está obtendo dados da tabela Produto. Isso está calculando o 5 * p.DaysToManufacturevalor. Esse valor não está sendo passado para a ramificação que extrai dados da tabela TransactionHistory, mas está sendo usado na junção de mesclagem. Como Residual.

Sneaky Residual!

Portanto, a junção de mesclagem está consumindo TODAS as linhas, não apenas as primeiras, porém muitas são necessárias, mas todas elas e, em seguida, fazendo uma verificação residual. Isso é perigoso à medida que o número de transações aumenta. Não sou fã desse cenário - predicados residuais no Merge Joins podem aumentar rapidamente. Outra razão pela qual eu prefiro o APPLY/TOPcenário.

No caso especial em que é exatamente uma linha, para a pergunta 3 , podemos obviamente usar as mesmas consultas, mas com em 1vez de 5. Mas então temos uma opção extra, que é usar agregados regulares.

SELECT ProductID, MAX(TransactionDate)
FROM Production.TransactionHistory
GROUP BY ProductID;

Uma consulta como essa seria um começo útil e poderíamos modificá-la facilmente para extrair o TransactionID também para fins de desempate (usando uma concatenação que seria então dividida), mas analisamos o índice inteiro ou mergulhamos produto por produto e não obtemos realmente uma grande melhoria no que tínhamos antes nesse cenário.

Mas devo salientar que estamos analisando um cenário específico aqui. Com dados reais e uma estratégia de indexação que pode não ser ideal, a milhagem pode variar consideravelmente. Apesar do fato de que vimos que APPLYé forte aqui, pode ser mais lento em algumas situações. Porém, raramente bloqueia, pois tem uma tendência a usar loops aninhados, que muitas pessoas (inclusive eu) acham muito atraente.

Não tentei explorar o paralelismo aqui, nem mergulhei muito na questão 3, que considero um caso especial que as pessoas raramente querem com base na complicação de concatenar e dividir. O principal a considerar aqui é que essas duas opções são muito fortes.

Eu prefiro APPLY. É claro, ele usa bem o operador Top e raramente causa bloqueio.

Rob Farley
fonte
44

A maneira típica de fazer isso no SQL Server 2005 e posterior é usar um CTE e funções de janela. Para os n primeiros por grupo, você pode simplesmente usar ROW_NUMBER()com uma PARTITIONcláusula e filtrá-la na consulta externa. Assim, por exemplo, os 5 principais pedidos mais recentes por cliente podem ser exibidos desta maneira:

DECLARE @top INT;
SET @top = 5;

;WITH grp AS 
(
   SELECT CustomerID, OrderID, OrderDate,
     rn = ROW_NUMBER() OVER
     (PARTITION BY CustomerID ORDER BY OrderDate DESC)
   FROM dbo.Orders
)
SELECT CustomerID, OrderID, OrderDate
  FROM grp
  WHERE rn <= @top
  ORDER BY CustomerID, OrderDate DESC;

Você também pode fazer isso com CROSS APPLY:

DECLARE @top INT;
SET @top = 5;

SELECT c.CustomerID, o.OrderID, o.OrderDate
FROM dbo.Customers AS c
CROSS APPLY 
(
    SELECT TOP (@top) OrderID, OrderDate 
    FROM dbo.Orders AS o
    WHERE CustomerID = c.CustomerID
    ORDER BY OrderDate DESC
) AS o
ORDER BY c.CustomerID, o.OrderDate DESC;

Com a opção adicional especificada por Paul, digamos que a tabela Customers tenha uma coluna indicando quantas linhas incluir por cliente:

;WITH grp AS 
(
   SELECT CustomerID, OrderID, OrderDate,
     rn = ROW_NUMBER() OVER
     (PARTITION BY CustomerID ORDER BY OrderDate DESC)
   FROM dbo.Orders
)
SELECT c.CustomerID, grp.OrderID, grp.OrderDate
  FROM grp 
  INNER JOIN dbo.Customers AS c
  ON grp.CustomerID = c.CustomerID
  AND grp.rn <= c.Number_of_Recent_Orders_to_Show
  ORDER BY c.CustomerID, grp.OrderDate DESC;

E, novamente, usando CROSS APPLYe incorporando a opção adicionada de que o número de linhas de um cliente seja determinado por alguma coluna na tabela customers:

SELECT c.CustomerID, o.OrderID, o.OrderDate
FROM dbo.Customers AS c
CROSS APPLY 
(
    SELECT TOP (c.Number_of_Recent_Orders_to_Show) OrderID, OrderDate 
    FROM dbo.Orders AS o
    WHERE CustomerID = c.CustomerID
    ORDER BY OrderDate DESC
) AS o
ORDER BY c.CustomerID, o.OrderDate DESC;

Observe que eles terão um desempenho diferente, dependendo da distribuição de dados e da disponibilidade dos índices de suporte; portanto, otimizar o desempenho e obter o melhor plano realmente dependerá de fatores locais.

Pessoalmente, prefiro as soluções CTE e de janelas em vez de CROSS APPLY/ TOPporque elas separam a lógica melhor e são mais intuitivas (para mim). Em geral (neste caso e na minha experiência geral), a abordagem CTE produz planos mais eficientes (exemplos abaixo), mas isso não deve ser tomado como uma verdade universal - você sempre deve testar seus cenários, especialmente se os índices mudaram ou os dados se inclinaram significativamente.


Exemplos do AdventureWorks - sem nenhuma alteração

  1. Liste as cinco datas e IDs de transações recentes mais recentes da TransactionHistorytabela, para cada produto que começa com uma letra de M a R. inclusive.
-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn <= 5;

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (5) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Comparação dessas duas métricas de tempo de execução:

insira a descrição da imagem aqui

CTE / OVER()plano:

insira a descrição da imagem aqui

CROSS APPLY plano:

insira a descrição da imagem aqui

O plano de CTE parece mais complicado, mas na verdade é muito mais eficiente. Preste pouca atenção aos números estimados de% de custo, mas concentre-se em observações reais mais importantes , como muito menos leituras e uma duração muito menor. Eu também os executei sem paralelismo, e essa não era a diferença. Métricas de tempo de execução e o plano CTE (o CROSS APPLYplano permaneceu o mesmo):

insira a descrição da imagem aqui

insira a descrição da imagem aqui

  1. O mesmo novamente, mas com nlinhas de histórico por produto, onde né cinco vezes o DaysToManufactureatributo Produto.

Mudanças muito pequenas são necessárias aqui. Para o CTE, podemos adicionar uma coluna à consulta interna e filtrar a consulta externa; para o CROSS APPLY, podemos realizar o cálculo dentro do correlacionado TOP. Você acha que isso daria alguma eficiência à CROSS APPLYsolução, mas isso não acontece neste caso. Consultas:

-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, p.DaysToManufacture, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn <= (5 * DaysToManufacture);

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (5 * p.DaysToManufacture) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Resultados do tempo de execução:

insira a descrição da imagem aqui

CTE / OVER()plano paralelo :

insira a descrição da imagem aqui

CTE / OVER()plano de thread único :

insira a descrição da imagem aqui

CROSS APPLY plano:

insira a descrição da imagem aqui

  1. O mesmo se aplica ao caso especial em que é necessária exatamente uma linha do histórico por produto (a entrada mais recente única de TransactionDatetie-break) TransactionID.

Mais uma vez, pequenas mudanças aqui. Na solução CTE, adicionamos TransactionIDà OVER()cláusula e alteramos o filtro externo para rn = 1. Para o CROSS APPLY, alteramos o TOPpara TOP (1)e adicionamos TransactionIDao interno ORDER BY.

-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC, TransactionID DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn = 1;

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (1) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC, TransactionID DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Resultados do tempo de execução:

insira a descrição da imagem aqui

CTE / OVER()plano paralelo :

insira a descrição da imagem aqui

Plano CTE / OVER () de thread único:

insira a descrição da imagem aqui

CROSS APPLY plano:

insira a descrição da imagem aqui

As funções de janelas nem sempre são a melhor alternativa (tente COUNT(*) OVER()) e essas não são as duas únicas abordagens para resolver o problema de n linhas por grupo, mas nesse caso específico - dado o esquema, os índices existentes e a distribuição de dados - o CTE se saiu melhor em todas as contas significativas.


Exemplos do AdventureWorks - com flexibilidade para adicionar índices

No entanto, se você adicionar um índice de suporte, semelhante ao que Paulo mencionou em um comentário, mas com a 2ª e a 3ª colunas ordenadas DESC:

CREATE UNIQUE NONCLUSTERED INDEX UQ3 ON Production.TransactionHistory 
  (ProductID, TransactionDate DESC, TransactionID DESC);

Na verdade, você obteria planos muito mais favoráveis ​​e as métricas alternariam para favorecer a CROSS APPLYabordagem nos três casos:

insira a descrição da imagem aqui

Se esse fosse o meu ambiente de produção, provavelmente ficaria satisfeito com a duração desse caso e não me incomodaria em otimizar ainda mais.


Isso tudo foi muito mais feio no SQL Server 2000, que não suportava APPLYnem a OVER()cláusula.

Aaron Bertrand
fonte
24

No DBMS, como o MySQL, que não possui funções de janela ou CROSS APPLY, a maneira de fazer isso seria usar o SQL padrão (89). O caminho mais lento seria uma junção cruzada triangular com agregados. A maneira mais rápida (mas ainda assim e provavelmente não tão eficiente quanto o uso de aplicação cruzada ou a função row_number) seria o que eu chamo de "homem pobre CROSS APPLY" . Seria interessante comparar esta consulta com as outras:

Suposição: Orders (CustomerID, OrderDate)tem uma UNIQUErestrição:

DECLARE @top INT;
SET @top = 5;

SELECT o.CustomerID, o.OrderID, o.OrderDate
  FROM dbo.Customers AS c
    JOIN dbo.Orders AS o
      ON  o.CustomerID = c.CustomerID
      AND o.OrderID IN
          ( SELECT TOP (@top) oi.OrderID
            FROM dbo.Orders AS oi
            WHERE oi.CustomerID = c.CustomerID
            ORDER BY oi.OrderDate DESC
          )
  ORDER BY CustomerID, OrderDate DESC ;

Para o problema extra de linhas superiores personalizadas por grupo:

SELECT o.CustomerID, o.OrderID, o.OrderDate
  FROM dbo.Customers AS c
    JOIN dbo.Orders AS o
      ON  o.CustomerID = c.CustomerID
      AND o.OrderID IN
          ( SELECT TOP (c.Number_of_Recent_Orders_to_Show) oi.OrderID
            FROM dbo.Orders AS oi
            WHERE oi.CustomerID = c.CustomerID
            ORDER BY oi.OrderDate DESC
          )
  ORDER BY CustomerID, OrderDate DESC ;

Nota: No MySQL, em vez de AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...)um usaria AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1)). SQL-Server adicionou FETCH / OFFSETsintaxe na versão 2012. As consultas aqui foram ajustadas IN (TOP...)para funcionar com versões anteriores.

ypercubeᵀᴹ
fonte
21

Eu tomei uma abordagem um pouco diferente, principalmente para ver como essa técnica se compara às outras, porque ter opções é bom, certo?

O teste

Por que não começamos apenas olhando como os vários métodos se comparam. Eu fiz três conjuntos de testes:

  1. O primeiro conjunto foi executado sem modificações no banco de dados
  2. O segundo conjunto foi executado depois que um índice foi criado para suportar TransactionDateconsultas baseadas em Production.TransactionHistory.
  3. O terceiro set fez uma suposição um pouco diferente. Como todos os três testes foram executados na mesma lista de produtos, e se colocarmos em cache essa lista? Meu método usa um cache na memória enquanto os outros métodos usavam uma tabela temporária equivalente. O índice de suporte criado para o segundo conjunto de testes ainda existe para esse conjunto de testes.

Detalhes adicionais do teste:

  • Os testes foram executados AdventureWorks2012no SQL Server 2012, SP2 (Developer Edition).
  • Para cada teste, identifiquei de quem recebi a resposta e de qual consulta específica.
  • Usei a opção "Descartar resultados após a execução" de Opções de consulta | Resultados.
  • Observe que, para os dois primeiros conjuntos de testes, o RowCountsparece estar "desativado" para o meu método. Isso se deve ao fato de meu método ser uma implementação manual do que CROSS APPLYestá sendo feito: ele executa a consulta inicial Production.Producte recupera 161 linhas, que são usadas para as consultas Production.TransactionHistory. Portanto, os RowCountvalores para minhas entradas são sempre 161 a mais que as outras entradas. No terceiro conjunto de testes (com armazenamento em cache), a contagem de linhas é a mesma para todos os métodos.
  • Eu usei o SQL Server Profiler para capturar as estatísticas em vez de confiar nos planos de execução. Aaron e Mikael já fizeram um ótimo trabalho mostrando os planos para suas consultas e não há necessidade de reproduzir essas informações. E a intenção do meu método é reduzir as consultas para uma forma tão simples que realmente não importa. Há um motivo adicional para usar o Profiler, mas isso será mencionado posteriormente.
  • Em vez de usar a Name >= N'M' AND Name < N'S'construção, eu escolhi usar Name LIKE N'[M-R]%', e o SQL Server os trata da mesma maneira.

Os resultados

Nenhum índice de suporte

Este é essencialmente o AdventureWorks2012 pronto para uso. Em todos os casos, meu método é claramente melhor que alguns outros, mas nunca tão bom quanto os 1 ou 2 métodos mais importantes.

Teste 1 Resultados do teste 1 - sem índice
O CTE de Aaron é claramente o vencedor aqui.

Teste 2 Resultados do teste 2 - sem índice
O CTE de Aaron (novamente) e o segundo apply row_number()método de Mikael é um segundo próximo.

Teste 3 Resultados do teste 3 - sem índice
O CTE de Aaron (novamente) é o vencedor.

Conclusão
Quando não há índice de suporte ativado TransactionDate, meu método é melhor do que fazer um padrão CROSS APPLY, mas ainda assim, usar o método CTE é claramente o caminho a percorrer.

Com índice de suporte (sem armazenamento em cache)

Para esse conjunto de testes, adicionei o índice óbvio, TransactionHistory.TransactionDatepois todas as consultas são classificadas nesse campo. Eu digo "óbvio", já que a maioria das outras respostas também concorda com esse ponto. E como as consultas estão todas querendo as datas mais recentes, o TransactionDatecampo deve ser ordenado DESC, então peguei a CREATE INDEXdeclaração na parte inferior da resposta de Mikael e adicionei um explícito FILLFACTOR:

CREATE INDEX [IX_TransactionHistoryX]
    ON Production.TransactionHistory (ProductID ASC, TransactionDate DESC)
    WITH (FILLFACTOR = 100);

Quando esse índice está em vigor, os resultados mudam bastante.

Teste 1 Resultados do teste 1 - com índice de suporte
Desta vez, é o meu método que sai à frente, pelo menos em termos de leituras lógicas. O CROSS APPLYmétodo, anteriormente o pior desempenho do Teste 1, vence no Duration e até supera o método CTE nas Logical Reads.

Teste 2 Resultados do Teste 2 - com índice de suporte
Desta vez, é o primeiro apply row_number()método de Mikael que vence quando se lê o Reads, enquanto anteriormente era um dos piores desempenhos. E agora meu método chega em um segundo lugar muito próximo ao olhar para o Reads. De fato, fora do método CTE, o restante é bastante próximo em termos de leitura.

Teste 3 Resultados do teste 3 - com índice de suporte
Aqui o CTE ainda é o vencedor, mas agora a diferença entre os outros métodos é quase imperceptível em comparação com a diferença drástica que existia antes da criação do índice.

Conclusão
A aplicabilidade do meu método é mais aparente agora, embora seja menos resistente a não ter índices adequados.

Com índice e cache de suporte

Para esse conjunto de testes, usei o cache porque, bem, por que não? Meu método permite o uso de cache na memória que os outros métodos não podem acessar. Portanto, para ser justo, criei a seguinte tabela temporária que foi usada no lugar de Product.Producttodas as referências nesses outros métodos nos três testes. O DaysToManufacturecampo é usado apenas no Teste Número 2, mas era mais fácil ser consistente nos scripts SQL para usar a mesma tabela e não fazia mal tê-la lá.

CREATE TABLE #Products
(
    ProductID INT NOT NULL PRIMARY KEY,
    Name NVARCHAR(50) NOT NULL,
    DaysToManufacture INT NOT NULL
);

INSERT INTO #Products (ProductID, Name, DaysToManufacture)
    SELECT  p.ProductID, p.Name, p.DaysToManufacture
    FROM    Production.Product p
    WHERE   p.Name >= N'M' AND p.Name < N'S'
    AND    EXISTS (
                    SELECT  *
                    FROM    Production.TransactionHistory th
                    WHERE   th.ProductID = p.ProductID
                );

ALTER TABLE #Products REBUILD WITH (FILLFACTOR = 100);

Teste 1 Resultados do Teste 1 - com índice suportado E cache
Todos os métodos parecem se beneficiar igualmente do cache, e meu método ainda está à frente.

Teste 2 Resultados do Teste 2 - com suporte ao índice E cache
Aqui agora vemos uma diferença na programação, já que meu método sai à frente, apenas 2 leituras melhor que o primeiro apply row_number()método de Mikael , enquanto que sem o armazenamento em cache, meu método ficou atrasado em 4 leituras.

Teste 3 Resultados do Teste 3 - com índice E cache de suporte
Consulte a atualização na parte inferior (abaixo da linha) . Aqui novamente vemos alguma diferença. O sabor "parametrizado" do meu método agora está quase na liderança em 2 leituras em comparação com o método CROSS APPLY de Aaron (sem cache, eles eram iguais). Mas o mais estranho é que, pela primeira vez, vemos um método que é afetado negativamente pelo cache: o método CTE de Aaron (que anteriormente era o melhor para o teste número 3). Mas não vou levar o crédito onde não é devido e, como o método CTE do Aaron em cache ainda é mais rápido do que o meu método aqui com o cache, a melhor abordagem para essa situação específica parece ser o método CTE do Aaron.

Conclusão Consulte a atualização na parte inferior (abaixo da linha). As
situações que fazem uso repetido dos resultados de uma consulta secundária geralmente podem (mas nem sempre) se beneficiar do armazenamento em cache desses resultados. Mas quando o armazenamento em cache é um benefício, o uso de memória para esse armazenamento em cache tem alguma vantagem sobre o uso de tabelas temporárias.

O método

Geralmente

I separada da consulta "cabeçalho" (isto é, ficando a ProductIDs, e, num caso, também o DaysToManufacture, com base no Namecomeçando com determinadas letras) a partir das consultas "detail" (isto é, ficando os TransactionIDs e TransactionDates). O conceito era realizar consultas muito simples e não permitir que o otimizador se confundisse ao se juntar a elas. Claramente, isso nem sempre é vantajoso, pois também impede o otimizador de, assim, otimizar. Mas, como vimos nos resultados, dependendo do tipo de consulta, esse método tem seus méritos.

A diferença entre os vários sabores desse método são:

  • Constantes: envie quaisquer valores substituíveis como constantes em linha em vez de serem parâmetros. Isso se refere aos ProductIDtrês testes e também ao número de linhas a serem retornadas no Teste 2, pois isso é uma função de "cinco vezes o DaysToManufactureatributo Produto". Esse sub-método significa que cada um ProductIDterá seu próprio plano de execução, o que pode ser benéfico se houver uma grande variação na distribuição de dados ProductID. Mas se houver pouca variação na distribuição de dados, o custo de gerar os planos adicionais provavelmente não valerá a pena.

  • Parametrizado: envie pelo menos ProductIDcomo @ProductID, permitindo o armazenamento em cache e a reutilização do plano de execução. Há uma opção de teste adicional para também tratar o número variável de linhas a serem retornadas para o Teste 2 como um parâmetro.

  • Otimizar desconhecido: ao referenciar ProductIDcomo @ProductID, se houver uma grande variação na distribuição de dados, é possível armazenar em cache um plano que tenha um efeito negativo em outros ProductIDvalores, portanto, seria bom saber se o uso dessa dica de consulta ajuda alguma.

  • Produtos de cache: em vez de consultar a Production.Producttabela a cada vez, apenas para obter a mesma lista exata, execute a consulta uma vez (e enquanto estivermos nela, filtre quaisquer ProductIDs que não estejam na TransactionHistorytabela para não desperdiçar nada) recursos lá) e armazene em cache essa lista. A lista deve incluir o DaysToManufacturecampo Usando esta opção, há um acerto inicial ligeiramente mais alto nas leituras lógicas para a primeira execução, mas depois disso é apenas a TransactionHistorytabela que é consultada.

Especificamente

Ok, mas como é possível emitir todas as subconsultas como consultas separadas sem usar um CURSOR e despejar cada conjunto de resultados em uma tabela ou variável de tabela temporária? Claramente, o método CURSOR / Temp Table refletiria obviamente nas leituras e gravações. Bem, usando SQLCLR :). Ao criar um procedimento armazenado SQLCLR, consegui abrir um conjunto de resultados e essencialmente transmitir os resultados de cada subconsulta a ele, como um conjunto de resultados contínuos (e não vários conjuntos de resultados). Fora das informações do produto (ie ProductID, NameeDaysToManufacture), nenhum dos resultados da subconsulta precisou ser armazenado em qualquer lugar (memória ou disco) e passou como o principal conjunto de resultados do procedimento armazenado SQLCLR. Isso me permitiu fazer uma consulta simples para obter as informações do produto e depois percorrer as mesmas, emitindo consultas muito simples TransactionHistory.

E é por isso que tive que usar o SQL Server Profiler para capturar as estatísticas. O procedimento armazenado SQLCLR não retornou um plano de execução, definindo a opção de consulta "Incluir plano de execução real" ou emitindo SET STATISTICS XML ON;.

Para o cache de informações do produto, usei uma readonly staticlista genérica (ou seja, _GlobalProductsno código abaixo). Parece que adicionar coleções não viola a readonlyopção; portanto, esse código funciona quando o assembly possui um PERMISSON_SETde SAFE:), mesmo que isso seja contra-intuitivo.

As consultas geradas

As consultas produzidas por este procedimento armazenado SQLCLR são as seguintes:

Informação do produto

Números de teste 1 e 3 (sem armazenamento em cache)

SELECT prod1.ProductID, prod1.Name, 1 AS [DaysToManufacture]
FROM   Production.Product prod1
WHERE  prod1.Name LIKE N'[M-R]%';

Teste número 2 (sem armazenamento em cache)

;WITH cte AS
(
    SELECT prod1.ProductID
    FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
    WHERE  prod1.Name LIKE N'[M-R]%'
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

Números de teste 1, 2 e 3 (armazenamento em cache)

;WITH cte AS
(
    SELECT prod1.ProductID
    FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
    WHERE  prod1.Name LIKE N'[M-R]%'
    AND    EXISTS (
                SELECT *
                FROM Production.TransactionHistory th
                WHERE th.ProductID = prod1.ProductID
                  )
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

Informações da transação

Números de teste 1 e 2 (constantes)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = 977
ORDER BY th.TransactionDate DESC;

Números de teste 1 e 2 (com parâmetros)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
;

Números de teste 1 e 2 (Parametrizado + OTIMIZAR DESCONHECIDO)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

Teste número 2 (ambos parametrizados)

SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
;

Teste Número 2 (Parametrizado Ambos + OTIMIZAR DESCONHECIDO)

SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

Teste número 3 (constantes)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = 977
ORDER BY th.TransactionDate DESC, th.TransactionID DESC;

Teste número 3 (parametrizado)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC, th.TransactionID DESC
;

Teste Número 3 (Parametrizado + OTIMIZAR DESCONHECIDO)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC, th.TransactionID DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

O código

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;

public class ObligatoryClassName
{
    private class ProductInfo
    {
        public int ProductID;
        public string Name;
        public int DaysToManufacture;

        public ProductInfo(int ProductID, string Name, int DaysToManufacture)
        {
            this.ProductID = ProductID;
            this.Name = Name;
            this.DaysToManufacture = DaysToManufacture;

            return;
        }
    }

    private static readonly List<ProductInfo> _GlobalProducts = new List<ProductInfo>();

    private static void PopulateGlobalProducts(SqlBoolean PrintQuery)
    {
        if (_GlobalProducts.Count > 0)
        {
            if (PrintQuery.IsTrue)
            {
                SqlContext.Pipe.Send(String.Concat("I already haz ", _GlobalProducts.Count,
                            " entries :)"));
            }

            return;
        }

        SqlConnection _Connection = new SqlConnection("Context Connection = true;");
        SqlCommand _Command = new SqlCommand();
        _Command.CommandType = CommandType.Text;
        _Command.Connection = _Connection;
        _Command.CommandText = @"
   ;WITH cte AS
   (
     SELECT prod1.ProductID
     FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
     WHERE  prod1.Name LIKE N'[M-R]%'
     AND    EXISTS (
                     SELECT *
                     FROM Production.TransactionHistory th
                     WHERE th.ProductID = prod1.ProductID
                   )
   )
   SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
   FROM   Production.Product prod2
   INNER JOIN cte
           ON cte.ProductID = prod2.ProductID;
";

        SqlDataReader _Reader = null;

        try
        {
            _Connection.Open();

            _Reader = _Command.ExecuteReader();

            while (_Reader.Read())
            {
                _GlobalProducts.Add(new ProductInfo(_Reader.GetInt32(0), _Reader.GetString(1),
                                                    _Reader.GetInt32(2)));
            }
        }
        catch
        {
            throw;
        }
        finally
        {
            if (_Reader != null && !_Reader.IsClosed)
            {
                _Reader.Close();
            }

            if (_Connection != null && _Connection.State != ConnectionState.Closed)
            {
                _Connection.Close();
            }

            if (PrintQuery.IsTrue)
            {
                SqlContext.Pipe.Send(_Command.CommandText);
            }
        }

        return;
    }


    [Microsoft.SqlServer.Server.SqlProcedure]
    public static void GetTopRowsPerGroup(SqlByte TestNumber,
        SqlByte ParameterizeProductID, SqlBoolean OptimizeForUnknown,
        SqlBoolean UseSequentialAccess, SqlBoolean CacheProducts, SqlBoolean PrintQueries)
    {
        SqlConnection _Connection = new SqlConnection("Context Connection = true;");
        SqlCommand _Command = new SqlCommand();
        _Command.CommandType = CommandType.Text;
        _Command.Connection = _Connection;

        List<ProductInfo> _Products = null;
        SqlDataReader _Reader = null;

        int _RowsToGet = 5; // default value is for Test Number 1
        string _OrderByTransactionID = "";
        string _OptimizeForUnknown = "";
        CommandBehavior _CmdBehavior = CommandBehavior.Default;

        if (OptimizeForUnknown.IsTrue)
        {
            _OptimizeForUnknown = "OPTION (OPTIMIZE FOR (@ProductID UNKNOWN))";
        }

        if (UseSequentialAccess.IsTrue)
        {
            _CmdBehavior = CommandBehavior.SequentialAccess;
        }

        if (CacheProducts.IsTrue)
        {
            PopulateGlobalProducts(PrintQueries);
        }
        else
        {
            _Products = new List<ProductInfo>();
        }


        if (TestNumber.Value == 2)
        {
            _Command.CommandText = @"
   ;WITH cte AS
   (
     SELECT prod1.ProductID
     FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
     WHERE  prod1.Name LIKE N'[M-R]%'
   )
   SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
   FROM   Production.Product prod2
   INNER JOIN cte
           ON cte.ProductID = prod2.ProductID;
";
        }
        else
        {
            _Command.CommandText = @"
     SELECT prod1.ProductID, prod1.Name, 1 AS [DaysToManufacture]
     FROM   Production.Product prod1
     WHERE  prod1.Name LIKE N'[M-R]%';
";
            if (TestNumber.Value == 3)
            {
                _RowsToGet = 1;
                _OrderByTransactionID = ", th.TransactionID DESC";
            }
        }

        try
        {
            _Connection.Open();

            // Populate Product list for this run if not using the Product Cache
            if (!CacheProducts.IsTrue)
            {
                _Reader = _Command.ExecuteReader(_CmdBehavior);

                while (_Reader.Read())
                {
                    _Products.Add(new ProductInfo(_Reader.GetInt32(0), _Reader.GetString(1),
                                                  _Reader.GetInt32(2)));
                }

                _Reader.Close();

                if (PrintQueries.IsTrue)
                {
                    SqlContext.Pipe.Send(_Command.CommandText);
                }
            }
            else
            {
                _Products = _GlobalProducts;
            }

            SqlDataRecord _ResultRow = new SqlDataRecord(
                new SqlMetaData[]{
                    new SqlMetaData("ProductID", SqlDbType.Int),
                    new SqlMetaData("Name", SqlDbType.NVarChar, 50),
                    new SqlMetaData("TransactionID", SqlDbType.Int),
                    new SqlMetaData("TransactionDate", SqlDbType.DateTime)
                });

            SqlParameter _ProductID = new SqlParameter("@ProductID", SqlDbType.Int);
            _Command.Parameters.Add(_ProductID);
            SqlParameter _RowsToReturn = new SqlParameter("@RowsToReturn", SqlDbType.Int);
            _Command.Parameters.Add(_RowsToReturn);

            SqlContext.Pipe.SendResultsStart(_ResultRow);

            for (int _Row = 0; _Row < _Products.Count; _Row++)
            {
                // Tests 1 and 3 use previously set static values for _RowsToGet
                if (TestNumber.Value == 2)
                {
                    if (_Products[_Row].DaysToManufacture == 0)
                    {
                        continue; // no use in issuing SELECT TOP (0) query
                    }

                    _RowsToGet = (5 * _Products[_Row].DaysToManufacture);
                }

                _ResultRow.SetInt32(0, _Products[_Row].ProductID);
                _ResultRow.SetString(1, _Products[_Row].Name);

                switch (ParameterizeProductID.Value)
                {
                    case 0x01:
                        _Command.CommandText = String.Format(@"
   SELECT TOP ({0}) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = @ProductID
   ORDER BY th.TransactionDate DESC{2}
   {1};
", _RowsToGet, _OptimizeForUnknown, _OrderByTransactionID);

                        _ProductID.Value = _Products[_Row].ProductID;
                        break;
                    case 0x02:
                        _Command.CommandText = String.Format(@"
   SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = @ProductID
   ORDER BY th.TransactionDate DESC
   {0};
", _OptimizeForUnknown);

                        _ProductID.Value = _Products[_Row].ProductID;
                        _RowsToReturn.Value = _RowsToGet;
                        break;
                    default:
                        _Command.CommandText = String.Format(@"
   SELECT TOP ({0}) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = {1}
   ORDER BY th.TransactionDate DESC{2};
", _RowsToGet, _Products[_Row].ProductID, _OrderByTransactionID);
                        break;
                }


                _Reader = _Command.ExecuteReader(_CmdBehavior);

                while (_Reader.Read())
                {
                    _ResultRow.SetInt32(2, _Reader.GetInt32(0));
                    _ResultRow.SetDateTime(3, _Reader.GetDateTime(1));

                    SqlContext.Pipe.SendResultsRow(_ResultRow);
                }
                _Reader.Close();
            }

        }
        catch
        {
            throw;
        }
        finally
        {
            if (SqlContext.Pipe.IsSendingResults)
            {
                SqlContext.Pipe.SendResultsEnd();
            }

            if (_Reader != null && !_Reader.IsClosed)
            {
                _Reader.Close();
            }

            if (_Connection != null && _Connection.State != ConnectionState.Closed)
            {
                _Connection.Close();
            }

            if (PrintQueries.IsTrue)
            {
                SqlContext.Pipe.Send(_Command.CommandText);
            }
        }


    }
}

As consultas de teste

Não há espaço suficiente para postar os testes aqui, então vou encontrar outro local.

A conclusão

Para certos cenários, o SQLCLR pode ser usado para manipular certos aspectos de consultas que não podem ser feitas no T-SQL. E existe a capacidade de usar memória para armazenamento em cache em vez de tabelas temporárias, embora isso deva ser feito com moderação e cuidado, pois a memória não é liberada automaticamente de volta ao sistema. Esse método também não é algo que ajudará consultas ad hoc, embora seja possível torná-lo mais flexível do que mostrei aqui, simplesmente adicionando parâmetros para personalizar mais aspectos das consultas que estão sendo executadas.


ATUALIZAR

Teste Adicional
Meus testes originais que incluíam um índice de suporte TransactionHistoryusavam a seguinte definição:

ProductID ASC, TransactionDate DESC

Eu tinha decidido na época renunciar à inclusão TransactionId DESCno final, imaginando que, embora isso possa ajudar o Teste Número 3 (que especifica o desempate nos mais recentesTransactionId , "o mais recente" é assumido, uma vez que não foi declarado explicitamente, mas todos parecem concordar com essa suposição), provavelmente não haveria laços suficientes para fazer a diferença.

Porém, Aaron testou novamente com um índice de suporte que incluiu TransactionId DESCe descobriu que o CROSS APPLYmétodo foi o vencedor nos três testes. Isso foi diferente do meu teste, que indicou que o método CTE era melhor para o Teste Número 3 (quando nenhum cache foi usado, o que reflete o teste de Aaron). Ficou claro que havia uma variação adicional que precisava ser testada.

Removai o índice de suporte atual, criei um novo TransactionIde limpei o cache do plano (apenas para ter certeza):

DROP INDEX [IX_TransactionHistoryX] ON Production.TransactionHistory;

CREATE UNIQUE INDEX [UIX_TransactionHistoryX]
    ON Production.TransactionHistory (ProductID ASC, TransactionDate DESC, TransactionID DESC)
    WITH (FILLFACTOR = 100);

DBCC FREEPROCCACHE WITH NO_INFOMSGS;

Voltei a executar o Teste Número 1 e os resultados foram os mesmos, conforme o esperado. Em seguida, refiz o Teste Número 3 e os resultados realmente mudaram:

Resultados do teste 3 - com índice de suporte (com TransactionId DESC)
Os resultados acima são para o teste padrão sem cache. Desta vez, não apenas CROSS APPLYvence o CTE (como o teste de Aaron indicou), mas o processo SQLCLR assumiu a liderança em 30 leituras (woo hoo).

Teste 3 resultados - com índice de suporte (com TransactionId DESC) E armazenamento em cache
Os resultados acima são para o teste com o cache ativado. Desta vez, o desempenho do CTE não é degradado, embora o CROSS APPLYainda o supere. No entanto, agora o processo SQLCLR assume a liderança em 23 leituras (woo hoo, novamente).

Aprendizado

  1. Existem várias opções para usar. É melhor tentar vários, pois cada um tem seus pontos fortes. Os testes realizados aqui mostram uma variação bastante pequena nas leituras e na duração entre os melhores e os piores desempenhos em todos os testes (com um índice de suporte); a variação nas leituras é de cerca de 350 e a duração é de 55 ms. Embora o processo SQLCLR tenha vencido em todos os testes, exceto um (em termos de leituras), salvar apenas algumas leituras geralmente não vale o custo de manutenção de seguir a rota SQLCLR. Mas no AdventureWorks2012, a Producttabela possui apenas 504 linhas e TransactionHistoryapenas 113.443 linhas. A diferença de desempenho entre esses métodos provavelmente se torna mais acentuada à medida que a contagem de linhas aumenta.

  2. Embora essa pergunta tenha sido específica para obter um conjunto específico de linhas, não se deve esquecer que o maior fator de desempenho foi a indexação e não o SQL em particular. Um bom índice precisa estar em vigor antes de determinar qual método é realmente melhor.

  3. A lição mais importante encontrada aqui não é sobre CROSS APPLY vs CTE vs SQLCLR: trata-se de TESTAR. Não assuma. Obtenha idéias de várias pessoas e teste o maior número possível de cenários.

Solomon Rutzky
fonte
2
Veja minha edição da resposta de Mikael para saber o motivo das leituras extra lógicas associadas à aplicação.
Paul White
18

APPLY TOPou ROW_NUMBER()? O que poderia haver mais a dizer sobre esse assunto?

Uma breve recapitulação das diferenças e, para realmente mantê-lo breve, mostrarei apenas os planos para a opção 2 e adicionei o índice Production.TransactionHistory.

create index IX_TransactionHistoryX on 
  Production.TransactionHistory(ProductID, TransactionDate)

A row_number()consulta :.

with C as
(
  select T.TransactionID,
         T.TransactionDate,
         P.DaysToManufacture,
         row_number() over(partition by P.ProductID order by T.TransactionDate desc) as rn
  from Production.Product as P
    inner join Production.TransactionHistory as T
      on P.ProductID = T.ProductID
  where P.Name >= N'M' and
        P.Name < N'S'
)
select C.TransactionID,
       C.TransactionDate
from C
where C.rn <= 5 * C.DaysToManufacture;

insira a descrição da imagem aqui

A apply topversão:

select T.TransactionID, 
       T.TransactionDate
from Production.Product as P
  cross apply (
              select top(cast(5 * P.DaysToManufacture as bigint))
                T.TransactionID,
                T.TransactionDate
              from Production.TransactionHistory as T
              where P.ProductID = T.ProductID
              order by T.TransactionDate desc
              ) as T
where P.Name >= N'M' and
      P.Name < N'S';

insira a descrição da imagem aqui

A principal diferença entre eles é que os apply topfiltros na expressão superior abaixo dos loops aninhados se unem onde a row_numberversão é filtrada após a união. Isso significa que há mais leituras do Production.TransactionHistoryque realmente é necessário.

Se houvesse apenas uma maneira de enviar os operadores responsáveis ​​por enumerar linhas para a ramificação inferior antes da junção, row_number versão seria melhor.

Então digite a apply row_number()versão.

select T.TransactionID, 
       T.TransactionDate
from Production.Product as P
  cross apply (
              select T.TransactionID,
                     T.TransactionDate
              from (
                   select T.TransactionID,
                          T.TransactionDate,
                          row_number() over(order by T.TransactionDate desc) as rn
                   from Production.TransactionHistory as T
                   where P.ProductID = T.ProductID
                   ) as T
              where T.rn <= cast(5 * P.DaysToManufacture as bigint)
              ) as T
where P.Name >= N'M' and
      P.Name < N'S';

insira a descrição da imagem aqui

Como você pode ver, apply row_number()é praticamente o mesmo que apply topapenas um pouco mais complicado. O tempo de execução também é o mesmo ou um pouco mais lento.

Então, por que eu me incomodei em encontrar uma resposta que não é melhor do que a que já temos? Bem, você tem mais uma coisa a experimentar no mundo real e, na verdade, há uma diferença nas leituras. Um que eu não tenho uma explicação para *.

APPLY - ROW_NUMBER
(961 row(s) affected)
Table 'TransactionHistory'. Scan count 115, logical reads 230, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Product'. Scan count 1, logical reads 15, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

APPLY - TOP
(961 row(s) affected)
Table 'TransactionHistory'. Scan count 115, logical reads 268, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Product'. Scan count 1, logical reads 15, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Enquanto estou nisso, eu também poderia lançar uma segunda row_number()versão que, em certos casos, pode ser o caminho a percorrer. Esses casos seriam quando você espera que realmente precise da maioria das linhas, Production.TransactionHistoryporque aqui você obtém uma junção de mesclagem Production.Producte a enumerada Production.TransactionHistory.

with C as
(
  select T.TransactionID,
         T.TransactionDate,
         T.ProductID,
         row_number() over(partition by T.ProductID order by T.TransactionDate desc) as rn
  from Production.TransactionHistory as T
)
select C.TransactionID,
       C.TransactionDate
from C
 inner join Production.Product as P
      on P.ProductID = C.ProductID
where P.Name >= N'M' and
      P.Name < N'S' and
      C.rn <= 5 * P.DaysToManufacture;

insira a descrição da imagem aqui

Para obter a forma acima sem um operador de classificação, você também deve alterar o índice de suporte por ordem TransactionDatedecrescente.

create index IX_TransactionHistoryX on 
  Production.TransactionHistory(ProductID, TransactionDate desc)

* Editar: as leituras lógicas extras são devidas à pré-busca de loops aninhados usada com o apply-top. Você pode desativar isso com o TF 8744 não processado (e / ou 9115 em versões posteriores) para obter o mesmo número de leituras lógicas. A pré-busca pode ser uma vantagem da alternativa de aplicação superior nas circunstâncias certas. - Paul White

Mikael Eriksson
fonte
11

Normalmente, uso uma combinação de CTEs e funções de janelas. Você pode obter essa resposta usando algo como o seguinte:

;WITH GiveMeCounts
AS (
    SELECT CustomerID
        ,OrderDate
        ,TotalAmt

        ,ROW_NUMBER() OVER (
            PARTITION BY CustomerID ORDER BY 
            --You can change the following field or sort order to whatever you'd like to order by.
            TotalAmt desc
            ) AS MySeqNum
    )
SELECT CustomerID, OrderDate, TotalAmt
FROM GiveMeCounts
--Set n per group here
where MySeqNum <= 10

Para a parte de crédito extra, em que grupos diferentes podem querer retornar números diferentes de linhas, você pode usar uma tabela separada. Digamos que usando critérios geográficos como estado:

+-------+-----------+
| State | MaxSeqnum |
+-------+-----------+
| AK    |        10 |
| NY    |         5 |
| NC    |        23 |
+-------+-----------+

Para conseguir isso onde os valores podem ser diferentes, você precisará associar seu CTE à tabela State semelhante a esta:

SELECT [CustomerID]
    ,[OrderDate]
    ,[TotalAmt]
    ,[State]
FROM GiveMeCounts gmc
INNER JOIN StateTable st ON gmc.[State] = st.[State]
    AND gmc.MySeqNum <= st.MaxSeqNum
Kris Gruttemeyer
fonte