はじめに
業務でRAGチャットボット開発をしていますが、常駐先の社員の方が、約20テーブル、現時点で50万行のビューを作成して、それに対してLIKE句やWHERE句を用いて検索するシステム案になりそうだと相談を受け、初心者の私は、重い処理がえぐい数発生するけど、パフォーマンス大丈夫か!?と心配になり、基本的な事を調べてみようと思ったのが、この記事のきっかけです。
対象者
- 未来の自分
- 駆け出しエンジニアの同志
- DBのパフォーマンスってSQLクエリの書き方一つだろ!と過去の私のような方
使用ツール
SQL Server(SSMS 21)
1. 私の勘違い
SQLクエリをチューニングする際、以下のように「JOINする前にサブクエリで絞る」書き方と、「JOINした後にWHERE句で絞る」書き方をしたとします。
-- パターン1:JOIN後にWHERE句で絞る
SELECT *
FROM tableA
JOIN tableB ON tableB.id = tableA.id
WHERE tableA.Name = 'TEST';
-- パターン2:サブクエリを使って先に絞ってからJOINする
SELECT *
FROM (SELECT * FROM tableA WHERE Name = 'TEST') tableA_filtered
JOIN tableB ON tableB.id = tableA_filtered.id;
無知な私は、先に絞った方て件数を少なくしてからJOINした方が速いだろ!!と思っていました。
しかし、この2つはDB側(オプティマイザ)が自動で実行順序を最適化してくれるため、SQLの書き方を工夫しただけではパフォーマンスが改善しないことがよくあります(ヒント句などで強制的に実行計画を作れば別ですが、基本的にはそこまでしない想定です)。
では、どのようにしてDBのボトルネックを探し、対処していけばいいのか?
その具体的な手順を見ていきます。
2. 実行計画を出力する方法
- SSMS(SQL Server Management Studio)の【新しいクエリ】で検証したいクエリを作成します。
- 上のツールバーにある 【推定実行プランの表示 (Ctrl + L)】 または、【実際の実行プランを含める (Ctrl + M)】 を選択してからクエリを実行します。
- 実行するとツリーが表示されます。これは 右から左に向かって実行された順 に並んでいます。
チューニング前後の比較用に、表示されたツリーをスクリーンショットで残しておくことをお勧めします。
併せて、メッセージタブにある「経過時間」もチェックしておくと、本当に速くなったのか定量的に測れます。
時間が出力されない場合は、クエリの先頭に以下のコードを追加してください。
SET STATISTICS TIME ON; -- 実行時間を表示
SET STATISTICS IO ON; -- I/O統計を表示(超重要)
3. 実行計画の読み取り方
Point0. コストが高い所を見つける
実行計画のツリーを見ると、各処理が全体の処理コストの中で「何%」の割合を占めているかが分かります。
基本的には コストが高い(%が大きい)= ボトルネックになっている と判断して調査を進めます。
Point1. I/O統計情報を確認する
SET STATISTICS IO ON; を有効にして実行すると、メッセージタブに以下のような情報が出力されます。
テーブル 'pig'。スキャン数 1、論理読み取り数 2040、物理読み取り数 0、(以下省略)
テーブル 'dog'。スキャン数 1、論理読み取り数 456、物理読み取り数 0、(以下省略)
テーブル 'cat'。スキャン数 1、論理読み取り数 1874、物理読み取り数 0、(以下省略)
SQL Server 実行時間:
CPU 時間 = 94 ミリ秒、経過時間 = 90 ミリ秒。
ここでは以下の3つの情報を読み取ります。
- 論理読み取り(Logical Reads):メモリ上のデータページを何ページ読み取ったか。この数値が大きいテーブルがボトルネックの可能性大です。
- 物理読み取り(Physical Reads):0であれば、データが全てメモリに載っているためディスクI/Oは発生していません。それでも処理時間が掛かっている場合は、処理ロジック自体が重い(Index ScanやHash Joinなど)と判断できます。
- 経過時間:純粋なクエリの実行時間です。
これらから、どのテーブルの読み取りに負荷がかかっているかを特定します。
Point2. テーブル読み取り方法の確認
実行計画のアイコンを見て、テーブルの読み取りが Index Seek になっているか確認します。
- Index Seek:インデックスを利用して、ピンポイントで必要なデータだけを読み取っている理想的な状態です。
- Index Scan / Table Scan:データを全件スキャンして読み込んでいる状態です。
WHERE句で条件を指定しているにも関わらず、Index ScanやTable Scanになっている場合は、全件読み込みながら対象を探しているためコストが高くなります。
Point3. JOINの種類(Nested Loop vs Hash Join)
JOINの結合アルゴリズムには、主に Nested Loop, Hash Join, Merge Join があります。
例えば、1回目の結合時にWHERE句で10件程度に絞り込まれたデータを、50万行のテーブルと結合する場合は Nested Loop が適しています。
逆に、数十万件同士の大きなデータを結合する場合は Hash Join が選ばれることが多いです。これはロジックの違いによるものです。
Nested Loop と Hash Join の違い
ざっくりとイメージで表現すると以下のようになります。
- Nested Loop:件数が多いテーブル(50万行)のインデックスを使って、絞った10件を1件ずつ探しにいく。
- Hash Join:行数が少ない方のテーブル(Build側)を一度全部読み込んで「ハッシュテーブル(辞書)」を作った後、もう片方のテーブル(Probe側)を上から一行ずつハッシュ化しながら辞書と突き合わせていく。インデックスは使わない。
【疑問】データ量が多くても、結局全件探すという過程は一緒なら、インデックスを使ってNested Loopで探した方が速いのでは?
私は学習中、ここで一番引っかかりました。この違いの本質は 「データへのアクセスパターン」 にあります。
- Nested Loop:インデックスを利用するため ランダムI/O が発生します。つまり、インデックスがあるので目的地は分かっていますが、目的地が(A → Z → F ・・・)のようにばらばらになるので、データを読み込むまでに待ち時間が発生します。
- Hash Join:テーブルを上から順番に読み込むため シーケンシャルI/O になります。また、一度ハッシュ辞書を作ってしまえば、メモリ上での高速な突き合わせ処理だけで済みます。
つまり、件数が少ないうちは「あちこち取りに行く(Nested Loop)」方が速いですが、件数が多くなると移動のロス(ランダムI/Oのコスト)が膨大になるため、「最初に上から順番に読んで辞書を作る(Hash Join)」方が結果的に速くなる現象が起きます。
4.ボトルネックの特定と改善アプローチ
実行計画から以下のポイントをチェックし、改善策を検討します。
1. Index Scanが使われている場合
本来WHERE句で絞り込めるはずなのに全件読み込み(Scan)されている場合は、適切なインデックスが作成されていない可能性があります。
→ 検索条件やJOINの結合キーになっているカラムにインデックスの作成を検討します。
2. JOINの方式に違和感がある場合
実行計画上で、明らかに件数が数件に絞り込まれているのに Hash Join が使われている場合などは、「インデックスが存在しない」、または「統計情報がおかしくてオプティマイザが件数を誤認している」可能性があります。
実行計画のノードにマウスカーソルを合わせると詳細が表示されます。
【実際の行数(実際に読み取られた行数)】 と 【推定行数(すべての実行の予測行数)】 を比較してください。
ここが大きく乖離している場合(例:予測は100万件なのに実際は10件だった)、オプティマイザの予測ミスです。これは 統計情報が無い、または古い ことが原因で発生します。
💡 Tips: SSMSを使用している場合、実際の実行プランの空いているところを右クリックし、「実際の実行プランの分析」を押すと、予実対比での分析を自動で行ってくれます。
5.統計情報の確認と更新方法
予測行数と実際の行数にズレがあった場合は、統計情報を確認します。
5-1. 統計情報一覧の取得
SELECT *
FROM sys.stats
WHERE object_id = OBJECT_ID('対象のテーブル名');
取得できる主な情報:
① インデックスに紐づく統計:インデックスを作成すると自動作成されます。
② 自動生成統計(_WA_Sys_00000003_XXXXXXなど):AUTO_CREATE_STATISTICS = ON の場合、WHERE句などで検索されたカラムにインデックスがないと、SQL Serverが自動で統計情報だけを作成してくれます。
5-2. 統計情報の詳細確認
5-1で調べた統計情報の名前(name列の値)を使って詳細を確認します。
DBCC SHOW_STATISTICS ('対象のテーブル名', '5-1で調べた統計情報のname');
5-3. 見るべきポイント
出力結果は3つの表に分かれていますが、特に重要なのは以下の2つです。
A. Header情報
Rows(全行数)と Rows Sampled(サンプリングされた行数)、そして Updated(最終更新日時)を確認します。ここが古いと、推定ミスの原因になります。
B. Histogram(ヒストグラム)
データの分布状況(どの値が何件くらいあるか)を示しています。オプティマイザはこのヒストグラムを見て「この条件なら約〇〇件ヒットするはず」と予測を立てています。
4. 統計情報の更新
情報が古い、またはデータが大きく変動した場合は、手動で統計情報を更新します。
-- 対象テーブルのすべての統計情報をフルスキャンで更新
UPDATE STATISTICS 対象のテーブル名 WITH FULLSCAN;
まとめ
SQL Serverのパフォーマンス改善は、クエリの書き方を試行錯誤する前に、まずは 「実行計画とI/O統計を見て事実を確認する」 ことが最短ルートです。
-
SET STATISTICS IO ON;でボトルネックのテーブル(論理読み取りが多いテーブル)を特定する。 - 実行プランで、Scanになっている箇所や、予実(推定行数と実際の行数)がズレている箇所を見つける。
- 必要に応じてインデックスの作成や、統計情報の更新(
UPDATE STATISTICS)を行う。
この手順を踏むことで、闇雲なチューニングから脱却できるようになります!
併せて、そもそものDB設計が終わっているとどうにもならなかったりするので、DB設計がうまくいってるか?という所の確認も必要だと思います。
DB設計についてもまとめているので、良ければ参考にしてください。
蛇足
基本的にはよくない方針だが、あまりにもパフォーマンス改善しない場合は、疑似マテリアライズドビューを作成して、検索の度にJOIN処理を走らせないのも手ではある。
ただ、これは非正規化になるので、最終奥義ぐらいの気持ち。
DBってデータを保存するだけの場所という認識やったけど、知れば知るほど奥深いし難しいしおもしろすぎる・・・