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.
Puis-je améliorer ces performances? Toute restructuration de la table ou des index supplémentaires est très bien.
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.
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:
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.
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 1ms
sur 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
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.
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
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.
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!
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.
Ensuite, codage avec des crayons!
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 :
Jusqu'à présent, je ne suis pas impressionné. J'ai hâte de supprimer cette machine virtuelle.
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 :
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.