カバリングインデックスによるMySQLクエリ最適化:テーブルルックアップを完全に排除する極意

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

データ件数が1,000万行を超えたときに直面するパフォーマンスショック

MySQLを扱い始めたばかりの頃、多くのエンジニアは「WHERE句で遅いカラムがあれば、とりあえずそこへインデックスを貼る」という運用をしがちです。データが数万件程度であれば、この方法でも軽快に動作します。しかし、現実はそう甘くありません。

プロジェクト内のusersテーブルが1,200万行に達したとき、システムは悲鳴を上げ始めました。ユーザープロファイルを取得する一連のクエリでレイテンシが跳ね上がり、従来の20msから一気に1.8秒へと悪化。不可解なことに、EXPLAINを確認してもセカンダリインデックスは正しく使われていました。ピーク時にはデータベースのCPU使用率が92%に張り付き、ディスクI/Oアラートが点滅し続ける状態に陥ったのです。

インデックスで絞り込んでいるにもかかわらず、なぜシステムはボトルネックに陥るのでしょうか? その主犯こそが、テーブルルックアップ(またはクラスタ化インデックスルックアップ)と呼ばれる処理です。このメカニズムが、クエリ実行のたびにディスクI/Oリソースを水面下で浪費していたのです。

テーブルルックアップの本質とカバリングインデックスの原理

B-Treeの仕組みとランダムI/Oの高コスト性

根本原因を突き止めるには、InnoDBにおけるB-Tree構造を理解する必要があります。

  • クラスタ化インデックス(Clustered Index): プライマリキーと直結しています。リーフノードには各行の実データ(行データ全体)が格納されています。
  • セカンダリインデックス(Secondary Index): セカンダリインデックスのリーフノードには、行全体のデータは保持されません。インデックス対象カラムの値と、対応するプライマリキーのみが格納されます。

よくある以下のSQLクエリを例に考えてみましょう。

SELECT id, username, email, created_at 
FROM users 
WHERE status = 'active' 
ORDER BY created_at DESC 
LIMIT 20;

ここで、テーブルにはstatusカラム単体のインデックスしか存在しないと仮定します。処理の流れは次のようになります。

  1. MySQLはstatusインデックスのツリーを走査し、'active'に合致するレコードを抽出します。
  2. 各リーフノードから、(status, id)のペアを取得します。
  3. クエリはさらにusername、email、created_atを要求しています。しかしセカンダリインデックスにはこれらのフィールドが含まれていないため、MySQLは取得した各idをもとにクラスタ化インデックスのツリーへジャンプし、行全体のデータを掘り起こす必要があります。この行データの再取得アクションこそがテーブルルックアップです。

もし絞り込み条件に50,000行がヒットした場合、MySQLはディスクに対してきっかり50,000回のランダムアクセス(ランダムI/O)を発生させます。これがデータベースのスループットを急激に低下させるボトルネックとなるのです。

カバリングインデックスはどのように問題を解決するのか?

カバリングインデックス(Covering Index)は特殊な専用構文ではなく、インデックス設計のスマートなアプローチです。クエリ内で参照されるすべてのカラム(SELECT、WHERE、JOINからORDER BY、GROUP BYに至るまで)が、そのインデックス内だけで完全に満たされている状態を指します。

これにより、MySQLはセカンダリインデックスのバッファプール(Buffer Pool)から直接データを読み取ってクライアントへ返すことが可能になります。クラスタ化インデックスへのテーブルルックアップが100%不要となり、ディスクI/Oはほぼゼロにまで削減されます。

実践的な最適化:EXPLAINによる検証

以下は、ステージング環境の1,000万行テーブルで実施した最適化の実例です。

ステップ1:サンプルテーブルの定義

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at DATETIME NOT NULL,
    bio TEXT,
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

ステップ2:カバリングインデックス導入前の現状分析

実行計画を確認します。

EXPLAIN SELECT id, username, email 
FROM users 
WHERE status = 'active';

出力結果:

+----+-------------+-------+------------+------+---------------+------------+---------+-------+--------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key        | key_len | ref   | rows   | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------------+---------+-------+--------+----------+-------------+
|  1 | SIMPLE      | users | NULL       | ref  | idx_status    | idx_status | 82      | const | 450000 |   100.00 | NULL        |
+----+-------------+-------+------------+------+---------------+------------+---------+-------+--------+----------+-------------+

ExtraカラムがNULLになっています。これはidx_statusで450,000件のレコードを走査した後、usernameとemailを取得するためにテーブル本体へ同等の回数だけルックアップしていることを意味します。SHOW PROFILEで実測したところ、クエリ完了までに1.45秒を要していました。

ステップ3:複合インデックス(Composite Index)の追加

参照が必要なカラムをひとつの複合インデックスにまとめます。

ALTER TABLE users ADD INDEX idx_status_user_email (status, username, email);

Tips: InnoDBのセカンダリインデックスのリーフノードには、すでにプライマリキーのidが暗黙的に含まれています。そのため、インデックス定義にわざわざidを追加して容量を消費する必要はありません。

ステップ4:結果の検証

インデックス作成後、再度EXPLAINを実行します。

+----+-------------+-------+------------+------+-----------------------+-----------------------+---------+-------+--------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys         | key                   | key_len | ref   | rows   | filtered | Extra       |
+----+-------------+-------+------------+------+-----------------------+-----------------------+---------+-------+--------+----------+-------------+
|  1 | SIMPLE      | users | NULL       | ref  | idx_status_user_email | idx_status_user_email | 82      | const | 450000 |   100.00 | Using index |
+----+-------------+-------+------------+------+-----------------------+-----------------------+---------+-------+--------+----------+-------------+

注目すべきは、Extraカラムに現れたUsing indexの文字です。MySQLは本体テーブルに触れることなく、B-Treeインデックスから直接データを読み取っています。実際のレスポンスタイムは、1.45秒からわずか2.8msへと劇的に短縮されました。

応用編:ディファード結合(Deferred Join)による深いページネーションの改善

大規模テーブルにおいて、深いOFFSETを持つページネーションも典型的な課題です。後ろのページへ進むほど、クエリは顕著に重くなります。

-- 完了までに約4.2秒を要する
SELECT id, username, email, bio 
FROM users 
WHERE status = 'active' 
ORDER BY id 
LIMIT 500000, 20;

bioカラムはTEXT型のため、インデックスに含めることは現実的ではありません。そこで、カバリングインデックスとディファード結合(遅延結合 / Deferred Join)を組み合わせたテクニックを活用します。

SELECT u.id, u.username, u.email, u.bio
FROM users u
INNER JOIN (
    -- このサブクエリは100%カバリングインデックスで処理され、極めて高速に動作する
    SELECT id 
    FROM users 
    WHERE status = 'active' 
    ORDER BY id 
    LIMIT 500000, 20
) AS tmp ON u.id = tmp.id;

決定的な違いはディスクアクセスの回数です。元のクエリは500,020回のルックアップを行った後に最初の500,000行を破棄していました。一方、新しいクエリはインデックス走査で500,000行を素早くスキップし、実際に描画が必要な20件のみテーブルルックアップを実行します。結果として、実行時間は4.2秒から115msへと改善しました。

トレードオフ:避けるべきケースとは?

カバリングインデックスは読み取り速度を飛躍的に高めますが、どんな技術にもトレードオフが存在します。

  • バッファプールのRAM圧迫: インデックスサイズが肥大化すると、キャッシュとして利用可能な実メモリが圧迫されます。単にSELECT *をカバーしたいがために、インデックスをテーブル全体の複製にしてしまわないよう注意が必要です。
  • 書き込み負荷の増加(Write Penalty): INSERT、UPDATE、DELETEが走るたびにB-Treeの再構成が発生します。秒間5,000件以上の書き込みが発生するような高頻度の書き込み環境では、インデックスの過剰な配置がトランザクション待ちを引き起こします。
  • 最左プレフィックスルール(Leftmost Prefix Rule): インデックスの先頭には完全一致条件(=)のカラムを配置し、次に範囲検索(BETWEEN、>、<)カラム、最後にSELECTでのみ使用するカラムを並べるのが基本設計原則です。
Share: