MySQL vs MariaDB Binlog: The Fork That Split the Stream
MygramDBのMariaDB対応を始めた。MygramDBはMySQL binlogレプリケーションでデータを取り込む全文検索エンジンで、binlogのバイナリフォーマットを自前でパースしている。「MariaDBはMySQLと互換性がある」とよく言われるので、少し手を入れれば動くだろうと思っていた。
MariaDB 13.0.1のソースコードを開いて、その見積もりが甘かったことを知った。
行イベントは1バイトも違わない。NULLビットマップ、packed integer、TABLE_MAP——全部同じだ。ところがGTIDは完全に別物だった。イベント型番号も違う、バイナリレイアウトも違う、レプリケーションプロトコルも違う。行パーサーの変更はゼロ。GTID周りはゼロから書き直し。そういう対応になった。
この記事の前提知識
binlogの基本構造(イベントヘッダ、TABLE_MAP→ROWSの2段構え、packed integerなど)は「The MySQL Binlog」で詳しく書いた。MySQL 9.xでの変更点は「MySQL 9 Binlog: What's Actually Different」にまとめている。この記事はその続編として、MariaDBとの差分に焦点を当てる。
同じもの、違うもの
まず全体像を整理する。
| 領域 | 同じか |
|---|---|
マジックバイト(\xfebin) | 同一 |
| binlogバージョン | 4のまま |
| イベントヘッダ(19バイト固定) | 同一 |
| CRC32チェックサム | 同一 |
| TABLE_MAP_EVENT(type 19) | 同一 |
| WRITE/UPDATE/DELETE_ROWS(type 30-32) | 同一 |
| NULLビットマップ、packed integer | 同一 |
| DECIMAL、VARCHAR、BLOBのエンコーディング | 同一 |
| GTIDのイベント型番号 | 異なる |
| GTIDのバイナリフォーマット | 異なる |
| GTIDの文字列表現 | 異なる |
| レプリケーションダンプ開始プロトコル | 異なる |
| 160番台のMariaDB固有イベント | MySQL側に存在しない |
binlogパーサーを書いたことがある人間にとって、この表は安心と不安が半々だ。行データを扱う部分——パーサーの中で最も複雑で、最もバグが出やすい部分——には一切触らなくていい。しかしGTIDはレプリケーションの根幹だ。「一部だけ違う」のは、「全部違う」より厄介なこともある。
2013年、同じ問題に二つの答え
なぜ同じ祖先を持つソフトウェアのbinlogが、ここまで分岐しているのか。ソースコードのコメントとcopyright headerから、経緯がかなり読み取れる。
MariaDBはMySQL 5.5からフォークした。MySQL 5.5にはGTIDがない。GTIDが入ったのはMySQL 5.6(2013年)だ。
MariaDBのフォーク背景
2008年、OracleがSun Microsystemsを買収し、間接的にMySQLの権利を取得した。MySQLの生みの親であるMonty Widenius(Michael Widenius)はOracleの下でのMySQL開発に懸念を抱き、2009年にMariaDB(娘の名前に由来する)をフォークした。MySQL 5.5をベースに開発が始まり、以降は独自の機能拡張を進めている。
一方、MariaDBは独自にGTIDを実装していた。include/rpl_gtid_base.hのcopyright headerには「Kristian Nielsen and MariaDB Services Ab, 2013, 2024」とある。MySQL 5.6とほぼ同時期に、別のチームが、別の設計思想で、同じ問題を解いた。
MySQL側のGTIDはUUID:sequenceという形式を採った。サーバーをUUIDで識別し、トランザクションに連番を振る。グローバルに一意で、単純明快だ。
MariaDBは別の道を選んだ。domain_id-server_id-seq_no——3つの数値をハイフンで繋ぐ形式だ。この設計の核心は、先頭のdomain_idにある。
domain_id——なぜMariaDBはこれを必要としたのか
sql/rpl_gtid.hにこうある。
For every independent replication stream (identified by domain_id), this remembers the last gtid applied on the slave within this domain. Since events are always committed in-order within a single domain, this is sufficient to maintain the state of the replication slave.
「独立したレプリケーションストリーム」。これがMariaDB GTIDの設計原理だ。
MySQLのGTIDは単一のグローバルストリームを前提にしている。1つのプライマリから1つのレプリカへ、1本の流れ。フェイルオーバー時に「どこまで適用したか」を追跡するにはそれで十分だ。
MariaDBが解きたかった問題は違う。マルチソースレプリケーション——複数のマスターから1台のスレーブに複製する構成——で、各マスターからの流れを独立に追跡したかった。
マルチソースレプリケーションとは
通常のレプリケーションは1対1(プライマリ→レプリカ)だが、マルチソースでは複数のプライマリから1台のレプリカにデータを集約する。たとえばシャーディングされた複数のDBの変更を、分析用の1台に統合するようなケースだ。各プライマリからの変更を混同せずに追跡する必要がある。
domain_idがあることで、各ドメインの中ではイベントが順序通りにコミットされることが保証される。ドメイン間は独立だ。あるドメインのレプリケーションが遅延しても、他のドメインには影響しない。
sql/rpl_gtid.ccには、マルチソースレプリケーションで同じGTIDが複数のマスター接続から届く場合の制御コードがある。--gtid-ignore-duplicatesオプションで、同一GTIDの重複適用を防ぐ仕組みだ。1つのドメインにつき1つのRelay_log_infoだけが「所有者」となり、他の接続はそのドメインの所有権が解放されるまで待つ。
この設計は並列レプリケーションにも効いている。MariaDBのGTIDイベントにはFL_ALLOW_PARALLELフラグ(bit 3)がある。異なるドメインのイベントグループは、依存関係がないかぎり並列に適用できる。FL_WAITEDフラグ(bit 4)は行ロック待ちが発生したことを示し、並列適用の判断材料になる。
MySQLは後にMTS(Multi-Threaded Slave)でlogical-clockベースの並列適用を実装した。アプローチは異なるが、解きたい問題は同じだ。
バイナリフォーマットの実際
ソースコードから読み取れるバイナリレイアウトを比較する。
MySQL GTID_LOG_EVENT(type 33)
MySQLのGTIDイベントは、16バイトのUUID(バイナリ)に8バイトのシーケンス番号が続く。
| フィールド | サイズ | 内容 |
|---|---|---|
| flags | 1 byte | コミットフラグ |
| UUID | 16 bytes | サーバーUUID(バイナリ) |
| gno | 8 bytes | トランザクション番号 |
文字列表現: 550e8400-e29b-41d4-a716-446655440000:42
MariaDB GTID_EVENT(type 162)
MariaDBのGTIDイベントは19バイトのポストヘッダを持つ。log_event.hのコメントにはこう書いてある。
The binary format for Gtid_log_event has 6 extra reserved bytes to make the length a total of 19 byte (+ 19 bytes of header in common with all events). This is just the minimal size for a BEGIN query event, which makes it easy to replace this event with such BEGIN event to remain compatible with old slave servers.
19バイトという長さはBEGINクエリイベントの最小サイズと同じだ。古いスレーブに送るとき、GTIDイベントをBEGINクエリイベントにin-placeで書き換えられるよう、意図的にサイズを揃えている。
| フィールド | サイズ | 内容 |
|---|---|---|
| seq_no | 8 bytes | トランザクション連番(LE) |
| domain_id | 4 bytes | レプリケーションドメインID(LE) |
| flags2 | 1 byte | FL_STANDALONE, FL_GROUP_COMMIT_ID等 |
| reserved / commit_id | 6 / 8 bytes | 予約領域またはグループコミットID |
server_idはイベント共通ヘッダ(19バイト)のバイト5-8から取る。ポストヘッダには含まれない。
文字列表現: 0-1-42(domain_id=0, server_id=1, seq_no=42)
flags2のビットフィールドが興味深い。
| ビット | 名前 | 意味 |
|---|---|---|
| 0 | FL_STANDALONE | COMMITなしのスタンドアロンイベント |
| 1 | FL_GROUP_COMMIT_ID | グループコミットの一部 |
| 2 | FL_TRANSACTIONAL | 安全にロールバック可能 |
| 3 | FL_ALLOW_PARALLEL | 並列レプリケーション許可 |
| 4 | FL_WAITED | 行ロック待ち発生 |
| 5 | FL_DDL | DDLを含む |
| 6 | FL_PREPARED_XA | XAトランザクション準備済み |
| 7 | FL_COMPLETED_XA | XAトランザクション完了 |
MySQLのGTIDイベントにはこのレベルの並列レプリケーション制御フラグがない。MariaDBはGTIDイベント自体にレプリケーション最適化のメタデータを埋め込んでいる。
MariaDB GTID_LIST_EVENT(type 163)
MySQLのPREVIOUS_GTIDS_LOG_EVENT(type 35)に相当する。binlogファイルの先頭に記録され、その時点のレプリケーション状態を表す。
count 4 bytes(下位28ビットがカウント、上位4ビットがフラグ)
entry × count:
domain_id 4 bytes
server_id 4 bytes
seq_no 8 bytes1エントリ16バイト。element_size = 4+4+8とソースコードにある。countの上位4ビットをフラグに使うのは、フィールドを追加せずに機能を拡張するためだろう。
160番台——MariaDBだけの世界
MariaDBはtype 160から独自のイベント型番号を使っている。ソースコード上でMARIA_EVENTS_BEGIN= 160と定義されている。
| type | 名前 | 用途 |
|---|---|---|
| 160 | ANNOTATE_ROWS_EVENT | ROWSイベントに元SQLをアノテーション |
| 161 | BINLOG_CHECKPOINT_EVENT | binlogチェックポイント |
| 162 | GTID_EVENT | MariaDB GTID |
| 163 | GTID_LIST_EVENT | binlog先頭のGTIDリスト |
| 164 | START_ENCRYPTION_EVENT | binlog暗号化 |
| 165 | QUERY_COMPRESSED_EVENT | クエリの圧縮版 |
| 166-168 | ROWS_COMPRESSED_EVENT_V1 | 行イベントV1の圧縮版 |
| 169-171 | ROWS_COMPRESSED_EVENT | 行イベントV2の圧縮版 |
| 172 | PARTIAL_ROW_DATA_EVENT | 部分行データ |
逆に、MySQLのtype 33〜35(GTID_LOG_EVENT、ANONYMOUS_GTID、PREVIOUS_GTIDS)はMariaDBのソースコードで明示的にこうコメントされている。
/* MySQL 5.6 GTID events, ignored by MariaDB */
GTID_LOG_EVENT= 33,
ANONYMOUS_GTID_LOG_EVENT= 34,
PREVIOUS_GTIDS_LOG_EVENT= 35,パーサーの実装でも、これらはIgnorable_log_eventとして処理される。読み飛ばすだけだ。
binlogパーサーを書く立場から言えば、イベントヘッダの19バイトにイベント長が入っている設計が活きている。未知のイベント型が来ても、長さ分だけスキップすれば次のイベントに進める。MySQLのパーサーがMariaDBのbinlogを読んだとき、type 162のイベントを「知らないイベント」としてスキップできる。逆もまた然りだ。この後方互換の仕組みは、20年前の設計者に感謝すべきだろう。
binlogの判別方法
マジックバイトではMySQLとMariaDBを区別できない。どちらも\xfebinだ。
判別手段は3つある。
1. FORMAT_DESCRIPTION_EVENT内のバージョン文字列
binlogファイルの先頭にあるFORMAT_DESCRIPTION_EVENTには、50バイトのサーバーバージョン文字列が入っている。MariaDBなら"13.0.1-MariaDB"のように"MariaDB"を含む。ソースコード内のFormat_description_log_eventクラスにはmaster_version_splitというパーサーがあり、KIND_MYSQLとKIND_MARIADBのenumで区別する。
2. SELECT VERSION()
接続時にSELECT VERSION()を実行すれば、結果に"MariaDB"が含まれるかで判別できる。binlogファイルを直接読む場合ではなく、レプリケーション接続を確立する場合はこちらが使える。
3. イベント型番号
160番以上のイベントが現れたらMariaDBだ。とくにtype 162(GTID_EVENT)は、すべてのトランザクションの先頭に出現するので、ストリームを少し読めばすぐわかる。
MygramDBでは、接続時にSELECT VERSION()で自動判別し、Connectionオブジェクトにフレーバーを記録する設計にした。
レプリケーションプロトコルの違い
GTIDベースのレプリケーションを開始するプロトコルが、MySQL/MariaDBで根本的に異なる。
MySQL: COM_BINLOG_DUMP_GTID
MySQLは専用のコマンドCOM_BINLOG_DUMP_GTIDを使う。GTIDセットをバイナリエンコードしてパケットに詰め、サーバーに送る。
MariaDB: SQLでセットしてからCOM_BINLOG_DUMP
MariaDBはもっとシンプルだ。GTIDの位置をSQLのセッション変数で指定してから、古いCOM_BINLOG_DUMPコマンドを発行する。
SET @slave_connect_state='0-1-42,1-1-100'; -- GTID位置
SET @slave_gtid_strict_mode=1;
SET @master_heartbeat_period=3000000000; -- 3秒
SET @master_binlog_checksum=@@global.binlog_checksum;この後、COM_BINLOG_DUMPパケットを組み立てて送る。
binlog_pos 4 bytes 開始位置(通常4)
binlog_flags 2 bytes フラグ
server_id 4 bytes レプリカのサーバーID
filename variable binlogファイル名MySQLのCOM_BINLOG_DUMP_GTIDがバイナリのGTIDセットを1パケットに詰め込むのに対して、MariaDBはSQLとバイナリプロトコルを組み合わせている。実装する側としては、MariaDBの方がデバッグしやすい。SQLで状態を確認できるからだ。
ただし、COM_BINLOG_DUMPパケットの送信にはsimple_command()、レスポンスの読み取りにはcli_safe_read()が必要で、どちらもクライアントライブラリの内部関数だ。extern "C"宣言でリンクする必要がある。
COM_BINLOG_DUMPとCOM_BINLOG_DUMP_GTID
どちらもMySQLのバイナリプロトコルで定義されたコマンドだ。COM_BINLOG_DUMPはMySQL 4.0時代からある古いコマンドで、binlogファイル名+オフセットでストリーム位置を指定する。COM_BINLOG_DUMP_GTIDはMySQL 5.6で追加された新しいコマンドで、GTIDセットをバイナリエンコードしてパケットに含める。MariaDBは古いCOM_BINLOG_DUMPを使いつつ、SQLのセッション変数でGTID位置を事前にセットする方式を採っている。
ソースコードを読んで
MariaDBのsql/log.ccは15,000行ある。sql/log_event.hは6,200行。sql/log_event_server.ccは8,900行。sql/sql_repl.ccは5,800行。sql/rpl_gtid.ccは4,100行。binlog関連だけで40,000行を超える。全部読んだとは言わない。GTIDとイベント定義に関連する部分だけを追った。
印象的だったのは、随所に埋め込まれた後方互換への執着だ。GTIDイベントのサイズをBEGINクエリイベントと揃えたこと、型番号を160番台に飛ばしてMySQLと衝突しないようにしたこと、互いのGTIDイベントをIgnorable_log_eventとして安全に読み飛ばせること。フォークした2つのプロジェクトが、相手のストリームを壊さないように気を配っている。
MariaDBのビルド設定
デフォルトのビルドタイプはRelWithDebInfo——リリース最適化にデバッグ情報を付けたもの。C++17標準。-fno-omit-frame-pointerでフレームポインタを保持し、-D_FORTIFY_SOURCE=2でバッファオーバーフローを検出する。ASAN、TSAN、UBSAN、MSANのサニタイザが全部使える。ソースの規模のわりに、デバッグ体験は意外と良い。
MygramDBへの影響
最後に、実際の対応方針を整理する。
| 領域 | 変更の有無 |
|---|---|
| 行イベントパーサー(rows_parser) | 変更なし |
| NULLビットマップ、packed integer | 変更なし |
| TABLE_MAPキャッシュ | 変更なし |
| GTID解析 | 新規実装 |
| レプリケーションプロトコル | 新規実装 |
| サーバーフレーバー検出 | 新規実装 |
| 接続バリデーション | 分岐追加 |
IBinlogStreamというインターフェースを導入して、MySQL用とMariaDB用のストリーム実装をStrategy Patternで切り替える設計にした。行イベントの処理は共通のまま。GTID関連のコードだけがフレーバーごとに分岐する。
フォーマットの分岐点が明確なおかげで、設計は比較的きれいに切れた。「互換性がある部分」と「完全に別物の部分」の境界が、曖昧ではなくバイナリレベルで明確だったからだ。
顔は似ている、性格が違う
MySQLとMariaDBのbinlogは、双子のようなものだ。マジックバイトが同じ、ヘッダが同じ、行データが同じ。見た目はそっくりで、「互換性がある」という評判はここから来ている。
しかしGTID——レプリケーションの要——になると、性格が出る。UUIDベースのグローバル一意性を選んだMySQLと、ドメインベースの独立ストリームを選んだMariaDB。2013年に同時期に、同じ問題に対して、異なる答えを出した結果だ。
binlogパーサーを書いた人間としては、「行データが同一」というのが何より大きい。パーサーで最も複雑で、最もバグが出やすい部分が共有されている。GTIDは設計し直す必要があるが、それは明確に定義された範囲の作業だ。無限に広がる互換性問題ではない。
顔が同じだからといって、同じ人間だと思ってはいけない。でも、同じ親から生まれたことは間違いない。