Optimización de búsquedas de rango numérico (intervalo) en SQL Server

18

Esta pregunta es similar a la optimización de la búsqueda de rango de IP? pero ese está restringido a SQL Server 2000.

Supongamos que tengo 10 millones de rangos almacenados provisionalmente en una tabla estructurada y poblada como se muestra a continuación.

CREATE TABLE MyTable
(
Id        INT IDENTITY PRIMARY KEY,
RangeFrom INT NOT NULL,
RangeTo   INT NOT NULL,
CHECK (RangeTo > RangeFrom),
INDEX IX1 (RangeFrom,RangeTo),
INDEX IX2 (RangeTo,RangeFrom)
);

WITH RandomNumbers
     AS (SELECT TOP 10000000 ABS(CRYPT_GEN_RANDOM(4)%100000000) AS Num
         FROM   sys.all_objects o1,
                sys.all_objects o2,
                sys.all_objects o3,
                sys.all_objects o4)
INSERT INTO MyTable
            (RangeFrom,
             RangeTo)
SELECT Num,
       Num + 1 + CRYPT_GEN_RANDOM(1)
FROM   RandomNumbers 

Necesito saber todos los rangos que contienen el valor 50,000,000. Intento la siguiente consulta

SELECT *
FROM MyTable
WHERE 50000000 BETWEEN RangeFrom AND RangeTo

SQL Server muestra que hubo 10,951 lecturas lógicas y se leyeron casi 5 millones de filas para devolver las 12 coincidentes.

ingrese la descripción de la imagen aquí

¿Puedo mejorar este rendimiento? Cualquier reestructuración de la tabla o índices adicionales está bien.

Martin Smith
fuente
Si entiendo la configuración de la tabla correctamente, está eligiendo números aleatorios de manera uniforme para formar sus rangos, sin restricciones en el "tamaño" de cada rango. Y su sonda es para la mitad del rango general 1..100M. En ese caso, sin agrupamiento aparente debido a una aleatoriedad uniforme, no sé por qué sería útil un índice en el límite inferior o superior. ¿Puedes explicar eso?
davidbak
@davidbak los índices convencionales de esta tabla no son de mucha ayuda en el peor de los casos, ya que tiene que escanear la mitad del rango y, por lo tanto, solicitar mejoras potenciales. Hay una buena mejora en la pregunta vinculada para SQL Server 2000 con la introducción del "gránulo". Esperaba que los índices espaciales pudieran ayudar aquí ya que admiten containsconsultas y, aunque funcionan bien para reducir la cantidad de datos leídos, parecen agregar otros sobrecarga que contrarresta esto.
Martin Smith
No tengo la posibilidad de probarlo, pero me pregunto si dos índices, uno en el límite inferior, uno en el superior, y luego una unión interna, permitirían que el optimizador de consultas resolviera algo.
davidbak
44
Relacionado: Seleccione todos los rangos superpuestos del rango inicial
Paul White Restablezca a Monica

Respuestas:

11

Columnstore es muy útil aquí en comparación con un índice no agrupado que escanea la mitad de la tabla. Un índice de almacén de columnas no agrupado proporciona la mayor parte del beneficio, pero insertar datos ordenados en un índice de almacén de columnas agrupado es aún mejor.

DROP TABLE IF EXISTS dbo.MyTableCCI;

CREATE TABLE dbo.MyTableCCI
(
Id        INT PRIMARY KEY,
RangeFrom INT NOT NULL,
RangeTo   INT NOT NULL,
CHECK (RangeTo > RangeFrom),
INDEX CCI CLUSTERED COLUMNSTORE
);

INSERT INTO dbo.MyTableCCI
SELECT TOP (987654321) *
FROM dbo.MyTable
ORDER BY RangeFrom ASC
OPTION (MAXDOP 1);

Por diseño, puedo obtener la eliminación de grupos de filas en la RangeFromcolumna, lo que eliminará la mitad de mis grupos de filas. Pero debido a la naturaleza de los datos, también obtengo la eliminación del grupo de filas en la RangeTocolumna:

Table 'MyTableCCI'. Segment reads 1, segment skipped 9.

Para tablas más grandes con más datos variables, hay diferentes formas de cargar los datos para garantizar la mejor eliminación posible de grupos de filas en ambas columnas. Para sus datos en particular, la consulta toma 1 ms.

Joe Obbish
fuente
Sí, definitivamente está buscando otros enfoques a considerar sin la restricción de 2000. No suena así será golpeado.
Martin Smith
9

Paul White señaló una respuesta a una pregunta similar que contiene un enlace a un artículo interesante de Itzik Ben Gan . Esto describe el modelo de "Árbol de intervalo relacional estático" que permite que esto se haga de manera eficiente.

En resumen, este enfoque implica almacenar un valor calculado ("forknode") basado en los valores de intervalo en la fila. Al buscar rangos que se cruzan con otro rango, es posible calcular previamente los posibles valores de forknode que deben tener las filas coincidentes y usar esto para encontrar los resultados con un máximo de 31 operaciones de búsqueda (lo siguiente admite números enteros en el rango 0 al máximo 32 con signo poco int)

En base a esto, reestructuré la tabla de la siguiente manera.

CREATE TABLE dbo.MyTable3
(
  Id        INT IDENTITY PRIMARY KEY,
  RangeFrom INT NOT NULL,
  RangeTo   INT NOT NULL,   
  node  AS RangeTo - RangeTo % POWER(2, FLOOR(LOG((RangeFrom - 1) ^ RangeTo, 2))) PERSISTED NOT NULL,
  CHECK (RangeTo > RangeFrom)
);

CREATE INDEX ix1 ON dbo.MyTable3 (node, RangeFrom) INCLUDE (RangeTo);
CREATE INDEX ix2 ON dbo.MyTable3 (node, RangeTo) INCLUDE (RangeFrom);

SET IDENTITY_INSERT MyTable3 ON

INSERT INTO MyTable3
            (Id,
             RangeFrom,
             RangeTo)
SELECT Id,
       RangeFrom,
       RangeTo
FROM   MyTable

SET IDENTITY_INSERT MyTable3 OFF 

Y luego usó la siguiente consulta (el artículo busca intervalos de intersección, por lo que encontrar un intervalo que contenga un punto es un caso degenerado de esto)

DECLARE @value INT = 50000000;

;WITH N AS
(
SELECT 30 AS Level, 
       CASE WHEN @value > POWER(2,30) THEN POWER(2,30) END AS selected_left_node, 
       CASE WHEN @value < POWER(2,30) THEN POWER(2,30) END AS selected_right_node, 
       (SIGN(@value - POWER(2,30)) * POWER(2,29)) + POWER(2,30)  AS node
UNION ALL
SELECT N.Level-1,   
       CASE WHEN @value > node THEN node END AS selected_left_node,  
       CASE WHEN @value < node THEN node END AS selected_right_node,
       (SIGN(@value - node) * POWER(2,N.Level-2)) + node  AS node
FROM N 
WHERE N.Level > 0
)
SELECT I.id, I.RangeFrom, I.RangeTo
FROM dbo.MyTable3 AS I
  JOIN N AS L
    ON I.node = L.selected_left_node
    AND I.RangeTo >= @value
    AND L.selected_left_node IS NOT NULL
UNION ALL
SELECT I.id, I.RangeFrom, I.RangeTo
FROM dbo.MyTable3 AS I
  JOIN N AS R
    ON I.node = R.selected_right_node
    AND I.RangeFrom <= @value
    AND R.selected_right_node IS NOT NULL
UNION ALL
SELECT I.id, I.RangeFrom, I.RangeTo
FROM dbo.MyTable3 AS I
WHERE node = @value;

Esto normalmente se ejecuta en 1msmi máquina cuando todas las páginas están en caché, con estadísticas de E / S.

Table 'MyTable3'. Scan count 24, logical reads 72, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 4, logical reads 374, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

y plan

ingrese la descripción de la imagen aquí

NB: La fuente utiliza TVF de varias etapas en lugar de un CTE recursivo para lograr que los nodos se unan, pero en aras de que mi respuesta sea autónoma, he optado por este último. Para uso en producción, probablemente usaría los TVF.

Martin Smith
fuente
9

Pude encontrar un enfoque de modo de fila que es competitivo con el enfoque N / CCI, pero necesita saber algo sobre sus datos. Suponga que tiene una columna que contiene la diferencia de RangeFromy RangeToy la indexa junto con RangeFrom:

ALTER TABLE dbo.MyTableWithDiff ADD DiffOfColumns AS RangeTo-RangeFrom;

CREATE INDEX IXDIFF ON dbo.MyTableWithDiff (DiffOfColumns,RangeFrom) INCLUDE (RangeTo);

Si conocía todos los valores distintos de DiffOfColumnsentonces, podría realizar una búsqueda de cada valor DiffOfColumnscon un filtro de rango activado RangeTopara obtener todos los datos relevantes. Por ejemplo, si sabemos que DiffOfColumns= 2, entonces los únicos valores permitidos RangeFromson 49999998, 49999999 y 50000000. La recursión se puede usar para obtener todos los valores distintos DiffOfColumnsy funciona bien para su conjunto de datos porque solo hay 256 de ellos. La consulta a continuación toma aproximadamente 6 ms en mi máquina:

WITH RecursiveCTE
AS
(
    -- Anchor
    SELECT TOP (1)
        DiffOfColumns
    FROM dbo.MyTableWithDiff AS T
    ORDER BY
        T.DiffOfColumns

    UNION ALL

    -- Recursive
    SELECT R.DiffOfColumns
    FROM
    (
        -- Number the rows
        SELECT 
            T.DiffOfColumns,
            rn = ROW_NUMBER() OVER (
                ORDER BY T.DiffOfColumns)
        FROM dbo.MyTableWithDiff AS T
        JOIN RecursiveCTE AS R
            ON R.DiffOfColumns < T.DiffOfColumns
    ) AS R
    WHERE
        -- Only the row that sorts lowest
        R.rn = 1
)
SELECT ca.*
FROM RecursiveCTE rcte
CROSS APPLY (
    SELECT mt.Id, mt.RangeFrom, mt.RangeTo
    FROM dbo.MyTableWithDiff mt
    WHERE mt.DiffOfColumns = rcte.DiffOfColumns
    AND mt.RangeFrom >= 50000000 - rcte.DiffOfColumns AND mt.RangeFrom <= 50000000
) ca
OPTION (MAXRECURSION 0);

Puede ver la parte recursiva habitual junto con la búsqueda de índice para cada valor distinto:

plan de consulta 1

El defecto con este enfoque es que comienza a disminuir cuando hay demasiados valores distintos para DiffOfColumns. Hagamos la misma prueba, pero use en CRYPT_GEN_RANDOM(2)lugar de CRYPT_GEN_RANDOM(1).

DROP TABLE IF EXISTS dbo.MyTableBigDiff;

CREATE TABLE dbo.MyTableBigDiff
(
Id        INT IDENTITY PRIMARY KEY,
RangeFrom INT NOT NULL,
RangeTo   INT NOT NULL,
CHECK (RangeTo > RangeFrom)
);

WITH RandomNumbers
     AS (SELECT TOP 10000000 ABS(CRYPT_GEN_RANDOM(4)%100000000) AS Num
         FROM   sys.all_objects o1,
                sys.all_objects o2,
                sys.all_objects o3,
                sys.all_objects o4)
INSERT INTO dbo.MyTableBigDiff
            (RangeFrom,
             RangeTo)
SELECT Num,
       Num + 1 + CRYPT_GEN_RANDOM(2) -- note the 2
FROM   RandomNumbers;


ALTER TABLE dbo.MyTableBigDiff ADD DiffOfColumns AS RangeTo-RangeFrom;

CREATE INDEX IXDIFF ON dbo.MyTableBigDiff (DiffOfColumns,RangeFrom) INCLUDE (RangeTo);

La misma consulta ahora encuentra 65536 filas de la parte recursiva y toma 823 ms de CPU en mi máquina. Hay PAGELATCH_SH esperas y otras cosas malas que suceden. Puedo mejorar el rendimiento al agrupar los valores de diferencias para mantener el número de valores únicos bajo control y ajustar el agrupamiento en el CROSS APPLY. Para este conjunto de datos, intentaré 256 cubos:

ALTER TABLE dbo.MyTableBigDiff ADD DiffOfColumns_bucket256 AS CAST(CEILING((RangeTo-RangeFrom) / 256.) AS INT);

CREATE INDEX [IXDIFF😎] ON dbo.MyTableBigDiff (DiffOfColumns_bucket256, RangeFrom) INCLUDE (RangeTo);

Una forma de evitar obtener filas adicionales (ahora me estoy comparando con un valor redondeado en lugar del valor verdadero) es filtrando en RangeTo:

CROSS APPLY (
    SELECT mt.Id, mt.RangeFrom, mt.RangeTo
    FROM dbo.MyTableBigDiff mt
    WHERE mt.DiffOfColumns_bucket256 = rcte.DiffOfColumns_bucket256
    AND mt.RangeFrom >= 50000000 - (256 * rcte.DiffOfColumns_bucket256)
    AND mt.RangeFrom <= 50000000
    AND mt.RangeTo >= 50000000
) ca

La consulta completa ahora toma 6 ms en mi máquina.

Joe Obbish
fuente
8

Una forma alternativa de representar un rango sería como puntos en una línea.

Lo siguiente migra todos los datos a una nueva tabla con el rango representado como un geometrytipo de datos.

CREATE TABLE MyTable2
(
Id INT IDENTITY PRIMARY KEY,
Range GEOMETRY NOT NULL,
RangeFrom AS Range.STPointN(1).STX,
RangeTo   AS Range.STPointN(2).STX,
CHECK (Range.STNumPoints() = 2 AND Range.STPointN(1).STY = 0 AND Range.STPointN(2).STY = 0)
);

SET IDENTITY_INSERT MyTable2 ON

INSERT INTO MyTable2
            (Id,
             Range)
SELECT ID,
       geometry::STLineFromText(CONCAT('LINESTRING(', RangeFrom, ' 0, ', RangeTo, ' 0)'), 0)
FROM   MyTable

SET IDENTITY_INSERT MyTable2 OFF 


CREATE SPATIAL INDEX index_name   
ON MyTable2 ( Range )  
USING GEOMETRY_GRID  
WITH (  
BOUNDING_BOX = ( xmin=0, ymin=0, xmax=110000000, ymax=1 ),  
GRIDS = (HIGH, HIGH, HIGH, HIGH),  
CELLS_PER_OBJECT = 16); 

La consulta equivalente para encontrar rangos que contengan el valor 50,000,000está debajo.

SELECT Id,
       RangeFrom,
       RangeTo
FROM   MyTable2
WHERE  Range.STContains(geometry::STPointFromText ('POINT (50000000 0)', 0)) = 1 

Las lecturas para esto muestran una mejora con 10,951respecto a la consulta original.

Table 'MyTable2'. Scan count 0, logical reads 505, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'extended_index_1797581442_384000'. Scan count 4, logical reads 17, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Sin embargo, no hay una mejora significativa sobre el original en términos de tiempo transcurrido . Los resultados de ejecución típicos son 250 ms frente a 252 ms.

El plan de ejecución es más complejo como se muestra a continuación.

ingrese la descripción de la imagen aquí

El único caso en el que la reescritura funciona mejor para mí es con un caché frío.

Tan decepcionante en este caso y difícil recomendar esta reescritura, pero la publicación de resultados negativos también puede ser útil.

Martin Smith
fuente
5

Como homenaje a nuestros nuevos señores de los robots, decidí ver si alguna de las nuevas funcionalidades de R y Python podría ayudarnos aquí. La respuesta es no, al menos para los scripts que podría obtener trabajando y devolviendo resultados correctos. Si viene alguien con mejores conocimientos, bueno, siéntase libre de pegarme. Mis tarifas son razonables.

Para hacer esto, configuré una máquina virtual con 4 núcleos y 16 GB de RAM, pensando que esto sería suficiente para manejar un conjunto de datos de ~ 200 MB.

¡Comencemos con el lenguaje que no existe en Boston!

R

EXEC sp_execute_external_script 
@language = N'R', 
@script = N'
tweener = 50000000
MO = data.frame(MartinIn)
MartinOut <- subset(MO, RangeFrom <= tweener & RangeTo >= tweener, select = c("Id","RangeFrom","RangeTo"))
', 
@input_data_1_name = N'MartinIn',
@input_data_1 = N'SELECT Id, RangeFrom, RangeTo FROM dbo.MyTable',
@output_data_1_name = N'MartinOut',
@parallel = 1
WITH RESULT SETS ((ID INT, RangeFrom INT, RangeTo INT));

Este fue un mal momento.

Table 'MyTable'. Scan count 1, logical reads 22400

 SQL Server Execution Times:
   CPU time = 3219 ms,  elapsed time = 5349 ms.

El plan de ejecución es bastante poco interesante, aunque no sé por qué el operador del medio tiene que llamarnos nombres.

NUECES

¡A continuación, codificando con crayones!

Pitón

EXEC sp_execute_external_script 
@language = N'Python', 
@script = N'
import pandas as pd
MO = pd.DataFrame(MartinIn)
tweener = 50000000
MartinOut = MO[(MO.RangeFrom <= tweener) & (MO.RangeTo >= tweener)]
', 
@input_data_1_name = N'MartinIn',
@input_data_1 = N'SELECT Id, RangeFrom, RangeTo FROM dbo.MyTable',
@output_data_1_name = N'MartinOut',
@parallel = 1
WITH RESULT SETS ((ID INT, RangeFrom INT, RangeTo INT));

Justo cuando pensabas que no podía ser peor que R:

Table 'MyTable'. Scan count 1, logical reads 22400

 SQL Server Execution Times:
   CPU time = 3797 ms,  elapsed time = 10146 ms.

Otro plan de ejecución mal hablado :

NUECES

Hmm y Hmmer

Hasta ahora, no estoy impresionado. No puedo esperar para eliminar esta VM.

Erik Darling
fuente
1
También puede pasar parámetros, por ejemplo, DECLARE @input INT = 50000001; EXEC dbo.sp_execute_external_script @language = N'R', @script = N'OutputDataSet <- InputDataSet[which(x >= InputDataSet$RangeFrom & x <= InputDataSet$RangeTo) , ]', @parallel = 1, @input_data_1 = N'SELECT Id, RangeFrom, RangeTo FROM dbo.MyTable;', @params = N'@x INT', @x = 50000001 WITH RESULT SETS ( ( Id INT NOT NULL, RangeFrom INT NOT NULL, RangeTo INT NOT NULL ));pero sí, el rendimiento no es excelente. Uso R para cosas que no puedes hacer en SQL, por ejemplo, si quieres predecir algo.
wBob
4

Encontré una solución bastante buena usando una columna calculada, sin embargo, solo es buena para un solo valor. Dicho esto, si tienes un valor mágico, tal vez sea suficiente.

Comenzando con su muestra dada, luego modificando la tabla:

ALTER TABLE dbo.MyTable
    ADD curtis_jackson 
        AS CONVERT(BIT, CASE 
                            WHEN RangeTo >= 50000000
                            AND RangeFrom < 50000000
                            THEN 1 
                            ELSE 0 
                        END);

CREATE INDEX IX1_redo 
    ON dbo.MyTable (curtis_jackson) 
        INCLUDE (RangeFrom, RangeTo);

La consulta simplemente se convierte en:

SELECT *
FROM MyTable
WHERE curtis_jackson = 1;

Que devuelve los mismos resultados que su consulta inicial. Con los planes de ejecución desactivados, aquí están las estadísticas (truncadas por brevedad):

Table 'MyTable'. Scan count 1, logical reads 3...

SQL Server Execution Times:
  CPU time = 0 ms,  elapsed time = 0 ms.

Y aquí está el plan de consulta :

NUECES

Erik Darling
fuente
¿No puede superar la imitación de la columna calculada / índice filtrado con un índice activado WHERE (50000000 BETWEEN RangeFrom AND RangeTo) INCLUDE (..)?
ypercubeᵀᴹ
3
@ yper-crazyhat-cubeᵀᴹ - sí. CREATE INDEX IX1_redo ON dbo.MyTable (curtis_jackson) INCLUDE (RangeFrom, RangeTo) WHERE RangeTo >= 50000000 AND RangeFrom <= 50000000trabajaría. Y la consulta lo SELECT * FROM MyTable WHERE RangeTo >= 50000000 AND RangeFrom <= 50000000;usa, así que no hay mucha necesidad del pobre Curtis
Martin Smith
3

Mi solución se basa en la observación de que el intervalo tiene un máximo conocida anchura W . Para los datos de muestra, este es un byte o 256 enteros. Por lo tanto, para una búsqueda de valor de parámetro determinado P conocemos la RangeFrom más pequeño que puede estar en el conjunto de resultados es P - W . Agregar esto al predicado da

declare @P int = 50000000;
declare @W int = 256;

select
    *
from MyTable
where @P between RangeFrom and RangeTo
and RangeFrom >= (@P - @W);

Dada la configuración original y la consulta, mi máquina (Windows 10 de 64 bits, i7 hyperthreaded de 4 núcleos, 2,8 GHz, 16 GB de RAM) devuelve 13 filas. Esa consulta utiliza una búsqueda de índice paralelo del índice (RangeFrom, RangeTo). La consulta revisada también realiza una búsqueda de índice paralelo en el mismo índice.

Las medidas para las consultas originales y revisadas son

                          Original  Revised
                          --------  -------
Stats IO Scan count              9        6
Stats IO logical reads       11547        6

Estimated number of rows   1643170  1216080
Number of rows read        5109666       29
QueryTimeStats CPU             344        2
QueryTimeStats Elapsed          53        0

Para la consulta original, el número de filas leídas es igual al número de filas que son menores o iguales a @P. El optimizador de consultas (QO) no tiene otra alternativa, pero léalas todas, ya que no puede determinar de antemano si estas filas satisfarán el predicado. El índice de varias columnas en (RangeFrom, RangeTo) no es útil para eliminar filas que no coinciden con RangeTo ya que no hay correlación entre la primera clave de índice y la segunda que se puede aplicar. Por ejemplo, la primera fila puede tener un intervalo pequeño y eliminarse, mientras que la segunda fila tiene un intervalo grande y se devuelve, o viceversa.

En un intento fallido intenté proporcionar esa certeza a través de una restricción de verificación:

alter table MyTable with check
add constraint CK_MyTable_Interval
check
(
    RangeTo <= RangeFrom + 256
);

No hizo ninguna diferencia.

Al incorporar mi conocimiento externo de la distribución de datos en el predicado, puedo hacer que el QO omita las filas RangeFrom de bajo valor, que nunca pueden ser parte del conjunto de resultados, y atraviese la columna principal del índice a las filas admisibles. Esto se muestra en el predicado de búsqueda diferente para cada consulta.

En un argumento espejo del límite superior de RangeTo es P + W . Sin embargo, esto no es útil, porque no existe una correlación entre RangeFrom y RangeTo que permita que la columna final de un índice de varias columnas elimine filas. Por lo tanto, no es beneficioso agregar esta cláusula a la consulta.

Este enfoque obtiene la mayor parte de su beneficio del pequeño intervalo de tamaño. A medida que aumenta el tamaño del intervalo posible, disminuye el número de filas omitidas de bajo valor, aunque algunas se omitirán. En el caso limitante, con el intervalo tan grande como el rango de datos, este enfoque no es peor que la consulta original (que es una comodidad fría, lo admito).

Pido disculpas por los errores ocasionales que puedan existir en esta respuesta.

Michael Green
fuente