【Laravel】JSON型のカラムにindexを生成してもindexが効かなくなった

本稿では、JSON型のカラムにindexを貼ってみたものの、Laravelで想定通りindexを使ってくれなかったケースを紹介します。

JSON型カラム

MySQLのJSON型って便利ですよね。あらかじめデータ構造を定義する必要が無いので、データ構造がまだ決まっていなかったり、頻繁に変更があったりする場合は、とりあえずJSON型にして突っ込んでおくという使い方ができますし。

ただ、なんでもかんでもJSON型のカラムに突っ込んで痛い目を見てきた身からすると、使うケースは熟考した方が良いだろうなとも思います。特にJSON型のカラムのindex周りは注意が必要です。

JSON型のカラムにindex貼ってみた

MySQLでJSON型のカラムにindexを付けるには、Generated Columnを使うことが多いようです。Laravelのmigrationでは、virtualAs を使ってGenerated Columnを利用することができます。

以下は post_meta カラムはJSON型で、slug という属性に対してindexを貼りたいときのmigrationの例です。post_meta_slug をGenerated Columnで追加し、そこにindexを追加しています。

public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->json('post_meta');
        $table->string('post_meta_slug')->nullable()->virtualAs('JSON_UNQUOTE(post_meta->"$.slug")');
        $table->index('post_meta_slug');
    });
}

CREATE TABLEを見てみると、Generated Columnが生成され、indexが追加されていることが確認できます。

mysql> show create table posts;
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                                                                                                                                                                                            |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| posts | CREATE TABLE `posts` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `post_meta` json NOT NULL,
  `post_meta_slug` varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (json_unquote(json_extract(`post_meta`,_utf8mb4'$.slug'))) VIRTUAL,
  PRIMARY KEY (`id`),
  KEY `posts_post_meta_slug_index` (`post_meta_slug`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

実際にテストデータを入れてindexが効いているかどうかの確認のため、実行計画を見てみます。
まずはデータを用意します。

INSERT INTO posts (post_meta) values
('{"slug":"6nwolmsp8y", "title": "AWS-CDKWordPress"}'),
('{"slug":"4yeiux7wqq", "title": "WPScanxmlrpc"}'),
('{"slug":"3yjela8tgv", "title": "WordPressHTTPHTTPS (version5.7)"}'),
('{"slug":"4771lv37ot", "title": "Laravel SailMySQLPostgres"}'),
('{"slug":"2xch5zq3e9", "title": "BeautifulSoupSelenium"}'),
('{"slug":"1xbq0pridw", "title": "ACF10Delicious Brains Inc."}'),
('{"slug":"10bwr53umq", "title": "Amazon LightsailAWS CDK"}'),
('{"slug":"43nyielz64", "title": "Amazon LightsailSSL"}'),
('{"slug":"40a2u2w7rv", "title": "AWS Solutions Architect Associate"}'),
('{"slug":"26ru99uao0", "title": "AWS CDKTypeScriptLambda + SAM Local"}')
;
mysql> select * from posts;
+----+------------------------------------------------------------------------+----------------+
| id | post_meta                                                              | post_meta_slug |
+----+------------------------------------------------------------------------+----------------+
|  1 | {"slug": "6nwolmsp8y", "title": "AWS-CDKWordPress"}                    | 6nwolmsp8y     |
|  2 | {"slug": "4yeiux7wqq", "title": "WPScanxmlrpc"}                        | 4yeiux7wqq     |
|  3 | {"slug": "3yjela8tgv", "title": "WordPressHTTPHTTPS (version5.7)"}     | 3yjela8tgv     |
|  4 | {"slug": "4771lv37ot", "title": "Laravel SailMySQLPostgres"}           | 4771lv37ot     |
|  5 | {"slug": "2xch5zq3e9", "title": "BeautifulSoupSelenium"}               | 2xch5zq3e9     |
|  6 | {"slug": "1xbq0pridw", "title": "ACF10Delicious Brains Inc."}          | 1xbq0pridw     |
|  7 | {"slug": "10bwr53umq", "title": "Amazon LightsailAWS CDK"}             | 10bwr53umq     |
|  8 | {"slug": "43nyielz64", "title": "Amazon LightsailSSL"}                 | 43nyielz64     |
|  9 | {"slug": "40a2u2w7rv", "title": "AWS Solutions Architect Associate"}   | 40a2u2w7rv     |
| 10 | {"slug": "26ru99uao0", "title": "AWS CDKTypeScriptLambda + SAM Local"} | 26ru99uao0     |
+----+------------------------------------------------------------------------+----------------+
10 rows in set (0.00 sec)

post_meta_slug にダブルクオーテーションが除去された文字列が入っていることが確認できます。

JSON型のカラムの属性でWHERE句で絞り込みたい場合は、JSON_EXTRACTまたは短縮演算子->を使用します。

mysql> SELECT * FROM posts WHERE `post_meta`->"$.slug" = "6nwolmsp8y";
+----+-----------------------------------------------------+----------------+
| id | post_meta                                           | post_meta_slug |
+----+-----------------------------------------------------+----------------+
|  1 | {"slug": "6nwolmsp8y", "title": "AWS-CDKWordPress"} | 6nwolmsp8y     |
+----+-----------------------------------------------------+----------------+
1 row in set (0.02 sec)

mysql> SELECT * FROM posts WHERE JSON_UNQUOTE(JSON_EXTRACT(`post_meta`, "$.slug"))  = "6nwolmsp8y";
+----+-----------------------------------------------------+----------------+
| id | post_meta                                           | post_meta_slug |
+----+-----------------------------------------------------+----------------+
|  1 | {"slug": "6nwolmsp8y", "title": "AWS-CDKWordPress"} | 6nwolmsp8y     |
+----+-----------------------------------------------------+----------------+
1 row in set (0.00 sec)

実行計画を出してみます。

mysql> EXPLAIN SELECT * FROM posts WHERE `post_meta`->"$.slug" = "6nwolmsp8y";
+----+-------------+-------+------------+------+----------------------------+----------------------------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys              | key                        | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+------+----------------------------+----------------------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | posts | NULL       | ref  | posts_post_meta_slug_index | posts_post_meta_slug_index | 1023    | const |    1 |   100.00 | NULL  |
+----+-------------+-------+------------+------+----------------------------+----------------------------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)

mysql> EXPLAIN SELECT * FROM posts WHERE JSON_UNQUOTE(JSON_EXTRACT(`post_meta`, "$.slug"))  = "6nwolmsp8y";
+----+-------------+-------+------------+------+----------------------------+----------------------------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys              | key                        | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+------+----------------------------+----------------------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | posts | NULL       | ref  | posts_post_meta_slug_index | posts_post_meta_slug_index | 1023    | const |    1 |   100.00 | NULL  |
+----+-------------+-------+------------+------+----------------------------+----------------------------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)

keyを見ると、Generated Columnで追加したカラムを含むindexが使用されていることが確認できます。

Laravelでやってみた

Laravel側の準備をします。postsテーブルに対応するPostモデルを作っておきます。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model { }

tinkerを起動してEloquentを使ってクエリを組み立てます。今回は実行計画を見たいので、explain()を使います。

> \App\Models\Post::where('post_meta->slug', '6nwolmsp8y')->explain();
= Illuminate\Support\Collection {#6207
    all: [
      {#7161
        +"id": 1,
        +"select_type": "SIMPLE",
        +"table": "posts",
        +"partitions": null,
        +"type": "ALL",
        +"possible_keys": null,
        +"key": null,
        +"key_len": null,
        +"ref": null,
        +"rows": 10,
        +"filtered": 100.0,
        +"Extra": "Using where",
      },
    ],
  }

indexは使われていないようです。。

indexが使われない原因

toSqlで実際にどのようなクエリが組み立てられているのかを確認してみます。

> \App\Models\Post::where('post_meta->slug', '6nwolmsp8y')->toSql();
= "select * from `posts` where json_unquote(json_extract(`post_meta`, '$."slug"')) = ?"
SELECT * FROM `posts` WHERE JSON_UNQUOTE(JSON_EXTRACT(`post_meta`, '$."slug"')) = "6nwolmsp8y";

一見、何も問題ないように見えますが、slugがダブルクオーテーションで括られていることがわかります。

Laravelではwhereで「->」を含む文字列が渡された場合、JSON型へのクエリーであると判断してJSONの組み込み関数へと展開されます。同時にJSONのパスをダブルクオーテーションで括るように変換するようです。

公式ドキュメントには以下の記述があります。

クエリー式が生成されたカラム定義と一致するには、式が同一であり、同じ結果タイプである必要があります。 たとえば、生成されたカラム式が f1 + 1 の場合、クエリーで 1 + f1 が使用されているか、f1 + 1 (整数式) が文字列と比較されても、オプティマイザは一致を認識しません。

https://dev.mysql.com/doc/refman/8.0/ja/generated-column-index-optimizations.html

おそらく、オプティマイザがGenerated Columnの式とクエリー式が一致していないと判断しているため、indexが使われないと考えられます。

indexが使われるようにする

1つは、whereRaw を使う方法が挙げられます。whereRawは素のwhere句を記述することができるので、勝手に変換されることを回避できます。

> \App\Models\Post::query()->whereRaw('JSON_UNQUOTE(JSON_EXTRACT(post_meta, "$.slug")) = ?', ['4yeiux7wqq'])->explain();
= Illuminate\Support\Collection {#7174
    all: [
      {#6210
        +"id": 1,
        +"select_type": "SIMPLE",
        +"table": "posts",
        +"partitions": null,
        +"type": "ref",
        +"possible_keys": "posts_post_meta_slug_index",
        +"key": "posts_post_meta_slug_index",
        +"key_len": "1023",
        +"ref": "const",
        +"rows": 1,
        +"filtered": 100.0,
        +"Extra": null,
      },
    ],
  }

もう一つは、Generated Columnをwhere句に指定する方法です。

> \App\Models\Post::query()->where('post_meta_slug', '4yeiux7wqq')->explain();
= Illuminate\Support\Collection {#7175
    all: [
      {#7165
        +"id": 1,
        +"select_type": "SIMPLE",
        +"table": "posts",
        +"partitions": null,
        +"type": "ref",
        +"possible_keys": "posts_post_meta_slug_index",
        +"key": "posts_post_meta_slug_index",
        +"key_len": "1023",
        +"ref": "const",
        +"rows": 1,
        +"filtered": 100.0,
        +"Extra": null,
      },
    ],
  }

どちらもindexを使っていることを確認できますが、個人的には後者の方がシンプルで良いと思います。

おまけ

MySQL 8.0.13以降では、関数インデックスが使用できます。やっていることはGenerated Columnとあまり変わりないですが、CHARでCASTすることとcollationを明示する必要があるところが異なります。

LaravelではrawIndexでもcollationの指定ができなかった(多分)ので、DB::statementで直接ALTER TABLEを書いています。

public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->json('post_meta');
    });
    DB::statement('ALTER TABLE posts ADD INDEX post_meta_slug_index((CAST(post_meta->>"$.slug" as CHAR(255)) COLLATE utf8mb4_bin))');
}
mysql> show create table posts;
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                                                                                                                    |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| posts | CREATE TABLE `posts` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `post_meta` json NOT NULL,
  PRIMARY KEY (`id`),
  KEY `post_meta_slug_index` (((cast(json_unquote(json_extract(`post_meta`,_utf8mb4'$.slug')) as char(255) charset utf8mb4) collate utf8mb4_bin)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.01 sec)

関数インデックスでも式が一致していないとindexが使われないというのは同じようです。

参考リンク

タイトルとURLをコピーしました