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

18

Cette question est similaire à Optimizing IP Range Search? mais celui-ci est limité à 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.

entrez la description de l'image ici

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

Martin Smith
la source
Si je comprends bien la configuration de la table, vous choisissez des nombres aléatoires uniformément pour former vos plages, sans aucune contrainte sur la "taille" de chaque plage. Et votre sonde est pour le milieu de la plage globale 1..100M. Dans ce cas - pas de regroupement apparent en raison d'un caractère aléatoire uniforme - je ne sais pas pourquoi un index sur la borne inférieure ou la borne supérieure serait utile. Pouvez-vous expliquer cela?
davidbak
@davidbak les index conventionnels sur cette table ne sont en effet pas très utiles dans le pire des cas car il doit balayer la moitié de la plage et donc demander des améliorations potentielles. Il y a une belle amélioration dans la question liée pour SQL Server 2000 avec l'introduction du "granule" J'espérais que les index spatiaux pourraient aider ici car ils prennent en charge les containsrequêtes et bien qu'ils fonctionnent bien pour réduire la quantité de données lues, ils semblent ajouter d'autres frais généraux qui contrecarre cela.
Martin Smith
Je n'ai pas la possibilité de l'essayer - mais je me demande si deux index - un sur la borne inférieure, un sur la partie supérieure - puis une jointure interne - permettraient à l'optimiseur de requête de résoudre quelque chose.
davidbak

Réponses:

11

Columnstore est très utile ici par rapport à un index non cluster qui scanne la moitié du tableau. 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);

Par conception, je peux obtenir l'élimination des groupes de lignes sur la RangeFromcolonne, 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 RangeTocolonne:

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.

Joe Obbish
la source
yep certainement à la recherche d'autres approches à envisager sans la restriction de 2000. Ne sonne pas comme ça sera battu.
Martin Smith
9

Paul White a indiqué une réponse à une question similaire contenant un lien vers un 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 1mssur ma machine lorsque toutes les pages sont dans le cache - avec les statistiques d'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.

et planifier

entrez la description de l'image ici

NB: La source utilise des TVF multi-états 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.

Martin Smith
la source
9

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 de RangeFromet RangeToet que vous l'ayez 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, DiffOfColumnsvous pouvez effectuer une recherche de chaque valeur de DiffOfColumnsavec un filtre de plage RangeTopour obtenir toutes les données pertinentes. Par exemple, si nous savons que DiffOfColumns= 2, les seules valeurs autorisées pour RangeFromsont 49999998, 49999999 et 50000000. La récursivité peut être utilisée pour obtenir toutes les valeurs distinctes de DiffOfColumnset 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:

plan de requête 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 à la CRYPT_GEN_RANDOM(2)place 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.

Joe Obbish
la source
8

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 geometrytype de données.

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 trouver des plages contenant la valeur 50,000,000est ci-dessous.

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

Les lectures pour cela montrent une amélioration par 10,951rapport à 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

entrez la description de l'image ici

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.

Martin Smith
la source
5

En hommage à nos nouveaux robots seigneurs, j'ai décidé de voir si l'une des nouvelles fonctionnalités R et Python pourrait nous aider ici. La réponse est non, du moins pour les scripts que j'ai pu 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 configuré une machine virtuelle avec 4 cœurs et 16 Go de RAM, pensant que cela suffirait pour gérer 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.

DES NOISETTES

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 plan d'exécution grossier :

DES NOISETTES

Hmm et Hmmer

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

Erik Darling
la source
1
Vous pouvez également passer des paramètres, par exemple, 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 ));mais les performances ne sont pas excellentes. J'utilise R pour des choses que vous ne pouvez pas faire en SQL, dites si vous voulez prédire quelque chose.
wBob
4

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 :

DES NOISETTES

Erik Darling
la source
Ne pouvez-vous pas surmonter l'imitation de colonne calculée / index filtré avec un index activé WHERE (50000000 BETWEEN RangeFrom AND RangeTo) INCLUDE (..)?
ypercubeᵀᴹ
3
@ yper-crazyhat-cubeᵀᴹ - oui. CREATE INDEX IX1_redo ON dbo.MyTable (curtis_jackson) INCLUDE (RangeFrom, RangeTo) WHERE RangeTo >= 50000000 AND RangeFrom <= 50000000travaillerait. Et la requête l' SELECT * FROM MyTable WHERE RangeTo >= 50000000 AND RangeFrom <= 50000000;utilise - donc pas grand besoin du pauvre Curtis
Martin Smith
3

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é 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 choix 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.

Michael Green
la source