Otimizando pesquisas de intervalo numérico (intervalo) no SQL Server

18

Esta pergunta é semelhante a Otimizando a pesquisa por intervalo de IP? mas esse é restrito ao SQL Server 2000.

Suponha que eu tenha 10 milhões de intervalos armazenados provisoriamente em uma tabela estruturada e preenchida como abaixo.

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 

Eu preciso conhecer todos os intervalos que contêm o valor 50,000,000. Eu tento a seguinte consulta

SELECT *
FROM MyTable
WHERE 50000000 BETWEEN RangeFrom AND RangeTo

O SQL Server mostra que havia 10.951 leituras lógicas e quase 5 milhões de linhas foram lidas para retornar as 12 correspondentes.

insira a descrição da imagem aqui

Posso melhorar esse desempenho? Qualquer reestruturação da tabela ou índices adicionais é boa.

Martin Smith
fonte
Se estou entendendo a configuração da tabela corretamente, você escolhe números aleatórios uniformemente para formar seus intervalos, sem restrições no "tamanho" de cada intervalo. E sua sonda é para o meio do intervalo geral de 1..100M. Nesse caso - nenhum agrupamento aparente devido à aleatoriedade uniforme - não sei por que um índice no limite inferior ou superior seria útil. Você pode explicar isso?
Davidbak
@davidbak os índices convencionais nesta tabela não são realmente muito úteis no pior dos casos, uma vez que ele tem que digitalizar metade do intervalo, portanto, solicitando melhorias em potencial. Há uma boa melhoria na questão vinculada do SQL Server 2000 com a introdução do "grânulo". Esperava que os índices espaciais pudessem ajudar aqui, pois oferecem suporte a containsconsultas e, embora funcionem bem em reduzir a quantidade de dados lidos, parecem adicionar outros sobrecarga que neutraliza isso.
Martin Smith
Não tenho a capacidade de experimentá-lo - mas me pergunto se dois índices - um no limite inferior, um no superior - e depois uma junção interna - permitiriam que o otimizador de consultas resolvesse algo.
Davidbak
4
Relacionado: Selecione todos os intervalos sobrepostos do intervalo inicial
Paul White diz o GoFundMonica

Respostas:

11

O columnstore é muito útil aqui em comparação com um índice não clusterizado que varre metade da tabela. Um índice columnstore não clusterizado fornece a maior parte do benefício, mas a inserção de dados ordenados em um índice columnstore clusterizado é ainda melhor.

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 design, posso obter a eliminação de grupos de linhas na RangeFromcoluna, o que eliminará metade dos meus grupos de linhas. Mas, devido à natureza dos dados, também recebo a eliminação de grupos de linhas na RangeTocoluna:

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

Para tabelas maiores com mais dados variáveis, existem diferentes maneiras de carregar os dados para garantir a melhor eliminação possível de grupos de linhas nas duas colunas. Para seus dados em particular, a consulta leva 1 ms.

Joe Obbish
fonte
Sim, definitivamente procurando outras abordagens a serem consideradas sem a restrição de 2000. Não soa assim será derrotado.
Martin Smith
9

Paul White apontou uma resposta para uma pergunta semelhante contendo um link para um artigo interessante de Itzik Ben Gan . Isso descreve o modelo "Árvore de intervalo relacional estático" que permite que isso seja feito com eficiência.

Em resumo, essa abordagem envolve o armazenamento de um valor calculado ("forknode") com base nos valores de intervalo na linha. Ao pesquisar intervalos que cruzam outro intervalo, é possível pré-calcular os possíveis valores de código de código que as linhas correspondentes devem ter e usá-lo para encontrar os resultados com um máximo de 31 operações de busca (o abaixo suporta números inteiros no intervalo de 0 a o máximo assinado 32 bit int)

Com base nisso, reestruturei a tabela como abaixo.

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 

E, em seguida, usou a seguinte consulta (o artigo está procurando intervalos de interseção, portanto, encontrar um intervalo contendo um ponto é um caso degenerado disso)

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;

Isso normalmente é executado na 1msminha máquina quando todas as páginas estão em cache - com estatí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.

e planejar

insira a descrição da imagem aqui

NB: A fonte usa TVFs com várias instruções em vez de uma CTE recursiva para obter a adesão dos nós, mas no interesse de tornar minha resposta independente, eu optei pela última. Para uso em produção, eu provavelmente usaria os TVFs.

Martin Smith
fonte
9

Consegui encontrar uma abordagem de modo de linha que seja competitiva com a abordagem N / CCI, mas você precisa saber algo sobre seus dados. Suponha que você tinha uma coluna que continha a diferença de RangeFrome RangeToe indexado-lo junto com RangeFrom:

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

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

Se você soubesse todos os valores distintos de DiffOfColumns, poderia realizar uma pesquisa para cada valor DiffOfColumnscom um filtro de intervalo ativado RangeTopara obter todos os dados relevantes. Por exemplo, se soubermos que DiffOfColumns= 2, os únicos valores permitidos para RangeFromsão 49999998, 49999999 e 50000000. A recursão pode ser usada para obter todos os valores distintos DiffOfColumnse funciona bem para o seu conjunto de dados, porque existem apenas 256 deles. A consulta abaixo leva cerca de 6 ms na minha 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);

Você pode ver a parte recursiva usual junto com a busca do índice para cada valor distinto:

plano de consulta 1

A falha nessa abordagem é que ela começa a ficar lenta quando há muitos valores distintos DiffOfColumns. Vamos fazer o mesmo teste, mas use em CRYPT_GEN_RANDOM(2)vez 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);

A mesma consulta agora encontra 65536 linhas da parte recursiva e ocupa 823 ms de CPU na minha máquina. Há PAGELATCH_SH espera e outras coisas ruins acontecendo. Eu posso melhorar o desempenho agrupando os valores diff para manter o número de valores exclusivos sob controle e ajustando-os para o agrupamento no CROSS APPLY. Para este conjunto de dados, tentarei 256 blocos:

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

Uma maneira de evitar a obtenção de linhas extras (agora estou comparando com um valor arredondado em vez do valor verdadeiro) é filtrando 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

A consulta completa agora leva 6 ms na minha máquina.

Joe Obbish
fonte
8

Uma maneira alternativa de representar um intervalo seria como pontos em uma linha.

A seguir, migra todos os dados para uma nova tabela com o intervalo representado como um geometrytipo de dados.

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

A consulta equivalente para encontrar intervalos contendo o valor 50,000,000está abaixo.

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

As leituras para isso mostram uma melhoria na 10,951consulta 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.

No entanto, não há melhorias significativas em relação ao original em termos de tempo decorrido . Os resultados típicos da execução são 250 ms vs 252 ms.

O plano de execução é mais complexo como abaixo

insira a descrição da imagem aqui

O único caso em que a reescrita apresenta um desempenho melhor para mim é com um cache frio.

Tão decepcionante neste caso e difícil recomendar essa reescrita, mas a publicação de resultados negativos também pode ser útil.

Martin Smith
fonte
5

Como uma homenagem aos nossos novos senhores de robôs, decidi ver se alguma das novas funcionalidades R e Python poderia nos ajudar aqui. A resposta é não, pelo menos para os scripts que eu poderia trabalhar e retornar resultados corretos. Se alguém com melhor conhecimento aparecer, fique à vontade para me bater. Minhas taxas são razoáveis.

Para fazer isso, configurei uma VM com 4 núcleos e 16 GB de RAM, pensando que isso seria suficiente para lidar com um conjunto de dados de ~ 200 MB.

Vamos começar com o idioma que não existe em 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 foi um momento ruim.

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

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

O plano de execução é bastante desinteressante, embora eu não saiba por que o operador do meio precisa nos chamar de nomes.

NUTS

Em seguida, codificando com giz de cera!

Pitão

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

Apenas quando você pensou que não poderia ficar pior do que R:

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

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

Outro plano de execução de boca suja :

NUTS

Hmm e Hmmer

Até agora, não estou impressionado. Mal posso esperar para excluir esta VM.

Erik Darling
fonte
11
Você também pode passar parâmetros, por exemplo, 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 ));mas sim, o desempenho não é ótimo. Eu uso R para coisas que você não pode fazer no SQL, digamos, se você quisesse prever alguma coisa.
wBob
4

Encontrei uma solução muito boa usando uma coluna computada, no entanto, é apenas válida para um único valor. Dito isto, se você tem um valor mágico, talvez seja o suficiente.

Começando com a amostra especificada e modificando a tabela:

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

A consulta simplesmente se torna:

SELECT *
FROM MyTable
WHERE curtis_jackson = 1;

O que retorna os mesmos resultados que sua consulta inicial. Com os planos de execução desativados, eis as estatísticas (truncadas por questões de brevidade):

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

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

E aqui está o plano de consulta :

NUTS

Erik Darling
fonte
Você não pode superar a imitação de coluna filtrada / índice filtrado com um índice ativado WHERE (50000000 BETWEEN RangeFrom AND RangeTo) INCLUDE (..)?
ypercubeᵀᴹ
3
@ yper-crazyhat-cubeᵀᴹ - sim. CREATE INDEX IX1_redo ON dbo.MyTable (curtis_jackson) INCLUDE (RangeFrom, RangeTo) WHERE RangeTo >= 50000000 AND RangeFrom <= 50000000podia funcionar. E a consulta a SELECT * FROM MyTable WHERE RangeTo >= 50000000 AND RangeFrom <= 50000000;usa - então não há muita necessidade de Curtis pobre
Martin Smith
3

Meu solução baseia-se na observação de que o intervalo de tem um máximo conhecida largura W . Para os dados de amostra, esse é um byte ou 256 inteiros. Daí para uma determinada pesquisa valor do parâmetro P sabemos o menor RangeFrom que podem estar no conjunto de resultados é P - W . Adicionar isso ao predicado fornece

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

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

Dada a configuração original e a consulta à minha máquina (Windows 10 de 64 bits, i7 hyperthreaded de 4 núcleos, 2,8 GHz, 16 GB de RAM) retorna 13 linhas. Essa consulta usa uma busca de índice paralelo do índice (RangeFrom, RangeTo). A consulta revisada também realiza uma busca de índice paralelo no mesmo índice.

As medidas para as consultas originais e revisadas são

                          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 a consulta original, o número de linhas lidas é igual ao número de linhas que são menores ou iguais a @P. O otimizador de consulta (QO) não tem alternativa, mas lê todas elas, pois não pode determinar antecipadamente quais dessas linhas satisfarão o predicado. O índice de várias colunas em (RangeFrom, RangeTo) não é útil para eliminar linhas que não correspondem a RangeTo, pois não há correlação entre a primeira chave de índice e a segunda que pode ser aplicada. Por exemplo, a primeira linha pode ter um pequeno intervalo e ser eliminada, enquanto a segunda linha tem um grande intervalo e é retornada, ou vice-versa.

Em uma tentativa fracassada, tentei fornecer essa certeza através de uma restrição de verificação:

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

Não fez diferença.

Ao incorporar meu conhecimento externo da distribuição de dados ao predicado, posso fazer com que o QO pule as linhas RangeFrom de baixo valor, que nunca podem fazer parte do conjunto de resultados, e passe a coluna principal do índice para as linhas admissíveis. Isso é mostrado no predicado de busca diferente para cada consulta.

Em um argumento espelho do limite superior de RangeTo é P + W . Isso não é útil, no entanto, porque não há correlação entre RangeFrom e RangeTo que permitiria à coluna à direita de um índice de várias colunas eliminar linhas. Portanto, não há benefício em adicionar esta cláusula à consulta.

Essa abordagem ganha a maior parte de seu benefício com o pequeno tamanho do intervalo. À medida que o tamanho do intervalo possível aumenta, o número de linhas de baixo valor ignoradas diminui, embora algumas ainda sejam ignoradas. No caso limitativo, com um intervalo tão grande quanto o intervalo de dados, essa abordagem não é pior que a consulta original (que é um conforto frio, admito).

Peço desculpas por quaisquer erros pontuais que possam existir nesta resposta.

Michael Green
fonte