はじめに
結果として、SQLのような関係型の言語は非手続き型の言語と呼ばれるが、これはユーザは「いかに」ではなく「何を」を指定する―つまり、獲得するための手続きを指定することなしにユーザは何が欲しいかをいう―からである。
── Christpher J. Date[1]
Webの入力フォームであれ、コマンドラインツールであれ、インタフェースにかかわらず、リレーショナルデータベースに対する操作はSQLという専用の言語で行われます。ユーザ(プログラマ含む)が意識的にコーディングするのは通常このSQLレベルまでで、あとはDBMSが処理を終え、結果を返却するのを待つのみです。SQLとRDBMSにおいては、ユーザはデータのありかを知る必要もなければ、そこへのアクセス方法も考えません。そういう仕事は、全部DBMSに任せています。
このプロセスは、通常「プログラミング」と呼ばれるものとはかなり異なります。普通、データの検索や更新をプログラミング言語によって行う場合、どこにあるデータをどのように探すか、ということを細部に渡って記述しなければなりません。
手続き型と非手続き型の違い
この態度の違いは、良いとか悪いというものではなく、ソフトウェアの設計思想の違いです。C言語、JavaからRubyに至るまで、手続き型を基礎とする言語とシステムにおいては、ユーザがデータアクセスのための手段(How)を責任持って記述することが前提です。他方、非手続き型であるリレーショナルデータベースはその仕事をユーザからシステム側に移管しました。その結果、ユーザのすることは対象(What)の記述だけに限定されたのです。
権限委譲の功罪
リレーショナルデータベースが、このような大胆な権限委譲を断行したことには、もちろん正当な理由があります。「そのほうがビジネス全体の生産性は上がるから」です。現在の状況を眺めると、この言葉は半面正しく、半面間違っていました。正しかったことは、RDBMSがシステムの世界の隅々にまで浸透したことからわかります。間違っていることは、それでもやっぱり私たちはRDBMSを扱うのに苦労しているからです。SQLは思ったほど簡単な言語じゃなかったし、パフォーマンスが悪いこともよくあります。
本稿では、こうしたRDBMSが抱える諸刃の剣とも言うべきパフォーマンスの部分について、いつもどおりDBMSの内部動作を明らかにしながら、その対処方法を探っていきます。
- 対象読者
- RDBMSのパフォーマンス問題に悩まされたことのある人
- 「実行計画」という言葉を聞いたことがあるが、実際に見たことはない人
- 「実行計画」を見たことはあるが、どう見ればよいか理解できなかった人
- 対象環境
データへのアクセス方法はこうやって決まる
RDBMSにおいて、データアクセスの手続きを決めるモジュールがクエリ評価エンジンです。これは、ユーザから送信されたSQL文を最初に受け取るモジュールでもあります[2]。クエリ評価エンジンは、さらに複数のサブモジュールから構成されています。
クエリが処理される流れ
ここでクエリがどのように処理されて、実際にデータアクセスが実行されるのかをおおまかに図示すると、図1のようになります。
パーサ
①パーサ(parser)の役割は、名前のとおり構文解析です。つまり受け取ったSQL文(クエリ)を一度バラバラの要素に分解し、それをDBMSが処理しやすい形式に変換することです。
なぜこの処理が最初に必要かといえば、第一に、受け取ったSQL文が常に構文的に適正である保証がないため、整合性チェックが必要だからです。ユーザがカンマを書き忘れたり、FROM句に存在しないテーブル名を書いたりしてきたときには、「書類審査」で落第させる必要があります。
第二の理由は、SQL文を定型的な形式に変換することで、DBMS内部での後続の処理が効率化されるからです。構文解析は、SQLに限らず一般のプログラミング言語のコンパイル時にも同様に実行されるものです。
オプティマイザ
書類審査をパスしたクエリは、次にオプティマイザ(optimizer)に送られます。オプティマイズの和訳に最適化という語が当てられているとおり、ここで「最適」なデータアクセスの方法(実行計画)が決定されます。この処理がDBMSの頭脳におけるコアです。
オプティマイザは、複数のアクセス経路、インデックスの有無、データの分散や偏りの度合い、DBMSの内部パラメータなどの条件を考慮して、選択可能な多くの実行計画を作成し(②プラン生成)、それらのコストを計算して(③コスト評価)、最も低コストな一つに絞り込みます。
カタログマネージャ
そのとき、重要な情報を提供するのが④カタログマネージャです。カタログとはDBMSの内部情報を集めたテーブル群で、テーブルやインデックスの統計情報が格納されています。そのため、このカタログの情報を単に「統計情報」とも呼びます。本稿でもこの呼び名を使います。
RDBMSがデータアクセスの手続き決定を自動化している理由は、候補の数が多いうえに、それら個々のプランについてしらみつぶしにコスト計算をして、互いを比較考慮しなければならないためです。このような計算はコンピュータにより高速に処理可能ですので、オプティマイザに任せたほうが得です。
プラン評価
オプティマイザが1つの実行計画に絞り込んだら、それを受け取って実行するのが、⑤プラン評価の処理です。実行計画というのは、あとで実際にいくつかのサンプルを見ていきますが、まだそのままDBMSが実行できるようなコードにはなっていません。むしろ人間が目で読むことができる、本当の「計画書」です。そのため、これを評価して手続き型のコードへと変換することが必要です。
オプティマイザとうまく付き合う
以上が、DBMSがクエリを受け取ってから実際のデータアクセスを行うまでの流れです。オプティマイザ内部の処理については、このエンジンそのものを実装するエンジニア以外には関係しないため、本稿では踏み込みません。むしろ、データベースのユーザとしては、このオプティマイザをうまく使ってやることのほうが大事です。というのも、オプティマイザは放っておけば万事よろしくやってくれるほど万能ではないからです。特に、④カタログマネージャが管理する統計情報については、DBエンジニアは常に神経を使う必要があります。
適切なプランが選ばれない場合
プラン選択をオプティマイザ任せにしている場合、現実には最適なプランが選ばれないことが多々あります。代表的なパターンはいくつかありますが、中でも最も初歩的かつありがちなのが、統計情報が間違っているケースです。
実装によって差はありますが、カタログに含まれている統計情報は次のようなものです。
- 各テーブルのレコード数
- 各テーブルの列数と列のサイズ
- 列値のカーディナリティ(値の個数)
- 列値のデータ分布(どの値がいくつあるかのヒストグラム)
- 列内のNULLの数
- インデックス情報
これらの情報を入力として、オプティマイザはプランを作ります。問題が起こるのは、このカタログ情報がテーブルやインデックスの実体と一致しない場合です。テーブルに対してデータの挿入/更新/削除が行われたのに、カタログ情報が最新化されていないと、オプティマイザは古い情報をもとに実行計画を作ろうとします。手元にそれしか情報がないのだから当たり前です[3]。
たとえば極端な例ですが、テーブルを作ったばかりのレコード0件の状態でカタログ情報が保存され、その後レコードを1億件ロードしたのにカタログ情報を更新しなかった場合、オプティマイザはデータ0件を前提してプラン生成をしようとします。これでは最適なプランにはなり得ません。「Garbage In,Garbage Out」(ゴミのような入力からはゴミのような結果しか生まれない)というやつです。それでクエリが遅かったからといって、オプティマイザのせいにするのは酷です。
適切なプランが作成されるようにするには
このため、テーブルのデータが大きく更新されたら、カタログの統計情報もセットで更新することは、DBエンジニアの間では不文律のルールになっています。マニュアルで更新するだけでなく、夜間バッチに組み込む運用も多いですし、Oracleのようにデフォルト設定で定期的に統計情報更新のジョブが動くDBMSもあります。
この統計情報の更新は、対象のテーブルやインデックスのサイズと数によっては数十分~数時間を要する場合もあり、実行コストの高い面倒な作業ではありますが、DBMSが適切なプランを選択するための必要条件ですので、手を抜かずに最新化する必要があります。
代表的なDBMSの統計情報更新コマンドを一覧として掲載します(表1)。ここに掲載するのは基本構文のみで、オプションのパラメータによって、テーブル単位ではなくスキーマ全体で取得したり、サンプリングレートを指定したり、テーブルに付与されているインデックスの統計情報もあわせて取得したりなど、さまざまな制御が可能です。詳細は各DBMSのマニュアルを参照してください。
表1 代表的なDBMSの統計情報更新コマンド
DBMS | 統計情報収集コマンド[1] |
Oracle | exec DBMS_STATS.GATHER_TABLE_STATS(OWNNAME => <スキーマ名>, TABNAME =><テーブル名>); |
SQL Server | UPDATE STATISTICS<テーブル名> |
DB2 | RUNSTATS ON TABLE <スキーマ名>.<テーブル名>; |
PostgreSQL | ANALYZE <スキーマ名>.<テーブル名>; |
MySQL | ANALYZE TABLE <スキーマ名>.<テーブル名>; |
※1 複数行になっているコマンドも実際は1行