web-dev-qa-db-ja.com

SQL Serverでの数値範囲(間隔)検索の最適化

この質問は IP範囲検索の最適化? に似ていますが、SQL Server 2000に限定されています。

以下のように構造化され、入力されたテーブルに一時的に1000万個の範囲が格納されているとします。

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 

50,000,000という値を含むすべての範囲を知る必要があります。次のクエリを試します

SELECT *
FROM MyTable
WHERE 50000000 BETWEEN RangeFrom AND RangeTo

SQL Serverは、10,951の論理読み取りがあり、12の一致するものを返すために500万行近くが読み取られたことを示しています。

enter image description here

このパフォーマンスを改善できますか?テーブルまたは追加のインデックスの再構築は問題ありません。

19
Martin Smith

列ストアは、テーブルの半分をスキャンする非クラスター化インデックスと比較して、ここでは非常に大量です。非クラスター化列ストアインデックスはほとんどの利点を提供しますが、順序付けされたデータをクラスター化列ストアインデックスに挿入することはさらに優れています。

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

設計により、RangeFrom列で行グループの削除を取得できます。これにより、行グループの半分が削除されます。しかし、データの性質上、RangeTo列でも行グループが削除されます。

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

可変データが多い大きなテーブルの場合、データをロードして、両方の列で可能な最高の行グループの削除を保証するさまざまな方法があります。特にデータの場合、クエリには1ミリ秒かかります。

11
Joe Obbish

N/CCIアプローチと競合する行モードアプローチを見つけることができましたが、データについて何かを知る必要があります。 RangeFromRangeToの違いを含む列があり、RangeFromとともにインデックスを作成したとします。

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

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

DiffOfColumnsの個別の値がすべてわかっている場合は、DiffOfColumnsの範囲フィルターを使用してRangeToのすべての値に対してシークを実行し、関連するすべてのデータを取得できます。たとえば、DiffOfColumns = 2であることがわかっている場合、RangeFromに許可される値は49999998、49999999、50000000のみです。再帰を使用して、DiffOfColumnsおよび256しかないため、データセットに適しています。以下のクエリは私のマシンで約6 msかかります:

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

すべての個別の値のインデックスシークとともに、通常の再帰部分を確認できます。

query plan 1

このアプローチの欠点は、DiffOfColumnsの個別の値が多すぎると遅くなることです。同じテストをしてみましょう。ただし、CRYPT_GEN_RANDOM(2)ではなく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);
_

同じクエリで、再帰部分から65536行が検出され、マシンで823ミリ秒のCPUが使用されます。 PAGELATCH_SH待機とその他の悪いことが起こっています。差分値をバケット化して一意の値の数を制御下に保ち、_CROSS APPLY_のバケット化を調整することで、パフォーマンスを向上させることができます。このデータセットでは、256バケットを試してみます。

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

追加の行を取得しないようにする1つの方法(今は、真の値ではなく丸められた値と比較しています)は、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
_

完全なクエリは、私のマシンで6ミリ秒かかります。

9
Joe Obbish

Paul Whiteは、 Itzik Ben Ganによる興味深い記事 へのリンクを含む同様の質問への回答を指摘しました。これは、これを効率的に実行できるようにする「静的リレーショナル間隔ツリー」モデルについて説明しています。

要約すると、このアプローチには、行の間隔値に基づいて計算された(「フォークノード」)値を格納することが含まれます。別の範囲と交差する範囲を検索する場合、一致する行に必要な可能なフォークノード値を事前計算し、これを使用して最大31のシーク操作で結果を見つけることができます(以下は0から最大の符号付き32の範囲の整数をサポートしています)ビット整数)

これに基づいて、次のように表を再構成しました。

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 

そして、次のクエリを使用しました(記事は交差する間隔を探しているため、点を含む間隔を見つけることはこれの退化したケースです)

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;

これは通常、1msすべてのページがキャッシュにあるときにマシン上で-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.

そして計画

enter image description here

注意:ソースは、ノードを結合させるために再帰CTEではなくマルチステートメントTVFを使用しますが、私の答えを自己完結させるために、後者を選択しました。本番環境では、おそらくTVFを使用します。

9
Martin Smith

範囲を表す1つの代替方法は、線上の点としてです。

以下は、すべてのデータを、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); 

50,000,000を含む範囲を検索するための同等のクエリを以下に示します。

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

この読み取りは、元のクエリからの10,951の改善を示しています。

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.

ただし、経過時間の点で、オリジナルより大幅な改善はありません。通常の実行結果は250ミリ秒対252ミリ秒です。

実行計画は以下のようにより複雑です

enter image description here

書き換えが確実にパフォーマンスを向上させる唯一のケースは、コールドキャッシュです。

したがって、この場合はがっかりし、この書き換えを推奨することは困難ですが、否定的な結果の公開も役立つ可能性があります。

8
Martin Smith

新しいロボットオーバーロードへのオマージュとして、新しいRとPythonの機能のいずれかがここで私たちを助けることができるかどうかを確認することにしました。うまくいき、正しい結果を返すことができました。知識が豊富な人がいれば、遠慮なく私を殴ってください。

これを行うには、4つのコアと16 GBのRAMを備えたVMをセットアップしました。これで約200MBのデータセットを処理するには十分だと思います。

ボストンには存在しない言語から始めましょう!

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

これは悪い時間でした。

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

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

実行計画 は、非常に興味深いものではありませんが、中間の演算子が私たちに名前を呼ばなければならない理由はわかりません。

NUTS

次は、クレヨンでコーディング!

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

あなたがRより悪くならないと思ったときだけ:

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

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

別の反則 実行計画

NUTS

うーんとうーん

これまでのところ、私は感心していません。このVMを削除するのが待ちきれません。

5
Erik Darling

計算列を使用してかなり良い解決策を見つけましたが、それは単一の値に対してのみ良いです。そうは言っても、魔法の価値があれば、それで十分でしょう。

指定したサンプルから始めて、テーブルを変更します。

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

クエリは次のようになります。

SELECT *
FROM MyTable
WHERE curtis_jackson = 1;

これは、最初のクエリと同じ結果を返します。実行プランがオフになっている場合、統計は次のとおりです(簡潔にするために省略しています)。

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

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

そして、これが query plan です。

NUTS

4
Erik Darling

私の解決策は、間隔に既知の最大幅[〜#〜] w [〜#〜]があるという観察に基づいています。サンプルデータの場合、これは1バイトまたは256整数です。したがって、指定された検索パラメータ値[〜#〜] p [〜#〜]の場合、結果セットに含めることができる最小のRangeFromはP-Wであることがわかります。これを述語に追加すると、

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

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

元のセットアップと私のマシン(64ビットWindows 10、4コアハイパースレッドi7、2.8 GHz、16 GB RAM)をクエリすると、13行が返されます。そのクエリは、(RangeFrom、RangeTo)インデックスの並列インデックスシークを使用します。修正されたクエリは、同じインデックスに対して並列インデックスシークも実行します。

元のクエリと改訂されたクエリの測定値は

                          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

元のクエリの場合、読み込まれる行数は@P以下の行数と同じです。クエリオプティマイザー(QO)には、これらの行が述部を満たすかどうかを事前に判断できないため、代替手段はありませんが、すべて読み取ります。 (RangeFrom、RangeTo)の複数列インデックスは、最初のインデックスキーと適用できる2番目のインデックスキーの間に相関関係がないため、RangeToに一致しない行を削除するのに役立ちません。たとえば、最初の行の間隔が小さくて削除される場合がありますが、2番目の行の間隔は大きくて返されます。逆の場合も同様です。

1つの失敗した試みで、チェック制約を通じてその確実性を提供しようとしました。

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

違いはありませんでした。

データ分布に関する外部の知識を述語に組み込むことにより、QOが結果セットの一部になることのない低い値のRangeFrom行をスキップし、インデックスの先頭列を許容行にトラバースできます。これは、クエリごとに異なるシーク述語で示されます。

ミラー引数では、RangeToの上限はP + Wです。ただし、これは有用ではありません。複数列インデックスの末尾の列が行を削除できるようにするRangeFromとRangeToの間に相関関係がないためです。したがって、この句をクエリに追加してもメリットはありません。

このアプローチは、間隔のサイズが小さいことから、ほとんどのメリットを得られます。可能な間隔サイズが大きくなると、スキップされる低い値の行の数は減少しますが、一部はスキップされます。限定的なケースでは、データ範囲と同じくらいの間隔で、このアプローチは元のクエリ(これは私が認める寒さの快適さ)よりも悪くはありません。

この回答に存在する可能性のあるオフバイワンエラーをお詫び申し上げます。

3
Michael Green