MySQL で複合インデックスを作成する際には必ず Explain の key_len を確認すべきという話

「Rails Developers Meetup 2018 で「MySQL/InnoDB の裏側」を発表しました」でちゃんと触れられてないので今更ながら key_len について補足します。発表で触れた内容については言及しないので、storage engine や B+ tree といった用語がよくわからない方は発表内容を参照してください。
なお、MySQL のバージョンは 5.7.38 です。

mysql> SELECT @@version;
+-----------+
| @@version |
+-----------+
| 5.7.38    |
+-----------+
1 row in set (0.00 sec)

事前準備

sample-data-railsdm-2018 の orders テーブルを少しいじって、キャンセル時刻(canceled_at)、配送予定時刻(delivered_at)カラムを追加してみます。また、同じ商品に対する注文の数を増やしてみます。
具体的には次のような変更を加えます。

diff --git a/Rakefile b/Rakefile
index 6d1d89d..01df6dd 100644
--- a/Rakefile
+++ b/Rakefile
@@ -35,8 +35,8 @@ namespace :db do
 
     user_count    = 100
     shop_count    = 10
-    product_count = 5_000
-    order_count   = 10_000
+    product_count = 10
+    order_count   = 200_000
 
     prng = Random.new(42)
     Faker::Config.random = prng
@@ -71,11 +71,13 @@ namespace :db do
       product_id = prng.rand(product_count) + 1
       count = (prng.rand(5) + 1)
       product = id_to_product[product_id]
+      created_at = Faker::Time.between_dates(from: product.started_at, to: product.ended_at)
       orders << {
         user_id: user_id,
         product_id: product_id,
         count: count,
-        created_at: Faker::Time.between_dates(from: product.started_at, to: product.ended_at),
+        created_at: created_at,
+        delivered_at: created_at + 86400 * (prng.rand(7) + 1),
       }
       if orders.size == 1_000
         Order.insert_all!(orders)
diff --git a/Schemafile b/Schemafile
index b40e65f..1327c6d 100644
--- a/Schemafile
+++ b/Schemafile
@@ -19,9 +19,12 @@ add_foreign_key 'products', 'shops',           name: 'fk_products_shops'
 
 create_table 'orders', unsigned: true, force: :cascade do |t|
   t.references 'user',       unsigned: true, null: false
-  t.references 'product',    unsigned: true, null: false
+  t.references 'product',    unsigned: true, null: false, index: false
   t.integer    'count',      unsigned: true, null: false
-  t.datetime   'created_at', precision: 0,   null: false
+  t.datetime   'created_at',   precision: 0, null: false
+  t.datetime   'canceled_at',  precision: 0
+  t.datetime   'delivered_at', precision: 0, null: false
 end
+add_index 'orders', ['product_id', 'canceled_at', 'delivered_at'], name: 'ix_product_id_canceled_at_delivered_at'
 add_foreign_key 'orders', 'users',    name: 'fk_orders_users'
 add_foreign_key 'orders', 'products', name: 'fk_orders_products'

最終的なテーブルの定義は次のとおりです。

CREATE TABLE `orders` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` bigint(8) unsigned NOT NULL,
  `product_id` bigint(8) unsigned NOT NULL,
  `count` int(10) unsigned NOT NULL,
  `created_at` datetime NOT NULL,
  `canceled_at` datetime DEFAULT NULL,
  `delivered_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `ix_product_id_canceled_at_delivered_at` (`product_id`,`canceled_at`,`delivered_at`),
  KEY `index_orders_on_user_id` (`user_id`),
  CONSTRAINT `fk_orders_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`),
  CONSTRAINT `fk_orders_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=200001 DEFAULT CHARSET=utf8;

key_len とは何か

key_len は explain に表示される情報の 1 項目で、storage engine が B+ tree を辿る際にインデックスの先頭何バイトを使ったかを意味します。
例えば、explain の結果が次のようになったとします。

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: ix_product_id_canceled_at_delivered_at
          key: ix_product_id_canceled_at_delivered_at
      key_len: 19
          ref: NULL
         rows: 17190
     filtered: 100.00
        Extra: Using index condition
1 row in set, 1 warning (0.00 sec)

ix_product_id_canceled_at_delivered_at は (product_id, canceled_at, delivered_at) から成る複合インデックスで、product_id は非 null な bigint なので 8 バイト、canceled_at は nullable な datetime なので 6 バイト、delivered_at は 非 null な datetime なので 5 バイトであるため、合計 19 バイトです。
つまり、上記の explain の結果から、この SQL はインデックスを構成する 3 カラム全ての情報が使われていることがわかります。もし他の SQL で key_len が 8 になったら、それは 1 カラムの情報しか使われていないことを意味します。同様に、key_len が 14 なら 2 カラムです。

なお、各型のサイズは MySQL :: MySQL 5.7 Reference Manual :: 11.7 Data Type Storage Requirements に記載されています。
nullable なカラムは通常の型のサイズに加えて 1 バイトか 2 バイト必要です。

An SQL NULL value reserves one or two bytes in the record directory. An SQL NULL value reserves zero bytes in the data part of the record if stored in a variable-length column. For a fixed-length column, the fixed length of the column is reserved in the data part of the record. Reserving fixed space for NULL values permits columns to be updated in place from NULL to non-NULL values without causing index page fragmentation.

cf. MySQL :: MySQL 5.7 Reference Manual :: 14.11 InnoDB Row Formats

key_len の例

例えば、次の商品を取得するのに必要な SQL を考えます。

  • 商品 ID が 1
  • キャンセルされていない
  • 配送予定時刻が 2018-04-01 00:00:00 以降
SELECT
  *
FROM
  orders
WHERE
  product_id = 1
  AND canceled_at IS NULL
  AND delivered_at >= '2018-04-01 00:00:00'
;

この SQL の explain 結果は次のとおりです。

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: ix_product_id_canceled_at_delivered_at
          key: ix_product_id_canceled_at_delivered_at
      key_len: 19
          ref: NULL
         rows: 17190
     filtered: 100.00
        Extra: Using index condition
1 row in set, 1 warning (0.00 sec)

これは key_len の説明で使用した explain 結果で、前述のとおり、この SQL ではインデックスを構成する 3 カラム全ての情報が使われていることがわかります。

ここで、元の SQL から canceled_at の条件を外してみます。

SELECT
  *
FROM
  orders
WHERE
  product_id = 1
  AND delivered_at >= '2018-04-01 00:00:00'
;

すると、explain の結果は次のように変わります。

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: ix_product_id_canceled_at_delivered_at
          key: ix_product_id_canceled_at_delivered_at
      key_len: 8
          ref: const
         rows: 42826
     filtered: 33.33
        Extra: Using index condition
1 row in set, 1 warning (0.01 sec)

key_len が 8 になったので、product_id の情報のみが使われていることがわかります。canceled_at が NULL のレコードも非 NULL のレコードも全て取得する必要があるので当然ですね。ICP で delivered_at の条件が使われるので、クラスタインデックスへのアクセスは減らせますが、key_len が 19 の場合に比べると B+ tree から受けられる恩恵は少ないです。
もしインデックスを最大限利用したい場合は (product_id, delivered_at) の複合インデックスを別途作成する必要があります。

今度は次のような SQL を考えます。

SELECT
  *
FROM
  orders
  INNER JOIN
    products
  ON
    orders.product_id = products.id
WHERE
  canceled_at IS NULL
  AND delivered_at <= '2017-01-01 00:00:00'
  AND shop_id = 3
;

この SQL の explain 結果は次のとおりです。

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: products
   partitions: NULL
         type: ref
possible_keys: PRIMARY,ix_shop_id_ended_at
          key: ix_shop_id_ended_at
      key_len: 8
          ref: const
         rows: 3
     filtered: 100.00
        Extra: NULL
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: ix_product_id_canceled_at_delivered_at
          key: ix_product_id_canceled_at_delivered_at
      key_len: 14
          ref: railsdm2018.products.id,const
         rows: 22179
     filtered: 33.33
        Extra: Using index condition
2 rows in set, 1 warning (0.00 sec)

まず最初に、products テーブルから shop_id = 3 にマッチするレコードを取得しており、rows の情報からマッチしたレコードは 3 レコードです。
次に、取得した product レコードごとに、orders テーブルから条件を満たすレコードを取得しています。ところが、ix_product_id_canceled_at_delivered_at の key_len は 14 であり、delivered_at の情報が使われないことがわかります。実は delivered_at <= '2017-01-01 00:00:00' の条件を満たすレコードは存在しないのですが、delivered_at の情報が使われないこともあって rows は 22179 と大きな数字になっています。

shop_id = 3 にマッチするレコードは次のとおりです。

mysql> SELECT id, name FROM products WHERE shop_id = 3;
+----+------------------------------+
| id | name                         |
+----+------------------------------+
| 10 | Eyeless in Gaza              |
|  6 | The Soldier's Art            |
|  2 | Let Us Now Praise Famous Men |
+----+------------------------------+
3 rows in set (0.00 sec)

MySQL の join は nested loop join であることから、orders テーブルからレコードを取得する処理は、上記の products.id を指定した次の SQL と等価と言えます。

SELECT
  *
FROM
  orders
WHERE
  product_id IN (10, 6, 2)
  AND canceled_at IS NULL
  AND delivered_at <= '2017-01-01 00:00:00'
;

こちらの SQL の explain 結果は次のとおりです。

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: ix_product_id_canceled_at_delivered_at
          key: ix_product_id_canceled_at_delivered_at
      key_len: 19
          ref: NULL
         rows: 3
     filtered: 100.00
        Extra: Using index condition
1 row in set, 1 warning (0.00 sec)

key_len が 19 なので、インデックスを構成する全てのカラムの情報が使われています。rows も 3 に減りました。

JOIN が絡むと key_len が 14 になってしまう理由はよくわかっていないですが、key_len を意識することの重要性がよくわかる例じゃないでしょうか。

なお、上記のようなケースでは、クライアント側で最初に shop_id = 3 にマッチする products.id を取得し、その情報を使って SQL を組み立てる必要があるんじゃないかと思います。key_len を指定するためのヒントがあれば良いんですが…

まとめ

  • key_len は storage engine が B+ tree を辿る際にインデックスの先頭何バイトを使ったかを意味する
  • 複合インデックスを作成しても、一部のカラムの情報しか使われないことがある
  • 複合インデックスが意図したとおりに使われているか explain で確認することが重要