NGW開発BLOG
← 記事一覧へ
·#022

複合索引ひとつで決裁一覧が変わる全表スキャンを畳む

職員ごとの決裁状況一覧が本番規模で遅くなった。複合索引を1本、列順を正しく張るだけで全表スキャンとfilesortが消えた話。

一覧が遅い、と言われて

職員ごとの決裁状況を一覧するクエリが遅い、という報告が来た。条件はごく素直なものだ。「担当者が自分(あるいは自分の配下)である」かつ「申請日がこの期間内」——つまり staff_cd IN (...)sinsei_date BETWEEN ? AND ? の組み合わせである。

奇妙だったのは、同じ画面で走る周辺のサブクエリはどれも軽かったことだ。マスタ参照も、件数カウントも、必要な列に索引が張ってあってミリ秒で返る。重いのはこの一覧の本体クエリ、それ一本だけだった。

EXPLAIN を取ると答えは明快だった。type=ALL。全表スキャンである。さらに ExtraUsing filesort。ワークフローのテーブルには主キー(id)以外の索引が一切なく、検索条件に使う staff_cd にも sinsei_date にも索引がなかった。だから十数万行規模のテーブルを毎回頭から舐め、条件で絞り、最後に並べ替える——これを一覧を開くたびに繰り返していた。

列順が命

対策は索引を1本足すだけだ。ただし「どの列に」ではなく「どの列を、どの順で」が肝になる。

CREATE INDEX ix_workflow_staff_sinsei
  ON workflow (staff_cd, sinsei_date);

なぜ staff_cd が先で sinsei_date が後なのか。B-Tree索引は左の列から順に並んでいる。今回の条件は staff_cd が等値(IN は複数の等値の束だ)、sinsei_date が範囲(BETWEEN)である。

複合索引で範囲条件を使うと、その列から先は索引としての絞り込みが効かなくなる。だから 等値の列を先に、範囲の列を後に 並べるのが定石だ。先頭で staff_cd を等値で確定させると、索引上ではその担当者の行が sinsei_date 順に固まって並ぶ。あとはその区間を範囲でスパッと切り出せる。

もし順を逆にして (sinsei_date, staff_cd) としたらどうなるか。先頭の sinsei_date を範囲で使った時点で、続く staff_cd は索引順の恩恵を受けられず、区間内を拾い直すことになる。同じ2列でも列順を誤ると効き目は大きく削がれる。

張り直したあとの EXPLAIN は別物だった。type=ALLrange(または ref)に変わり、Using filesort も消えた。索引が既に sinsei_date 順に並んでいるので、並べ替えそのものが不要になったからだ。全表スキャンとfilesortを一手で畳んだことになる。

本番規模でしか見えない重さ

ここで気になるのは、なぜ今まで問題にならなかったのか、という点だ。

答えは単純で、この重さは最初から潜んでいた。全表スキャンは行数が少ないうちは一瞬で終わる。開発用の数百行なら索引が無くても誰も気づかない。データが本番規模に育って初めて、線形の重さが体感できる遅さとして表面化したのだ。性能問題の多くはこういう顔をしている——バグのように壊れているのではなく、規模が閾値を越えた瞬間に姿を現す。だからこそ、本番相当のデータ量で EXPLAIN を読む習慣が要る。

索引の追加はオンラインDDLで無停止に張れる。

CREATE INDEX ix_workflow_staff_sinsei
  ON workflow (staff_cd, sinsei_date)
  ALGORITHM=INPLACE, LOCK=NONE;

InnoDBならテーブルを書き込みロックせずに索引を構築できるので、サービスを止めずに適用できる。効かなければ DROP INDEX で即座に切り戻せるのも、索引という対策の気楽なところだ。

ひとつだけ運用上の宿題が残る。この変更はコード側のマイグレーションに残らない、手作業のDB変更になった。同じ構成のDBが複数あると、張り忘れや二重適用が起きやすい。そこで適用スクリプトは冪等にしておく。information_schema.statistics に同名の索引が既に存在するかを確認し、無ければ張る、というガードを一枚かませておけば、何度流しても安全だ。

AIとの協働作業での学び

得たものは三つだ。

  1. 複合索引は列順が命——等値の列を先に、範囲の列を後に。
  2. 性能問題は本番規模のデータでしか見えないから、EXPLAIN を読む習慣を持つこと。
  3. オンラインDDLを知っていれば、索引の追加は無停止で試せる安価な一手になる。

type=ALL を見つけたら、まず条件に合った複合索引を疑ってみるといい。