はじめに
MySQL に重い集計クエリを投げて、呼び出し側(クライアント)がタイムアウトした。エラーで終わったので処理も止まったと思っていたら、サーバーの中ではクエリがまだ走っていた。
先日、私(クジラ)が AI のコーディングアシスタント(Claude Code)に本番 DB の集計を任せたときに、これが起きました。手元のコマンドは 120 秒で打ち切られたのに、サーバー上では同じクエリが実行中のまま残っていて、147 秒目に Claude が自分で見つけて止めるまで誰も止めていませんでした。読み取りだけのクエリだったのでデータは無事でしたが、本番 DB に 2 分半ほど余計な負荷をかけたことになります。
この記事では、そのとき確認した手順を「見つける → 止める → 原因を見る → 次から防ぐ」の順にまとめます。
クライアントのタイムアウトは、サーバーに「止めて」と言っていない
クライアント側のタイムアウトは「待つのをやめる」だけです。クエリを受け取った MySQL から見れば、頼まれた仕事を続けているだけなので、結果を返そうとして相手がいないと気づくまで計算を続けることがあります。
PHP の max_execution_time も当てになりません。PHP マニュアルの set_time_limit() の項にあるとおり、この上限はスクリプト自身の実行時間だけを数え、DB への問い合わせなど外部で過ごした時間は含みません(Windows のみ実時間)。Linux サーバーでは、PHP が重いクエリの返事を待っている間、PHP の時計はほとんど進みません。
今回の流れは次のとおりでした。
- 手元のコマンドからサーバー上の PHP スクリプトを動かし、集計クエリを 1 本投げた
- 手元のコマンドが 120 秒の制限で打ち切られ、PHP のプロセスも終わった
- DB のプロセス一覧を見ると、同じクエリが実行中で残っていた(経過 147 秒)
- その場で止めた
見つける: SHOW FULL PROCESSLIST
SHOW FULL PROCESSLIST;
FULL を付けないと Info 列(クエリ本文)が先頭 100 文字で切れるので、長い集計クエリは見分けにくくなります。
出力イメージ(説明用に作った例です):
Id User Host db Command Time State Info
12345 app_user 10.0.0.5:51234 appdb Query 147 Sending data SELECT SUM(EXISTS ...
| 列 | 見るポイント |
|---|---|
Command |
Query なら実行中。Sleep は接続が待機しているだけ |
Time |
その状態になってからの秒数。クライアントのタイムアウトより大きければ取り残しを疑う |
Info |
クエリ本文。自分が流したものか確認する |
他人の接続まで見るには PROCESS 権限が必要ですが、自分のクエリを探すだけなら自ユーザーのスレッドが見えれば足ります。
止める: KILL QUERY と KILL
-- 実行中のクエリだけ止める(接続は残す)
KILL QUERY 12345;
-- 接続ごと切る(KILL CONNECTION と同じ)
KILL 12345;
取り残しを止めるだけなら、影響の小さい KILL QUERY から試せば十分です。今回もこれで止まりました。
注意点:
- すぐ消えないことがある。KILL は止めるための目印を立てるだけで、スレッドが決まったタイミングで確認するまで時間がかかることがあります。少し待って再度 PROCESSLIST を確認します
-
更新系は途中までの変更が残る。トランザクションを使っていない
UPDATE/DELETEを止めた場合、それまでの変更は戻りません。SELECTなら心配ありません -
Id の打ち間違いに注意。
Info列で中身を見てから、その行の Id を使います
なぜ重かったか: 相関サブクエリ × インデックスなし
クエリの骨組みは次のような形でした(名前は一般化しています)。
users の全行 u について
SUM(EXISTS(table_a に user_id = u.id の行があるか))
SUM(EXISTS(table_b に user_id = u.id の行があるか))
SUM(EXISTS(table_c に user_id = u.id の行があるか))
... 計5本
外側の users は数十万行規模で、EXISTS (...) は外側の 1 行ごとに評価される相関サブクエリです。それが 5 本。さらに内側の表の 1 つ(数万行規模)に user_id のインデックスがありませんでした。オプティマイザが書き換える場合もあるので「毎回全件を読み直した」とまでは言えませんが、数十秒で終わる見込みの無いクエリだったのは確かです。
流す前の見積もり 3 点セット
どれも数秒で終わります。
-- 1. おおよその行数(InnoDB では推定値。桁を知るには十分)
SHOW TABLE STATUS LIKE 'users';
-- 2. 絞り込み列にインデックスがあるか
SHOW INDEX FROM table_c;
-- 3. 実行計画(実行はされない)
EXPLAIN SELECT ...;
EXPLAIN で type が ALL、rows が大きい表があれば、そこが重さの原因です。EXPLAIN ANALYZE は実際に実行して時間を測るので、見積もり段階では使いません。
割合が知りたいだけなら、全件を数えない
本当に知りたかったのは「関連表に記録がある利用者は何割か」でした。後から利用者を 50 人選んで WHERE user_id IN (...) で引き直したところ、インデックスの無い同じ表でも 0.05 秒で返りました。対象を先に絞れば、読む量が桁違いに減ります。
全件の正確な数字が必要なら、空いている時間帯に流す・読み取り用レプリカで流すなどを先に検討します。
予防: max_execution_time でサーバー側に上限を付ける
MySQL 5.7.8 以降なら、SELECT にサーバー側の実行時間上限(ミリ秒)を付けられます。
-- この接続の SELECT に 30 秒の上限
SET SESSION max_execution_time = 30000;
1 本だけに付けたいときは、SELECT の直後にオプティマイザヒント MAX_EXECUTION_TIME(ミリ秒) を書く方法もあります。サーバー自身が打ち切るので、取り残しが起きません。
- 対象は
SELECTのみ(UPDATE/DELETEには効かない) - MariaDB は設定名も単位も異なるので、リファレンスで確認する
-
GLOBALに設定すると既存のバッチまで止めかねないので、調べものはセッション単位で
まとめ
| 場面 | やること | 注意点 |
|---|---|---|
| クライアントがタイムアウトした |
SHOW FULL PROCESSLIST で残っていないか確認 |
PHP の実行時間上限は DB 待ちを数えない(Windows 以外) |
| 取り残しを見つけた |
Info で確認してから KILL QUERY <Id>
|
すぐ消えないことがある/更新系は途中の変更が残る |
| 全件集計を書いた | 行数・インデックス・EXPLAIN を先に確認 |
EXPLAIN ANALYZE は実行される |
| 割合だけ知りたい | 数十〜数百件のサンプルで代用 |
IN (...) で先に絞る |
| 調べものの予防線 | SET SESSION max_execution_time |
SELECT のみ・ミリ秒指定 |
AI に DB 作業を任せるときも人が自分でやるときも、「タイムアウトしたら、手元ではなくサーバーを見る」を手順に入れておくと安心です。
元になった記事(AI 側の視点で書いたもの): https://kujiragames.com/2026/10/mysql-query-keeps-running/