MySQL(InnoDB)のテーブルから行を大量削除する場合に気をつけること

DBのデータを、月1などのバッチ処理やスポット処理でまとめて削除するときの注意点です。

いわゆる「MySQLあるある」の1つですが、実際に問題が発生すると、すぐには原因がわからず焦りますね。

---

**2017/12/22追記：**
（古い記事なのに）まだまだある程度参照されているようですが、無駄に長くて回りくどい記事なので、要点（と若干の補足）だけ先に箇条書きしておきます。

 * MySQL(InnoDB)では、少量のデータページをサンプリングしてINDEX統計情報を生成し、オプティマイザのコスト計算などに使っています。
 * ある程度の行数分更新（INSERT／UPDATE／DELETE）が行われると、INDEX統計情報がサンプリング・更新されます。
 * 特に大量のDELETEを行っている途中・直後にINDEX統計情報のサンプリング・更新が行われると、実態と合わない統計情報になってしまい、オプティマイザのコスト計算が狂って実行計画がおかしくなる可能性があります。
 * MySQL 5.6以降は↑のようなトラブルになりにくいよう幾分改善されていますが、大量DELETEの途中などに「ANALYZE TABLE」「SHOW TABLE STATUS」などINDEX統計情報のサンプリング・更新トリガになるようなSQLを無闇に発行するのはやめましょう。
 * 逆に、すでに異常が発生してしまったときには↑を発行することで実行計画が直る可能性もあります。
 * 仕様上許される場合は、「ちまちま削除する」「日付（RANGE）パーティションのDROPを使う」のも手です（「覚悟を決めてじっくりきれいにする」ことができるのなら、そうしたほうがいいかも。でも、大抵、無理）。
 * …というようなことを説明しようとして、（正確性を犠牲にしてでも）わかりやすく説明しよう…と思ったら、ただ長くて読みにくいものになってしまいました。ここまでの説明で理解できたら、ここから先は時間の無駄かもしれません（末尾の「[その他の注意点](https://qiita.com/hmatsu47/items/a779213a71c3c1f72763#%E3%81%9D%E3%81%AE%E4%BB%96%E3%81%AE%E6%B3%A8%E6%84%8F%E7%82%B9)」へジャンプ！）。

---

# MySQL(InnoDB)のINDEX統計情報

MySQL5.5までは、テーブルにある1つのINDEXあたり8ページという、非常に少ないページ数をサンプリングした結果をもとに生成・更新されていました（MySQLを停止すると消えます）。

MySQL5.6からは、サンプリングするページ数を変える、統計情報をストレージに書き出して永続化する等の機能が追加されましたが、それでも20ページです。

※初出時、「デフォルト8ページ・永続化なし」としていましたが、「20ページ・永続化あり」の間違いです。訂正します。

INDEX統計情報の更新タイミングは、

- テーブル内のデータがある割合で変化したとき（MySQL5.5では全データの1/16、5.6以降では1/10）
- 「ANALYZE TABLE」したとき
- 「SHOW TABLE STATUS」等の統計情報出力を行ったとき（MySQL5.6.6以降のデフォルト設定の場合は除く）

などです。

# 大量削除で何が問題になるのか

DELETEでデータ行を削除しても、削除したデータはすぐにパージ（物理削除・空き領域回収）されるわけではありません。そのため、大量にまとめて削除すると、テーブル内には削除された行を含む「穴あき」のデータページが大量に存在する状態になります。

その状態でINDEX統計情報の更新が行われると、サンプリングされたデータページの内容から推計された統計情報が、実体とかけ離れたものになりやすいようです。

また、テーブル全行数に対する削除行数の割合が大きい場合は、削除後に残った行数の推計値が乖離する問題もあります（InnoDBでは全行数の情報を常時保持していないので、テーブルに対してCOUNT(*)しないと正確な行数がわかりません）。

すると、その後に実行されるSELECT・UPDATEなどで、「どのINDEXを使ってデータの抽出を行うべきか？」の判断に狂いが生じ、本来であれば使うべきINDEXが選択されず、

- 効率の悪いINDEXが選択される
- INDEXによるデータの抽出効率が悪いと判断され、テーブルフルスキャンが行われる

といった現象が発生しやすくなります。

JOIN（結合）や、COUNT(*)などの集計関数を含むSQLが、特に対象になりやすいです。

そのような現象が発生すると、MySQLのグローバルステータス変数

- Handler_read_first
- Handler_read_rnd_next

が大幅に増加するようになります。

テーブルフルスキャンの指標としては前者が例として挙げられることが多いのですが、フルスキャン時には2行目以降の走査のほうが多いので、後者の数のほうが圧倒的に増加します。

なお、バッファプールの容量が十分な場合は、ストレージI/Oは増加しません。

SELECT・UPDATEが遅くなるといっても、1つのSQLあたり数秒で完了してしまい、10秒程度に設定したスロークエリログにはあがってこないことがあります。

メモリ上の一時テーブルの生成数と、DISK上の一時テーブルの生成数、およびその比率についても、増加／変化することもあればしないこともあるので、一見しただけでは障害の原因がわかりづらいです。

# トラブルを避けるには

「ゴミを溜めておいて、一気にDELETE」のようなオペレーションをなるべく避けることが望ましいのですが、避けられない場合は、大量DELETEの後にALTER TABLE（列追加などを何もしないALTER TABLE）またはOPTIMIZE TABLEでデータページの整理をするのが良いでしょう。なお、オンラインDDL（ALGORITHM=INPLACE）が使えるのであれば更新がブロックされないので良いのですが、オンラインDDLに対応しているバージョン（5.6.17以降）でも、条件により使えない場合があるので注意しましょう（FULLTEXT インデックス利用時など）。

オンラインDDLが使えない場合も含め、日付の範囲を元にデータを（年単位などで）一括DELETEするのであれば、日付列によるRANGEパーティショニングも検討してみるといいでしょう。行DELETEではなくDROP PARTITIONで対象範囲のデータを消す形の運用です。

なお、すでにSELECT・UPDATEなどの速度低下が生じてしまい、すぐに何らかの対処をしたいときは、

- ANALYZE TABLE
- SHOW TABLE STATUS（MySQL5.6.6以降のデフォルト設定の場合は除く）
- SHOW INDEX FROM（同上）

を対象（と思われる）テーブルに対して実行してみると、INDEX統計情報が更新され、（一時的にですが）問題が解消される可能性があります。

何度か繰り返すうちに、適した統計情報になることもありますので、一度であきらめずに何度か実行してみるのも手です。

# その他の注意点

MySQL 5.6のリファレンスマニュアルにもある通り、大きなテーブルから多数の行を削除する場合は、InnoDB テーブルに対するロックテーブルのサイズを超える可能性があります。

[MySQL 5.6リファレンスマニュアル 13.2.2 DELETE 構文](https://dev.mysql.com/doc/refman/5.6/ja/delete.html)（InnoDB テーブルの項）

また、容量の大きなレコードを削除する場合は、以下の点にも注意が必要です。
[MySQL（InnoDB）でBLOBカラムのデータを一括削除するときの注意点（バッファプールに与える影響）](http://qiita.com/hmatsu47/items/ebeacd179024c8515c8c)