状況
ALTER TABLE や DROP TABLE を実行しても、ただ... 止まったまま。エラーも進捗もない。SHOW PROCESSLIST で予想通りの結果が確認できる:
Waiting for table metadata lock
そのテーブルに触れるクエリはすべてその後ろに積み上がっていく。ロックを保持しているものが解放するまで、テーブルは事実上オフライン状態になる。
なぜこうなるのか
MySQLはテーブル構造を**メタデータロック(MDL)**で保護している。ALTER TABLEには排他的なMDLが必要だが、そのテーブルで開いているすべてのトランザクションが完了するまで取得できない。コミットもロールバックもされず半開きのまま放置されたアイドル状態のトランザクションも含まれる。
よくある原因:
- MySQLクライアントのタブで開いたままのSELECT — 何時間もアイドル状態で、一度もコミットされていない
- トランザクションが開始されたが一度もコミットされないアプリのバグ(autocommitをオフに設定したコネクションプールでよく見られる)
- 別のセッションでまだ開いている明示的な
LOCK TABLES - 処理中の
FLUSH TABLES - MySQL 5.7以降では、テーブルに触れただけのアイドル状態のトランザクションでもMDLを保持し続ける
ステップ1 — ブロック元を特定する
まずクイックスナップショットを確認する:
SHOW FULL PROCESSLIST;
Sleep状態でTimeの値が大きいスレッドを探す。多くの場合それだけで原因を特定できる。特定できない場合はperformance_schemaを掘り下げる:
-- MySQL 5.6 / 5.7 のみ — innodb_lock_waits は MySQL 8.0 で削除された
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM
information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
MDLはInnoDBの行ロックではないため、このクエリは何も返さないことが多い。信頼できるアプローチ — MySQL 8.0以降では唯一の選択肢 — はperformance_schemaに直接クエリすることだ:
-- MySQL 5.7+ / 8.0 — 対象テーブルのMDLを保持しているものを特定する
SELECT
waiting.THREAD_ID AS waiting_thread_id,
waiting.PROCESSLIST_ID AS waiting_pid,
waiting.PROCESSLIST_INFO AS waiting_query,
blocking.THREAD_ID AS blocking_thread_id,
blocking.PROCESSLIST_ID AS blocking_pid,
blocking.PROCESSLIST_INFO AS blocking_query,
ml.LOCK_STATUS
FROM
performance_schema.metadata_locks ml
JOIN performance_schema.threads blocking
ON ml.OWNER_THREAD_ID = blocking.THREAD_ID
JOIN performance_schema.threads waiting
ON waiting.PROCESSLIST_INFO IS NOT NULL
WHERE
ml.LOCK_STATUS = 'GRANTED'
AND ml.OBJECT_SCHEMA = 'your_database'
AND ml.OBJECT_NAME = 'your_table';
実際のデータベース名とテーブル名に置き換えること。
ステップ2 — ブロックしている接続を特定する
上記のクエリでblocking_pidが得られる。SHOW FULL PROCESSLISTでそれに一致するものを探す。問題のある行は通常このようになっている:
- Command:
Sleep - Time: 恥ずかしいほど大きな値 — 300、1800、あるいは7200秒
- Info:
NULL— クエリはとっくに終わっているが、トランザクションがクローズされていない
そのスリープ中の接続が最後のクエリでメタデータロックを取得したまま... 開き続けている。自分がスキーマ変更をブロックしていることに気づいていない。
ステップ3 — ブロッカーをKillする
KILL <blocking_pid>;
例えば:
KILL 42;
ALTER TABLEはほぼ即座に動き始めるはずだ。状態を確認する:
SHOW PROCESSLIST;
ステータスがWaiting for table metadata lockからcopy to tmp tableまたはaltering tableに変わるのを確認する。それが完了したサインだ。
ステップ4 — 修正を確認する
-- このテーブルのMDL待ちがなくなったことを確認する
SELECT *
FROM performance_schema.metadata_locks
WHERE OBJECT_SCHEMA = 'your_database'
AND OBJECT_NAME = 'your_table';
ALTERが完了すると、これはゼロ行を返す。実行中は自分のセッションに対して1つのGRANTED行が表示されるが、それは正常だ。
スキーマ変更が正しく反映されたことをダブルチェックする:
DESCRIBE your_table;
SHOW CREATE TABLE your_table\G
ブロッカーがPROCESSLISTにいない場合は?
接続がすでに切断されている可能性がある — クライアントのクラッシュ、ネットワーク切断など — しかしMySQLがトランザクションをロールバックしていない。wait_timeoutとinteractive_timeoutがいずれクリーンアップしてくれる。しかしMySQLのデフォルト28800秒(8時間)設定では、「いずれ」がとても長い待ち時間を意味することがある。
タイムアウトを一時的に短縮して強制的にクリーンアップする:
-- 現在のタイムアウト設定を確認する
SHOW VARIABLES LIKE '%timeout%';
-- 古い接続をフラッシュするために一時的に短縮する(直後に元に戻すこと)
SET GLOBAL wait_timeout = 30;
SET GLOBAL interactive_timeout = 30;
切断された接続がクリアされたら、適切なデフォルト値に戻す:
SET GLOBAL wait_timeout = 28800;
SET GLOBAL interactive_timeout = 28800;
30秒のままにしてはいけない — 正当な長時間実行クエリが切断されてしまう。
再発防止策
- 適切なタイムアウトを設定する。
wait_timeoutを60〜300秒にすればほとんどのアプリ接続をカバーできる。8時間がふさわしい値であることはほぼない。 - コネクションプールでautocommitを使用する。 ほとんどのORM(Laravel、Django、Spring)はデフォルトでautocommitがオンになっている。オフにした場合、すべてのトランザクションに明示的な
COMMITまたはROLLBACKが必要だ — 例外はない。 - DDLはトラフィックの少ない時間帯に実行する。 大きなテーブル(5000万行以上)ではgh-ostまたは
pt-online-schema-changeを使用する。どちらもマイグレーション全体にわたって排他的なMDLを保持することを回避できる。 - DDLタイムアウトを追加する。 セッションごとに
lock_wait_timeoutを設定して、ALTERがすべてのクエリをブロックし続けるのではなく素早く失敗するようにする:
SET SESSION lock_wait_timeout = 10;
ALTER TABLE your_table ADD COLUMN new_col INT DEFAULT 0;
- 長時間実行トランザクションをアラートで監視する。 これをモニタリングに組み込む。60秒以上開いているものは調査する価値がある:
SELECT *
FROM information_schema.innodb_trx
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60
ORDER BY trx_started ASC;
クイックリファレンス
-- 1. ブロック元を特定する
SELECT THREAD_ID, PROCESSLIST_ID, PROCESSLIST_TIME, PROCESSLIST_STATE, PROCESSLIST_INFO
FROM performance_schema.threads
WHERE PROCESSLIST_STATE IS NOT NULL
ORDER BY PROCESSLIST_TIME DESC
LIMIT 20;
-- 2. ブロッカーをKillする
KILL <pid>;
-- 3. ALTERが実行中であることを確認する
SHOW PROCESSLIST;

