インデックスは書き込みにいくら払っているか — INSERT/UPDATE/DELETEのコスト構造

「インデックスを増やせば検索は速くなるが、書き込みは遅くなる」とよく言われますが、実際にどれくらい遅くなるのか、INSERT/UPDATE/DELETE それぞれで何が起きているのかを把握しないと、トレードオフ判断ができません。

本記事ではインデックスの書き込みコストを DML 別に整理します(Use The Index, Luke! 第8章 より)。

インデックスは冗長データという原則

インデックスは「テーブルのデータを別の順序でも引けるようにする」ための 冗長なデータ です。テーブル本体に何かを書き込むたびに、インデックス側でも一貫性を保つ処理が走ります。

具体的には、リーフノードへのエントリ追加/削除と、それに伴うノード分割や再バランス、ブランチノードの更新が発生します(B-tree内部構造 を参照)。

インデックスが多いほど書き込み時のオーバーヘッドが積み上がるため、「とりあえずインデックスを増やす」はアンチパターンです(インデックスショットガン3アンチパターン)。

INSERT

INSERT は WHERE 句を持たない唯一の DML で、インデックスから直接の利益を受けません。一方コストは最も大きくなります。

何が起きるか

  1. テーブル本体への行追加(空きブロックに書く軽い処理)
  2. インデックスごとに以下を実行:
    • 適切なリーフノードを B-tree 走査で探す
    • エントリを挿入
    • 必要ならノード分割
    • 上位ブランチノードの更新

つまり、インデックスを 5 個張ったテーブルに 1 行 INSERT すると、内部では 1 + 5 = 6 箇所への書き込みが発生します。

コストのスケール感

Use The Index, Luke! 第8章のグラフでは、インデックスゼロの状態の挿入が約 0.0003 秒、インデックス 1 個で約 100 倍、その後インデックスを増やすたびにさらに伸びる、という測定結果が示されています。

1 つインデックスを追加するだけで挿入コストは劇的に変わる

ことを意識する必要があります。

バルクロード時の対処

データウェアハウスやバッチ処理での大量 INSERT では、一時的に全インデックスを落としてからロード後に再構築する のが正しい運用です。

-- ロード前: インデックスを削除(または無効化)
DROP INDEX idx_xxx ON sales;
DROP INDEX idx_yyy ON sales;

-- 大量 INSERT
LOAD DATA INFILE 'sales.csv' INTO TABLE sales ...;

-- ロード後: インデックスを再作成
CREATE INDEX idx_xxx ON sales(...);
CREATE INDEX idx_yyy ON sales(...);

行ごとにインデックスを更新するより、最後にまとめてソート+ビルドする方が桁違いに速いためです。

DELETE

DELETE は WHERE 句を持つので「行を見つける部分」では検索のロジックが効きます。WHERE 列にインデックスがあれば、対象行の特定は INDEX RANGE SCAN で高速に終わります。

何が起きるか

  1. WHERE 句で対象行を特定
  2. テーブル本体から行を物理削除
  3. 対象行に紐づく全インデックスのエントリを削除
  4. ノード結合や再バランスが必要なら実行

実際の削除処理は INSERT に近く、ツリーバランス維持のためインデックス数に比例して遅くなります

TRUNCATE TABLE を使うべきケース

WHERE 句なしで全行削除するなら TRUNCATE TABLE を使うのが定石です。DELETE と違って各行を個別に処理せず、テーブル領域ごと初期化するため桁違いに高速です。

-- 遅い: 1行ずつ削除+インデックス更新
DELETE FROM sales;

-- 速い: テーブル領域を初期化
TRUNCATE TABLE sales;

ただし副作用に注意:

  • 暗黙コミット が発生(ロールバック不可)
  • トリガが動かない ため、削除トリガに依存する処理がある場合は使えない
  • 外部キーで参照されている場合は MySQL では実行できない

PostgreSQL の MVCC は別物

PostgreSQL の MVCC では DELETE はテーブルブロックに「削除済みフラグ」を立てるだけで、インデックス数に直接影響されません。物理削除とインデックスメンテは VACUUM の仕事です。

ただし、VACUUM が走るまではインデックスのリーフにも古いエントリが残るため、長期的なオーバーヘッドはやはりインデックス数に比例します。

UPDATE

UPDATE は 対象列を含むインデックスだけが更新されます

何が起きるか

UPDATE は「古いエントリを削除して新しい場所に入れる」動作です。対象列の値が変わると、そのインデックス上での位置も変わるため、リーフノードから古いエントリを削除し、新しい位置にエントリを挿入し直します。

つまり、name 列だけを UPDATE しても、name を含むインデックス(idx_nameidx_name_age 等)だけが更新され、age 単独のインデックスは触られません。

ORM の「全列 UPDATE」問題

問題は ORM が 「変更のなかった列も含めて全列 UPDATE」 を発行するケースです。たとえば 5 列のテーブルで name だけを変えたつもりでも、ORM が

UPDATE users SET id = ?, name = ?, age = ?, email = ?, created_at = ? WHERE id = ?;

のような SQL を発行すると、5 列すべてに紐づくインデックスが更新対象 になります。本来 1 つのインデックスだけ触れば済む処理が、5 つのインデックスを舐めることになります。

各 ORM の挙動:

ORMデフォルト挙動
Hibernate全列 UPDATE。@DynamicUpdate で変更カラムのみに切り替え可能
Active Record(Rails)変更カラムのみ UPDATE(partial_writes 設定。デフォルト ON)
Doctrine(PHP)デフォルトは全列 UPDATE。changeset 検出はあり
TypeORM@UpdateDateColumn などで部分更新サポート

ORM 利用時は SQL ロギングを開発時に有効化して 生成 SQL を確認する のが推奨運用です。

まとめ

  • インデックスは冗長データであり、書き込みのたびに一貫性を保つコストを払う
  • INSERT は WHERE がないため利益ゼロ。インデックス 1 個で挿入コストが約 100 倍に跳ね上がるケースも
  • バルクロード時は 一時的にインデックスを落とす のが定石
  • DELETE は対象行特定にインデックスが効くが、削除処理自体はインデックス数に比例
  • 全削除は TRUNCATE TABLE(暗黙コミット・トリガ不発に注意)
  • UPDATE は対象列を含むインデックスだけが更新される
  • ORM の「全列 UPDATE」は無関係なインデックスまで触る。SQL ロギングで確認

参考

タグ: MySQL , PostgreSQL , パフォーマンスチューニング