web-dev-qa-db-fra.com

Optimisation des recherches de plage numérique (intervalle) dans SQL Server

Cette question est similaire à Optimizing IP Range Search? mais celle-ci est limitée à SQL Server 2000.

Supposons que j'ai 10 millions de plages stockées provisoirement dans un tableau structuré et rempli comme ci-dessous.

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 

J'ai besoin de connaître toutes les plages contenant la valeur 50,000,000. J'essaie la requête suivante

SELECT *
FROM MyTable
WHERE 50000000 BETWEEN RangeFrom AND RangeTo

SQL Server montre qu'il y a eu 10 951 lectures logiques et près de 5 millions de lignes ont été lues pour renvoyer les 12 correspondances.

enter image description here

Puis-je améliorer ces performances? Toute restructuration de la table ou des index supplémentaires est très bien.

19
Martin Smith

Columnstore est très utile ici par rapport à un index non cluster qui analyse la moitié de la table. Un index columnstore non cluster offre la plupart des avantages, mais l'insertion de données ordonnées dans un index columnstore en cluster est encore meilleure.

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

De par ma conception, je peux obtenir l'élimination des groupes de lignes sur la colonne RangeFrom, ce qui éliminera la moitié de mes groupes de lignes. Mais en raison de la nature des données, j'obtiens également l'élimination des groupes de lignes sur la colonne RangeTo:

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

Pour les tables plus grandes avec plus de données variables, il existe différentes façons de charger les données pour garantir la meilleure élimination possible des groupes de lignes sur les deux colonnes. Pour vos données en particulier, la requête prend 1 ms.

11
Joe Obbish

J'ai pu trouver une approche en mode ligne qui est compétitive avec l'approche N/CCI, mais vous devez savoir quelque chose sur vos données. Supposons que vous disposiez d'une colonne contenant la différence entre RangeFrom et RangeTo et que vous l'avez indexée avec RangeFrom:

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

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

Si vous connaissiez toutes les valeurs distinctes de DiffOfColumns, vous pouvez effectuer une recherche pour chaque valeur de DiffOfColumns avec un filtre de plage sur RangeTo pour obtenir toutes les données pertinentes. Par exemple, si nous savons que DiffOfColumns = 2, les seules valeurs autorisées pour RangeFrom sont 49999998, 49999999 et 50000000. La récursivité peut être utilisée pour obtenir toutes les valeurs distinctes de DiffOfColumns et cela fonctionne bien pour votre ensemble de données car il n'y en a que 256. La requête ci-dessous prend environ 6 ms sur ma machine:

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

Vous pouvez voir la partie récursive habituelle ainsi que la recherche d'index pour chaque valeur distincte:

query plan 1

Le défaut de cette approche est qu'elle commence à ralentir lorsqu'il y a trop de valeurs distinctes pour DiffOfColumns. Faisons le même test, mais utilisons CRYPT_GEN_RANDOM(2) au lieu 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 même requête trouve maintenant 65536 lignes de la partie récursive et prend 823 ms de CPU sur ma machine. Il y a des attentes PAGELATCH_SH et d'autres mauvaises choses en cours. Je peux améliorer les performances en regroupant les valeurs de diff pour garder le nombre de valeurs uniques sous contrôle et en ajustant le regroupement dans le CROSS APPLY. Pour cet ensemble de données, j'essaierai 256 compartiments:

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

Une façon d'éviter d'avoir des lignes supplémentaires (maintenant je compare à une valeur arrondie au lieu de la vraie valeur) est de filtrer sur 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 requête complète prend maintenant 6 ms sur ma machine.

9
Joe Obbish

Paul White a indiqué une réponse à une question similaire contenant un lien vers n article intéressant d'Itzik Ben Gan . Ceci décrit le modèle "Static Relational Interval Tree" qui permet de le faire efficacement.

En résumé, cette approche implique le stockage d'une valeur calculée ("forknode") basée sur les valeurs d'intervalle dans la ligne. Lors de la recherche de plages qui croisent une autre plage, il est possible de précalculer les valeurs possibles de knode que les lignes correspondantes doivent avoir et de l'utiliser pour trouver les résultats avec un maximum de 31 opérations de recherche (le ci-dessous prend en charge les entiers compris entre 0 et le maximum signé 32). bit int)

Sur cette base, j'ai restructuré le tableau comme ci-dessous.

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 

Et puis utilisé la requête suivante (l'article recherche des intervalles qui se croisent donc trouver un intervalle contenant un point en est un cas dégénéré)

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;

Cela s'exécute généralement dans 1mssur ma machine lorsque toutes les pages sont en cache - avec IO stats.

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.

et planifier

enter image description here

NB: La source utilise des TVF multistatements plutôt qu'un CTE récursif pour obtenir la connexion des nœuds mais dans l'intérêt de rendre ma réponse autonome, j'ai opté pour ce dernier. Pour une utilisation en production, j'utiliserais probablement les TVF.

9
Martin Smith

Une autre façon de représenter une plage serait de représenter des points sur une ligne.

Ce qui suit migre toutes les données dans une nouvelle table avec la plage représentée comme un type de données geometry.

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 requête équivalente pour rechercher des plages contenant la valeur 50,000,000 est inférieure à.

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

Les lectures pour cela montrent une amélioration sur le 10,951 de la requête d'origine.

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.

Cependant il n'y a pas d'amélioration significative par rapport à l'original en termes de temps écoulé . Les résultats d'exécution typiques sont de 250 ms contre 252 ms.

Le plan d'exécution est plus complexe que ci-dessous

enter image description here

Le seul cas où la réécriture fonctionne de manière fiable mieux pour moi est avec un cache froid.

Tellement décevant dans ce cas et difficile de recommander cette réécriture mais la publication de résultats négatifs peut également être utile.

8
Martin Smith

En hommage à nos nouveaux seigneurs de robots, j'ai décidé de voir si l'une des fonctionnalités new-ish R et Python pourrait nous aider ici. La réponse est non, au moins pour les scripts qui Je pourrais travailler et renvoyer des résultats corrects. Si quelqu'un avec une meilleure connaissance vient, eh bien, n'hésitez pas à me fesser. Mes tarifs sont raisonnables.

Pour ce faire, j'ai mis en place un VM avec 4 cœurs et 16 Go de RAM, pensant que cela suffirait pour traiter un ensemble de données de ~ 200 Mo.

Commençons par la langue qui n'existe pas à 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));

Ce fut un mauvais moment.

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

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

Le plan d'exécution est assez inintéressant, bien que je ne sache pas pourquoi l'opérateur intermédiaire doit nous appeler des noms.

NUTS

Ensuite, codage avec des crayons!

Python

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

Juste au moment où vous pensiez que cela ne pouvait pas être pire que R:

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

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

Un autre grossier plan d'exécution :

NUTS

Hmm et Hmmer

Jusqu'à présent, je ne suis pas impressionné. J'ai hâte de supprimer cette machine virtuelle.

5
Erik Darling

J'ai trouvé une assez bonne solution en utilisant une colonne calculée, mais elle n'est bonne que pour une seule valeur. Cela étant dit, si vous avez une valeur magique, c'est peut-être suffisant.

En commençant par votre échantillon donné, puis en modifiant le tableau:

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 requête devient simplement:

SELECT *
FROM MyTable
WHERE curtis_jackson = 1;

Qui renvoie les mêmes résultats que votre requête de départ. Avec les plans d'exécution désactivés, voici les statistiques (tronquées par souci de concision):

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

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

Et voici le plan de requête :

NUTS

4
Erik Darling

Ma solution est basée sur l'observation que l'intervalle a une largeur maximale connue W . Pour les exemples de données, il s'agit d'un octet ou de 256 entiers. Par conséquent, pour une valeur de paramètre de recherche donnée P nous savons que le plus petit RangeFrom qui peut être dans le jeu de résultats est P - W. L'ajout de cela au prédicat donne

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

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

Compte tenu de la configuration et de la requête d'origine, ma machine (Windows 10 64 bits, i7 hyperthread 4 cœurs, 2,8 GHz, 16 Go de RAM) renvoie 13 lignes. Cette requête utilise une recherche d'index parallèle de l'index (RangeFrom, RangeTo). La requête révisée effectue également une recherche d'index parallèle sur le même index.

Les mesures pour les requêtes originales et révisées sont

                          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

Pour la requête d'origine, le nombre de lignes lues est égal au nombre de lignes inférieures ou égales à @P. L'optimiseur de requêtes (QO) n'a pas d'autre alternative que de les lire toutes car il ne peut pas déterminer à l'avance si ces lignes satisferont le prédicat. L'index multi-colonnes sur (RangeFrom, RangeTo) n'est pas utile pour éliminer les lignes qui ne correspondent pas à RangeTo car il n'y a pas de corrélation entre la première clé d'index et la seconde qui peut être appliquée. Par exemple, la première ligne peut avoir un petit intervalle et être éliminée tandis que la deuxième ligne a un grand intervalle et est renvoyée, ou vice versa.

Dans une tentative infructueuse, j'ai essayé de fournir cette certitude grâce à une contrainte de vérification:

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

Cela n'a fait aucune différence.

En incorporant mes connaissances externes de la distribution des données dans le prédicat, je peux faire en sorte que le QO ignore les lignes RangeFrom de faible valeur, qui ne peuvent jamais faire partie de l'ensemble de résultats, et traverse la colonne de tête de l'index vers les lignes admissibles. Cela apparaît dans les différents prédicats de recherche pour chaque requête.

Dans un argument miroir, la limite supérieure de RangeTo est P + W. Cela n'est cependant pas utile, car il n'existe aucune corrélation entre RangeFrom et RangeTo qui permettrait à la colonne de fin d'un index multi-colonnes d'éliminer les lignes. Par conséquent, il n'y a aucun avantage à ajouter cette clause à la requête.

Cette approche tire l'essentiel de ses avantages de la petite taille de l'intervalle. À mesure que la taille d'intervalle possible augmente, le nombre de lignes de faible valeur ignorées diminue, bien que certaines soient toujours ignorées. Dans le cas limite, avec un intervalle aussi grand que la plage de données, cette approche n'est pas pire que la requête originale (qui est un confort froid, je l'admets).

Je m'excuse pour toutes les erreurs ponctuelles pouvant exister dans cette réponse.

3
Michael Green