The MySQL Binlog
MySQLのbinlog(バイナリログ)パーサーを自分で書いた。MygramDBという全文検索エンジンを作ったとき、MySQLのデータをリアルタイムに取り込む必要があった。binlogを読めばできる。そう思って始めたら、想像の10倍くらい深い穴だった。
binlogとは
MySQLがデータの変更(INSERT、UPDATE、DELETE)を記録するログ。主な用途は3つ。レプリケーション(レプリカにデータを複製する)、ポイントインタイムリカバリ(特定時点にデータを戻す)、CDC(Change Data Capture、変更をリアルタイムに外部に配信する)。MygramDBはCDCに使っている。
3つの記録形式——そして歴史
binlogの記録形式には3つある。Statement-based(SBR)、Row-based(RBR)、Mixed。これは歴史そのものだ。
Statement-basedは最も古い方式で、実行されたSQL文そのものをログに書く。UPDATE users SET active = 0 WHERE last_login < '2025-01-01'という文が、そのまま記録される。コンパクトで読みやすい。
問題は非決定性だった。NOW()を含むSQL文をレプリカで再実行すると、タイムスタンプがずれる。ORDER BYなしのDELETE ... LIMITで削除される行が変わる。UUIDを生成するトリガーが異なる値を返す。プライマリとレプリカでデータが食い違う。サイレントに。
Row-based(MySQL 5.1〜)は、SQL文ではなく「どの行がどう変わったか」を記録する。UPDATEなら変更前と変更後の行データがバイナリで書かれる。非決定性の問題は起きない。NOW()だろうがRAND()だろうが、結果の行を記録するのだから。
代償はログサイズだ。1万行を更新するSQLは、Statement-basedなら1行のSQL文で済む。Row-basedでは1万行分の変更前後データが書かれる。
MixedはMySQLが文脈を見て自動で切り替える方式。安全なSQL文はStatement-based、非決定性があればRow-basedにフォールバックする。賢い。ただ、「いつどちらが選ばれるか」を正確に予測するのは難しい。CDCでパーサーを書く側としては、Row-basedに統一されているほうがずっとありがたい。いまの本番環境でbinlog_format=ROW以外を使う理由は、ほぼない。
GTIDの導入(MySQL 5.6)
GTID(Global Transaction Identifier)は各トランザクションにサーバーUUID + 連番のIDを振る仕組み。GTID以前のレプリケーションは「binlogファイル名 + オフセット」で位置を管理していた。プライマリが切り替わると位置の対応関係が崩れ、手動での再設定が必要だった。GTIDなら「このIDまで適用済み」という状態だけで位置が特定できる。フェイルオーバーが劇的に簡単になった。MygramDBのレプリケーションもGTIDベースで動いている。
バイナリフォーマットの深淵
binlogのパーサーを書いて最初に思ったのは、「これを設計した人は何と戦っていたのか」ということだ。
binlogイベントは19バイトの固定ヘッダで始まる。タイムスタンプ4バイト、イベントタイプ1バイト、サーバーID 4バイト、イベント長4バイト、次のイベント位置4バイト、フラグ2バイト。すべてリトルエンディアン。ここまでは普通だ。
地獄はその先にある。
TABLE_MAP → ROWS の二段構造
Row-basedのイベントは2段階で構成される。まずTABLE_MAP_EVENTがテーブルのスキーマ(テーブル名、カラム型、NULLableフラグ等)を通知する。その直後にWRITE_ROWS_EVENT(INSERT)、UPDATE_ROWS_EVENT(UPDATE)、DELETE_ROWS_EVENT(DELETE)が来て、実際の行データを運ぶ。
ROWSイベント単体には「このカラムはVARCHARです」という情報がない。直前のTABLE_MAP_EVENTを覚えていないとデコードできない。パーサーは状態を持たなければならない。
Packed Integerの罠
MySQLのバイナリプロトコルでは、整数値のエンコーディングが文脈によって変わる。小さい値は1バイト、大きい値は3バイトや8バイト——いわゆるpacked integer(length-encoded integer)だ。先頭1バイトの値で後続のバイト数が決まる。
| 先頭バイト | 意味 |
|---|---|
| 0〜250 | その値そのもの(1バイト) |
| 252 | 後続2バイトが実際の値 |
| 253 | 後続3バイト |
| 254 | 後続8バイト |
シンプルに見える。だが、VARCHARの長さ、BLOBのサイズ、NULLビットマップの後の行データ——あらゆるところにこのエンコーディングが出現する。見落とすと1バイトずれ、以降のすべてのパースが壊れる。デバッグは楽しくない。
DECIMALのバイナリエンコーディング
binlogで最も意味不明だったのはDECIMAL型のバイナリフォーマットだ。
DECIMAL(10,2)に「12345.67」を格納するとき、整数部と小数部を分けて、それぞれを9桁ずつのグループに切って4バイトのビッグエンディアン整数に詰める。端数のグループは桁数に応じて1〜4バイト。符号は先頭バイトのMSBをXORで反転。負数はさらに全バイトをビット反転。
DECIMALバイナリフォーマットの詳細
たとえばDECIMAL(10,2)で「-12345.67」を格納する場合:
- 整数部「12345」→ 9桁グループに分割:「12345」(5桁、3バイト)
- 小数部「67」→ 9桁グループに分割:「67」(2桁、1バイト)
- 整数部をビッグエンディアンで書く:
0x00 0x30 0x39 - 小数部をビッグエンディアンで書く:
0x43 - 負数なので全バイトをビット反転:
0xFF 0xCF 0xC6 0xBC - 先頭バイトのMSB(0x80)をXOR:
0x7F 0xCF 0xC6 0xBC
なぜビッグエンディアンなのか(他のフィールドはリトルエンディアンなのに)。なぜXOR反転なのか。理由はmemcmpでソートできるようにするためだ。この設計により、バイト列の辞書順比較がそのまま数値の大小比較になる。合理的だが、パーサーを書く人間には優しくない。
mysql-event-streamのrows_parserでこのデコーダーを書いたとき、テストケースを37パターン用意した。正の数、負の数、ゼロ、先頭ゼロあり、小数部のみ、整数部のみ、精度の限界値。全パターンでMySQLの実際の出力と突合した。1パターンでもずれたらデータが壊れる。
レプリケーション遅延の正体
binlogで避けて通れないのがレプリケーション遅延——プライマリとレプリカの間にデータの差が生まれる現象だ。
原因の構造はシンプルだ。プライマリは複数のスレッドで並行してトランザクションを処理し、binlogに書き込む。レプリカ側は伝統的にシングルスレッドのSQL Applierがこれを読んで再適用していた。プライマリが並列で書いた変更を、レプリカが1本のスレッドで直列に処理する。負荷が高ければ、当然追いつかない。
MySQL 5.6でデータベース単位の並列適用が導入され、MySQL 5.7でMTS(Multi-Threaded Slave)として論理クロックベースの並列適用に進化した。「プライマリで同時にコミットできたトランザクションは、レプリカでも並列に適用できる」という考え方だ。MySQL 8.0.27でWriteset-basedの依存関係追跡が導入され、さらに並列度が上がった。
MygramDBでの経験
MygramDBはMySQLのレプリカとして振る舞うが、SQLを再実行するのではなく、ROWSイベントからn-gramインデックスを直接更新する。この場合、MySQL標準のMTSは使えない——自前でイベントの並行処理を設計する必要があった。
MygramDBのBinlogReaderはReaderスレッドとWorkerスレッドの2スレッド構成で、間に10,000件のイベントキューを挟んでいる。Readerスレッドはひたすらbinlogイベントを読んでキューに入れ、Workerスレッドがキューから取り出してインデックスを更新する。キューが満杯になるとReaderがブロックされ、自然にバックプレッシャーがかかる。MySQLサーバー側はbinlog送信を一時停止して待つ。
binlog.cc——10,771行の深淵
MySQLのソースコードを読もうと思ったのは、パーサーのバグが取れなかったときだ。sql/binlog.ccを開いて最初に見えたのはファイルサイズ——380KB、10,771行。binlog周りのロジックが1ファイルに詰め込まれている。グループコミット、ローテーション、パージ、リカバリ。AIに要約させながら読んだが、それでも丸一日かかった。自力なら発狂していただろう。
ただ、読んだことで「なぜbinlogのフォーマットがこうなっているか」が見えてきた。互換性の制約だ。新しいイベントタイプを追加するとき、古いバージョンのMySQLが読んでもクラッシュしないようにしなければならない。未知のイベントはスキップできるように、ヘッダにイベント長が含まれている。V2 rows eventも、WRITE_ROWS_EVENTとは別のイベントタイプコード(30 vs 23)を割り当てて後方互換を保っている。
歴史のレイヤーが積み重なったフォーマットだ。洗練されているかと聞かれたら、正直微妙だ。でも、20年以上にわたって後方互換を維持しながら拡張し続けたプロトコルとしては、驚くほどよくできている。壊れずに動いているものを雑に批判するのは、動かした経験がない人間のやることだ。
パーサーを書くということ
MygramDBのbinlogパーサーを書き始めてから安定するまで、数ヶ月かかった。mysql-event-streamとして切り出すときにもう一度ゼロから設計し直した。2周してようやく「こう書くべきだった」がわかった。
binlogのパーサーを書くという行為は、MySQLの設計判断を追体験する行為でもあった。なぜpacked integerなのか(帯域の節約)。なぜTABLE_MAPとROWSが分離しているのか(同じテーブルへの連続操作でスキーマ情報を重複送信しないため)。なぜDECIMALだけビッグエンディアンなのか(memcmpソートのため)。
ひとつひとつに理由がある。ドキュメントに書かれていない理由が。ソースコードを読まないとわからない理由が。
MySQLのbinlogは、20年以上の本番運用の歴史が圧縮されたバイナリストリームだ。きれいではない。でもきれいなプロトコルが20年保つとは限らない。