Recupero di n righe per gruppo


88

Ho spesso bisogno di selezionare un numero di righe da ciascun gruppo in un set di risultati.

Ad esempio, potrei voler elencare i valori di ordine "n" più recenti o più recenti per cliente.

In casi più complessi, il numero di righe da elencare potrebbe variare per gruppo (definito da un attributo del record raggruppamento / padre). Questa parte è decisamente facoltativa / per un credito extra e non intende dissuadere le persone dalla risposta.

Quali sono le principali opzioni per risolvere questi tipi di problemi in SQL Server 2005 e versioni successive? Quali sono i principali vantaggi e svantaggi di ciascun metodo?

Esempi di AdventureWorks (per chiarezza, facoltativo)

  1. Elencare le cinque date e gli ID delle transazioni più recenti più recenti dalla TransactionHistorytabella, per ciascun prodotto che inizia con una lettera dalla M alla R inclusa.
  2. Lo stesso, ma con le nrighe della cronologia per prodotto, dove nè cinque volte l' DaysToManufactureattributo Prodotto.
  3. Lo stesso, per il caso speciale in cui è richiesta esattamente una riga della cronologia per prodotto (la sola registrazione più recente di TransactionDate, tie-break on TransactionID.

Risposte:


71

Cominciamo con lo scenario di base.

Se voglio ottenere un numero di righe da una tabella, ho due opzioni principali: funzioni di classificazione; o TOP.

Innanzitutto, consideriamo l'intero set di Production.TransactionHistoryper un particolare ProductID:

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

Ciò restituisce 418 righe e il piano mostra che controlla ogni riga della tabella che lo cerca: una scansione dell'indice cluster senza restrizioni, con un predicato per fornire il filtro. 797 legge qui, il che è brutto.

Scansione costosa con predicato "residuo"

Quindi cerchiamo di essere onesti e creiamo un indice che sarebbe più utile. Le nostre condizioni richiedono una corrispondenza di uguaglianza ProductID, seguita da una ricerca dell'ultima di TransactionDate. Abbiamo bisogno della TransactionIDtornati anche, in modo andiamo con: CREATE INDEX ix_FindingMostRecent ON Production.TransactionHistory (ProductID, TransactionDate) INCLUDE (TransactionID);.

Dopo aver fatto ciò, il nostro piano cambia in modo significativo e riduce le letture a soli 3. Quindi stiamo già migliorando le cose di oltre 250x circa ...

Piano migliorato

Ora che abbiamo livellato il campo di gioco, diamo un'occhiata alle opzioni migliori - funzioni di classificazione 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;

Due piani: TOP \ RowNum di base

Noterai che la seconda ( TOP) query è molto più semplice della prima, sia nella query che nel piano. Ma molto significativamente, entrambi usano TOPper limitare il numero di righe effettivamente estratte dall'indice. I costi sono solo stime e vale la pena ignorarli, ma puoi vedere molta somiglianza nei due piani, con la ROW_NUMBER()versione che fa una piccola quantità di lavoro extra per assegnare numeri e filtrare di conseguenza, ed entrambe le query finiscono per fare solo 2 letture da fare il loro lavoro. Query Optimizer riconosce sicuramente l'idea di filtrare su un ROW_NUMBER()campo, rendendosi conto che può usare un operatore Top per ignorare le righe che non saranno necessarie. Entrambe queste query sono abbastanza buone - TOPnon è molto meglio che valga la pena cambiare il codice, ma è più semplice e probabilmente più chiara per i principianti.

Quindi questo funziona su un singolo prodotto. Ma dobbiamo considerare cosa succede se dobbiamo farlo su più prodotti.

Il programmatore iterativo prenderà in considerazione l'idea di scorrere ciclicamente i prodotti di interesse e di chiamare questa query più volte, e possiamo effettivamente cavarcela scrivendo una query in questo modulo - non usando i cursori, ma usando APPLY. Sto usando OUTER APPLY, immaginando che potremmo voler restituire il Prodotto con NULL, se non ci sono Transazioni per esso.

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';

Il piano per questo è il metodo dei programmatori iterativi: Nested Loop, che esegue un'operazione Top e Seek (quelle 2 letture che avevamo prima) per ciascun Prodotto. Questo dà 4 letture contro il prodotto e 360 ​​contro TransactionHistory.

APPLICA piano

Utilizzando ROW_NUMBER(), il metodo è utilizzare PARTITION BYnella OVERclausola, in modo da riavviare la numerazione per ciascun Prodotto. Questo può quindi essere filtrato come prima. Il piano finisce per essere piuttosto diverso. Le letture logiche sono inferiori di circa il 15% su TransactionHistory, con una scansione dell'indice completa in corso per estrarre le righe.

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;

Piano ROW_NUMBER

Significativamente, tuttavia, questo piano ha un costoso operatore di ordinamento. Il Merge Join non sembra mantenere l'ordine delle righe in TransactionHistory, i dati devono essere ricorsi per poter trovare i rownumber. Ci sono meno letture, ma questo tipo di blocco potrebbe essere doloroso. Utilizzando APPLY, il ciclo annidato restituirà le prime righe molto rapidamente, dopo poche letture, ma con un ordinamento, ROW_NUMBER()restituirà le righe solo dopo che la maggior parte del lavoro è stata completata.

È interessante notare che se la ROW_NUMBER()query utilizza INNER JOINinvece di LEFT JOIN, viene visualizzato un piano diverso.

ROW_NUMBER () con INNER JOIN

Questo piano utilizza un ciclo annidato, proprio come con APPLY. Ma non esiste un operatore Top, quindi estrae tutte le transazioni per ciascun prodotto e utilizza molte più letture rispetto a prima: 492 letture rispetto a TransactionHistory. Non c'è una buona ragione per non scegliere l'opzione Unisci qui, quindi immagino che il piano sia stato considerato "Abbastanza buono". Tuttavia - non si blocca, il che è bello - ma non altrettanto bello APPLY.

La PARTITION BYcolonna che ho usato ROW_NUMBER()era h.ProductIDin entrambi i casi, perché avevo voluto dare al QO l'opzione di produrre il valore RowNum prima di unirmi alla tabella Product. Se uso p.ProductID, vediamo lo stesso piano di forma della INNER JOINvariazione.

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;

Ma l'operatore Join dice "Left Outer Join" anziché "Inner Join". Il numero di letture è ancora poco meno di 500 letture rispetto alla tabella TransactionHistory.

PARTITION BY su p.ProductID invece di h.ProductID

Comunque - torniamo alla domanda in corso ...

Abbiamo risposto alla domanda 1 , con due opzioni tra cui puoi scegliere. Personalmente, mi piace l' APPLYopzione.

Per estenderlo per usare un numero variabile ( domanda 2 ), il 5giusto deve essere modificato di conseguenza. Oh, e ho aggiunto un altro indice, in modo che ci fosse un indice Production.Product.Nameche includesse la DaysToManufacturecolonna.

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';

Ed entrambi i piani sono quasi identici a quelli che erano prima!

Righe variabili

Ancora una volta, ignora i costi stimati, ma mi piace ancora lo scenario TOP, poiché è molto più semplice e il piano non ha alcun operatore di blocco. Le letture sono meno su TransactionHistory a causa dell'elevato numero di zero in DaysToManufacture, ma nella vita reale, dubito che sceglieremmo quella colonna. ;)

Un modo per evitare il blocco è quello di elaborare un piano che gestisca il ROW_NUMBER()bit a destra (nel piano) del join. Possiamo convincere che ciò accada facendo l'unione al di fuori del 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';

Il piano qui sembra più semplice: non si sta bloccando, ma c'è un pericolo nascosto.

Partecipare al di fuori di CTE

Notare lo scalare di calcolo che sta estraendo i dati dalla tabella dei prodotti. Questo sta elaborando il 5 * p.DaysToManufacturevalore. Questo valore non viene trasferito nel ramo che sta estraendo i dati dalla tabella TransactionHistory, ma viene utilizzato nel Merge Join. Come un residuo.

Residuo subdolo!

Quindi il Merge Join sta consumando TUTTE le righe, non solo le prime per quanto necessarie, ma tutte e poi eseguendo un controllo residuo. Ciò è pericoloso all'aumentare del numero di transazioni. Non sono un fan di questo scenario: i predicati residui in Merge Joins possono rapidamente aumentare. Un altro motivo per cui preferisco lo APPLY/TOPscenario.

Nel caso speciale in cui è esattamente una riga, per la domanda 3 , possiamo ovviamente usare le stesse query, ma con 1invece di 5. Ma poi abbiamo un'opzione extra, che è quella di utilizzare aggregati regolari.

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

Una query come questa sarebbe un utile inizio e potremmo facilmente modificarla per estrarre TransactionID anche a fini di tie-break (usando una concatenazione che verrebbe quindi scomposta), ma guardiamo l'intero indice, oppure ci tuffiamo nel prodotto per prodotto e non otteniamo un grande miglioramento rispetto a quello che avevamo prima in questo scenario.

Ma dovrei sottolineare che stiamo guardando uno scenario particolare qui. Con dati reali e con una strategia di indicizzazione che potrebbe non essere ideale, il chilometraggio può variare considerevolmente. Nonostante il fatto che abbiamo visto che APPLYè forte qui, in alcune situazioni può essere più lento. Tuttavia, raramente si blocca, poiché ha la tendenza a utilizzare i cicli annidati, che molte persone (incluso me stesso) trovano molto interessanti.

Non ho cercato di esplorare il parallelismo qui, o ho approfondito molto la domanda 3, che vedo come un caso speciale che le persone raramente vogliono sulla base della complicazione del concatenare e della divisione. La cosa principale da considerare qui è che queste due opzioni sono entrambe molto forti.

Io preferisco APPLY. È chiaro, utilizza bene l'operatore Top e raramente causa blocchi.


45

Il modo tipico per eseguire questa operazione in SQL Server 2005 e versioni successive è utilizzare un CTE e le funzioni di windowing. Per top n per gruppo puoi semplicemente usare ROW_NUMBER()con una PARTITIONclausola e filtrare contro quello nella query esterna. Quindi, ad esempio, i primi 5 ordini più recenti per cliente potrebbero essere visualizzati in questo modo:

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;

Puoi anche farlo con 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;

Con l'opzione aggiuntiva specificata da Paul, supponiamo che la tabella Clienti abbia una colonna che indica quante righe includere per 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 ancora, usando CROSS APPLYe incorporando l'opzione aggiunta che il numero di righe per un cliente è dettato da qualche colonna nella tabella clienti:

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;

Si noti che questi funzioneranno in modo diverso a seconda della distribuzione dei dati e della disponibilità degli indici di supporto, quindi l'ottimizzazione delle prestazioni e l'ottenimento del piano migliore dipenderanno realmente da fattori locali.

Personalmente, preferisco le soluzioni CTE e windowing rispetto a CROSS APPLY/ TOPperché separano meglio la logica e sono più intuitive (per me). In generale (sia in questo caso che nella mia esperienza generale), l'approccio CTE produce piani più efficienti (esempi di seguito), ma questo non dovrebbe essere preso come una verità universale - dovresti sempre testare i tuoi scenari, specialmente se gli indici sono cambiati o i dati sono stati distorti in modo significativo.


Esempi di AdventureWorks - senza modifiche

  1. Elencare le cinque date e gli ID delle transazioni più recenti più recenti dalla TransactionHistorytabella, per ciascun prodotto che inizia con una lettera dalla M alla R inclusa.
-- 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';

Confronto di questi due nelle metriche di runtime:

inserisci qui la descrizione dell'immagine

CTE / OVER()piano:

inserisci qui la descrizione dell'immagine

CROSS APPLY Piano:

inserisci qui la descrizione dell'immagine

Il piano CTE sembra più complicato, ma in realtà è molto più efficiente. Presta poca attenzione ai numeri di costo stimati, ma concentrati su osservazioni reali più importanti , come molte meno letture e una durata molto inferiore. Ho anche eseguito questi senza parallelismo, e questa non era la differenza. Metriche di runtime e piano CTE (il CROSS APPLYpiano è rimasto lo stesso):

inserisci qui la descrizione dell'immagine

inserisci qui la descrizione dell'immagine

  1. Lo stesso, ma con le nrighe della cronologia per prodotto, dove nè cinque volte l' DaysToManufactureattributo Prodotto.

Qui sono richieste modifiche molto lievi. Per il CTE, possiamo aggiungere una colonna alla query interna e filtrare la query esterna; per il CROSS APPLY, possiamo eseguire il calcolo all'interno del correlato TOP. Penseresti che ciò darebbe un po 'di efficienza alla CROSS APPLYsoluzione, ma in questo caso ciò non accade. Interrogazioni:

-- 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';

Risultati di runtime:

inserisci qui la descrizione dell'immagine

CTE / OVER()piano parallelo :

inserisci qui la descrizione dell'immagine

CTE / OVER()piano a thread singolo :

inserisci qui la descrizione dell'immagine

CROSS APPLY Piano:

inserisci qui la descrizione dell'immagine

  1. Lo stesso, per il caso speciale in cui è richiesta esattamente una riga della cronologia per prodotto (la sola registrazione più recente di TransactionDate, tie-break on TransactionID.

Ancora una volta, piccole modifiche qui. Nella soluzione CTE, aggiungiamo TransactionIDalla OVER()clausola e cambiamo il filtro esterno in rn = 1. Per il CROSS APPLY, cambiamo il TOPa TOP (1), e aggiungiamo TransactionIDquello interiore 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';

Risultati di runtime:

inserisci qui la descrizione dell'immagine

CTE / OVER()piano parallelo :

inserisci qui la descrizione dell'immagine

Piano CTE / OVER () a thread singolo:

inserisci qui la descrizione dell'immagine

CROSS APPLY Piano:

inserisci qui la descrizione dell'immagine

Le funzioni di windowing non sono sempre la migliore alternativa (provate a COUNT(*) OVER()) e questi non sono gli unici due approcci per risolvere il problema n righe per gruppo, ma in questo caso specifico - dati lo schema, gli indici esistenti e la distribuzione dei dati - il CTE si è comportato meglio con tutti i resoconti significativi.


Esempi di AdventureWorks - con flessibilità per aggiungere indici

Tuttavia, se aggiungi un indice di supporto, simile a quello menzionato da Paul in un commento ma con la seconda e la terza colonna ordinate DESC:

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

Avresti effettivamente piani molto più favorevoli dappertutto, e le metriche girerebbero per favorire l' CROSS APPLYapproccio in tutti e tre i casi:

inserisci qui la descrizione dell'immagine

Se questo fosse il mio ambiente di produzione, probabilmente sarei soddisfatto della durata in questo caso e non mi preoccuperei di ottimizzare ulteriormente.


Tutto ciò era molto più brutto in SQL Server 2000, che non supportava APPLYné la OVER()clausola.


24

In DBMS, come MySQL, che non hanno funzioni di finestra o CROSS APPLY, il modo per farlo sarebbe usare SQL standard (89). La via lenta sarebbe una croce triangolare unita con aggregato. Il modo più veloce (ma ancora e probabilmente non efficiente come usare cross apply o la funzione row_number) sarebbe quello che io chiamo "povero CROSS APPLY" . Sarebbe interessante confrontare questa query con le altre:

Assunzione: Orders (CustomerID, OrderDate)ha un UNIQUEvincolo:

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 ;

Per il problema aggiuntivo delle prime righe personalizzate per gruppo:

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: in MySQL, invece di AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...)utilizzare uno AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1)). SQL Server ha aggiunto la FETCH / OFFSETsintassi nella versione 2012. Le query qui sono state adattate IN (TOP...)per funzionare con le versioni precedenti.


21

Ho adottato un approccio leggermente diverso, principalmente per vedere come questa tecnica sarebbe paragonabile alle altre, perché avere opzioni è buono, giusto?

Il test

Perché non iniziamo solo osservando come i vari metodi si sono sovrapposti. Ho fatto tre serie di test:

  1. Il primo set è stato eseguito senza modifiche al DB
  2. Il secondo set è stato eseguito dopo la creazione di un indice per supportare le TransactionDatequery basate su Production.TransactionHistory.
  3. Il terzo set ha assunto un'ipotesi leggermente diversa. Dal momento che tutti e tre i test sono stati eseguiti sullo stesso elenco di prodotti, cosa accadrebbe se memorizzassimo tale elenco nella cache? Il mio metodo utilizza una cache in memoria mentre gli altri metodi utilizzavano una tabella temporanea equivalente. L'indice di supporto creato per il secondo set di test esiste ancora per questo set di test.

Ulteriori dettagli del test:

  • I test sono stati eseguiti AdventureWorks2012su SQL Server 2012, SP2 (Developer Edition).
  • Per ogni test ho etichettato la cui risposta ho preso la query e quale particolare query era.
  • Ho usato l'opzione "Elimina risultati dopo l'esecuzione" di Opzioni query | Risultati.
  • Si noti che per le prime due serie di test, RowCountssembra che sia "off" per il mio metodo. Ciò è dovuto al fatto che il mio metodo è un'implementazione manuale di ciò che CROSS APPLYsta facendo: esegue la query iniziale Production.Producte ottiene 161 righe indietro, che quindi utilizza per le query a fronte Production.TransactionHistory. Quindi, i RowCountvalori per le mie voci sono sempre 161 in più rispetto alle altre voci. Nella terza serie di test (con memorizzazione nella cache) i conteggi delle righe sono gli stessi per tutti i metodi.
  • Ho usato SQL Server Profiler per acquisire le statistiche invece di fare affidamento sui piani di esecuzione. Aaron e Mikael hanno già fatto un ottimo lavoro mostrando i piani per le loro domande e non è necessario riprodurre tali informazioni. E l'intento del mio metodo è quello di ridurre le domande in una forma così semplice che non avrebbe davvero importanza. C'è un motivo in più per usare Profiler, ma che verrà menzionato più avanti.
  • Piuttosto che usare il Name >= N'M' AND Name < N'S'costrutto, ho scelto di usare Name LIKE N'[M-R]%', e SQL Server li tratta allo stesso modo.

I risultati

Nessun indice di supporto

Questo è essenzialmente AdventureWorks2012 pronto all'uso. In tutti i casi il mio metodo è chiaramente migliore di alcuni degli altri, ma mai buono come i primi 1 o 2 metodi.

Test 1 Risultati del test 1 senza indice
Il CTE di Aaron è chiaramente il vincitore qui.

Prova 2 Risultati del test 2 senza indice
Il CTE di Aaron (di nuovo) e il secondo apply row_number()metodo di Mikael è un secondo vicino.

Test 3 Test 3 risultati senza indice
Il CTE di Aaron (di nuovo) è il vincitore.

Conclusione
Quando non c'è un indice di supporto attivo TransactionDate, il mio metodo è meglio che fare uno standard CROSS APPLY, ma comunque, usare il metodo CTE è chiaramente la strada da percorrere.

Con indice di supporto (nessuna memorizzazione nella cache)

Per questa serie di test ho aggiunto l'indice ovvio TransactionHistory.TransactionDatedato che tutte le query sono ordinate su quel campo. Dico "ovvio" poiché la maggior parte delle altre risposte concordano anche su questo punto. E poiché le query richiedono tutte le date più recenti, il TransactionDatecampo dovrebbe essere ordinato DESC, quindi ho appena preso l' CREATE INDEXaffermazione in fondo alla risposta di Mikael e ho aggiunto un esplicito FILLFACTOR:

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

Una volta che questo indice è a posto, i risultati cambiano un po '.

Test 1 Risultati del test 1 con indice di supporto
Questa volta è il mio metodo che viene fuori, almeno in termini di Letture logiche. Il CROSS APPLYmetodo, in precedenza il peggiore per il Test 1, vince su Durata e batte persino il metodo CTE su Letture logiche.

Test 2 Test 2 Risultati-con indice di supporto
Questa volta è il primo apply row_number()metodo di Mikael ad essere il vincitore quando guarda Reads, mentre in precedenza era uno dei peggiori. E ora il mio metodo arriva ad un secondo posto molto vicino quando si guarda a Reads. In effetti, al di fuori del metodo CTE, il resto è abbastanza vicino in termini di letture.

Test 3 Test 3 risultati: con indice di supporto
Qui il CTE è ancora il vincitore, ma ora la differenza tra gli altri metodi è appena percettibile rispetto alla drastica differenza esistente prima della creazione dell'indice.

Conclusione
L'applicabilità del mio metodo è più evidente ora, anche se è meno resistente a non disporre di indici adeguati.

Con indice di supporto e memorizzazione nella cache

Per questa serie di test ho usato la cache perché, beh, perché no? Il mio metodo consente di utilizzare la memorizzazione nella cache a cui gli altri metodi non possono accedere. Quindi, per essere onesti, ho creato la seguente tabella temporanea che è stata usata al posto di Product.Producttutti i riferimenti in quegli altri metodi in tutti e tre i test. Il DaysToManufacturecampo viene utilizzato solo nel Test numero 2, ma era più facile essere coerenti tra gli script SQL per utilizzare la stessa tabella e non ha fatto male averlo 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);

Test 1 Risultati del test 1: con indice di supporto E memorizzazione nella cache
Tutti i metodi sembrano beneficiare allo stesso modo della memorizzazione nella cache e il mio metodo è ancora in vantaggio.

Test 2 Test 2 Risultati: con indice di supporto E memorizzazione nella cache
Qui ora vediamo una differenza nell'allineamento poiché il mio metodo esce appena avanti, solo 2 legge meglio del primo apply row_number()metodo di Mikael , mentre senza la memorizzazione nella cache il mio metodo era indietro di 4 letture.

Test 3 Test 3 risultati: con indice di supporto E memorizzazione nella cache
Vedi aggiornamento verso il basso (sotto la linea) . Qui vediamo di nuovo qualche differenza. Il sapore "parametrizzato" del mio metodo ora è a malapena in testa a 2 letture rispetto al metodo CROSS APPLY di Aaron (senza memorizzazione nella cache erano uguali). Ma la cosa davvero strana è che per la prima volta vediamo un metodo che è influenzato negativamente dalla memorizzazione nella cache: il metodo CTE di Aaron (che era precedentemente il migliore per il Test numero 3). Ma non mi prenderò il merito dove non è dovuto, e poiché senza la memorizzazione nella cache il metodo CTE di Aaron è ancora più veloce di quanto il mio metodo sia qui con la memorizzazione nella cache, l'approccio migliore per questa particolare situazione sembra essere il metodo CTE di Aaron.

Conclusione Si prega di consultare l'aggiornamento verso il basso (sotto la riga) Le
situazioni che fanno un uso ripetuto dei risultati di una query secondaria possono spesso (ma non sempre) trarre vantaggio dalla memorizzazione nella cache di tali risultati. Ma quando la memorizzazione nella cache è un vantaggio, l'utilizzo della memoria per detta memorizzazione nella cache presenta alcuni vantaggi rispetto all'utilizzo di tabelle temporanee.

Il metodo

Generalmente

Ho separato la query "header" (cioè ottenendo la ProductIDs, e in un caso anche la DaysToManufacture, basata Namesull'avvio con determinate lettere) dalle query "dettagliate" (cioè ottenendo la TransactionIDs e la TransactionDates). L'idea era di eseguire query molto semplici e di non confondere l'ottimizzatore durante l'adesione. Chiaramente questo non è sempre vantaggioso in quanto impedisce anche all'ottimizzatore di, bene, l'ottimizzazione. Ma come abbiamo visto nei risultati, a seconda del tipo di query, questo metodo ha i suoi meriti.

Le differenze tra i vari gusti di questo metodo sono:

  • Costanti: inviare eventuali valori sostituibili come costanti incorporate anziché essere parametri. Ciò farebbe riferimento a ProductIDtutti e tre i test e anche al numero di righe da restituire nel Test 2 in quanto questa è una funzione di "cinque volte l' DaysToManufactureattributo Prodotto". Questo sotto-metodo significa che ognuno ProductIDotterrà il proprio piano di esecuzione, il che può essere utile se c'è una grande variazione nella distribuzione dei dati per ProductID. Ma se c'è una piccola variazione nella distribuzione dei dati, il costo di generazione dei piani aggiuntivi probabilmente non ne varrà la pena.

  • Parametrizzato: inviare almeno ProductIDcome @ProductID, consentendo la memorizzazione e il riutilizzo della cache del piano di esecuzione. Esiste un'opzione di test aggiuntiva per trattare anche il numero variabile di righe da restituire per Test 2 come parametro.

  • Ottimizza sconosciuto: quando si fa riferimento ProductIDa @ProductID, se esiste una grande variazione nella distribuzione dei dati, è possibile memorizzare nella cache un piano che ha un effetto negativo su altri ProductIDvalori, quindi sarebbe bene sapere se l'utilizzo di questo suggerimento per le query è utile.

  • Prodotti cache: Invece di Production.Producteseguire una query sulla tabella ogni volta, solo per ottenere lo stesso elenco esatto, esegui la query una volta (e mentre ci siamo, filtra qualsiasi ProductIDs che non sia nemmeno nella TransactionHistorytabella in modo da non sprecare alcun risorse lì) e memorizza nella cache quell'elenco. L'elenco dovrebbe includere il DaysToManufacturecampo. Usando questa opzione c'è un hit iniziale leggermente più alto nelle Letture logiche per la prima esecuzione, ma dopo è solo la TransactionHistorytabella a cui viene interrogata.

In particolare

Ok, ma allora, come è possibile emettere tutte le sottoquery come query separate senza usare un CURSORE e scaricare ogni set di risultati in una tabella temporanea o variabile di tabella? Chiaramente fare il metodo CURSOR / Temp Table rifletterebbe abbastanza ovviamente nelle letture e scritture. Bene, usando SQLCLR :). Creando una procedura memorizzata SQLCLR, sono stato in grado di aprire un set di risultati e essenzialmente trasmettere i risultati di ogni sottointerrogazione su di esso, come set di risultati continuo (e non più set di risultati). Al di fuori delle informazioni del prodotto (vale a dire ProductID, NameeDaysToManufacture), nessuno dei risultati della sottoquery doveva essere archiviato ovunque (memoria o disco) e appena passato come set di risultati principale della procedura memorizzata SQLCLR. Questo mi ha permesso di fare una semplice query per ottenere le informazioni sul prodotto e poi scorrere attraverso di essa, inviando domande molto semplici contro TransactionHistory.

Ed è per questo che ho dovuto utilizzare SQL Server Profiler per acquisire le statistiche. La procedura memorizzata SQLCLR non ha restituito un piano di esecuzione, né impostando l'opzione di query "Includi piano di esecuzione effettivo" o emettendo SET STATISTICS XML ON;.

Per la memorizzazione nella cache delle informazioni sul prodotto, ho utilizzato un readonly staticelenco generico (ovvero _GlobalProductsnel codice seguente). Sembra che l'aggiunta alle raccolte non violi l' readonlyopzione, quindi questo codice funziona quando l'assembly ha un segno PERMISSON_SETdi SAFE:), anche se è controintuitivo.

Le query generate

Le query prodotte da questa stored procedure SQLCLR sono le seguenti:

Informazioni sul prodotto

Test numeri 1 e 3 (nessuna memorizzazione nella cache)

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

Test numero 2 (nessuna memorizzazione nella 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;

Test numeri 1, 2 e 3 (memorizzazione nella 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;

Informazioni sulla transazione

Test numeri 1 e 2 (costanti)

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

Test numeri 1 e 2 (parametrizzati)

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

Numeri di test 1 e 2 (parametrizzati + OTTIMIZZA SCONOSCIUTO)

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));

Test numero 2 (entrambi parametrizzati)

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

Test numero 2 (parametrizzato entrambi + OTTIMIZZA SCONOSCIUTO)

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));

Test numero 3 (Costanti)

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

Test numero 3 (parametrizzato)

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

Test numero 3 (parametrizzato + OTTIMIZZA SCONOSCIUTO)

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));

Il codice

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);
            }
        }


    }
}

Le query di prova

Non c'è abbastanza spazio per pubblicare i test qui, quindi troverò un'altra posizione.

La conclusione

Per alcuni scenari, SQLCLR può essere utilizzato per manipolare determinati aspetti delle query che non possono essere eseguiti in T-SQL. E c'è la possibilità di usare la memoria per la memorizzazione nella cache anziché le tabelle temporanee, sebbene ciò dovrebbe essere fatto con parsimonia e attenzione poiché la memoria non viene automaticamente rilasciata di nuovo nel sistema. Questo metodo non è anche qualcosa che aiuterà le query ad hoc, anche se è possibile renderlo più flessibile di quello che ho mostrato qui semplicemente aggiungendo parametri per personalizzare più aspetti delle query in esecuzione.


AGGIORNARE

Test aggiuntivo I
miei test originali che includevano un indice di supporto hanno TransactionHistoryutilizzato la seguente definizione:

ProductID ASC, TransactionDate DESC

Avevo deciso in quel momento di rinunciare anche TransactionId DESCalla fine, immaginando che mentre potrebbe aiutare il Test Numero 3 (che specifica la rottura del più recente - TransactionIdbeh, si presume che il "più recente" non sia esplicitamente dichiarato, ma tutti sembrano per concordare su questo presupposto), probabilmente non ci sarebbero legami sufficienti per fare la differenza.

Ma poi Aaron riprovò con un indice di supporto che includeva TransactionId DESCe scoprì che il CROSS APPLYmetodo era il vincitore in tutti e tre i test. Questo era diverso dal mio test che indicava che il metodo CTE era il migliore per il Test Numero 3 (quando non veniva usata la cache, il che rispecchia il test di Aaron). Era chiaro che c'era una variazione aggiuntiva che doveva essere testata.

Ho rimosso l'attuale indice di supporto, ne ho creato uno nuovo TransactionIde ho cancellato la cache del piano (per essere sicuro):

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;

Ho rieseguito il test numero 1 e i risultati sono stati gli stessi, come previsto. Ho quindi rieseguito il test numero 3 e i risultati sono effettivamente cambiati:

Test 3 risultati-con indice di supporto (con TransactionId DESC)
I risultati sopra riportati sono per il test standard senza memorizzazione nella cache. Questa volta, non solo CROSS APPLYbatte il CTE (come indicato dal test di Aaron), ma il proc SQLCLR ha preso il comando di 30 Read (woo hoo).

Test 3 risultati: con indice di supporto (con TransactionId DESC) E memorizzazione nella cache
I risultati sopra riportati sono per il test con memorizzazione nella cache abilitata. Questa volta le prestazioni del CTE non sono degradate, anche se lo CROSS APPLYbatte ancora. Tuttavia, ora il proc SQLCLR prende il comando di 23 letture (woo hoo, di nuovo).

Take Aways

  1. Ci sono varie opzioni da usare. È meglio provarne diversi in quanto ognuno ha i propri punti di forza. I test effettuati qui mostrano una varianza piuttosto piccola sia in Letture che in Durata tra i migliori e i peggiori in tutti i test (con un indice di supporto); la variazione in Letture è di circa 350 e la durata è di 55 ms. Mentre il proc SQLCLR ha vinto in tutti tranne 1 test (in termini di letture), il salvataggio di poche letture di solito non vale il costo di manutenzione per andare sulla rotta SQLCLR. Ma in AdventureWorks2012, la Producttabella ha solo 504 righe e TransactionHistorysolo 113.443 righe. La differenza di prestazioni tra questi metodi probabilmente diventa più pronunciata all'aumentare del numero di righe.

  2. Mentre questa domanda era specifica per ottenere un particolare set di righe, non si deve trascurare il fatto che il singolo fattore più importante nelle prestazioni era l'indicizzazione e non il particolare SQL. Un buon indice deve essere in atto prima di determinare quale metodo è veramente migliore.

  3. La lezione più importante trovata qui non riguarda CROSS APPLY vs CTE vs SQLCLR: si tratta di TEST. Non dare per scontato Ottieni idee da più persone e testa quanti più scenari puoi.


2
Vedi la mia modifica alla risposta di Mikael per il motivo delle letture logiche aggiuntive associate a apply.
Paul White

18

APPLY TOPo ROW_NUMBER()? Cosa potrebbe esserci di più da dire al riguardo?

Un breve riepilogo delle differenze e, per farla breve, mostrerò solo i piani per l'opzione 2 e ho aggiunto l'indice Production.TransactionHistory.

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

La row_number()query :.

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;

inserisci qui la descrizione dell'immagine

La apply topversione:

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';

inserisci qui la descrizione dell'immagine

La differenza principale tra questi è che i apply topfiltri sull'espressione superiore sotto i loop nidificati si uniscono dove la row_numberversione filtra dopo il join. Ciò significa che ci sono più letture di Production.TransactionHistoryquanto sia realmente necessario.

Se esistesse solo un modo per spingere gli operatori responsabili dell'enumerazione delle righe nel ramo inferiore prima del join, la row_numberversione potrebbe fare di meglio.

Quindi inserisci la apply row_number()versione.

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';

inserisci qui la descrizione dell'immagine

Come puoi vedere apply row_number()è praticamente lo stesso di un apply toppo 'più complicato. Anche il tempo di esecuzione è più o meno lo stesso.

Allora perché mi sono preso la briga di trovare una risposta non migliore di quella che già abbiamo? Bene, hai ancora una cosa da provare nel mondo reale e in realtà c'è una differenza nelle letture. Uno per il quale non ho una spiegazione per *.

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.

Mentre ci sono, potrei anche lanciare una seconda row_number()versione che in alcuni casi potrebbe essere la strada da percorrere. Quei casi certi si verificherebbero quando ti aspetti di aver effettivamente bisogno della maggior parte delle righe Production.TransactionHistoryperché qui ottieni un join di unione tra Production.Producte l'enumerato 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;

inserisci qui la descrizione dell'immagine

Per ottenere la forma sopra senza un operatore di ordinamento, è necessario modificare anche l'indice di supporto in ordine TransactionDatedecrescente.

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

* Modifica: le letture logiche aggiuntive sono dovute al prefetch dei loop nidificati utilizzato con apply-top. È possibile disabilitarlo con TF 8744 non modificato (e / o 9115 nelle versioni successive) per ottenere lo stesso numero di letture logiche. Il prefetching potrebbe essere un vantaggio dell'alternativa top-application nelle giuste circostanze. - Paul White


11

In genere utilizzo una combinazione di CTE e funzioni di windowing. È possibile ottenere questa risposta utilizzando qualcosa di simile al seguente:

;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

Per la parte di credito extra, in cui gruppi diversi potrebbero voler restituire numeri diversi di righe, è possibile utilizzare una tabella separata. Diciamo che usando criteri geografici come state:

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

Per raggiungere questo obiettivo in cui i valori possono essere diversi, è necessario unire il CTE alla tabella di stato simile a questo:

SELECT [CustomerID]
    ,[OrderDate]
    ,[TotalAmt]
    ,[State]
FROM GiveMeCounts gmc
INNER JOIN StateTable st ON gmc.[State] = st.[State]
    AND gmc.MySeqNum <= st.MaxSeqNum
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.