DBAなら誰もが経験する、あの場面
深夜2時、アラートが届く。本番データベースが重い、CPU使用率100%、数百のリクエストがタイムアウト。スロークエリログを開くと、原因は一目瞭然――5000万行のテーブルをフルスキャンするSELECT文が、インデックスなしで毎秒500回呼ばれていた。
開発チームは寝ている。翌朝のリリースに向けてコードはフリーズ中。CI/CDパイプラインのデプロイには30分かかる。でも30分の余裕はない――今すぐ直さなければならない。
MySQL Query Rewrite Pluginは、まさにそういう場面のために存在する。自分はこれを使って、アプリケーションのコードに一行も触れることなく、同様のトラブルを何度も乗り越えてきた。
Query Rewrite Pluginとは何か、どう動くか
Query Rewrite PluginはMySQL 5.7+(手動インストールが必要)およびMySQL 8.0+に組み込まれている。パーサー層に介入し、クエリが実行される前にquery_rewrite.rewrite_rulesテーブルのルールと照合する。マッチした場合、クエリは書き換えられたバージョンに置き換えられる。
書き換えには2種類ある:
- プリパース書き換え(プラグイン
Rewriter):パース後に動作し、リテラル値のワイルドカードとして?を使ったパターンマッチングを行う。 - ポストパース書き換え:あまり使われない。パース後のASTに対して動作する。
実際のところ、ほぼすべてのケースで使うのはRewriterプラグイン(プリパース)だ。
事前に知っておくべき制限
- 書き換え対象は
SELECT、INSERT、UPDATE、DELETEのみ――DDLは対象外。 - パターンマッチングは文字列ではなくクエリ構造に基づく。意味が同じでも書き方が違うクエリにはそれぞれ別のルールが必要。
- バイナリプロトコル経由のプリペアドステートメントには適用されない――テキストプロトコルのみ対応。
プラグインのインストールと有効化
MySQL 8.0ではインストールスクリプトが同梱されている:
# MySQLに付属のインストールスクリプトを実行
mysql -u root -p < /usr/share/mysql/install_rewriter.sql
# プラグインが有効かどうか確認
mysql -u root -p -e "SHOW PLUGINS\G" | grep -i rewriter
成功すると、次のような出力が表示される:
Name: Rewriter
Status: ACTIVE
Type: AUDIT
Library: rewriter.so
プラグインはquery_rewriteデータベースとrewrite_rulesテーブルを作成する――すべてのルールを定義する場所だ:
DESCRIBE query_rewrite.rewrite_rules;
+------------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------------+--------------+------+-----+---------+----------------+
| id | int | NO | PRI | NULL | auto_increment |
| pattern | longtext | NO | | NULL | |
| pattern_database | varchar(64) | YES | | NULL | |
| replacement | longtext | NO | | NULL | |
| enabled | enum('YES', | NO | | YES | |
| message | varchar(128) | YES | | NULL | |
| pattern_digest | varchar(64) | YES | | NULL | |
| normalized_patte | varchar(100) | YES | | NULL | |
+------------------+--------------+------+-----+---------+----------------+
実践:リアルなユースケース
ユースケース1:LIMITのないクエリにLIMITを追加する
よくあるミスだ。開発者がLIMITを書き忘れ、本番環境でテーブル全体を取得してしまう――実際に、1リクエストで230万行をフロントエンドに返すクエリを見たことがある:
INSERT INTO query_rewrite.rewrite_rules (pattern, replacement) VALUES (
'SELECT * FROM orders WHERE status = ?',
'SELECT * FROM orders WHERE status = ? LIMIT 1000'
);
-- INSERT/UPDATEのたびに、適用するためにフラッシュが必要:
CALL query_rewrite.flush_rewrite_rules();
これ以降、SELECT * FROM orders WHERE status = 'pending'のようなクエリにはすべて自動的にLIMIT 1000が付加される。アプリケーション側は何も知らないままだ。
ユースケース2:インデックスの使用を強制する
MySQLオプティマイザは、特にデータの偏りがある(スキュードデータ)テーブルでは、実行計画を誤ることがある。コードにヒントを書く代わりに、データベース層で直接書き換える:
INSERT INTO query_rewrite.rewrite_rules (pattern, replacement, pattern_database) VALUES (
'SELECT id, email, created_at FROM users WHERE created_at > ?',
'SELECT id, email, created_at FROM users USE INDEX (idx_created_at) WHERE created_at > ?',
'myapp'
);
CALL query_rewrite.flush_rewrite_rules();
pattern_databaseフィールドに注意。NULLにすると全データベースにルールが適用される――便利に見えるが、意図しない副作用を招きやすい。必ず特定のデータベースを指定すること。
ユースケース3:クエリをアーカイブテーブルにリダイレクトする
古いデータをorders_archiveに移行したが、コードはまだordersを参照している、というケース。コードを直す代わりに、ここでそのままリダイレクトする:
INSERT INTO query_rewrite.rewrite_rules (pattern, replacement, pattern_database) VALUES (
'SELECT ? FROM orders WHERE created_at < ?',
'SELECT ? FROM orders_archive WHERE created_at < ?',
'myapp'
);
CALL query_rewrite.flush_rewrite_rules();
ユースケース4:危険なクエリを一時的にブロックする
深夜3時にデータベース破損が発生し、バックアップからのリストアに2時間近くかかったことがある。その経験から学んだのは、後片付けを待つより、害を与えうるクエリを実行前にブロックする方がいいということだ。プラグインを使えば即座に空の結果を返せる:
-- 空の結果を返すクエリに置き換える
INSERT INTO query_rewrite.rewrite_rules (pattern, replacement, pattern_database) VALUES (
'SELECT * FROM users',
'SELECT * FROM users LIMIT 0',
'myapp'
);
CALL query_rewrite.flush_rewrite_rules();
ルールが正常に動作しているか確認する
-- すべてのルールとステータスを確認
SELECT id, pattern, replacement, enabled, message
FROM query_rewrite.rewrite_rules;
-- フラッシュ後に'message'カラムに値がある場合、
-- そのルールは構文エラーか無効なルールを示している
SELECT id, message FROM query_rewrite.rewrite_rules
WHERE message IS NOT NULL;
アプリケーションからクエリを実行したあと、すぐに以下で確認する:
-- 直前に実行したクエリの警告を確認
SHOW WARNINGS;
-- クエリが書き換えられた場合の出力例:
-- Level: Note
-- Code: 1105
-- Message: Query 'SELECT * FROM orders WHERE status = 'pending'' rewritten to
-- 'SELECT * FROM orders WHERE status = 'pending' LIMIT 1000' by a query rewrite plugin
ルールを無効化または削除する
-- ルールを一時的に無効化
UPDATE query_rewrite.rewrite_rules SET enabled = 'NO' WHERE id = 1;
CALL query_rewrite.flush_rewrite_rules();
-- ルールを完全に削除
DELETE FROM query_rewrite.rewrite_rules WHERE id = 1;
CALL query_rewrite.flush_rewrite_rules();
モニタリング:ルールがいつ適用されているかを把握する
MySQLにはステータス変数が用意されている:
SHOW STATUS LIKE 'Rewriter%';
+-----------------------------------+-------+
| Variable_name | Value |
+-----------------------------------+-------+
| Rewriter_number_loaded_rules | 3 |
| Rewriter_number_rewritten_queries | 1547 |
| Rewriter_number_unrecognized_hints| 0 |
| Rewriter_reload_error | OFF |
+-----------------------------------+-------+
Rewriter_number_rewritten_queriesが増え続けていれば、ルールは正常に動いている。クエリを実行したはずなのに変化がない場合は、パターンがマッチしていない。多くの場合、余分なスペース、カラムの順序の違い、またはエイリアスが原因だ。パフォーマンス全体を底上げしたい場合は、Buffer PoolやThread Poolのチューニングと組み合わせることで、より大きな効果が得られる。
パターンがマッチしない場合のデバッグ
よく使うテクニック:一時的にgeneral logを有効にして、MySQLに送信されているクエリの正確なテキストを確認する:
SET GLOBAL general_log = 'ON';
SET GLOBAL general_log_file = '/tmp/mysql_general.log';
-- アプリケーションからいくつかクエリを実行...
SET GLOBAL general_log = 'OFF';
tail -f /tmp/mysql_general.log | grep "Query"
ログ内のクエリテキストと定義したパターンを照らし合わせる。同じように見えて実は違う、というところに問題が潜んでいることが多い。
使うべき場面、使わない方がいい場面
使うべき場面:
- コードデプロイの時間がなく、本番環境を即座にホットフィックスしたいとき。
- ソースコードを変更する権限がないサードパーティアプリケーションを扱うとき。
- アプリケーションを変更せずにクエリ最適化をA/Bテストしたいとき。
- 新旧テーブルを並行稼働させながら段階的にマイグレーションするとき。
使わない方がいい場面:
- 恒久的な修正と見なすとき――書き換えルールはあくまで一時的な対処であり、コードを正しく修正することの代替にはならない。
- クエリのロジックが複雑で、書き換えによってセマンティクスが変わる可能性があるとき。
- 複数チームが共有する環境で、ルールがドキュメント化されていないとき――不可解なデバッグセッションの元凶になる。
まとめ
Query Rewrite Pluginを使って本番環境にパッチを当てた回数は、もう10回では収まらない。毎回、少なくとも30分のデプロイ時間を節約できた――ときにはオンコール対応まるごと一晩分を。DBAやDevOpsエンジニアにとって、知っている人が少ないわりに、30分習得する価値が十分あるツールだ。
一つだけ忘れないでほしい原則がある:すべてのルールは必ずドキュメント化すること――ルール内にコメントを書くか、社内Wikiに記録する。3ヶ月後に別のチームが、コードから期待される結果と実際の結果がまったく違うと頭を抱えてデバッグするほど最悪なことはない。

