1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【MySQL 8.4】LIKE検索とFULLTEXTインデックスの速度を比較してみたら、ORDER BYするカラムで結果が変わることが分かった

1
Posted at

はじめに

MySQLを勉強していてFULLTEXTインデックスというものがあることを知りました。
どのくらい速くなるのか、また、どんな条件なら差が出づらいのかを確認してみました。

MySQL 8.4 に10万件の日本語記事データを入れ、LIKE検索と FULLTEXTインデックス(ngramパーサ)の性能を実測しました。

結論(先に要点)

  • LIKEはヒット件数に関係なく、ほぼ一定の時間がかかる(全行を読み切るため)
  • FULLTEXTはヒット件数に比例して遅くなる(ヒットが少ないほど有利)
  • ORDER BY を付けた瞬間に差が決定的になる(LIKEの早期打ち切りが効かなくなる)
  • 例外は ORDER BY id(主キー順)のみ。この場合だけLIKEが勝つことがある
  • 代償はインデックスサイズ。本体144MBに対してFULLTEXTは93MB(約65%)

実務の検索機能は「新着順」「関連度順」などで並べますが、その前提なら、FULLTEXTが5〜30倍速いというのが今回の実測から得られた結論です。

検証環境

項目 値
MySQL 8.4.11(Docker, mysql:8.4)
innodb_buffer_pool_size 256MB
ngram_token_size 2(既定値)
ホスト macOS (Apple Silicon) / Docker Desktop

検証データ

articles テーブルに10万件。1件あたり本文8〜14文の日本語で、合計144MBです。

CREATE TABLE articles (
  id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  title      VARCHAR(255)    NOT NULL,
  body       TEXT            NOT NULL,
  created_at DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

データはPHPのシードスクリプトで生成し、出現率を制御したキーワードを埋め込みました。ヒット率の違いで挙動がどう変わるかを見るためです。

キーワード 実際のヒット件数 ヒット率
量子暗号 102件 0.1%
機械学習 5,052件 5%
最適化 30,164件 30%

FULLTEXTインデックスは次のように作成しました。

ALTER TABLE articles ADD FULLTEXT INDEX ft_body (body) WITH PARSER ngram;

計測1:件数カウント

まずは単純な件数カウントから。

SELECT COUNT(*) FROM articles WHERE body LIKE '%機械学習%';
SELECT COUNT(*) FROM articles WHERE MATCH(body) AGAINST('"機械学習"' IN BOOLEAN MODE);
キーワード ヒット件数 LIKE FULLTEXT 差
量子暗号 102件 0.18秒 0.04秒 4.5倍
機械学習 5,052件 0.17秒 0.04秒 4.3倍
最適化 30,164件 0.15秒 0.10秒 1.5倍

わかったこと

LIKEはヒット件数が300倍違っても実行時間がほぼ同じでした。
10万行すべてを取り出して1行ずつ部分文字列を照合するので、コストは「行数 × 本文長」で決まり、ヒット件数とは無関係になります。

一方FULLTEXTは、転置インデックスから該当文書IDのリストを取り出す処理量が件数に比例するため、ヒットが多いほど差が縮みます。

実行計画を見てみる

mysql> EXPLAIN SELECT COUNT(*) FROM articles WHERE body LIKE '%機械学習%';
+----+-------------+----------+------------+------+---------------+------+---------+------+-------+----------+-------------+
| id | select_type | table    | partitions | type | possible_keys | key  | key_len | ref  | rows  | filtered | Extra       |
+----+-------------+----------+------------+------+---------------+------+---------+------+-------+----------+-------------+
|  1 | SIMPLE      | articles | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 93068 |    11.11 | Using where |
+----+-------------+----------+------------+------+---------------+------+---------+------+-------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

ここで2つ気づいたことがあります。

  1. possible_keys すら NULL。仮に body にB-Treeインデックスを張っても LIKE '%...%' では使えません。B-Treeは値を先頭から順に並べた構造なので、前方一致 LIKE '機械%' なら範囲を絞れますが、先頭が不定の中間一致では絞れないためです。
  2. filtered=11.11 はオプティマイザの決め打ち定数(1/9)。中間一致LIKEの選択率は推定できないので固定値が使われます。実際のヒット率は5%でした。

FULLTEXT側はこうなりました。

mysql> EXPLAIN SELECT COUNT(*) FROM articles WHERE MATCH(body) AGAINST('"機械学習"' IN BOOLEAN MODE);
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra                        |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------------------+
|  1 | SIMPLE      | NULL  | NULL       | NULL | NULL          | NULL | NULL    | NULL | NULL |     NULL | Select tables optimized away |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------------------+
1 row in set, 1 warning (0.06 sec)

COUNT(*) の場合は転置インデックスだけで件数が確定するので、テーブルを一切読みません。

この最適化が効くのは COUNT(*) のときだけです。
実行計画を確認するときは、SELECT id, title ... のように行を返すクエリで見ないと実態を見誤ります。

計測2:LIMIT付き(実務に近い形)

次に、検索画面でよくある「先頭20件だけ取る」形で比較しました。

SELECT id, title FROM articles WHERE body LIKE '%機械学習%' LIMIT 20;
SELECT id, title FROM articles WHERE MATCH(body) AGAINST('"機械学習"' IN BOOLEAN MODE) LIMIT 20;

機械学習(ヒット率5%)

クエリ 実行時間 読んだ行数
LIKE 1.03ms 393行
FULLTEXT 0.86ms 20行

ほぼ互角でした。注目すべきは、LIKEが10万行を読んでいないこと。
ヒット率5%なので約400行読めば20件揃い、そこでスキャンを打ち切っています。

量子暗号(ヒット率0.1%)

クエリ 実行時間 読んだ行数
LIKE 41.7ms 21,125行
FULLTEXT 0.86ms 20行

こちらは48倍差。早期打ち切りは「20件見つかるまで」なので、ヒット率が低いと延々と読み続けることになります。

つまり、LIMIT の効果はヒット率に依存するということです。

計測3:ORDER BY付き(ここが決定的)

ORDER BY を付けると「全部見つけてからでないと上位20件が確定しない」ため、早期打ち切りが無効になります。

「機械学習」(5,052件)で、並び順の指定だけを変えて比較しました。

ORDER BY LIKE FULLTEXT 勝者
なし 1.03ms (rows=393) 0.86ms (rows=20) 互角
id DESC 2.26ms(Sortなし) 9.94ms (rows=5,052) LIKE
created_at DESC 148ms (rows=100,000) 5.71ms FULLTEXT(26倍)
body DESC 206ms (rows=100,000) 24.2ms FULLTEXT(8.5倍)

同じデータ・同じ検索語でも、並び順の指定だけで勝者が入れ替わりました。
これが今回いちばんの学びです。

なぜ ORDER BY id DESC だけLIKEが勝つのか

EXPLAIN ANALYZE を見ると理由がわかりました。

-> Limit: 20 row(s)
    -> Filter: (articles.body like '%機械学習%')
        -> Index scan on articles using PRIMARY (reverse)  rows=451

実行計画に Sort: ノードが存在しません。
InnoDBのテーブル本体は主キー順に並んだB-Tree(クラスタ化インデックス)なので、「id降順」は逆から読むだけで並び替えが完成します。並べ替えが不要になったことで早期打ち切りが復活し、451行で済んでいます。

「主キーインデックスがあるから速い」のではなく、
ORDER BYの順序が既存の物理的な並び順と一致したので、ソート処理そのものが消滅した、と理解するのが正確です。

なぜFULLTEXTでは Sort を消せないのか

-> Sort row IDs: articles.id DESC
    -> Filter: (match ...)
        -> Full-text index search on articles using ft_body  rows=5052

MySQLは1つのテーブルアクセスにつきインデックスを1つしか使えません。
ft_body を使って行を取り出すと決めた時点で、出てくる順序はFULLTEXTの内部順序であり主キー順ではないため、ORDER BY id には必ずソートが必要になります。

ちなみに Sort row IDs は、行全体ではなく行IDだけを並べ替える省メモリの最適化です。body のような巨大カラムを持つテーブルで効いてきます。

ただし、ヒット率が低いとLIKEの勝ち筋も消える

「量子暗号」(102件)で ORDER BY id DESC LIMIT 20 を試すとこうなりました。

クエリ 実行時間 読んだ行数
LIKE(ORDER BY id DESC) 69.4ms 21,109行
LIKE(ORDER BY body DESC) 199ms 100,000行

ソートは省略できても、20件揃うまで2万行読む必要があるので結局遅い、という結果です。

FULLTEXTの代償

インデックスサイズ

mysql> SELECT name, ROUND(file_size/1024/1024, 1) AS file_mb
    -> FROM information_schema.innodb_tablespaces
    -> WHERE name LIKE 'demo/%'
    -> ORDER BY file_size DESC
    -> LIMIT 12;
+----------------------------------------------------+---------+
| name                                               | file_mb |
+----------------------------------------------------+---------+
| demo/articles                                      |   144.0 |
| demo/fts_000000000000042f_00000000000000a4_index_6 |    52.0 |
| demo/fts_000000000000042f_00000000000000a4_index_1 |    40.0 |
| demo/users                                         |     0.1 |
| demo/orders                                        |     0.1 |
| demo/fts_000000000000042f_being_deleted            |     0.1 |
| demo/fts_000000000000042f_being_deleted_cache      |     0.1 |
| demo/fts_000000000000042f_config                   |     0.1 |
| demo/fts_000000000000042f_deleted                  |     0.1 |
| demo/fts_000000000000042f_deleted_cache            |     0.1 |
| demo/fts_000000000000042f_00000000000000a4_index_2 |     0.1 |
| demo/fts_000000000000042f_00000000000000a4_index_3 |     0.1 |
+----------------------------------------------------+---------+
12 rows in set (0.01 sec)

mysql> SELECT ROUND(SUM(file_size)/1024/1024, 1) AS fts_total_mb
    -> FROM information_schema.innodb_tablespaces
    -> WHERE name LIKE 'demo/fts_%';
+--------------+
| fts_total_mb |
+--------------+
|         93.0 |
+--------------+
1 row in set (0.00 sec)
対象 サイズ
テーブル本体 144MB
FULLTEXTインデックス 93.0MB(本体の約65%)

ngramで2文字ずつ刻むため、300文字の本文からは約300トークンが生成されます。本体の6割以上というのはなかなかの大きさです。

FULLTEXTインデックスは information_schema.tables の index_length に計上されません。
fts_..._index_1 〜 _6 という内部補助テーブルとして保存されるため、innodb_tablespaces を見る必要があります。
(index_1〜_6 はトークン先頭文字のコード範囲で6分割したもの。日本語は範囲が偏るので、index_1 と index_6 だけが大きくなっていました)

作成コスト

ALTER TABLE articles ADD FULLTEXT INDEX ft_body (body) WITH PARSER ngram;
-- Query OK, 0 rows affected, 1 warning (17.91 sec)

10万件で17.91秒かかりました。警告の内容は「FTS_DOC_ID カラム追加のためテーブルを再構築した」というもの。

InnoDBは全文検索用の隠しカラムを必要とするため、最初のFULLTEXT作成時だけテーブル全体を作り直します。2つ目以降のFULLTEXT追加ではこの再構築は不要です。

ハマりどころ

1. NATURAL LANGUAGE MODE は同じものを数えていない

最初、LIKEとFULLTEXTで件数が全然合わずに混乱しました。

ngramパーサは検索語も分割します。量子暗号 は 量子 子暗 暗号 に分解され、NATURAL LANGUAGE MODE ではそのORで検索されます。

SELECT COUNT(*) FROM articles WHERE body LIKE '%量子暗号%';                           --    102件
SELECT COUNT(*) FROM articles WHERE MATCH(body) AGAINST('量子暗号');                   -- 57,699件
SELECT COUNT(*) FROM articles WHERE MATCH(body) AGAINST('"量子暗号"' IN BOOLEAN MODE); --    102件

なんと566倍の差。「暗号」を含む記事をすべて拾ってしまうためです。
LIKEと公平に比較するには、ダブルクォートで囲んだBOOLEAN MODEのフレーズ検索を使う必要があります。

ちなみに 機械学習 では両モードとも5,052件で一致しました。これは検証データの語彙に 機械 学習 が「機械学習」以外の形で存在しなかったためです。
仕組み上の差が出るかどうかはデータ次第なので、差が出ないケースを見て安心できないと感じました。

2. ngram_token_size より短い語は検索できない

既定値は2なので、1文字での検索は0件になります。

SELECT COUNT(*) FROM articles WHERE MATCH(body) AGAINST('"暗"' IN BOOLEAN MODE);  -- 0件
SELECT COUNT(*) FROM articles WHERE body LIKE '%暗%';                             -- ヒットする

ngram_token_size はサーバ起動時にしか変更できず、変更したらFULLTEXTインデックスの作り直しも必要です。

3. 更新コスト(今回は未計測)

INSERT/UPDATE時には転置インデックスの更新が発生します。書き込みが多いテーブルでは別途計測が必要で、これは今後の宿題です。

計測で気をつけたこと

同じクエリ・同じ実行計画(rows=393)でも、1.03ms と 19.2ms のように20倍のばらつきが出ることがありました。バッファプールの状態や他プロセスの影響を受けるためです。

  • 必ず複数回実行し、2回目以降の値を見る(1回目はディスクI/O込み)
  • MySQL 8.4 にクエリキャッシュはない(8.0で廃止)ので、結果の使い回しは起きない
  • 時間だけでなく EXPLAIN ANALYZE の rows(実際に処理した行数) を見る。こちらは構造的な指標なのでばらつかない

EXPLAIN は推定値、EXPLAIN ANALYZE は実際に実行した実測値です。読み方は内側(インデントの深い方)から外側へ。

actual time=2.52..41.7  最初の行まで2.52ms、全部終わるまで41.7ms
rows=21125              実際に処理した行数
loops=1                 その処理の繰り返し回数

実務での判断基準(自分なりのまとめ)

FULLTEXTを選ぶべき場合

  • 検索結果を関連度順や新着順で並べる(ORDER BY が付く)
  • ヒット率が低い検索語が多い(固有名詞、専門用語など)
  • 検索が頻繁で、インデックスサイズとその更新コストを払える

LIKEで十分な場合

  • データ量が小さい(数千件程度なら差は体感できない)
  • ORDER BY id のような主キー順で、かつヒット率が高い
  • 書き込みが非常に多く、インデックス更新コストを避けたい
  • 1文字検索や部分文字列の厳密一致が要件にある

おわりに

「FULLTEXTは速い」という知識だけだと、ORDER BY id DESC のケースでLIKEに負けた理由を説明できませんでした。
実際に EXPLAIN ANALYZE で Sort ノードの有無 と 実際に読んだ行数 を追いかけたことで、

  • 速さを決めているのは「インデックスの有無」ではなく「何行読むか」と「ソートが必要か」
  • LIMIT による早期打ち切りは、ヒット率と ORDER BY 次第で効いたり効かなかったりする

という、より本質的な理解に辿り着けたのが今回の一番の収穫です。

次は今回宿題にした書き込み時の更新コストを計測してみたいと思います。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?