Ottimizzazione delle ricerche di intervallo numerico (intervallo) in SQL Server


18

Questa domanda è simile all'ottimizzazione della ricerca dell'intervallo IP? ma quello è limitato a SQL Server 2000.

Supponiamo che io abbia 10 milioni di intervalli temporaneamente memorizzati in una tabella strutturata e popolata come di seguito.

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 

Devo conoscere tutti gli intervalli che contengono il valore 50,000,000. Provo la seguente domanda

SELECT *
FROM MyTable
WHERE 50000000 BETWEEN RangeFrom AND RangeTo

SQL Server mostra che c'erano 10.951 letture logiche e quasi 5 milioni di righe sono state lette per restituire le 12 corrispondenti.

inserisci qui la descrizione dell'immagine

Posso migliorare questa prestazione? Qualsiasi ristrutturazione della tabella o indici aggiuntivi va bene.


Se capisco correttamente la configurazione della tabella, stai selezionando numeri casuali in modo uniforme per formare i tuoi intervalli, senza vincoli sulla "dimensione" di ciascun intervallo. E la tua sonda è a metà dell'intervallo complessivo 1..100M. In quel caso - nessun apparente raggruppamento dovuto alla casualità uniforme - non so perché un indice sul limite inferiore o superiore sarebbe utile. Puoi spiegarlo?
davidbak,

@davidbak gli indici convenzionali su questa tabella non sono davvero molto utili nel caso peggiore in quanto deve scansionare metà dell'intervallo e quindi richiedere potenziali miglioramenti su di esso. C'è un bel miglioramento nella domanda collegata per SQL Server 2000 con l'introduzione del "granulo" Speravo che gli indici spaziali potessero aiutare qui mentre supportano le containsquery e mentre lavorano bene per ridurre la quantità di dati letti sembrano aggiungere altro spese generali che contrasta questo.
Martin Smith,

Non ho la possibilità di provarlo - ma mi chiedo se due indici - uno sul limite inferiore, uno sul superiore - e quindi un join interno - permetterebbero a Query Optimizer di funzionare.
davidbak,

Risposte:


11

Columnstore è molto ricco qui rispetto a un indice non cluster che analizza metà della tabella. Un indice columnstore non cluster offre la maggior parte dei vantaggi ma l'inserimento di dati ordinati in un indice columnstore cluster è ancora migliore.

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

In base alla progettazione, posso ottenere l'eliminazione del rowgroup sulla RangeFromcolonna che eliminerà metà dei miei rowgroup. Ma a causa della natura dei dati ottengo anche l'eliminazione del rowgroup sulla RangeTocolonna:

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

Per tabelle più grandi con dati più variabili ci sono diversi modi per caricare i dati per garantire la migliore eliminazione possibile del rowgroup su entrambe le colonne. Per i tuoi dati in particolare, la query richiede 1 ms.


sicuramente cerco altri approcci da considerare senza la restrizione del 2000. Non sembra che sarà battuto.
Martin Smith,

9

Paul White ha indicato una risposta a una domanda simile contenente un collegamento a un articolo interessante di Itzik Ben Gan . Descrive il modello "Albero ad intervalli relazionali statici" che consente di eseguire questa operazione in modo efficiente.

In sintesi, questo approccio prevede la memorizzazione di un valore calcolato ("forknode") basato sui valori di intervallo nella riga. Durante la ricerca di intervalli che si intersecano con un altro intervallo è possibile precalcolare i possibili valori di forknode che devono avere le righe corrispondenti e utilizzarli per trovare i risultati con un massimo di 31 operazioni di ricerca (il seguente supporta numeri interi nell'intervallo 0 al massimo con segno 32 bit int)

Sulla base di questo ho ristrutturato la tabella come di seguito.

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 poi ha usato la seguente query (l'articolo sta cercando intervalli intersecanti, quindi trovare un intervallo contenente un punto è un caso degenerato di questo)

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;

Questo di solito viene eseguito 1mssul mio computer quando tutte le pagine sono nella cache - con statistiche IO.

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 piano

inserisci qui la descrizione dell'immagine

NB: La fonte utilizza TVF multistatoment piuttosto che un CTE ricorsivo per ottenere i nodi su cui unirsi, ma nell'interesse di rendere la mia risposta autonoma ho optato per quest'ultimo. Per uso di produzione probabilmente userei i TVF.


9

Sono stato in grado di trovare un approccio in modalità riga che sia competitivo con l'approccio N / CCI, ma è necessario conoscere qualcosa sui dati. Supponiamo che hai avuto una colonna che conteneva la differenza di RangeFrome RangeToed è indicizzato insieme con RangeFrom:

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

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

Se conoscessi tutti i valori distinti di DiffOfColumnsallora potresti eseguire una ricerca per ogni valore DiffOfColumnscon un filtro di intervallo attivo RangeToper ottenere tutti i dati rilevanti. Ad esempio, se sappiamo che DiffOfColumns= 2, gli unici valori consentiti per RangeFromsono 49999998, 49999999 e 50000000. La ricorsione può essere utilizzata per ottenere tutti i valori distinti di DiffOfColumnse funziona bene per il tuo set di dati perché ce ne sono solo 256. La query seguente richiede circa 6 ms sul mio computer:

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

Puoi vedere la solita parte ricorsiva insieme alla ricerca dell'indice per ogni valore distinto:

piano di query 1

Il difetto di questo approccio è che inizia a rallentare quando ci sono troppi valori distinti per DiffOfColumns. Facciamo lo stesso test, ma usiamo CRYPT_GEN_RANDOM(2)invece di 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 stessa query ora trova 65536 righe dalla parte ricorsiva e richiede 823 ms di CPU sul mio computer. Ci sono PAGELATCH_SH attese e altre cose brutte in corso. Posso migliorare le prestazioni inserendo i valori diff per tenere sotto controllo il numero di valori univoci e regolandoli per il bucketing in CROSS APPLY. Per questo set di dati proverò 256 bucket:

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

Un modo per evitare di ottenere righe extra (ora sto confrontando con un valore arrotondato invece del valore vero) è filtrando su 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 query completa ora richiede 6 ms sul mio computer.


8

Un modo alternativo di rappresentare un intervallo sarebbe come punti su una linea.

Di seguito migra tutti i dati in una nuova tabella con l'intervallo rappresentato come geometrytipo di dati.

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 query equivalente per trovare intervalli che contengono il valore 50,000,000è di seguito.

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

Le letture per questo mostrano un miglioramento 10,951rispetto alla query originale.

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.

Tuttavia, non vi è alcun miglioramento significativo rispetto all'originale in termini di tempo trascorso . I risultati tipici dell'esecuzione sono 250 ms contro 252 ms.

Il piano di esecuzione è più complesso come di seguito

inserisci qui la descrizione dell'immagine

L'unico caso in cui la riscrittura funziona in modo affidabile per me è con una cache fredda.

Così deludente in questo caso e difficile raccomandare questa riscrittura, ma può anche essere utile la pubblicazione di risultati negativi.


5

Come omaggio ai nostri nuovi padroni robot, ho deciso di vedere se una delle nuove funzionalità R e Python potesse aiutarci qui. La risposta è no, almeno per gli script che ho potuto far funzionare e restituire risultati corretti. Se arriva qualcuno con una conoscenza migliore, beh, sentiti libero di sculacciarmi. Le mie tariffe sono ragionevoli.

Per fare ciò, ho impostato una VM con 4 core e 16 GB di RAM, pensando che questo sarebbe sufficiente per gestire un set di dati di ~ 200 MB.

Cominciamo con la lingua che non esiste a 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));

È stato un brutto momento.

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

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

Il piano di esecuzione è piuttosto poco interessante, anche se non so perché l'operatore medio debba chiamarci nomi.

NOCCIOLINE

Successivamente, codifica con i pastelli!

Pitone

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

Proprio quando pensavi che non potesse andare peggio di R:

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

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

Un altro piano di esecuzione a bocca aperta :

NOCCIOLINE

Hmm e Hmmer

Finora non sono rimasto colpito. Non vedo l'ora di eliminare questa VM.


1
Puoi anche passare parametri, ad es. 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 ));Ma sì, le prestazioni non sono eccezionali. Uso R per cose che non puoi fare in SQL, per esempio se volevi prevedere qualcosa.
wBob,

4

Ho trovato una soluzione abbastanza buona utilizzando una colonna calcolata, tuttavia è valida solo per un singolo valore. Detto questo, se hai un valore magico, forse è abbastanza.

A partire dal tuo campione, quindi modificando la tabella:

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 query diventa semplicemente:

SELECT *
FROM MyTable
WHERE curtis_jackson = 1;

Che restituisce gli stessi risultati della query iniziale. Con i piani di esecuzione disattivati, ecco le statistiche (troncate per brevità):

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

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

Ed ecco il piano di query :

NOCCIOLINE


Non riesci a superare l'imitazione della colonna calcolata / indice filtrato con un indice attivo 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 <= 50000000funzionerebbe. E la query lo SELECT * FROM MyTable WHERE RangeTo >= 50000000 AND RangeFrom <= 50000000;usa - quindi non c'è molto bisogno del povero Curtis
Martin Smith il

3

Mia soluzione è basata sull'osservazione che l'intervallo ha un massimo noto larghezza W . Per i dati di esempio si tratta di un byte o 256 numeri interi. Quindi, per una determinata ricerca il valore del parametro P conosciamo la RangeFrom più piccolo che può essere nel set di risultati è P - W . Aggiungendo questo al predicato si ottiene

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

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

Vista la configurazione originale e la query sulla mia macchina (Windows 10 a 64 bit, i7 hyperthreaded a 4 core, 2,8 GHz, 16 GB di RAM) restituisce 13 righe. Quella query utilizza una ricerca di indice parallela dell'indice (RangeFrom, RangeTo). La query rivista esegue anche una ricerca di indici paralleli sullo stesso indice.

Le misure per le query originali e riviste sono

                          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

Per la query originale il numero di righe lette è uguale al numero di righe che sono inferiori o uguali a @P. Query Optimizer (QO) non ha alternative ma le legge tutte poiché non è in grado di determinare in anticipo se queste righe soddisfano il predicato. L'indice multi-colonna su (RangeFrom, RangeTo) non è utile per eliminare le righe che non corrispondono a RangeTo in quanto non esiste alcuna correlazione tra la prima chiave di indice e la seconda che è possibile applicare. Ad esempio, la prima riga può avere un intervallo ridotto ed essere eliminata mentre la seconda riga ha un intervallo ampio e viene restituita, o viceversa.

In un tentativo fallito ho provato a fornire quella certezza attraverso un vincolo di controllo:

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

Non ha fatto differenza.

Incorporando la mia conoscenza esterna della distribuzione dei dati nel predicato, posso far sì che il QO salti le righe RangeFrom a basso valore, che non possono mai far parte del set di risultati, e attraversa la colonna principale dell'indice fino alle righe ammissibili. Ciò viene mostrato nel predicato di ricerca diverso per ogni query.

In un argomento specchio il limite superiore di RangeTo è P + W . Ciò non è utile, tuttavia, poiché non esiste alcuna correlazione tra RangeFrom e RangeTo che consentirebbe alla colonna finale di un indice multi-colonna di eliminare le righe. Quindi non vi è alcun vantaggio dall'aggiunta di questa clausola alla query.

Questo approccio ottiene la maggior parte dei suoi benefici dall'intervallo ridotto. Man mano che le possibili dimensioni dell'intervallo aumentano, il numero di righe con valori bassi saltate diminuisce, sebbene alcune verranno comunque ignorate. Nel caso limitante, con un intervallo ampio quanto l'intervallo di dati, questo approccio non è peggiore della query originale (che è un comfort freddo, lo ammetto).

Mi scuso per eventuali errori off-by-one in questa risposta.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.