Recuperando n filas por grupo

88

A menudo necesito seleccionar varias filas de cada grupo en un conjunto de resultados.

Por ejemplo, podría querer enumerar los 'n' valores de orden recientes más altos o más bajos por cliente.

En casos más complejos, el número de filas para enumerar puede variar según el grupo (definido por un atributo de la agrupación / registro principal). Esta parte es definitivamente opcional / para crédito adicional y no tiene la intención de disuadir a las personas de responder.

¿Cuáles son las principales opciones para resolver este tipo de problemas en SQL Server 2005 y versiones posteriores? ¿Cuáles son las principales ventajas y desventajas de cada método?

Ejemplos de AdventureWorks (para mayor claridad, opcional)

  1. Enumere las cinco fechas e ID de transacciones recientes más recientes de la TransactionHistorytabla, para cada producto que comienza con una letra de M a R inclusive.
  2. Lo mismo otra vez, pero con nlíneas de historial por producto, donde nes cinco veces el DaysToManufactureatributo Producto.
  3. Lo mismo, para el caso especial donde se requiere exactamente una línea de historial por producto (la entrada más reciente por TransactionDate, desempate) TransactionID.
Paul White
fuente

Respuestas:

71

Comencemos con el escenario básico.

Si quiero obtener un número de filas de una tabla, tengo dos opciones principales: funciones de clasificación; o TOP.

Primero, consideremos todo el conjunto de Production.TransactionHistoryun particular ProductID:

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

Esto devuelve 418 filas, y el plan muestra que verifica cada fila de la tabla en busca de esto: una exploración de índice agrupado sin restricciones, con un predicado para proporcionar el filtro. 797 lee aquí, lo cual es feo.

Escaneo costoso con predicado 'residual'

Así que seamos justos y creemos un índice que sería más útil. Nuestras condiciones requieren una coincidencia de igualdad ProductID, seguida de una búsqueda de la más reciente por TransactionDate. Necesitamos la TransactionIDvolvieron también, así que vamos a ir con: CREATE INDEX ix_FindingMostRecent ON Production.TransactionHistory (ProductID, TransactionDate) INCLUDE (TransactionID);.

Una vez hecho esto, nuestro plan cambia significativamente y reduce las lecturas a solo 3. Así que ya estamos mejorando las cosas en más de 250 veces más o menos ...

Plan mejorado

Ahora que hemos nivelado el campo de juego, echemos un vistazo a las principales opciones: funciones de clasificación y 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;

Dos planes - TOP básico \ RowNum

Notará que la segunda TOPconsulta ( ) es mucho más simple que la primera, tanto en consulta como en plan. Pero muy significativamente, ambos usan TOPpara limitar el número de filas que en realidad se extraen del índice. Los costos son solo estimados y vale la pena ignorar, pero puede ver mucha similitud en los dos planes, con la ROW_NUMBER()versión haciendo una pequeña cantidad de trabajo adicional para asignar números y filtrar en consecuencia, y ambas consultas terminan haciendo solo 2 lecturas para hacer su trabajo. El Query Optimizer ciertamente reconoce la idea de filtrar en un ROW_NUMBER()campo, al darse cuenta de que puede usar un operador Top para ignorar las filas que no serán necesarias. Ambas consultas son lo suficientemente buenas: TOPno es mucho mejor que vale la pena cambiar el código, pero es más simple y probablemente más claro para los principiantes.

Entonces esto funciona en un solo producto. Pero debemos considerar qué sucede si necesitamos hacer esto en múltiples productos.

El programador iterativo considerará la idea de recorrer los productos de interés y llamar a esta consulta varias veces, y de hecho podemos salir escribiendo una consulta de esta forma, no usando cursores, sino usando APPLY. Estoy usando OUTER APPLY, pensando que podríamos querer devolver el Producto con NULL, si no hay Transacciones para ello.

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

El plan para esto es el método iterativo de los programadores: Nested Loop, que realiza una operación Top y Seek (esas 2 lecturas que teníamos antes) para cada Producto. Esto da 4 lecturas contra el Producto y 360 contra TransactionHistory.

APLICAR plan

Usando ROW_NUMBER(), el método es usar PARTITION BYen la OVERcláusula, para que reiniciemos la numeración de cada Producto. Esto se puede filtrar como antes. El plan termina siendo bastante diferente. Las lecturas lógicas son aproximadamente un 15% más bajas en TransactionHistory, con una exploración de índice completa para sacar las filas.

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;

Plan ROW_NUMBER

Sin embargo, este plan tiene un operador de clasificación costoso. La combinación de combinación no parece mantener el orden de las filas en TransactionHistory, los datos deben ser utilizados para poder encontrar los números de referencia. Son menos lecturas, pero este tipo de bloqueo podría ser doloroso. Utilizando APPLY, el Nested Loop devolverá las primeras filas muy rápidamente, después de unas pocas lecturas, pero con una Clasificación, ROW_NUMBER()solo devolverá filas después de que la mayor parte del trabajo haya finalizado.

Curiosamente, si la ROW_NUMBER()consulta usa en INNER JOINlugar de LEFT JOIN, entonces surge un plan diferente.

ROW_NUMBER () con INNER JOIN

Este plan utiliza un bucle anidado, al igual que con APPLY. Pero no hay un operador Top, por lo que extrae todas las transacciones para cada producto y usa muchas más lecturas que antes: 492 lecturas contra TransactionHistory. No hay una buena razón para que no elija la opción Combinar combinación aquí, así que supongo que el plan se consideró "lo suficientemente bueno". Aún así, no se bloquea, lo cual es bueno, solo que no es tan bueno como APPLY.

La PARTITION BYcolumna que utilicé ROW_NUMBER()fue h.ProductIDen ambos casos, porque quería darle al QO la opción de producir el valor RowNum antes de unirme a la tabla Producto. Si uso p.ProductID, vemos el mismo plan de forma que con la INNER JOINvariación.

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;

Pero el operador Join dice 'Left Outer Join' en lugar de 'Inner Join'. El número de lecturas sigue siendo inferior a 500 lecturas en la tabla TransactionHistory.

PARTITION BY en p.ProductID en lugar de h.ProductID

De todos modos, volviendo a la pregunta en cuestión ...

Hemos respondido la pregunta 1 , con dos opciones que puede elegir. Personalmente, me gusta la APPLYopción.

Para extender esto para usar un número variable ( pregunta 2 ), 5solo es necesario cambiarlo en consecuencia. Ah, y agregué otro índice, para que hubiera un índice Production.Product.Nameque incluyera la DaysToManufacturecolumna.

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

¡Y ambos planes son casi idénticos a lo que eran antes!

Filas variables

Nuevamente, ignore los costos estimados, pero todavía me gusta el escenario TOP, ya que es mucho más simple y el plan no tiene operador de bloqueo. Las lecturas son menos en TransactionHistory debido a la gran cantidad de ceros DaysToManufacture, pero en la vida real, dudo que estemos eligiendo esa columna. ;)

Una forma de evitar el bloqueo es crear un plan que maneje el ROW_NUMBER()bit a la derecha (en el plan) de la unión. Podemos persuadir que esto suceda haciendo la unión fuera 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';

El plan aquí parece más simple: no está bloqueando, pero existe un peligro oculto.

Unirse fuera de CTE

Observe el cómputo escalar que extrae datos de la tabla Producto. Esto está resolviendo el 5 * p.DaysToManufacturevalor. Este valor no se pasa a la rama que extrae datos de la tabla TransactionHistory, se usa en la combinación de combinación. Como Residual

Residual furtivo!

Por lo tanto, la combinación de combinación consume TODAS las filas, no solo las primeras, sin embargo, se necesitan muchas, sino todas y luego realiza una comprobación residual. Esto es peligroso a medida que aumenta el número de transacciones. No soy un fanático de este escenario: los predicados residuales en Merge Joins pueden escalar rápidamente. Otra razón por la que prefiero el APPLY/TOPescenario.

En el caso especial donde es exactamente una fila, para la pregunta 3 , obviamente podemos usar las mismas consultas, pero con en 1lugar de 5. Pero luego tenemos una opción adicional, que es usar agregados regulares.

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

Una consulta como esta sería un comienzo útil, y podríamos modificarla fácilmente para extraer el TransactionID también para fines de desempate (usando una concatenación que luego se desglosaría), pero miramos el índice completo o nos sumergimos en producto por producto, y realmente no obtenemos una gran mejora en lo que teníamos antes en este escenario.

Pero debo señalar que estamos viendo un escenario particular aquí. Con datos reales, y con una estrategia de indexación que puede no ser ideal, el kilometraje puede variar considerablemente. A pesar de que hemos visto que APPLYes fuerte aquí, puede ser más lento en algunas situaciones. Sin embargo, rara vez bloquea, ya que tiende a usar Nested Loops, lo que mucha gente (incluido yo mismo) encuentra muy atractivo.

No he tratado de explorar el paralelismo aquí, o me he sumergido mucho en la pregunta 3, que veo como un caso especial que la gente rara vez quiere debido a la complicación de concatenar y dividir. Lo principal a considerar aquí es que estas dos opciones son muy fuertes.

Yo prefiero APPLY. Está claro, utiliza bien el operador superior y rara vez provoca el bloqueo.

Rob Farley
fuente
45

La forma típica de hacer esto en SQL Server 2005 y versiones posteriores es usar un CTE y funciones de ventanas. Para los primeros n por grupo, simplemente puede usar ROW_NUMBER()con una PARTITIONcláusula y filtrar contra eso en la consulta externa. Entonces, por ejemplo, los 5 pedidos más recientes por cliente podrían mostrarse de esta manera:

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;

También puedes hacer esto 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 la opción adicional que Paul especificó, digamos que la tabla Clientes tiene una columna que indica cuántas filas 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;

Y nuevamente, usando CROSS APPLYe incorporando la opción agregada de que el número de filas para un cliente sea dictado por alguna columna en la tabla de clientes:

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;

Tenga en cuenta que estos funcionarán de manera diferente según la distribución de datos y la disponibilidad de índices de soporte, por lo que optimizar el rendimiento y obtener el mejor plan dependerá realmente de los factores locales.

Personalmente, prefiero las soluciones CTE y de ventanas sobre CROSS APPLY/ TOPporque separan mejor la lógica y son más intuitivas (para mí). En general (tanto en este caso como en mi experiencia general), el enfoque CTE produce planes más eficientes (ejemplos a continuación), pero esto no debe tomarse como una verdad universal: siempre debe probar sus escenarios, especialmente si los índices han cambiado o los datos se han sesgado significativamente.


Ejemplos de AdventureWorks - sin ningún cambio

  1. Enumere las cinco fechas e ID de transacciones recientes más recientes de la TransactionHistorytabla, para cada producto que comienza con una 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';

Comparación de estos dos en métricas de tiempo de ejecución:

ingrese la descripción de la imagen aquí

CTE / OVER()plan:

ingrese la descripción de la imagen aquí

CROSS APPLY plan:

ingrese la descripción de la imagen aquí

El plan CTE parece más complicado, pero en realidad es mucho más eficiente. Preste poca atención a los números de% de costo estimado, pero concéntrese en observaciones reales más importantes , como muchas menos lecturas y una duración mucho menor. También ejecuté estos sin paralelismo, y esta no era la diferencia. Métricas de tiempo de ejecución y el plan CTE (el CROSS APPLYplan se mantuvo igual):

ingrese la descripción de la imagen aquí

ingrese la descripción de la imagen aquí

  1. Lo mismo otra vez, pero con nlíneas de historial por producto, donde nes cinco veces el DaysToManufactureatributo Producto.

Se requieren cambios muy pequeños aquí. Para el CTE, podemos agregar una columna a la consulta interna y filtrar la consulta externa; para el CROSS APPLY, podemos realizar el cálculo dentro del correlacionado TOP. Se podría pensar que esto le daría algo de eficiencia a la CROSS APPLYsolución, pero eso no sucede en este 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 de tiempo de ejecución:

ingrese la descripción de la imagen aquí

CTE / OVER()plan paralelo :

ingrese la descripción de la imagen aquí

CTE / OVER()plan de un solo hilo :

ingrese la descripción de la imagen aquí

CROSS APPLY plan:

ingrese la descripción de la imagen aquí

  1. Lo mismo, para el caso especial donde se requiere exactamente una línea de historial por producto (la entrada más reciente por TransactionDate, desempate) TransactionID.

Nuevamente, pequeños cambios aquí. En la solución CTE, agregamos TransactionIDa la OVER()cláusula y cambiamos el filtro externo a rn = 1. Para el CROSS APPLY, cambiamos el TOPa TOP (1), y agregamos TransactionIDal interior 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 de tiempo de ejecución:

ingrese la descripción de la imagen aquí

CTE / OVER()plan paralelo :

ingrese la descripción de la imagen aquí

Plan CTE / OVER de un solo subproceso ():

ingrese la descripción de la imagen aquí

CROSS APPLY plan:

ingrese la descripción de la imagen aquí

Las funciones de ventanas no siempre son la mejor alternativa (intente COUNT(*) OVER()), y estos no son los únicos dos enfoques para resolver el problema de n filas por grupo, pero en este caso específico, dado el esquema, los índices existentes y la distribución de datos, al CTE le fue mejor en todas las cuentas significativas.


Ejemplos de AdventureWorks: con flexibilidad para agregar índices

Sin embargo, si agrega un índice de apoyo, similar al que Paul mencionó en un comentario pero con las columnas segunda y tercera ordenadas DESC:

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

En realidad, obtendría planes mucho más favorables y las métricas cambiarían para favorecer el CROSS APPLYenfoque en los tres casos:

ingrese la descripción de la imagen aquí

Si este fuera mi entorno de producción, probablemente estaría satisfecho con la duración en este caso, y no me molestaría en optimizar aún más.


Todo esto fue mucho más feo en SQL Server 2000, que no era compatible con APPLYla OVER()cláusula.

Aaron Bertrand
fuente
24

En DBMS, como MySQL, que no tienen funciones de ventana o CROSS APPLY, la forma de hacerlo sería usar SQL estándar (89). La forma lenta sería una unión triangular cruzada con agregado. La forma más rápida (pero aún así y probablemente no tan eficiente como usar la aplicación cruzada o la función row_number) sería lo que yo llamo el "hombre pobre CROSS APPLY" . Sería interesante comparar esta consulta con las otras:

Asunción: Orders (CustomerID, OrderDate)tiene una UNIQUErestricción:

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 el problema adicional de las filas 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: En MySQL, en lugar de AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...)uno usaría AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1)). SQL-Server agregó FETCH / OFFSETsintaxis en la versión 2012. Las consultas aquí se ajustaron IN (TOP...)para trabajar con versiones anteriores.

ypercubeᵀᴹ
fuente
21

Tomé un enfoque ligeramente diferente, principalmente para ver cómo esta técnica se compararía con las otras, porque tener opciones es bueno, ¿verdad?

La prueba

¿Por qué no comenzamos simplemente observando cómo los distintos métodos se comparan entre sí? Hice tres series de pruebas:

  1. El primer conjunto se ejecutó sin modificaciones de la base de datos.
  2. El segundo conjunto se ejecutó después de que se creó un índice para admitir TransactionDateconsultas basadas en Production.TransactionHistory.
  3. El tercer set hizo una suposición ligeramente diferente. Dado que las tres pruebas se ejecutaron en la misma lista de Productos, ¿qué pasa si almacenamos en caché esa lista? Mi método usa un caché en memoria mientras que los otros métodos usan una tabla temporal equivalente. El índice de soporte creado para el segundo conjunto de pruebas todavía existe para este conjunto de pruebas.

Detalles de prueba adicionales:

  • Las pruebas se ejecutaron AdventureWorks2012en SQL Server 2012, SP2 (Developer Edition).
  • Para cada prueba, marqué de qué respuesta tomé la consulta y de qué consulta en particular era.
  • Usé la opción "Descartar resultados después de la ejecución" de Opciones de consulta | Resultados
  • Tenga en cuenta que para los primeros dos conjuntos de pruebas, RowCountsparece estar "apagado" para mi método. Esto se debe a que mi método es una implementación manual de lo que CROSS APPLYestá haciendo: ejecuta la consulta inicial Production.Producty recupera 161 filas, que luego utiliza para las consultas Production.TransactionHistory. Por lo tanto, los RowCountvalores para mis entradas son siempre 161 más que las otras entradas. En el tercer conjunto de pruebas (con almacenamiento en caché) los recuentos de filas son los mismos para todos los métodos.
  • Usé SQL Server Profiler para capturar las estadísticas en lugar de confiar en los planes de ejecución. Aaron y Mikael ya hicieron un gran trabajo al mostrar los planes para sus consultas y no hay necesidad de reproducir esa información. Y la intención de mi método es reducir las consultas a una forma tan simple que realmente no importaría. Hay una razón adicional para usar Profiler, pero eso se mencionará más adelante.
  • En lugar de usar la Name >= N'M' AND Name < N'S'construcción, elegí usar Name LIKE N'[M-R]%', y SQL Server los trata de la misma manera.

Los resultados

Sin índice de respaldo

Esto es esencialmente AdventureWorks2012 listo para usar. En todos los casos, mi método es claramente mejor que algunos de los otros, pero nunca es tan bueno como los métodos 1 o 2 principales.

Prueba 1 Resultados de la prueba 1: sin índice
El CTE de Aaron es claramente el ganador aquí.

Prueba 2 Resultados de la prueba 2: sin índice
CTE de Aaron (nuevamente) y el segundo apply row_number()método de Mikael es un segundo cercano.

Prueba 3 Resultados de la prueba 3: sin índice
Aaron's CTE (nuevamente) es el ganador.

Conclusión
Cuando no hay un índice de respaldo TransactionDate, mi método es mejor que hacer un estándar CROSS APPLY, pero aun así, usar el método CTE es claramente el camino a seguir.

Con índice de soporte (sin almacenamiento en caché)

Para este conjunto de pruebas, agregué el índice obvio TransactionHistory.TransactionDateya que todas las consultas se ordenan en ese campo. Digo "obvio" ya que la mayoría de las otras respuestas también están de acuerdo en este punto. Y dado que todas las consultas desean las fechas más recientes, el TransactionDatecampo debe ordenarse DESC, por lo que simplemente tomé la CREATE INDEXdeclaración al final de la respuesta de Mikael y agregué un explícito FILLFACTOR:

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

Una vez que este índice está en su lugar, los resultados cambian bastante.

Prueba 1 Resultados de la prueba 1 con índice de apoyo
Esta vez es mi método el que sale adelante, al menos en términos de lecturas lógicas. El CROSS APPLYmétodo, que anteriormente era el de peor desempeño para la Prueba 1, gana en Duración e incluso supera el método CTE en Lecturas lógicas.

Prueba 2 Prueba 2 Resultados-con índice de apoyo
Esta vez, el primer apply row_number()método de Mikael es el ganador al mirar las Lecturas, mientras que anteriormente era uno de los de peor desempeño. Y ahora mi método viene en un segundo lugar muy cercano cuando se mira Lecturas. De hecho, fuera del método CTE, el resto están bastante cerca en términos de lecturas.

Prueba 3 Prueba 3 Resultados-con índice de apoyo
Aquí el CTE sigue siendo el ganador, pero ahora la diferencia entre los otros métodos es apenas notable en comparación con la diferencia drástica que existía antes de crear el índice.

Conclusión
La aplicabilidad de mi método es más evidente ahora, aunque es menos resistente a no tener índices adecuados en su lugar.

Con índice de soporte y almacenamiento en caché

Para este conjunto de pruebas utilicé el almacenamiento en caché porque, bueno, ¿por qué no? Mi método permite usar el almacenamiento en caché en memoria al que los otros métodos no pueden acceder. Para ser justos, creé la siguiente tabla temporal que se utilizó en lugar de Product.Producttodas las referencias en esos otros métodos en las tres pruebas. El DaysToManufacturecampo solo se usa en la Prueba número 2, pero fue más fácil ser coherente entre los scripts de SQL para usar la misma tabla y no hizo daño tenerlo allí.

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

Prueba 1 Prueba 1 Resultados: con índice de respaldo Y almacenamiento en caché
Todos los métodos parecen beneficiarse igualmente del almacenamiento en caché, y mi método aún sale adelante.

Prueba 2 Prueba 2 Resultados: con índice de respaldo Y almacenamiento en caché
Aquí ahora vemos una diferencia en la alineación ya que mi método sale apenas por delante, solo 2 lecturas mejor que el primer apply row_number()método de Mikael , mientras que sin el almacenamiento en caché mi método estaba retrasado por 4 lecturas.

Prueba 3 Prueba 3 Resultados: con índice de respaldo Y almacenamiento en caché
Consulte la actualización hacia la parte inferior (debajo de la línea) . Aquí nuevamente vemos alguna diferencia. El sabor "parametrizado" de mi método ahora apenas está a la cabeza en 2 lecturas en comparación con el método CROSS APPLY de Aaron (sin almacenamiento en caché, eran iguales). Pero lo realmente extraño es que, por primera vez, vemos un método que se ve afectado negativamente por el almacenamiento en caché: el método CTE de Aaron (que anteriormente era el mejor para la Prueba número 3). Pero, no voy a tomar crédito donde no se debe, y dado que sin el almacenamiento en caché, el método CTE de Aaron es aún más rápido que mi método aquí con el almacenamiento en caché, el mejor enfoque para esta situación particular parece ser el método CTE de Aaron.

Conclusión Consulte la actualización en la parte inferior (debajo de la línea). Las
situaciones que hacen uso repetido de los resultados de una consulta secundaria a menudo (pero no siempre) se benefician al almacenar en caché esos resultados. Pero cuando el almacenamiento en caché es un beneficio, el uso de memoria para dicho almacenamiento en caché tiene alguna ventaja sobre el uso de tablas temporales.

El método

Generalmente

Separé la consulta de "encabezado" (es decir, obteniendo el ProductIDs, y en un caso también el DaysToManufacture, basado en el Namecomienzo con ciertas letras) de las consultas de "detalle" (es decir, obteniendo los TransactionIDs y TransactionDates). El concepto era realizar consultas muy simples y no permitir que el optimizador se confunda al UNIRSE a ellas. Claramente, esto no siempre es ventajoso, ya que también impide que el optimizador optimice. Pero como vimos en los resultados, dependiendo del tipo de consulta, este método tiene sus ventajas.

La diferencia entre los diversos sabores de este método son:

  • Constantes: envíe los valores reemplazables como constantes en línea en lugar de ser parámetros. Esto se referiría a ProductIDlas tres pruebas y también al número de filas que se devolverán en la Prueba 2, ya que es una función de "cinco veces el DaysToManufactureatributo Producto". Este submétodo significa que cada uno ProductIDobtendrá su propio plan de ejecución, lo que puede ser beneficioso si existe una amplia variación en la distribución de datos ProductID. Pero si hay poca variación en la distribución de datos, el costo de generar los planes adicionales probablemente no valdrá la pena.

  • Parametrizado: envíe al menos ProductIDcomo @ProductID, permitiendo el almacenamiento en caché y la reutilización del plan de ejecución. Hay una opción de prueba adicional para tratar también el número variable de filas para devolver para la Prueba 2 como un parámetro.

  • Optimizar Desconocido: Cuando se hace referencia ProductIDcomo @ProductID, si existe una amplia variación de la distribución de datos, entonces es posible almacenar en caché un plan que tiene un efecto negativo sobre otros ProductIDvalores por lo que sería bueno saber si el uso de esta sugerencia de consulta ayuda a cualquier.

  • Productos de caché: en lugar de consultar la Production.Producttabla cada vez, solo para obtener exactamente la misma lista, ejecute la consulta una vez (y mientras lo hacemos, filtre cualquier ProductIDcorreo que ni siquiera esté en la TransactionHistorytabla para que no desperdiciemos ninguno recursos allí) y almacenar en caché esa lista. La lista debe incluir el DaysToManufacturecampo. Al usar esta opción, hay un impacto inicial ligeramente mayor en las lecturas lógicas para la primera ejecución, pero después de eso solo TransactionHistoryse consulta la tabla.

Específicamente

Ok, pero entonces, ¿cómo es posible emitir todas las subconsultas como consultas separadas sin usar un CURSOR y volcar cada conjunto de resultados en una tabla o variable de tabla temporal? Claramente, hacer el método CURSOR / Tabla temporal se reflejaría de manera bastante obvia en las lecturas y escrituras. Bueno, usando SQLCLR :). Al crear un procedimiento almacenado SQLCLR, pude abrir un conjunto de resultados y esencialmente transmitirle los resultados de cada subconsulta, como un conjunto de resultados continuo (y no múltiples conjuntos de resultados). Fuera de la información del producto (es decir ProductID, NameyDaysToManufacture), ninguno de los resultados de la subconsulta tuvo que almacenarse en ningún lugar (memoria o disco) y simplemente pasó como el conjunto de resultados principal del procedimiento almacenado SQLCLR. Esto me permitió hacer una consulta simple para obtener la información del Producto y luego recorrerla, emitiendo consultas muy simples en contra TransactionHistory.

Y es por eso que tuve que usar SQL Server Profiler para capturar las estadísticas. El procedimiento almacenado SQLCLR no devolvió un plan de ejecución, ya sea configurando la opción de consulta "Incluir plan de ejecución real" o emitiendo SET STATISTICS XML ON;.

Para el almacenamiento en caché de información del producto, utilicé una readonly staticlista genérica (es decir, _GlobalProductsen el código a continuación). Parece que la adición a las colecciones no viola la readonlyopción, por lo tanto, este código funciona cuando el conjunto tiene una PERMISSON_SETde SAFE:), incluso si esto es contrario a la intuición.

Las consultas generadas

Las consultas producidas por este procedimiento almacenado SQLCLR son las siguientes:

Información del producto

Números de prueba 1 y 3 (sin almacenamiento en caché)

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

Prueba número 2 (sin almacenamiento en caché)

;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 prueba 1, 2 y 3 (almacenamiento en caché)

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

Información de la transacción

Números de prueba 1 y 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 prueba 1 y 2 (parametrizados)

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

Números de prueba 1 y 2 (parametrizado + OPTIMIZAR DESCONOCIDO)

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

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

Prueba número 2 (parametrizado ambos + OPTIMIZAR DESCONOCIDO)

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

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

Prueba número 3 (parametrizada)

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

Prueba número 3 (parametrizado + OPTIMIZAR DESCONOCIDO)

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

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


    }
}

Las consultas de prueba

No hay suficiente espacio para publicar las pruebas aquí, así que encontraré otra ubicación.

La conclusión

Para ciertos escenarios, SQLCLR se puede usar para manipular ciertos aspectos de las consultas que no se pueden hacer en T-SQL. Y existe la capacidad de utilizar la memoria para el almacenamiento en caché en lugar de las tablas temporales, aunque eso debe hacerse con moderación y cuidado, ya que la memoria no se libera automáticamente al sistema. Este método tampoco es algo que ayude a las consultas ad hoc, aunque es posible hacerlo más flexible de lo que he mostrado aquí simplemente agregando parámetros para adaptar más aspectos de las consultas que se ejecutan.


ACTUALIZAR

Prueba adicional
Mis pruebas originales que incluían un índice de respaldo TransactionHistoryutilizaron la siguiente definición:

ProductID ASC, TransactionDate DESC

Decidí renunciar en ese momento, incluso TransactionId DESCal final, pensando que si bien podría ayudar la Prueba número 3 (que especifica el desempate en el más reciente TransactionId, bueno, se supone "más reciente", ya que no se indica explícitamente, pero todos parecen estar de acuerdo con esta suposición), probablemente no habría suficientes lazos para marcar la diferencia.

Pero, Aaron volvió a probar con un índice de apoyo que sí incluyó TransactionId DESCy descubrió que el CROSS APPLYmétodo fue el ganador en las tres pruebas. Esto fue diferente a mi prueba que indicaba que el método CTE era el mejor para la Prueba número 3 (cuando no se utilizó el almacenamiento en caché, que refleja la prueba de Aaron). Estaba claro que había una variación adicional que necesitaba ser probada.

Eliminé el índice de soporte actual, creé uno nuevo TransactionIdy borré el caché del plan (solo para estar seguro):

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;

Volví a ejecutar la Prueba número 1 y los resultados fueron los mismos, como se esperaba. Luego volví a ejecutar la Prueba número 3 y los resultados realmente cambiaron:

Prueba 3 Resultados: con índice de soporte (con TransactionId DESC)
Los resultados anteriores son para la prueba estándar sin almacenamiento en caché. Esta vez, no solo CROSS APPLYsupera el CTE (tal como lo indicó la prueba de Aaron), sino que el proceso SQLCLR tomó la delantera en 30 lecturas (woo hoo).

Prueba 3 Resultados: con índice de soporte (con TransactionId DESC) Y almacenamiento en caché
Los resultados anteriores son para la prueba con el almacenamiento en caché habilitado. Esta vez, el rendimiento del CTE no se degrada, aunque CROSS APPLYtodavía lo supera. Sin embargo, ahora el proceso SQLCLR toma la delantera en 23 lecturas (woo hoo, de nuevo).

Llevar

  1. Hay varias opciones para usar. Es mejor probar varios, ya que cada uno tiene sus puntos fuertes. Las pruebas realizadas aquí muestran una variación bastante pequeña tanto en Lecturas como en Duración entre los mejores y peores resultados en todas las pruebas (con un índice de apoyo); la variación en lecturas es de aproximadamente 350 y la duración es de 55 ms. Si bien el proceso SQLCLR ganó en todas las pruebas menos 1 (en términos de Lecturas), solo guardar algunas Lecturas generalmente no vale el costo de mantenimiento de seguir la ruta SQLCLR. Pero en AdventureWorks2012, la Producttabla tiene solo 504 filas y TransactionHistorysolo 113,443 filas. La diferencia de rendimiento entre estos métodos probablemente se vuelve más pronunciada a medida que aumenta el recuento de filas.

  2. Si bien esta pregunta era específica para obtener un conjunto particular de filas, no debe pasarse por alto que el factor más importante en el rendimiento fue la indexación y no el SQL particular. Es necesario establecer un buen índice antes de determinar qué método es realmente mejor.

  3. La lección más importante que se encuentra aquí no se trata de CROSS APPLY vs CTE vs SQLCLR: se trata de TESTING. No asumas Obtenga ideas de varias personas y pruebe tantos escenarios como pueda.

Solomon Rutzky
fuente
2
Vea mi edición de la respuesta de Mikael para conocer el motivo de las lecturas lógicas adicionales asociadas con apply.
Paul White
18

APPLY TOPo ROW_NUMBER()? ¿Qué podría haber más que decir al respecto?

Una breve recapitulación de las diferencias y, para ser realmente breve, solo mostraré los planes para la opción 2 y he agregado el índice Production.TransactionHistory.

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

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

ingrese la descripción de la imagen aquí

La apply topversión:

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

ingrese la descripción de la imagen aquí

La principal diferencia entre estos es que los apply topfiltros en la expresión superior debajo de los bucles anidados se unen donde la row_numberversión se filtra después de la unión. Eso significa que hay más lecturas de las Production.TransactionHistoryque realmente son necesarias.

Si solo existiera una forma de empujar a los operadores responsables de enumerar las filas hasta la rama inferior antes de la unión, la row_numberversión podría funcionar mejor.

Entonces ingrese la apply row_number()versión.

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

ingrese la descripción de la imagen aquí

Como puede ver, apply row_number()es casi lo mismo que apply topsolo un poco más complicado. El tiempo de ejecución también es casi igual o un poco más lento.

Entonces, ¿por qué me molesté en encontrar una respuesta que no sea mejor que la que ya tenemos? Bueno, tienes una cosa más para probar en el mundo real y en realidad hay una diferencia en las lecturas. Uno para el que no tengo una explicación *.

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.

Mientras estoy en ello, podría lanzar una segunda row_number()versión que, en ciertos casos, podría ser el camino a seguir. Esos ciertos casos serían cuando esperas que realmente necesites la mayoría de las filas Production.TransactionHistoryporque aquí obtienes una combinación de fusión entre Production.Producty los enumerados 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;

ingrese la descripción de la imagen aquí

Para obtener la forma anterior sin un operador de clasificación, también debe cambiar el índice de soporte al orden TransactionDatedescendiendo.

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

* Editar: Las lecturas lógicas adicionales se deben a la captación previa de bucles anidados utilizada con el aplicador. Puede deshabilitar esto con TF 8744 no documentado (y / o 9115 en versiones posteriores) para obtener el mismo número de lecturas lógicas. La captación previa podría ser una ventaja de la alternativa de aplicación superior en las circunstancias correctas. - Paul White

Mikael Eriksson
fuente
11

Normalmente uso una combinación de CTE y funciones de ventanas. Puede lograr esta respuesta usando algo como lo siguiente:

;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 la porción de crédito adicional, donde los diferentes grupos pueden querer devolver diferentes números de filas, puede usar una tabla separada. Digamos que usando criterios geográficos como el estado:

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

Para lograr esto donde los valores pueden ser diferentes, necesitaría unir su CTE a la tabla de estado similar a esto:

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