MySQL で行ベースレプリケーションしていて、特定の操作を行ったときにレプリケーションがエラーで止まる事象が発生しました。
そのときの調査のログや対応の方法のメモ。
原因調査
まず、レプリケーションのスレーブでレプリケーションの状態を見ました。
mysql -e 'show slave status \G'
以下は抜粋です。
Master_Log_File: binlog.000225
Read_Master_Log_Pos: 42626469
Relay_Master_Log_File: binlog.000216
Slave_IO_Running: Yes
Slave_SQL_Running: No
Exec_Master_Log_Pos: 66177326
Last_SQL_Errno: 1032
Last_SQL_Error: Could not execute Delete_rows event on table ore_db.ore_category; Can't find record in 'ore_category', Error_code: 1032; handler error HA_ERR_KEY_NOT_FOUND; the event's master log binlog.000216, end_log_pos 66178214
ore_category というテーブルの Delete_rows イベントを実行しようとしたところ HA_ERR_KEY_NOT_FOUND となったようです。要するに削除しようとしたレコードが存在しなかったということです。
また、スレーブのエラーログには下記が記録されていました(見やすくするために適当に改行を入れています)。
[ERROR] [MY-010584] [Repl] Slave SQL for channel '': Could not execute Delete_rows event on table ore_db.ore_category;
Can't find record in 'ore_category', Error_code: 1032;
handler error HA_ERR_KEY_NOT_FOUND; the event's master log binlog.000216, end_log_pos 66178214, Error_code: MY-001032
[Warning] [MY-010584] [Repl] Slave: Can't find record in 'ore_category' Error_code: MY-001032
[ERROR] [MY-010586] [Repl] Error running query, slave SQL thread aborted. Fix the problem, and restart the slave SQL thread with "SLAVE START".
We stoppedat log 'binlog.000216' position 66177326
マスターのバイナリログ binlog.000216 の 66178214 でエラーになったためレプリケーションが 66177326 で止まっている、とのことです。
この情報を頼りにバイナリログから原因のクエリを特定します。
mysqlbinlog binlog.000216 --base64-output=decode-rows -vv -j 66177326 --stop-position=66178214 > binlog.000216.sql
エラーでコケたトランザクションのクエリが表示されます。原因となるクエリは下記でした。
### DELETE FROM `ore_db`.`ore_list` ### WHERE ### @1=1 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @2='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ ### @3=900 /* SHORTINT meta=0 nullable=1 is_null=0 */ ### @4='2019-09-17 10:26:10' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### DELETE FROM `ore_db`.`ore_category` ### WHERE ### @1=1 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @2=1 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3='oreore' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ ### @4=NULL /* LONGINT meta=0 nullable=1 is_null=1 */
このスレーブの ore_category を見てみたところ ore_list も ore_category も上記のクエリに該当する行は存在していました。
ore_category には FOREIGN KEY (ore_list_id) REFERENCES ore_list (ore_list_id) ON DELETE CASCADE ON UPDATE CASCADE のような外部キー制約があって ore_list が削除されると ON DELETE CASCADE によって一緒に消えます。
どうやら、外部キー制約の ON DELETE CASCADE によって消えたはずの ore_category をさらに削除しようとしてエラーになったっぽいです。
適当なテーブルを作って試行錯誤してみたところ 外部キー制約の ON DELETE CASCADE によって削除された行はバイナリログに書かれない ことがわかりました。
つまりこのケースだと ore_category の削除がバイナリログに記録されるはずありません(ore_list の削除の時点で ore_category は消えてるので削除する方法がない(クエリを実行しても対象行がなければ行ベースのバイナリログには記録されない))。
これは MySQL のバグ臭い・・・と思って調べると下記が見つかりました。
multi-table DELETE ?
うーん Laravel の Eloquent 使っているのでそんなクエリは無いと思うのだけど・・・・
DELETE L, E, C, A FROM ore_latest L LEFT JOIN ore_list E ON E.ore_list_id = L.ore_list_id LEFT JOIN ore_category C ON C.ore_list_id = E.ore_list_id LEFT JOIN ore_article A ON A.article_id = C.article_id WHERE L.ore_latest_id = ?
普通にあったわ。記憶にあるような無いような・・・
復旧方法
MySQL でレプリケーションが止まったときの復旧方法といえば、かつては手作業でマスター・スレーブ間の整合をとって SET GLOBAL SQL_SLAVE_SKIP_COUNTER = 1 でステートメントをスキップする、とかでしたが、GTID レプリケーションしてたのでこの方法は使えません。
GTID レプリケーションの場合は原因となっている GTID を特定してスレーブでその GTID を空のトランザクションで上書きする、みたいな方法が使えるのですが、今回のケースだと ore_category の削除が誤ってバイナリログに書かれてしまっているだけなので HA_ERR_KEY_NOT_FOUND のエラーだけ無視してしまえば OK です。
slave_skip_errors パラメータを使えば特定のエラーだけ無視させることができるのですが、今回のケースなら slave_exec_mode を IDEMPOTENT にしてしまえばこのエラーは無視できます。
ので、原因の multi-table DELETE を個別の DELETE になるように修正した上で、下記の手順でレプリケーションを復旧させました。
set global slave_exec_mode = 'IDEMPOTENT'; show variables like 'slave_exec_mode'; /* IDEMPOTENT になっている */ start slave; show slave status \G /* Read_Master_Log_Pos と Exec_Master_Log_Pos が一致することを確認 */ /* Slave_SQL_Running が Yes */ /* Last_Error は記録されていない */ set global slave_exec_mode = 'STRICT'; show variables like 'slave_exec_mode'; /* STRICT になっている */
さいごに
この MySQL のバグは Verified なのでまだ修正されていないもよう。とりあえず行ベースレプリケーションしてるなら multi-table DELETE は使ったらダメ。
あと slave_exec_mode = IDEMPOTENT はそれほど害はないと思うし NDB ストレージエンジン(MySQL Cluster)だとこっちがデフォルトらしいので、もう slave_exec_mode = IDEMPOTENT で運用しでもいいんじゃないかと思った。