Seek Condition と Residual Condition の見分け方

Cloud Spanner は WHERE 句(述語)を適用するのに3つの方法を使い分ける。

  • FilterScan operator の Seek Condition
    • Seek Condition はスキャンするキー範囲の開始と終了の条件を表す。
    • Use The Index, Luke や SQL パフォーマンス詳解におけるアクセス述語に対応する。
  • FilterScan operator の Residual Condition
    • Residual Condition はキー範囲をスキャンしながら適用される。スキャンされる範囲の開始と終了の条件を表したり、キー範囲を狭めるものとしては扱われない。
    • Use The Index, Luke や SQL パフォーマンス詳解におけるフィルタ述語に対応する。
  • Filter operator の Condition
    • Scan を伴わずにインメモリで入力からフィルタを行う。これもフィルタ述語の一種と考えらる。

Seek Condition だけがキーを使ってスキャンする範囲を絞ることができるため、データ量が増えてもスケールさせたいクエリでは主要なフィルタが FilterScan の Seek Condition になっていることの確認を推奨する。

Seek Condition と Residual Condition の例

SELECT SingerId, AlbumId, TrackId
FROM Songs@{FORCE_INDEX=SongsBySongName}
WHERE SongName LIKE "A%";

例として、 Songs テーブルから SongName が A からはじまる行を取得するためのクエリを見ていく。 このクエリの WHERE 句に対応するフィルタは SongsBySongName インデックスに対する範囲のスキャンで表現できるため、適切なインデックスがあれば次のように Seek Condition として処理できる。 最終的な結果の行数とスキャンされる行数は一致するため、インデックスが最大限有効に使われていることが確認できる。

SELECT SingerId, AlbumId, TrackId
FROM Songs@{FORCE_INDEX=SongsBySongName}
WHERE SongName LIKE "A%";
+----+-------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan                            | Rows_Returned | Executions | Total_Latency |
+----+-------------------------------------------------+---------------+------------+---------------+
| *0 | Distributed Union                               | 16949         | 1          | 47.57 msecs   |
|  1 | +- Local Distributed Union                      | 16949         | 1          | 13.91 msecs   |
|  2 |    +- Serialize Result                          | 16949         | 1          | 13.52 msecs   |
| *3 |       +- FilterScan                             | 16949         | 1          | 10.75 msecs   |
|  4 |          +- Index Scan (Index: SongsBySongName) | 16949         | 1          | 8.71 msecs    |
+----+-------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
 0: Split Range: STARTS_WITH($SongName, 'A')
 3: Seek Condition: STARTS_WITH($SongName, 'A')

16949 rows in set (52.75 msecs)
timestamp: 2020-09-23T18:21:54.790063+09:00
cpu:       50.12 msecs
scanned:   16949 rows
optimizer: 2

上と同じく SongsBySongName インデックスから最初の文字が A のものを取得するクエリであっても、下記のクエリは Seek Condition で処理できずに Residual Condition となってしまい範囲を限定できない。 よってフルスキャンとなり、各 operator の Rows_Returned は同じでも scanned として表示されているスキャン対象の行数が大幅に異なることがわかる。

SELECT SingerId, AlbumId, TrackId
FROM Songs@{FORCE_INDEX=SongsBySongName}
WHERE SUBSTR(SongName, 0, 1) = "A";
+----+------------------------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan                                             | Rows_Returned | Executions | Total_Latency |
+----+------------------------------------------------------------------+---------------+------------+---------------+
| *0 | Distributed Union                                                | 16949         | 1          | 263.41 msecs  |
|  1 | +- Local Distributed Union                                       | 16949         | 1          | 262.46 msecs  |
|  2 |    +- Serialize Result                                           | 16949         | 1          | 261.99 msecs  |
| *3 |       +- FilterScan                                              |               |            |               |
|  4 |          +- Index Scan (Full scan: true, Index: SongsBySongName) | 16949         | 1          | 259.61 msecs  |
+----+------------------------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
 0: Split Range: (SUBSTR($SongName, 0, 1) = 'A')
 3: Residual Condition: (SUBSTR($SongName, 0, 1) = 'A')

16949 rows in set (269.45 msecs)
timestamp: 2020-09-23T18:24:29.831979+09:00
cpu:       264.2 msecs
scanned:   1024000 rows
optimizer: 2

ここまでで見たように、 Index Scan であることを確認するだけではデータ量が増えるほど計算量が増えてスケールしないクエリを見つけるには不十分であり、 フィルタがどのように処理されているかを確認することが一つの有効な手段となる。

WHERE 句と back join

長い間 SQL DBMS ではテーブルの行を保存する構造とセカンダリインデックスの構造は異なるものだった。 実行計画上もテーブルスキャンとインデックススキャンは区別され、インデックススキャンで特定した行の列をテーブルから取得することは暗黙的に行われたり、 TABLE ACCESS のような専用の operator で処理されるのが一般的だった。

一方、 Cloud Spanner ではベーステーブルとセカンダリインデックスは同じ構造であるため、それぞれからのスキャンは Scan operator の scan_typeTableScanIndexScan かのみで区別される。 セカンダリインデックスから行全体を取得するためにも専用の operator は用意されておらず JOIN(back join) そのもので処理される。 つまり、INTERLEAVE されていないインデックスならば Distributed Cross Apply を使った分散 JOIN としてして実行計画に現れることが多い。

テーブルアクセスが JOIN であることはフィルタにも関係があり、セカンダリインデックスに含まれない列を WHERE 句で指定した場合には、 対応するフィルタはセカンダリインデックス側の Scan ではなくベーステーブル側の Scan に対応する FilterScan で処理される。

セカンダリインデックスを使う場合は

  • キー部に対する Seek Condition
  • インデックスに含まれる列に対する Residual Condition
  • back join しないと適用できないベーステーブルへの Residual Condition

の3階層を意識すると良いだろう。

例として、セカンダリインデックスに含まれない列 BirthDate へのフィルタがどのように処理されるかを示す。

CREATE INDEX SingersByFirstLastName 
ON Singers (
	FirstName,
	LastName
);

SELECT *
FROM Singers@{FORCE_INDEX=SingersByFirstLastName}
WHERE FirstName < "J"
AND EXTRACT(DAYOFWEEK FROM BirthDate) = 1;
+-----+--------------------------------------------------------------+---------------+------------+---------------+
| ID  | Query_Execution_Plan                                         | Rows_Returned | Executions | Total_Latency |
+-----+--------------------------------------------------------------+---------------+------------+---------------+
|  *0 | Distributed Union                                            | 71            | 1          | 170.72 msecs  |
|  *1 | +- Distributed Cross Apply                                   | 71            | 1          | 170.68 msecs  |
|   2 |    +- [Input] Create Batch                                   |               |            |               |
|   3 |    |  +- Local Distributed Union                             | 503           | 1          | 1.18 msecs    |
|   4 |    |     +- Compute Struct                                   | 503           | 1          | 1.14 msecs    |
|  *5 |    |        +- FilterScan                                    | 503           | 1          | 0.93 msecs    |
|   6 |    |           +- Index Scan (Index: SingersByFirstLastName) | 503           | 1          | 0.89 msecs    |
|  18 |    +- [Map] Serialize Result                                 | 71            | 1          | 167.99 msecs  |
|  19 |       +- Cross Apply                                         | 71            | 1          | 167.85 msecs  |
|  20 |          +- [Input] Batch Scan (Batch: $v2)                  | 503           | 1          | 0.96 msecs    |
|  24 |          +- [Map] Local Distributed Union                    | 71            | 503        | 166.58 msecs  |
| *25 |             +- FilterScan                                    | 71            | 503        | 166.08 msecs  |
|  26 |                +- Table Scan (Table: Singers)                | 71            | 503        | 165.53 msecs  |
+-----+--------------------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
  0: Split Range: ($FirstName < 'J')
  1: Split Range: ($SingerId' = $SingerId)
  5: Seek Condition: ($FirstName < 'J')
 25: Seek Condition: ($SingerId' = $batched_SingerId)
     Residual Condition: (EXTRACT(DAYOFWEEK, $BirthDate) = 1)

71 rows in set (179.02 msecs)
timestamp: 2020-09-23T17:44:58.768598+09:00
cpu:       63.3 msecs
scanned:   1006 rows
optimizer: 2

元の SQL 文の WHERE 句には2つの条件が含まれているが、片方の条件で使われている BirthDate は使用するセカンダリインデックスには含まれない。 結果として、実行計画では Index Scan に伴う FilterScan(ID:5) では FirstName < "J" に対応する $FirstName < 'J' のみが Seek Condition として処理されており、 EXTRACT(DAYOFWEEK FROM BirthDate) = 1 に対応する EXTRACT(DAYOFWEEK, $BirthDate) = 1 はベーステーブルの Table Scan に伴う FilterScan(ID:25) の Residual Condition として処理されることが読み取れる。 つまり、インデックスではフィルタが完結せずベーステーブルを分散 JOIN しながら結果を捨てることも含めてやっと WHERE 句のフィルタが処理される。 実際に503件分散 JOIN しているにも関わらず71行を残して捨てていることが分かり、これは無駄な処理となる。

次に、意味は全く同じクエリだが、フィルタで使用する BirthDate を含んだセカンダリインデックスを作成してヒントで指定した際の実行計画と実行統計を示す。

CREATE INDEX SingersByFirstLastNameStoring 
ON Singers (
	FirstName,
	LastName
) STORING (
	BirthDate
);

SELECT *
FROM Singers@{FORCE_INDEX=SingersByFirstLastNameStoring}
WHERE FirstName < "J"
AND EXTRACT(DAYOFWEEK FROM BirthDate) = 1;
+-----+---------------------------------------------------------------------+---------------+------------+---------------+
| ID  | Query_Execution_Plan                                                | Rows_Returned | Executions | Total_Latency |
+-----+---------------------------------------------------------------------+---------------+------------+---------------+
|  *0 | Distributed Union                                                   | 71            | 1          | 5.64 msecs    |
|  *1 | +- Distributed Cross Apply                                          | 71            | 1          | 5.61 msecs    |
|   2 |    +- [Input] Create Batch                                          |               |            |               |
|   3 |    |  +- Local Distributed Union                                    | 71            | 1          | 0.49 msecs    |
|   4 |    |     +- Compute Struct                                          | 71            | 1          | 0.47 msecs    |
|  *5 |    |        +- FilterScan                                           | 71            | 1          | 0.43 msecs    |
|   6 |    |           +- Index Scan (Index: SingersByFirstLastNameStoring) | 71            | 1          | 0.42 msecs    |
|  26 |    +- [Map] Serialize Result                                        | 71            | 1          | 2.07 msecs    |
|  27 |       +- Cross Apply                                                | 71            | 1          | 2.04 msecs    |
|  28 |          +- [Input] Batch Scan (Batch: $v2)                         | 71            | 1          | 0.03 msecs    |
|  33 |          +- [Map] Local Distributed Union                           | 71            | 71         | 1.98 msecs    |
| *34 |             +- FilterScan                                           | 71            | 71         | 1.93 msecs    |
|  35 |                +- Table Scan (Table: Singers)                       | 71            | 71         | 1.88 msecs    |
+-----+---------------------------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
  0: Split Range: ($FirstName < 'J')
  1: Split Range: ($SingerId' = $SingerId)
  5: Seek Condition: ($FirstName < 'J')
     Residual Condition: (EXTRACT(DAYOFWEEK, $BirthDate) = 1)
 34: Seek Condition: ($SingerId' = $batched_SingerId)

71 rows in set (29.22 msecs)
timestamp: 2020-09-23T17:43:57.547539+09:00
cpu:       9.3 msecs
scanned:   574 rows
optimizer: 2

EXTRACT(DAYOFWEEK FROM BirthDate) = 1 が Index Scan に伴う FilterScan の Residual Condition として処理されるようになったため、 Index Scan の時点で最終的に必要な71件まで絞ることができ、フィルタを適用するためだけの分散 JOIN がなくなった。 上の例で分かるように、SELECT も含めて必要な列全てをインデックスに含めることでテーブルアクセスのための back join を完全になくす(いわゆるインデックスオンリースキャンが可能なカバリングインデックス)かどうかだけでなく、 フィルタ一つをインデックスで処理できるようにするだけでも back join の対象を減らす効果があるため多くの選択肢が存在する。

なお、EXTRACT(DAYOFWEEK FROM BirthDate) = 1 は Seek Condition として処理できないため、このクエリに関しては STORING ではなく複合キーにするメリットは特にない。

フィルタプッシュダウンと Filter operator

一般的に、 WHERE 句は可能な限りだけ実行計画ツリーの Scan に近い場所のフィルタとして実行することで、各 operator が処理する必要があるデータ量を減らす最適化であるフィルタプッシュダウンが行われる。

FilterScan ではなく Filter が現れるケースは Scan の場所までフィルタプッシュダウンができない理由があるケースである。 下記は公式ドキュメントの Filter の例 を元にしたクエリだが、サブクエリの中の Singers のスキャンにフィルタを適用すると Limit の結果が意味が変わってしまうため Limit の外側で Filter する必要があり、この実行計画となっている。

SELECT s.FirstName
FROM (SELECT s.FirstName
      FROM Singers AS s
      LIMIT 50) s
WHERE s.FirstName LIKE 'Z%';
+----+-------------------------------------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan                                                          | Rows_Returned | Executions | Total_Latency |
+----+-------------------------------------------------------------------------------+---------------+------------+---------------+
|  0 | Serialize Result                                                              | 0             | 1          | 0.17 msecs    |
| *1 | +- Filter                                                                     | 0             | 1          | 0.17 msecs    |
|  2 |    +- Global Limit                                                            | 50            | 1          | 0.16 msecs    |
|  3 |       +- Distributed Union                                                    | 50            | 1          | 0.16 msecs    |
|  4 |          +- Local Limit                                                       | 50            | 1          | 0.14 msecs    |
|  5 |             +- Local Distributed Union                                        | 50            | 1          | 0.14 msecs    |
|  6 |                +- Index Scan (Full scan: true, Index: SingersByFirstLastName) | 50            | 1          | 0.13 msecs    |
+----+-------------------------------------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
 1: Condition: STARTS_WITH($FirstName, 'Z')

Empty set (15.41 msecs)
timestamp: 2020-09-24T17:32:23.164311+09:00
cpu:       3.73 msecs
scanned:   50 rows
optimizer: 2

この場合、 Filter は Distributed Union よりも外側にあるため root server で処理することになる。フィルタとは関係なく Limit で50行を残して捨てた後で Filter をかけるため、50行の中にどの程度フィルタ条件にマッチする行が残っているかで結果が変わってしまう。 このケースでも最終的な結果が0件となっている。

LIMIT 句をサブクエリの外に出すとフィルタプッシュダウンが行われ、 FilterScan で処理されることが確認できる。 この場合はフィルタは全て Scan 対象の split を持つ各 remote server で処理され、root server にはフィルタ済の行だけが集まることになる。

SELECT s.FirstName
FROM (SELECT s.FirstName
      FROM Singers AS s) s
WHERE s.FirstName LIKE 'Z%';
LIMIT 50
+----+--------------------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan                                         | Rows_Returned | Executions | Total_Latency |
+----+--------------------------------------------------------------+---------------+------------+---------------+
|  0 | Global Limit                                                 | 35            | 1          | 0.94 msecs    |
| *1 | +- Distributed Union                                         | 35            | 1          | 0.94 msecs    |
|  2 |    +- Serialize Result                                       | 35            | 1          | 0.9 msecs     |
|  3 |       +- Local Limit                                         | 35            | 1          | 0.89 msecs    |
|  4 |          +- Local Distributed Union                          | 35            | 1          | 0.89 msecs    |
| *5 |             +- FilterScan                                    | 35            | 1          | 0.88 msecs    |
|  6 |                +- Index Scan (Index: SingersByFirstLastName) | 35            | 1          | 0.87 msecs    |
+----+--------------------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
 1: Split Range: STARTS_WITH($FirstName, 'Z')
 5: Seek Condition: STARTS_WITH($FirstName, 'Z')

35 rows in set (11.79 msecs)
timestamp: 2020-09-24T17:29:09.288925+09:00
cpu:       6.88 msecs
scanned:   35 rows
optimizer: 2

サブクエリ内で LIMIT を使うとクエリを字句的にも挙動的にも理解しづらくなり、フィルタプッシュダウンのようなオプティマイザの最適化も妨げる場合があるため、それ以外に選択可能な最適化の手段がある場合はまずはそちらを検討することを推奨する。

Seek Condition からでは見抜けないケース

細切れになりすぎる範囲のコスト

OR や IN などを使った際に Seek Condition は複数に分かれるような範囲も適切に扱うことができる。

SELECT SingerId, AlbumId, TrackId, SongName
FROM Songs@{FORCE_INDEX=SongsBySongName}
WHERE SongName LIKE "A%" OR SongName LIKE "Z%";
+----+-------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan                            | Rows_Returned | Executions | Total_Latency |
+----+-------------------------------------------------+---------------+------------+---------------+
| *0 | Distributed Union                               | 19767         | 1          | 19.83 msecs   |
|  1 | +- Local Distributed Union                      | 19767         | 1          | 18.56 msecs   |
|  2 |    +- Serialize Result                          | 19767         | 1          | 18.12 msecs   |
| *3 |       +- FilterScan                             | 19767         | 1          | 15.08 msecs   |
|  4 |          +- Index Scan (Index: SongsBySongName) | 19767         | 1          | 12.9 msecs    |
+----+-------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
 0: Split Range: (STARTS_WITH($SongName, 'A') OR STARTS_WITH($SongName, 'Z'))
 3: Seek Condition: (STARTS_WITH($SongName, 'A') OR STARTS_WITH($SongName, 'Z'))

19767 rows in set (48.99 msecs)
timestamp: 2020-09-28T04:24:46.070389+09:00
cpu:       42.35 msecs
scanned:   19767 rows
optimizer: 2

更に、複合キーの先頭以外の列を指定すると、細切れになっているはずの範囲でもスキャンすることさえできる。

SELECT SingerId, AlbumId, TrackId, SongName FROM Songs WHERE AlbumId = 1;
+----+----------------------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan                                           | Rows_Returned | Executions | Total_Latency |
+----+----------------------------------------------------------------+---------------+------------+---------------+
|  0 | Distributed Union                                              | 32000         | 1          | 143.71 msecs  |
|  1 | +- Local Distributed Union                                     | 32000         | 1          | 141.64 msecs  |
|  2 |    +- Serialize Result                                         | 32000         | 1          | 140.87 msecs  |
| *3 |       +- FilterScan                                            | 32000         | 1          | 135.59 msecs  |
|  4 |          +- Index Scan (Index: SongsBySingerAlbumSongNameDesc) | 32000         | 1          | 133.56 msecs  |
+----+----------------------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
 3: Seek Condition: ($AlbumId = 1)

32000 rows in set (155.41 msecs)
timestamp: 2020-09-28T04:27:59.592161+09:00
cpu:       70.9 msecs
scanned:   32000 rows
optimizer: 2

しかし、細切れになりすぎると連続した範囲と比べてパフォーマンスが劣化していくことが確認されている。このケースは無駄なスキャンが発生しないため、実行統計からでは認識できない。

範囲条件の離散化と optimizer version

Spanner: Becoming a SQL System の 4.3 Filter tree では、小さい整数区間が離散化されること(k BETWEEN 5 AND 7 は 5, 6, 7 の明示的な列挙になる)が説明されている。先頭キーの小さい範囲条件が離散化されれば、後続キーの条件も Seek Condition に含めることができる。

gcpug/nouhau の shard ノートの議論で観測されていた「shard の範囲条件と組み合わせると timestamp がフィルタ述語に落ちる」挙動を Spanner Omni 2026.r1-beta で再確認したところ、この離散化の有無は optimizer version に依存することが分かった。

SELECT OrderId, CreatedAt
FROM Order1M@{FORCE_INDEX=Order1MShardCreatedAtDesc}
WHERE ShardCreatedAt BETWEEN 0 AND 9
  AND CreatedAt BETWEEN TIMESTAMP '2018-09-05T09:00:00Z' AND TIMESTAMP '2018-09-05T10:00:00Z'
ORDER BY CreatedAt DESC
LIMIT 100
  • OPTIMIZER_VERSION 1〜6: seekable_key_size=2 となり、ShardCreatedAt BETWEEN 0 AND 9 が離散化されて CreatedAt の範囲も Seek Condition に含まれる(Residual Condition なし)。
  • OPTIMIZER_VERSION 7〜8(執筆時のデフォルト): seekable_key_size=1 となり、Seek Condition は shard の範囲のみで CreatedAt は Residual Condition に落ちる。ShardCreatedAt IN (0, 1, ..., 9) と明示的に列挙しても変わらない。
v3-v6: ... -> Filter Scan{seekable_key_size=2} -> Scan{scan_target=Order1MShardCreatedAtDesc; Function[Seek Condition]}
v7-v8: ... -> Filter Scan{seekable_key_size=1; Function[Residual Condition]} -> Scan{scan_target=Order1MShardCreatedAtDesc; Function[Seek Condition]}

なお、この観測は空のデータベース・統計情報なしで行ったものであり、コストベースの選択が統計によって変わる可能性はある。shard ごとの等値条件に書き換える(UNNEST(GENERATE_ARRAY(...)) と相関 ARRAY サブクエリを使う、nouhau shard ノートの V2/V3 の形)と、($ShardCreatedAt = $shard) AND ($CreatedAt >= ... AND $CreatedAt <= ...) の 2 キー Seek になり、optimizer version に依存せず timestamp まで seek できる。

重要な注意として、seekable_key_size=2 の Seek Condition が表示されることは、実行時に効率的な seek が行われることをただちに意味しない。この離散化は shard 数 × timestamp 範囲の細切れな多数の範囲を生むため、前節の細切れ範囲のコストの問題に加えて、次節の通り実行時には Residual Condition と同様にフィルタ処理されることが示唆されるケースに該当し得る。論文自体も、range extraction のコストが full scan を seek に変換する利益を上回り得ること、seeks vs scans のトレードオフは “very tricky” であり論文の範囲外であることを明記している。v7〜v8 が離散化された Seek を選ばなくなったことも、性能上の退行とは限らず、このトレードオフ判断の変化である可能性がある。プラン上の違いの性能影響を判断するには、PLAN ではなくデータを入れた上での実行統計(PROFILE)での検証が必要である。

seekable_key_size は点 Seek では 0 になる

seekable_key_size は「範囲を伴う Seek のキー prefix 長」を表し、キー条件が全て等値の純粋な点 Seek では 0 になる(Spanner Omni 2026.r1-beta、デフォルト optimizer で検証)。主キー (SingerId, AlbumId, TrackId) の 3 キーテーブルに対して:

WHERE の形 Seek Condition seekable_key_size
SingerId = 1 AND AlbumId = 2(リテラル点 prefix) 2 キー等値 0
SingerId = @sid AND AlbumId = @aid(パラメータ点 prefix) 2 キー等値 0
3 キー全等値(完全点読み) 3 キー等値 0
SingerId = 1 AND AlbumId > 2(prefix + 範囲) 2 キー・範囲付き 2
SingerId BETWEEN 1 AND 5(範囲のみ) 1 キー・範囲付き 1
使えるキー条件なし(full scan) なし 0

追試(同環境):

WHERE の形 Seek Condition Residual Condition seekable_key_size
SingerId = 1(3 キー中 1 キーの prefix 等値) ($SingerId = 1) なし 0
SingerId = 1 AND TrackId > 5(キーの gap) ($SingerId = 1) ($TrackId > 5) 0
SingerId = 1 AND AlbumId = 2 AND TrackId > 5 3 キー・範囲付き なし 3
SingerId IN (1, 2, 3) AND AlbumId = 2 2 キー・IN 列挙 なし 2

これは論文の point lookup と range extraction の区別と整合する: 点キーには interval 計算が不要なので、Filter Scan に range extraction として記述すべき仕事がない。なお IN リストは各要素が等値でも列挙 extraction として扱われ、seekable_key_size にカウントされる。

公式ドキュメントの SEEKABLE_KEY_SIZE テーブルヒント(016FORCE_INDEX 必須)は seekable key size を「seekable condition で使われるキー(主キーまたはインデックスキー)の長さで、残りは residual condition で使われる」と定義している。この定義を字義通りに読むと 2 キー等値の点 Seek は両キーが Seek Condition にあるので長さ 2 のはずだが、観測されるメタデータは 0 を返す。これは、メタデータ値が range で seekable な prefix のみを測っているためと整合する。全等値 prefix は Split Range / 直接キーアクセスで解決され(Split Range 述語と完全な Seek Condition として現れる)、Filter Scan には range として抽出すべき仕事が残らないので 0 になる。ヒントの値 0(「range で seekable なものが無く、全て residual」)と、完璧な点 Seek の観測値 0 が同じ数になるという、この 0 の二重の曖昧さが、0 を単独で seekability の指標として使えない理由である。

実務上の帰結として、seekable_key_size 単独では seek の良し悪しを判断できない:

  • 0 は「full scan」と「点 Seek」の両方で現れるため、Scan 側の Seek Condition の有無(点 Seek)と Full scan フラグ(本当の full scan)で読み分ける必要がある。
  • 0 の点 Seek でも lookup の深さは分からない。3 キー中 1 キーだけの prefix 等値(配下サブツリーを全て読む)と完全 3 キー点読みは同じ 0 を報告し、深さは Seek Condition のテキストにしか現れない。
  • 点 Seek(0)に「キー列を含む Residual Condition」が併存する形(上表の gap パターン)は、prefix 点 Seek の後に読みながらフィルタしていることを示し、読み過ぎの可能性を示唆する。
  • この分類は interleave の有無には依存しない。同じ 3 キー主キーを持つ interleaved テーブルと非 interleave テーブルで、上記の全形状(等値 prefix 各深さ・等値+範囲・範囲のみ・gap・IN)について同一の seekable_key_size を報告することを確認した。
  • 主キーだけでなくセカンダリインデックスのキーでも同じ(公式定義の「primary key or index key」と一致)。FORCE_INDEX した 2 キーインデックスで、等値 prefix(1 キー・完全 2 キー点)は完全な Seek Condition 付きで 0、等値+範囲は 2 を報告する。
  • 等値 prefix と範囲は物理的なアクセス形状が似ていても別カテゴリの値になる。3 キー中 1 キーの等値 prefix は連続したサブツリー範囲を読むが 0、同じ先頭キーの BETWEEN1 を報告する。この指標は seek 境界の計算方法(点キー直接計算 vs interval/列挙 extraction)を表すもので、スキャンの広さの指標ではないため、0k > 0 を狭さの比較に使うことはできない。

実際には Seek で表現できない Seek Condition

しかし、2020年9月現在一定以上複雑なクエリでは、全体が Seek Condition と実行計画には書かれているにも関わらず実行時に Residual Condition のようにフィルタする処理となるケースがある。 このようなケースでは実行計画ではフィルタがどう処理されるかを判断できなくなるため、留意する必要がある。

例えば、下記のクエリのフィルタは前方一致に限らない LIKE 式なので範囲だけでは実現できず STARTS_WITH 相当の前方一致部分をスキャンしながら読み飛ばす必要があるが、全て Seek Condition として表示されてしまっており、実行統計で FilterScan の段階で実行時にフィルタしていることが確認できる。

SELECT SingerId, AlbumId, TrackId, SongName
FROM Songs@{FORCE_INDEX=SongsBySongName}
WHERE SongName LIKE "A%z" OR SongName LIKE "Z%a";
+----+-------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan                            | Rows_Returned | Executions | Total_Latency |
+----+-------------------------------------------------+---------------+------------+---------------+
| *0 | Distributed Union                               | 116           | 1          | 15.54 msecs   |
|  1 | +- Local Distributed Union                      | 116           | 1          | 15.5 msecs    |
|  2 |    +- Serialize Result                          | 116           | 1          | 15.49 msecs   |
| *3 |       +- FilterScan                             | 116           | 1          | 15.45 msecs   |
|  4 |          +- Index Scan (Index: SongsBySongName) | 19767         | 1          | 10.49 msecs   |
+----+-------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
 0: Split Range: ((STARTS_WITH($SongName, 'A') AND ($SongName LIKE 'A%z')) OR (STARTS_WITH($SongName, 'Z') AND ($SongName LIKE 'Z%a')))
 3: Seek Condition: ((STARTS_WITH($SongName, 'A') AND ($SongName LIKE 'A%z')) OR (STARTS_WITH($SongName, 'Z') AND ($SongName LIKE 'Z%a')))

116 rows in set (78.2 msecs)
timestamp: 2020-09-28T04:26:42.814951+09:00
cpu:       44.01 msecs
scanned:   19767 rows
optimizer: 2