読者です 読者をやめる 読者になる 読者になる

BizStationブログ

ビズステーション株式会社の公式ブログです。

MySQL/MariaDB レプリケーション復旧 テーブルロックをせず特定テーブルのみコピーする その2

MariaDB MySQL Transactd

前回は、レプリケーション復旧においてMySQLのコマンドを使って最もロック時間を短くコピーし直す方法を紹介しました。
今回は、全くテーブルをロックせずに特定のテーブルやデータベースをコピーし直す方法を紹介します。

Nonlocking Replcopy

Nonlocking Replcopyは前回の記事の内容を実装したPHPスクリプトです。ブログのタイトル通り、テーブルロックをせず特定テーブルのみコピーできます。
スクリプトGitHubからダウンロードできます。実行環境や使い方の概要はreadmeをご覧ください。

github.com

また、テスト用のTransactdサーバー環境を手元のWindowsマシンに3分でセットアップできるTransactdTestSrvSetupもあります。是非活用してみてください。

Nonlocking Replcopyは以下のことができます。

それでは、このスクリプトを使った具体的な新規セットアップと、エラー別の修復方法を説明します。どの例もサーバーは稼働中のままでOKです。

新規にレプリケーションをセットアップする

設定はすべて設定ファイルに記述します。スクリプトコマンドラインパラメータには設定ファイル名を渡します。

$php replcopy.php repl_config.ini

具体的な新規セットアップ条件は以下の内容とします。

サーバーの種類MySQL 5.6
対象データサーバー全体
マスターホスト名server1
スレーブホスト名server2
以下は上記条件で新規レプリケーションをセットアップしたときの実行結果です。

[replcopy]# php dreplcpy.php ./repl_config_centOS.ini
Nonlocking replcopy version 1.0.0
--- Start replication setup  ---
Open slave database ...  done!
Stop slave ...  done!
Open master database ...  done!
Open master tables ...  done!
Begin snapshot on master ...  done!
Wait for stop slave until binlog pos ...  done!
Copying tables ...
  [database : test_v3]
    table : fieldtest ...  done!
    table : groups ...  done!
    table : nullkey ...  done!
    table : nullvalue ...  done!
    table : packrecord_test ...  done!
    table : scores ...  done!
    table : setenumbit ...  done!
    table : test ...  done!
    table : timetest ...  done!
    table : users ...  done!
Reset slave ...  done!
Change master ...
        set global gtid_purged='d30d02d6-3fe4-11e5-97db-00ffa4dbde57:1-8';
        change master to master_host='server1', master_port=3306, master_user='replication_user', master_password='123', master_auto_position = 1;  done!
Start slave ...  done!
Slave_IO_Running = Yes
Slave_SQL_Running = Yes
--- Replication setup has been completed ---
[replcopy]#

こんな感じで実行されます。どのような処理をしているかは、この実行結果の通りです。成功/失敗は最後のSlave_IO_Running = YesSlave_SQL_Running = Yesで判断しています。

設定のポイント

設定ファイルは以下のような内容です。

[master]
host=server1
repl_port=3306
repl_user=replication_user
repl_passwd=123

databases=
tables=
ignore_tables=

[slave]
host=server2
master_resettable=1
log_bin=0

[gtid]
using_mysql_gtid=1
type=2
  • databasestablesに何も指定しないとホスト全体をコピーします。
  • GTIDを使った新規セットアップはRESET MASTERの実行は必須です。master_resettable=1をセットして有効にします。
  • 新規セットアップなのでコピー処理のログは不要です。log-bin=0を指定してOFFにします。
  • MySQL 5.6のGTIDを使用するので、using_mysql_gtid=1をセットします。
  • MySQL 5.6のGTIDによるポジション指定でレプリケーションをセットアップするので、type=2を指定します。(0は非GTID、1はMariaDBのGTID)

mysql システムデータベース

mysqlという名前のデフォルトのデータベースがあります。このデータベースのテーブルはほとんどがMyISAMのテーブルです。MyISAMはMVCCをサポートしておらず、スナップショットが取れません。そのためコピー中にマスターで変更を加えると不整合を起こす場合があります。

Nonlocking Replcopyでは、ホスト全体を指定してもこのmysqlデータベースは含まれません。ホスト全体コピーの前にdatabases=mysqlとして実行し、その後ホスト全体を指定してコピーする分割方式で、完全な全体コピーを行うことができます。ただし、mysqlデータベースのコピー中はマスターでこのデータベースを変更しないことが条件です。

レプリケーションエラーを修復する

レプリケーションの修復を行う理由として

  • SQLスレッドエラー
  • I/Oスレッドエラー
  • データの不一致

などがあります。
それぞれのパターンごとに修復の仕方を説明します。

データの不一致を修復

これは最もシンプルです。データのコンペアツールやユーザーからの申告などでデータの不整合に気付いた場合です。SQLスレッドは運良くまだエラーにはなっていない場合です。
この場合は不整合のあるテーブルのみ列挙してコピーします。

不一致のあるデータベース名test
不一致のあるテーブル名tabel_a
table_b

設定ファイル上で、新規セットアップと異なる点は以下の部分です。

[master]
...
databases=test
tables=tabel_a,table_b
...

この設定で実行すれば、スムーズにコピーして終わります。

SQLスレッドのエラーを修復

設定内容は、上記の「データの不一致を修復」と同じです。ただし、SQLスレッドエラーを起こしているテーブルを事前にある程度調べる必要があります。そしてそのテーブルをコピー対象に設定します。もし、エラーの中に想定外のテーブルが出現した場合は、キャンセルして設定ファイルにそのテーブルを加えて再実行します。

このスクリプトは、START SLAVE UNTILを使って指定したポジションまでSQLスレッドが進むのを待機します。ところがSQLスレッドエラーが発生してしまってはそこまで進むことができません。そこで、待機時にSQLスレッドエラーがある場合、その内容を表示しスキップするかどうかを問い合わせします。

-------------------------
SQL thread has error(s).
-------------------------
Could not execute Update_rows_v1 event on table test_v3.fieldtest; Can't find record in 'fieldtest', Error_code: 1032; handler error HA_ERR_KEY_NOT_FOUND; the event's master log mysqld-bin.000016, end_log_pos 2424
Do you want skip only this error ?
Y: Skip this error | A: Skip all error | C: Cancel replication

選択肢は以下の3つです。

  • Y : このエラーのみスキップする
  • A : RESET SLAVEしてすべてのエラーをスキップする
  • C : レプリケーション修復をキャンセルする

あらゆる条件で矛盾なく進められるのは、Yの「このエラーのみスキップする」を選択し、かつこのエラーの起きているデータベースまたはテーブルが今回のコピー対象に含まれている場合です。
エラーのスキップはすべてのエラーがなくなるまで繰り返し行う必要があります。

エラーメッセージには、対象のデータベース名やテーブル名が含まれているので、必ず確認するようにします。

Aの「RESET SLAVEしてすべてのエラーをスキップする」では容易にコピーを開始することができますが、リレーログにコピー対象に含まれないトランザクションがあるとそれがスレーブに適用されず矛盾が生じます。ホスト全体が対象であったり、コピー対象外のデータがあっても問題ない場合やコピー対象外のデータは無いとわかっている場合はこれを選択できます。

もし、コピー対象外で問題のあるSQLスレッドエラーが出現したらCの「キャンセル」をして、そのテーブルやデータベースを設定に加えて再度実行してください。ここでのキャンセルは追加の問題を発生させたりはしません。

このスキップを通過すれば、あとは新規セットアップと同じようにテーブルがコピーされレプリケーションが再開されます。

I/Oスレッドのエラーを修復

I/Oスレッドエラーの原因は

  • レプリケーション用のアカウントが無効になった
  • ファイアウォール設定が変更されブロックされた
  • マスターが停止している
  • マスターで RESET MASTERなどが実行され、スレーブの要求するログがない

など多岐に渡ります。また修復方法も原因ごとに異なります。Nonlocking Replcopyはこれらを回復するためのものではありませんが、再セットアップしてしまいたい場合には有用です。

レプリケーションの再セットアップ

この設定は新規セットアップと何も変わりありません。
ただ、既にスレーブが設定済みのため、現状の状態にI/Oスレッドエラーやバイナリログの矛盾あったりします。その場合、それらを表示して継続するかどうか問い合わせします。

-------------------------
IO thread has error(s).
-------------------------
Got fatal error 1236 from master when reading data from binary log: 'Error: connecting slave requested to start from GTID 0-1-234, which is not in the master's binlog'
Do you want stop and reset slave ?
 (Y/N) ?

Yを選択すると、I/Oエラー自体を無視して再セットアップを行います。開発やテストなどでレプリケーションを行う場合、この再セットアップはとても頻繁に使用されます。

GTIDへの対応

Nonlocking ReplcopyMySQLMariaDBの両方のGTIDに対応しています。GTIDを使用している場合でも、修復コピーの考え方は前回の記事と同様です。
また、GTIDを使ったレプリケーションについては以下の記事も是非ご覧ください。
bizstation.hatenablog.com

エラーのスキップ

エラーのスキップについては、MySQL 5.6以降でgtid_mode=onの場合、空のトランザクションに代替する方法でスキップします。それ以外のサーバーはsql_slave_skip_counter=1を使ってスキップします。

ポジション指定

type=0とした場合、従来のバイナリログ名とポジションを使ってログポジションの指定をします。MySQLgtid_mode=onの場合でもこの指定は可能です。
type=1とした場合、MariaDBmaster_use_gtid = slave_posを使ってログポジションの指定をします。
type=2とした場合、MySQLmaster_auto_position = 1を使ってログポジションの指定をします。

フェイルオーバーでの注意点

GTIDを使ったマスターと複数のスレーブによるフェイルオーバー構成をしているときの注意点を説明します。
スレーブはフェイルオーバーによってマスターになる可能性があります。(ここでは以降、スレーブから昇格したマスターを新マスターと呼びます。)
新マスターは、遅れのあるスレーブに自身のバイナリログからトランザクションを転送しなければなりません。転送する可能性のあるのは、遅れスレーブとの差分です。

意図的に遅らせたスレーブがなければ、遅れはごくわずかな時間(例えば1秒)です。
例えば、あるスレーブを修復コピーしバイナリログを消去しても、それから1秒以上経てば、新マスターになって問題ありません。修復してから1秒ですから現実的にはほとんど問題ないでしょう。
しかし、意図的に12時間遅らせる等の設定をしたスレーブがある場合、バイナリログを消去したスレーブは、それから12時間以上経たないと新マスターになる資格がありません。フェイルオーバースクリプトなどを用意し、ある時間までは昇格できないと制御する必要があります。

Nonlocking Replcopyでバイナリログに関連するパラメータは以下の2つです。

  • log_bin
  • master_resettable

MySQLのGTIDとそれ以外の場合ではこれらに対する対処が異なるので、分けて説明します。

MySQL GTID

MySQLのGTIDはマスターとGTIDセットで比較するので、スレーブ上で余計なトランザクションを実行すると、新マスターになった時にそれが転送されてしまいます。ですのでmaster_resettable=1を指定して、そのスレーブのバイナリログをリセットした方がうまくいきます。ただ、リセットするとマスターになったときに遅延スレーブからのログ転送要求に答えることができません。遅延させたスレーブがある場合は、その遅延時間が経過するまで、修復したスレーブはマスターになれないとマークすることが必要です。

MariaDB GTID

MariaDBのGTIDの場合、log_bin=0としてコピーの内容をバイナリログに記録しないようにしましょう。遅れスレーブに対しても問題ありません。

もし、この修復したスレーブが多段構成でマスターでもある場合は、log_bin=1の必要が出てきます。この場合、MySQLの場合と同じように、遅延させたスレーブがある場合は、その遅延時間が経過するまで、修復したスレーブはマスターになれないとマークすることが必要です。さらに、修復直後にマスター上で何らかのトランザクションを発生せて、それが遅延スレーブに反映されるようにしましょう。そうでないと、遅延スレーブがコピーの内容以降のポジションを要求してくれません。

まとめ

2回に渡ってレプリケーションの復旧コピーについて説明しました。また、間にGTIDの詳細の記事も書きました。この3つの記事で、レプリケーションの復旧についてはだいたい網羅できたかと思います。

今回、パターン別修復方法の記事を書いたことで、自分の中でも修復時の問題と対処をうまく整理できました。

Nonlocking Replcopyは開発環境などではとても便利です。(開発時はあれやこれやですぐにSQLスレッドが止まってたりしますので)設定ファイルさえ作っておけば、コマンド一発でレプリケーションの修復ができます。

これがTransactdを使うきっかけになってくれれば嬉しいです。コメントも大歓迎! です。

MySQL/MariaDB GTID レプリケーション詳細

MariaDB MySQL Transactd

今回は、MySQL/MariaDB GTID レプリケーションの詳細を説明します。これは、Transactdによるレプリケーションセットアップ(修復)ツールを構築する際に調べたものです。

主に従来のバイナリログとポジションを使ったレプリケーションとGTIDによるレプリケーションの違いについて説明します。ある程度従来のレプリケーションのセットアップなどを理解していることを前提にしています。

なぜGTIDが必要なのか

まず、最初になぜGTIDが必要なのでしょうか?よく言われているのは、

  • CHANGE MASTER TOバイナリログポジションをいちいち指定しなくても良い

といったものですが、さほど大した問題では無いように感じます。本当のところ何のためでしょうか?

それは、MariaDBのドキュメントに明確に書かれています。

  • マスターと複数スレーブの構成で、マスターがダウンしてスレーブのいずれかがマスターに昇格したときに、他のスレーブがマスターを切替えるためCHANGE MASTERを発行するが、その時のポジション指定の問題を解決する

マスターがダウンしたら、スレーブ群の中から、レプリケーションの遅れが最も少ないスレーブをマスターに昇格*1させます。
他のスレーブは新マスターに対してCHANGE MASTERを発行してマスターを切り替えます。問題なのはこのときです。各スレーブごとに旧マスターのバイナリログ名とポジションでどこまでレプリケートしたかはわかっています。しかし、新マスターでいうところのどのファイル名でポジションがいくつなのかはわからないのです。*2
GTIDはこの問題を解決するために生まれました。

MariaDBのGTIDの目的はこれだけです。
MySQLのGTIDにはさらに「マスターとスレーブの一貫性の判断を容易にする」という目的が加えられています。これについては MySQLでGTIDを使う の項目で説明します。)

GTIDが活きるシナリオ

マスターと複数スレーブの構成でマスターを切り替えるフェイルオーバーを行うのであれば、GTIDの恩恵を大いに受けることができます。

正確にはもう少し限定的で、フェイルオーバーを行う際にスレーブ群の中で遅れているスレーブがあってもそれを救済するフェイルオーバーです。
もし、遅れているスレーブを切り捨てるならば、フェイルオーバーした新マスターと他のスレーブは同じデータを持っているので、新マスターのバイナリログをリセットreset masterしスレーブもreset slaveしてポジション指定なしでCHANGE MASTERすれば、従来の方法でも正しく切替ができるからです。

もう一つ、手動でマスターを切替える(スイッチオーバー)際に、すべてのスレーブが同期するのを待たなくて良くなります。同期できているスレーブが1台あればそれを新マスターにすることで遅れているスレーブはその新マスターからデータを受け取ることができるようになります。迅速なスイッチオーバーができます。

上記の必要がなければGTIDの恩恵はなく、従来のバイナリログとポジションを使ったレプリケーションとほぼ同等の機能です。(追記 マルチマスターになると話がややこしくなりますのでここではそれは除いて考えます)

GTIDによるポジションの解決

まず最初に、マスター切り替え時のポジション問題の解決方法について説明します。解決の考え方自体はMariaDBMySQLもほとんど同じです。(実装と扱うためのコマンドは異なります)

従来は、マスターが切り替わったときに、スレーブが新マスターに対して「旧マスターのこのポジション」と要求しても、新マスターのバイナリログはサーバーが異なるので意味がなく、どこのことかわからない状態でした、そのため、新マスターのバイナリログをスキャンし、スレーブが適用済みの位置を自前で探すしかありませんでした。位置を探すのも明確な目印があるわけでもなく、ある程度の長さが一致する部分を探すしかなかったわけです。

そこで、バイナリログに目印としてトランザクションごとに番号を振って、それを一緒に記録するようにしました。この番号がGTIDです。サーバーが異なってもIDが重複しないようにグローバルにユニークな番号です。(Global Transaction ID

また、スレーブはバイナリログの書き込みの際に、マスターから転送されたトランザクションのGTIDを必ず転記するようにします。そうすることで後でマスターになったとしても、スレーブからGTIDさえ教えてもらえればバイナリログのその場所を正しく探し出して返すことができます。

GTIDを使ったレプリケーションの処理をまとめると以下のようになります。

  • マスターはトランザクションごとに重複しないIDを振り、それをバイナリログに記録する
  • マスターはスレーブからのバイナリログ要求にGTIDの情報も加えて転送する
  • スレーブはマスターから受け取ったトランザクションを自身のバイナリログを記録する際にそのID(GTID)を転記する

これらの準備があったうえで、以下のようにすることで、旧マスターでのポジションが新マスターではどこかを解決できるようになります。

  • スレーブはバイナリログログポジションに代えて最後に受け取ったGTIDで要求する
  • マスターはバイナリログ中のGTIDを探し次のトランザクションを返す

具体的なGTID

GTIDはサーバーが異なっても重複しないユニークな番号ですが、MySQLMariaDBでは生成方法が異なります。

MariaDBdomain-id + server-id + サーバーごとのトランザクション番号
MySQLUUID + サーバーごとのトランザクション番号
MySQLのUUIDはWindowsでよく使われるあの長い322e3d16-355f-11e3-9ee3-00155d031304といった番号です。
MariaDBは従来のserver-idの前にdomain-idを付加することでユニークなものとしています。domain-idはmy.cnfで指定しますが何も指定しなければ0です。

実際の番号はそれぞれ以下のように文字列表現されます。

MariaDB 0-1-1234
MySQL 322e3d16-355f-11e3-9ee3-00155d031304:1234
MariaDBは要素をハイフンでつなぎます。MySQLはコロンでつなぎます。

GTIDを使うための設定

GTIDを使うためのmy.cnfの設定を説明します。

MariaDB/MySQL共通 server-id = 1
log-bin=mysqld-bin
binlog-format = ROW
log-slave-updates
MariaDB gtid_domain_id = 1 (書かなければ gtid_domain_id = 0)
MySQL gtid_mode=ON
enforce-gtid-consistency
赤色の値は、固定値ではなく任意に決めることのできる値です。それぞれの意味の詳細はMySQLのDocument等で確認いただければと思います。
MariaDBはGTIDの基本部分を自分で決めることができますが、MySQLは自動でUUIDを生成します。生成したUUIDはauto.cnfに記録されています。スレーブのデータをマスターからコピーする際にこれもコピーしてしまうと、UUIDが重複してしまうので、コピーしないようにします。

では実際の使い方を説明します。MariaDBMySQLでは異なっているのでそれぞれ分けて説明します。

MariaDBでGTIDを使う

MariaDBではバイナリログポジションがGTIDに代わっただけと考えてほぼ差支えありません。多少パラメータの指定方法が違いますが、従来の方法との互換性が高く、非常に使い易くなっています。
また、MariaDB 10.0以降では、server-idが指定されていれば自動でGTIDが振られているため、特別な設定はほとんどありません。GTIDでポジションを指定してもよいし、従来通り指定しても何ら問題ありません。

構成を行う前準備として、マスター/スレーブともにRESET SLAVE ALLRESET MASTERを事前に行い、現在のログをクリアしておくようにお勧めします。フェイルオーバーした際に、無効なログによる誤動作を防止できます。また、マスターの mysql.gtid_slave_posテーブルのレコードを必ず削除しておいてください。MariaDBのバグでRESET SLAVE ALLを行ってもその値をクリアしてくれません。ここに過去の意味のない情報が残っていると、ダウンしたマスターをスレーブ群に加える際誤動作します。

2つのGTIDポジションモード

スレーブがスタート時にマスターにバイナリログ内容の送信を要求する際には、2種類のGTIDポジションがあります。

  1. gtid_slave_pos:スレーブSQLスレッド*3が最後に実行したトランザクションのGTID
  2. gtid_current_pos:最後に処理したトランザクションのGTID

2つの違いを説明します。gtid_slave_posは、マスターから転送されたトランザクションのうち、最後に実行されたもののGTIDです。一方、gtid_current_posは、マスターから転送されたトランザクションだけでなく、スレーブで直接実行されたトランザクションも含めて最後に処理したもののGTIDです。
スレーブで直接実行されたトランザクションがなければ、gtid_slave_posgtid_current_posは同じ値になります。

SQLスレッドエラーの修復などで、スレーブで直接実行されたトランザクションがある場合は、異なった値になり得ます。
スレーブで直接実行されたトランザクションは、当然マスターには存在しません。そのため、gtid_current_posはマスターのバイナリログに存在しないGTIDの可能性があります。その場合はログ転送ができないため、スレーブはI/Oエラーで開始できません。

スレーブの開始時にgtid_current_posを使用する必要はないように思えますが、後述する「ダウンしたマスターをスレーブ群に加える」の際には便利です。

新規セットアップ

まず、マスターとスレーブのデータを事前に(データコピーやmysqldumpを使って)同じ内容にしておくのは従来と同じです。
レプリケーションを開始するバイナリログ名とポジションはCHANGE MASTER TOで指定していましたが、gtid_slave_posグローバル変数にGTIDを設定してからCHANGE MASTER TO master_use_gtid=slave_posとします。
設定すべきGTIDはマスターのselect @@gtid_binlog_pos;で取得します。また、Transactdではdatabase::beginSnapshot(binlogPos)binlogPos->gtidでスナップショットの開始と同時に取得できます。

SET GLOBAL gtid_slave_pos = "0-1-1234";
CHANGE MASTER TO master_host="master-host", master_port=3306, master_user="rep_useer", master_password="6789", master_use_gtid=slave_pos;
START SLAVE;

スレーブで、マスターを新マスターに切り替える

スレーブのSQLスレッドで最後に処理したGTID(このポジションをslave_posと呼びます)がわかれば、それ以降を新マスターに要求すれば済みます。しかし実際にはホストを指定し直すだけで、遅れていたスレーブも簡単に切替できます。なぜなら、gtid_slave_posグローバル変数には最後にSQLスレッドが処理したGTIDが入っているからです。
CHANGE MASTER TO master_host="master-host", master_port=3306でOKです。ポジションの指定は不要です。

スレーブをマスターに昇格させるときは、最後のGTIDを記録しましょう。旧マスターに未レプリケートのデータが残っていたときのためです。
もし、そのようなデータがある場合は、旧マスターが使えるようになったら一時的に新マスターを旧マスターのスレーブとしてそのGTIDで開始します。そうすれば、旧マスターにしかな残っていないデータを新マスターに補てんすることができます。(それができたらこの旧マスターをスレーブ群に追加することができます。)

余談ですが、仮にスレーブが従来のバイナリログ名とポジションを指定していたとします。それでも、CHANGE MASTER TO master_host="master-host", master_port=3306, master_use_gtid=current_posだけでGTIDを使ったマスター切替が行えます。

ダウンしたマスターをスレーブ群に加える

ダウンしたサーバーの回復が完了し、スレーブ群に加える際、このサーバーはスレーブになったことがないので、SQLスレッドで最後に処理したGTID(slave_pos)がありません。最後に処理したGTIDは自身が最後に処理したポジション(current_pos)です。この場合は、CHANGE MASTER TO master_use_gtid = current_posを指定します。ポジションの指定は不要です。
または、最後に処理したGTIDを select @@gtid_binlog_pos;で調べて、新規セットアップと同様に指定してもOKです。

SQLスレッドのエラーを修復する

MariaDBは従来通りの方法が使用できます。sql_slave_skip_counterでイベントをスキップするか、エラーの原因となっている問題を取り除いて再度スタートする方法です。ただし、MariaDB 10.0.12以前はsql_skip_counterが使用できなくなっていましたので、それ以降のバージョンにしましょう。
また、エラーの原因となっている問題を取り除く際、その処理がバイナリログに記録されない方がよい場合は、事前にSET sql_log_bin=OFFとし記録されないようにします。
テーブルやデータベースごとコピーし直したい場合は、以下の記事を参考にしてください。
bizstation.hatenablog.com

MySQLでGTIDを使う

MySQLでの使い方を説明する前に、MariaDBにはないGTIDのもう一つの目的「マスターとスレーブが一貫しているかどうかを判断する」について説明します。

GTIDセット

MySQLのGTIDによるログの転送は、従来のログファイル名とポジションで行っていたポイント指定(位置指定)でなく、マスター・スレーブのそれぞれで処理したすべてのトランザクション番号を保持し、それらが同じかどうかを比較し、不足分を要求するという方法です。
すなわち、ポイントを管理するのではなく、GTIDのセットを管理します。

この不足分を補う方法で「マスターとスレーブが一貫しているかどうかを判断する」を実現しようとしています。「しようとしています」としたのは、抜け道がたくさんあって「マスターとスレーブのデータが同一である」とは保証できないためです。

GTIDセットの表現方法

処理したGTIDをすべて列挙すると大変な量になってしまうため、連続したIDの場合「1-100」といったように最初と最後の番号のみを保持します。
具体的には、ID1~100であれば322e3d16-355f-11e3-9ee3-00155d031304:1-100のように表現します。マスターを切替えた場合などでは、スレーブの処理済みのGTIDセットには、最初のマスターの分と新マスターの分の両方が含まれます。GTIDセットは322e3d16-355f-11e3-9ee3-00155d031304:1-100,b17243ca-11c2-11e6-95ee-00ffcc08618a:1-1200のようにカンマで区切り列挙されます。

GTIDセットの比較

マスターとスレーブで適用されているトランザクションを比較しますが、適用されたGTIDセットをどこで確認するか説明します。

サーバー コマンド 名前
マスター SHOW MASTER STATUS Executed_Gtid_Set 322e3d16-355f-11e3-9ee3-00155d031304:1-100
スレーブ SHOW SLAVE STATUS Executed_Gtid_Set 322e3d16-355f-11e3-9ee3-00155d031304:1-100
Executed_Gtid_Setは、現在存在するバイナリログに記録されているすべてのGTID(グローバル変数gtid_executed)と、既に正しくパージ(削除)(gtid_purged)されたすべてのGTIDの両方を含めたものです。
Executed_Gtid_Setをクリアするには、マスター/スレーブに関わらず RESET MASTERを行います。RESET MASTERは同時にバイナリログを消去します。gtid_purgedは、gtid_executedが空の場合に限って変更可能です。
従って、マスターデータをスレーブにコピーしレプリケーションを開始する場合は、スレーブにてRESET MASTERを行ったあと、gtid_purgedにマスターのExecuted_Gtid_Setの内容を与えることで、双方の適用済みGTIDセットは同じであると判断させます。

GTIDと従来の方法の混在

MySQL 5.6では、GTIDを使う場合はすべてのサーバーでGTIDを使用しなければなりません。一度すべてのサーバーをシャットダウンして有効化する必要があります。5.7からはローリングアップデートと呼ばれる方法で順次有効化できるようです。(これについては未調査です。)

新規セットアップ

まず、マスターとスレーブのデータを事前に(データコピーやmysqldumpを使って)同じ内容にしておくのは従来と同じです。
MySQLのGTIDレプリケーションでは双方のGTIDセットを比較するので、gtid_purgedにマスターのExecuted_Gtid_Setをセットして、実行済みGTIDセット同じであるようにしてから開始します。
設定すべきGTIDは従来と同様マスターのSHOW MASTER STATUSで取得します。また、Transactdではdatabase::beginSnapshot(binlogPos)binlogPos->gtidで、スナップショットの開始と同時にGTIDセットを取得できます。

RESET MASTER;
SET GLOBAL gtid_purged="322e3d16-355f-11e3-9ee3-00155d031304:1-100";
CHANGE MASTER TO master_host="master-host", master_port=3306, master_user="rep_useer", master_password="6789", auto_position=1;
START SLAVE;

Executed_Gtid_Setは非常に長い文字列になる場合があり、従来のバイナリログ名とポジションより設定し易いとは言いがたいものです。
なお、mysqldumpを使用するとダンプファイルの最後の方にSET @@GLOBAL.GTID_PURGED='322e3d16-355f-11e3-9ee3-00155d031304:1-100';の一文を自動で含めてくれます。

スレーブで、マスターを新マスターに切り替える

これはとても簡単で、CHANGE MASTER TO master_host="master-host", master_port=3306, master_user="rep_user", master_password="password", auto_position=1;とするだけです。新マスターのGTIDセットとスレーブのGTIDセットを比較して、不足があれば補ってくれます。ポジションの指定は不要です。

スレーブをマスターに昇格させるときは、最後のバイナリログ名とポジションを記録しましょう。
もし、旧マスターにしかない未レプリケートデータが残っている分があった場合は、あとでその分を新マスターに補います。このとき、新マスターをもう一度旧マスターのスレーブにするだけでレプリケートしてくれるでしょうか? これはだめです。GTIDを使うとセット管理なのでわけのわからないことになってしまいます。ここは、従来のバイナリログ名とポジションを使って不足分だけをレプリケートしてください。

ダウンしたマスターをスレーブ群に加える

これもとても簡単で、CHANGE MASTER TO master_host="master-host", master_port=3306, master_user="rep_user", master_password="password", auto_position=1;とするだけです。新マスターのGTIDセットとダウンした旧マスターのGTIDセットを比較して不足があれば自動で補ってくれます。ポジションの指定は不要です。

SQLスレッドのエラーを修復する

結論から言うと、これはとても厄介です。マスター/スレーブの処理済みGTIDセットは同じでなければならず、不用意にスレーブに変更を加えると、その時はよくても、それがマスターになった際にはその変更が他のスレーブにも転送されてしまいます。内容によってはすべてのスレーブが停止してしまう原因になったりします。
また、違いをセットで比較する性質上sql_slave_skip_counterは使用できません。仮に使用できたとしても、GTIDセットに食い違いがでるのでその分を転送しようとしてしまいます。

そこで、エラーを起こすGTID番号のトランザクションの内容を他の内容に変えてしまう(代替)という方法で、SQLエラーを回避します。
これはMySQLの2つの特性を利用して行うものです。


これらを利用して以下のようにすると、sql_slave_skip_counterと同じように問題のイベントをスキップまたは修復できます。

SET gtid_next="322e3d16-355f-11e3-9ee3-00155d031304:101";
START TRANSACTION
... //ここに回復処理 単にスキップしたければ何もせずcommit
COMMIT
START SLAVE;

ここで、gtid_nextに指定するGTIDがいくつなのか調べる方法を説明します。
SQLエラーを起こした場合SHOW SLAVE STATUSLast_SQL_Errorにその内容が書かれます。しかし、バイナリログ名とポジションは記載されますが、GTIDはありません。Executed_Gtid_Setの末尾には最後に適用されたGTIDが書かれています。例えば322e3d16-355f-11e3-9ee3-00155d031304:1-101であれば322e3d16-355f-11e3-9ee3-00155d031304:101が最後のIDです。通常エラーを起こしたGTIDはこの次のID(102)です。

話は戻りますが、仮に先ほどのエラーを修正したスレーブがマスターになって、他のスレーブにその修正内容を適用するとまずい場合は、すぐに、

FLUSH LOGS;
PURGE BINARY LOGS TO 'mysql-bin.xxxx';

のようにして、ログからトランザクションの内容をパージしましましょう。遅れたスレーブがそのGTIDを要求すると「既にパージ済みで適用できない」といったエラーになってそのスレーブは停止してしまいますが、無用なトランザクションを適用して進んでしまうよりはマシなはずです。*4
もう一つの方法は、特定のテーブルやデータベースに限定できる場合にそれらのみ再コピーし、新規セットアップ同様に

RESET MASTER;
SET GLOBAL gtid_purged="322e3d16-355f-11e3-9ee3-00155d031304:1-100";
START SLAVE;

とすることです。これも以下の記事を参考にしてください。
bizstation.hatenablog.com

MySQLにおける「マスターとスレーブの一貫性の判断が容易」について

GTIDセットの一致を要求するMySQLの実装は、一見すると一貫性が保証されるように見えます。しかし、初期セットアップ時のデータ違いや、SQLスレッドのエラー修復など、運用管理の仕方によって簡単に矛盾した状態が生じます。この矛盾は従来の方法やMariaDBでも全く同様で、GTIDセットによる特別な効果はありません。

それでも、スレーブがバイナリログを書いたあとに(OSはsuccessを返したが)ディスクコントローラエラーなどで実際には記録されていなかった場合には、スレーブを再起動すると、マスターとスレーブのログの不一致を検出できるということはあるかと思います。すべての状況を考察したわけではありませんが、GTIDセットを比べることで得られるのは「スレーブで書いたはずのログに欠落があったとか、バグで転送されていない処理があった」などの検出でしょうか。

真のマスターとスレーブのデータの一貫性の保証は、従来通り双方のすべてのレコードをコンペアするしかないということには変わりないかと思います。

まとめ

  • GTIDのメリットとしてよく言われる「CHANGE MASTER TOバイナリログポジションをいちいち指定しなくてもよい」はスレーブがフェイルオーバーで昇格した新マスターに切り替える際の話であって、新規セットアップなどでは、バイナリログポジションに替わってGTID(セット)を指定する必要がある。(追記 新規レプリケーションセットアップ時にマスターのバイナリログも一緒にコピーすれば、自動でそのログをスキャンして無指定で開始することもできます。ただ、それであればそもそもすべて同じなのでGTIDによる恩恵とは言い難いものです。)
  • GTIDは、マスターと複数スレーブによる、マスターダウン時にフェイルオーバーする構成でメリットがある。
  • MariaDBのGTIDは、従来のバイナリログ名とポジションに番号を振っただけに近く、従来とほぼ同様の運用が行える。(sql_slave_skip_counterも使える)
  • MySQLのGTIDは、単一の位置情報でログを要求するのではなく、マスターとスレーブで適用されたすべてのトランザクション(GTIDセット)の比較で行われる。
  • MySQLで適用済みのGTIDセットを忘れさせるには、RESET MASTERを行う(しかない)。
  • MySQLのGTIDによる「マスターとスレーブの一貫性の判断が容易」は限定的であって、真のマスターとスレーブのデータの一貫性の保証はされない。

おまけ どっちがよいか?

レプリケーションマネージャを作成するために、数百回もレプリケーションを構築し、エラーを発生させ修復するといった作業を行ってきての感想です。

ポイントは「何かあったときの修復し易さ」です。
MySQLでGTIDを使う場合は、マスターデータとスレーブをきっちり同じにしてSQLエラーを絶対に起こさないように運用管理をしっかりしないと、フェイルオーバーしたあとで予期しない全スレーブのSQLスレッド停止なんてことになりかねません。また、そうなるとその修復でも問題が起き、収集がつかなくなります。

対してMariaDBは、もう少しラフに多少なにかあっても従来と同様に修復できます。sql-log-bin=offにして修復するか、もしくはgtid_slave_posモードであればその修復のことは忘れてくれます。

また、GTIDの使い始めもGTIDと従来の方法が混在可能なMariaDBはとても使い易いと思います。

MariaDBはデータの一貫性の保証はありません。MySQLには限定的な一貫性の保証の仕組みがありますが完全ではありません。ミッショクリチカルな用途での一貫性の保証はデータのコンペアなどをするのがベストかと思います。フェイルオーバーを中心に選択するなら、個人的には使いやすいMariaDBがいいです。

*1:昇格というと何か構成が変わるように見えますが、スレーブ機能をリセットし更新処理をそのサーバーに向けるだけのことです。他のスレーブがchange masterでそのサーバーを指定して初めて構成上のマスターになります。

*2:全く遅れがないスレーブ同士であれば、新マスターの show master statusで得られるポジションでOKです。しかし、遅れがあるスレーブだとこれではだめです。

*3:「スレーブSQLスレッド」についてはMySQL :: MySQL 5.6 リファレンスマニュアル :: 17.2.1 レプリケーション実装の詳細を参照してください。

*4:従来の方法やMariaDBの位置による管理の場合は、修復のための変更のあと、すべてのスレーブがより新しいポジションになれば、そのどれかマスター昇格した際にも修復のための変更のログが転送されることはないため、あまり問題にはなりません。

MySQLレプリケーション復旧 テーブルロックをせず特定テーブルのみコピーする その1

MySQL MariaDB Transactd

今回は、レプリケーションの問題発生時の復旧で、テーブルロックをせずに特定テーブルのみ再コピー(dumpとインポート)する方法について書きたいと思います。テーブルロックするとその間マスターサーバーの機能を制限してしまうの運用時にはなるべく避けたいものです。(この記事は、innoDBのデータベースやテーブルが対象です。)

初回のレプリケーションセットアップ

よくあるレプリケーションのセットアップ手順は

  1. マスターでサーバーIDとバイナリログを有効にする
  2. スレーブがアクセスするためのレプリケーションユーザーをマスターに作成する
  3. マスターでFLUSH TABLES WITH READ LOCKを開始する
  4. マスターでSHOW MASTER STATUSを実行し、binlogファイル名とポジションをメモする
  5. mysqldumpでマスターデータをダンプする
  6. マスターでUNLOCK TABLESを実行してロックを解放する
  7. スレーブでダンプしたデータをインポートする
  8. スレーブでCHANGE MASTER TOを実行してマスターホストとメモしたバイナリログポジションを登録する
  9. スレーブでSTART SLAVEを実行してレプリケーションを開始する

といった内容で、具体的には以下のようになります。

//my.cnfの設定 
server-id = 1
log-bin = mysqld-bin
binlog-format = ROW

//ユーザー作成、ロック、バイナリログポジション取得
master> GRANT REPLICATION SLAVE ON *.* TO 'replication_user'@'%' IDENTIFIED BY 'password_here';
master> FLUSH TABLES WITH READ LOCK;
master> SHOW MASTER STATUS;  //--> File: mysqld-bin.000001 Position: 1101983 

//マスターデータダンプ
$> mysqldump -uroot -p --databases databaseA databaseB databaseC > ./master_dump.sql

//ロック開放
master> UNLOCK TABLES;

// -----   ここからスレーブで ----------------
//スレーブでのデータインポート
$> mysql -uroot -p --default-character-set=utf8 < ./master_dump.sql

//スレーブにマスター登録とレプリケーションの開始
slave> STOP SLAVE;
slave> RESET SLAVE;
slave> CHANGE MASTER TO MASTER_HOST = '192.168.0.2',MASTER_USER='replication_user',MASTER_PASSWORD='password_here',MASTER_LOG_FILE='mysqld-bin.000001',MASTER_LOG_POS=1101983;
slave> START SLAVE;

レプリケーションの不具合

運用中に何らかの問題でスレーブのSQLスレッドが書き込みエラーを起こすと、マスターとスレーブにデータの違いが出てしまいます。最初に試みる修復は、エラーの内容を見てスレーブのレコードを削除したり、SQL_SLAVE_SKIP_COUNTER変数を使ってイベントをスキップすることで行います。これで解消できればよいですが、データに矛盾が無いかどうか確認しにくかったり、複雑で手に負えないこともあります。そのような場合、マスターからコピーし直せば確実です。

しかし、初回と同じ手順で再度セットアップすると、機能制限の時間が問題になります。

  • マスターの書き込み機能を、FLUSH TABLES WITH READ LOCKの間停止しなければならない。
  • 1つのマスターに複数のデータベースがある場合、必要なすべてのデータベースをダンプとインポートしなければならない。特にデータサイズが大きい場合は多くの時間がかかる。

テーブルロックフリーな部分コピーによる復旧

マスターの機能制限の時間が最小限になるよう、テーブルロックフリーでの部分コピーによる修復方法を考えてみたいと思います。スレーブは壊れた状態なので、復旧担当以外アクセスできないようになっていることが前提です。

ログポジションの問題

大抵の場合、問題は特定のデータベースやテーブルに限定できることが多いと思います。そこで、全部のデータベースではなく一部のみのダンプとインポートを行うとどうでしょうか。
初回セットアップ時と同じ手順で、テーブルロックなしに部分的にコピーしようとすると、下記の問題が生じます。

  • スレーブを停止せずに行うと、SHOW MASTER STATUS以降の変更が重複してスレーブに適用されてしまう。(ログポジションをSHOW MASTER STATUS時点に戻すため)
  • 事前にスレーブを停止すると、停止時のログポジションからSHOW MASTER STATUSするまでの変更ログが無くなる。(ログポジションをSHOW MASTER STATUS時点に進めてしまうため)

部分コピーを行うには、データに矛盾を発生させないように、スレーブ停止時とダンプ時のログポジションが一致する、すなわち、SHOW MASTER STATUSで確認したログポジションまでスレーブに適用された状態でスレーブを停止する必要があることがわかります。

START SLAVE UNTIL

START SLAVEにはUNTILというオプションがあります。UNTIL master_log_file='xxxx', master_log_pos=xxxのように使用します。UNTILはSQLスレッドが指定したログポジションまで達したら停止してくれます。これを利用して以下の手順を加えます。

  1. STOP SLAVEを実行します。
  2. マスターでSHOW MASTER STATUSを実行し、スレーブでそのポジションをUNTILに加えてSTART SLAVEします。
  3. 問題が発生した場合は、SQL_SLAVE_SKIP_COUNTERに1を指定してから、再度START SLAVE UNTILを実行し、問題のあるイベントをスキップします。これを繰り返して問題をすべてスキップします。
  4. SHOW SLAVE STATUSを何度か実行して、指定したポジションまでログが適用されSQLスレッドが停止するのを待ちます。
  5. スレーブでインポートします。
  6. スレーブでRESET SLAVE、マスター再登録とレプリケーションの開始を行います。

これで、サーバーの一部のデータベースのみ矛盾なくコピーし直すことができます。ただし、SHOW MASTER STATUS時点のコピー元データが必要です。これを現在のMySQLで実現するには2つの方法があります。

  1. 事前にFLUSH TABLES WITH READ LOCKしてダンプする。
  2. 事前にFLUSH TABLES WITH READ LOCKしてその間にスナップショットを開始しダンプする。

ちなみに、START SLAVE UNTILMySQL 5.5~5.7、MariaDB 5.5~10.1 で使用できます。

テーブルロック時間を最小限でダンプする

mysqldumpには、バイナリログポジションを同時に取得する--master-dataオプションがあります。これを使うと、自分でFLUSH TABLES WITH READ LOCKを発行する必要はありません。しかしながら、--master-dataオプションは内部でこのコマンドを使っていますので、完全にテーブルロックなしにダンプすることはできません。以下はその部分を示すmysqldumpのソースコードの抜粋です。

// mysql-5.6.20 mysqldump.c : 5797
  if ((opt_lock_all_tables || opt_master_data ||
       (opt_single_transaction && flush_logs)) &&
      do_flush_tables_read_lock(mysql))
    goto err;

--single-transactionオプションを使うと、ロック開始後にスナップショットを取り(start_transaction)、SHOW MASTER STATUSしてすぐにロックを開放します。これによりロック時間をほんのわずかな時間にすることができます。

// mysql-5.6.20 mysqldump.c : 5827 
  if (opt_single_transaction && start_transaction(mysql))
    goto err;

  /* Add 'STOP SLAVE to beginning of dump */
  if (opt_slave_apply && add_stop_slave())
    goto err;


  /* Process opt_set_gtid_purged and add SET @@GLOBAL.GTID_PURGED if required. */
  if (process_set_gtid_purged(mysql))
    goto err;


  if (opt_master_data && do_show_master_status(mysql))
    goto err;
  if (opt_slave_data && do_show_slave_status(mysql))
    goto err;
  if (opt_single_transaction && do_unlock_tables(mysql)) /* unlock but no commit! */
    goto err;

 // ...このあとダンプ開始

初回の方法よりはテーブルロックを十分短い時間にできるので、

$>mysqldump -uroot -p --master-data --single-transaction databaseA tableA tableB > ./master_dump.sql

のようにダンプしましょう。
しかし、START SLAVE UNTILを実行するためには、ダンプファイルを開いてログ名とポジションを自分で読み取らなければなりません。少々面倒ですね。

ここまでの方法は、mysqlクライアントとmysqldumpを使って行うことができます。後述のまとめに手順一式を書いておきます。

テーブルロックをせずコピーする

ここまで見てきた通り、mysqldumpでは、完全にテーブルロックをせずスナップショットとその時点のログポジションを取得することはできません。
しかし、Transactdのnsdatabase::beginSnapshotメソッドを使えば、テーブルロックをせずにスナップショットを開始し、その時点でのログポジションを取得できます。

なぜテーブルロックフリーで取得可能なのでしょうか。
MySQLはXAトランザクションを使ってバイナリログとinnoDBの処理結果に矛盾が無いようにしています。XAのコミットは、最初にcommitロックを取得してシリアライズされます。Transactdnsdatabase::beginSnapshotは、このcommitロックを使って以下のように処理します。

  1. commitロックを取得(他の書き込みブロック)
  2. ログポジションを取得
  3. スナップショットを開始
  4. commitロックを開放

commitロックは通常の書き込み処理と同様のロックなので、テーブルをロックすることはありません。他の処理を止めることなくスムーズに処理されます。
あとは、このスナップショット中にマスターデータを読み取ってスレーブにコピーします。
これで完全にテーブルロックフリーでコピーできます。マスターはほんのわずかな機能停止もありません。
具体的なコードは次回改めて詳しく書きたいと思います。

まとめ

mysqlクライアントとmysqldumpを使って、可能な限りロックを短くしたレプリケーションの部分コピーを行う方法は以下のようになります。
例としてdatabaseAtableAtableBのみコピーします。「障害はtableAtableBでのみ発生している」と特定できていることが条件です。

/*
 databaseA の tableA と tableB のみ再コピーするサンプル
 master> はマスターのmysqlクライアントでの処理
 slave>  はスレーブのmysqlクライアントでの処理
*/
//スレーブ停止
slave> STOP SLAVE;
//マスターデータダンプ (ロック、スナップショット、binlog posの取得)
$> mysqldump -uroot -p --master-data --single-transaction --add-drop-table databaseA tableA tableB > ./master_dump.sql
// ./master_dump.sqlからbinlogファイル名とポジションを読み取ってメモしておく
slave> START SLAVE UNTIL master_log_file='xxxx', master_log_pos=xxx; //ファイル名とポジションは先ほどメモした値
// -- ここから エラーが発生した場合の処理 --
slave> STOP SLAVE;
slave> SET GLOBAL SQL_SLAVE_SKIP_COUNTER = 1;
slave> START SLAVE UNTIL master_log_file='xxxx', master_log_pos=xxx; //ファイル名とポジションは先ほどメモした値
// -- エラーが発生した場合の処理 ここまで --
//    以上をエラーが発生するたびに繰り返し、エラーをすべてスキップする
slave> SHOW SLAVE STAUS;  // Exec_Master_Log_Posが指定したポジションになってSQLスレッドが停止するまで待つ
//スレーブ停止 ダンプしたデータと同じログポジションまで適用されている状態
slave> STOP SLAVE;
//スレーブでのデータインポート
$> mysql -uroot -p --default-character-set=utf8 < ./master_dump.sql
//スレーブへのマスター登録とレプリケーションの開始
slave> RESET SLAVE;
slave> CHANGE MASTER TO MASTER_HOST = '192.168.0.2',MASTER_USER='replication_user',MASTER_PASSWORD='password_here',MASTER_LOG_FILE='xxxx',MASTER_LOG_POS=xxx;
slave> START SLAVE;

今回はタイトルにある「テーブルロックをせず」まで至っていません。次回はTrasnactdを使って完全にテーブルロックしない方法でのスクリプトを紹介します。エラーのスキップも簡単にできてとても便利なスクリプトです。

追記 (2016/05/27) その2を公開しました。
bizstation.hatenablog.com

PHP7 ExtensionをSWIGで作る (Building a PHP7 Extension with SWIG)

Transactd C++ PHP SWIG

Transactd 3.0をリリースしました。また、PHPクライアントはPHP7に対応しました。(Transactd 3.0についてはまた改めて書きたいと思います)

TransactdのPHPクライアントは、SWIGで生成したラッパーコードをベースに構築されています。
PHP7では、基本的な変数の型や関数の引数型などに変更があり、PHP5系で使用していたコードのままではExtensionがコンパイルできません。SWIGでPHP7のExtensionが作成できればいいのですが、まだPHP7には対応していません。また、Transactdではラッパーをベースに関数のオーバーロードなどを変更しているため、現状のコードをPHP7に対応させる必要がありました。今回はその内容を備忘録的に書いておきたいと思います。

以降は、OneソースでPHP5とPHP7のどちらでもコンパイル・正常動作させるためのSWIGコードの変更手順です。TransactdのPHPクライアントに限らず、SWIGで生成されたPHP Extension共通で使用できると思います。

コードの変更の技術的な内容は、下記のPHPのマニュアルを基に行っています。 
Upgrading PHP extensions from PHP5 to NG

変更の概要

OneソースでPHP5とPHP7の両方で動作するようにするため、ZEND_ENGINEマクロのバージョンを調べて関数や型をマクロで切り替えます。次に、ラッパーコードをこのマクロを使用するように置換します。最後に組み込みの5つの関数を手直しします。まとめると以下のようになります。

  1. 切替マクロの挿入
  2. ラッパーコードをマクロで置換する
  3. SWIG_ZTS_SetPointerZval, SWIG_ZTS_ConvertResourcePtr, SWIG_ZTS_ConvertPtr, SWIG_Php_GetModule, SWIG_Php_SetModuleを置換する

これでPHP5とPHP7に対応したラッパーコードが完成します。

切替マクロの挿入

切替マクロは2か所に挿入します。それぞれ、SWIG_LONG_CONSTANTstatic ZEND_RSRC_DTOR_FUNCが出現する位置に、元のコードを削除して挿入します。

マクロ1

#ifdef ZEND_ENGINE_3
#define SIZE_OFFSET 1
#else
#define SIZE_OFFSET 0
#endif

#define SWIG_LONG_CONSTANT(N, V) zend_register_long_constant((char*)#N, sizeof(#N) - SIZE_OFFSET, V, CONST_CS | CONST_PERSISTENT, module_number TSRMLS_CC)
#define SWIG_DOUBLE_CONSTANT(N, V) zend_register_double_constant((char*)#N, sizeof(#N) - SIZE_OFFSET, V, CONST_CS | CONST_PERSISTENT, module_number TSRMLS_CC)
#define SWIG_STRING_CONSTANT(N, V) zend_register_stringl_constant((char*)#N, sizeof(#N) - SIZE_OFFSET, (char*)(V), strlen(V), CONST_CS | CONST_PERSISTENT, module_number TSRMLS_CC)
#define SWIG_CHAR_CONSTANT(N, V) do {\
    static char swig_char = (V);\
    zend_register_stringl_constant((char*)#N, sizeof(#N) - SIZE_OFFSET, &swig_char, 1, CONST_CS | CONST_PERSISTENT, module_number TSRMLS_CC);\
} while (0)

マクロ2

#ifdef ZEND_ENGINE_3
static ZEND_RSRC_DTOR_FUNC(SWIG_landfill) { zend_resource *rsrc = res; (void)rsrc; }

//Replace 
typedef zval zval_args_type;
#define ZVAL_ARGS &args
#define ZVAL_ARGS_ARRAY args
#define ZVAL_ARGV_ARRAY argv
#define CONV_to_double_ex(zv) 
#define CONV_to_long_ex(zv)
#define CONV_to_string_ex(zv)  convert_to_string(&zv)
#define CONV_to_boolean_ex(zv) ZVAL_LONG(&zv, (Z_TYPE(zv) == IS_TRUE) ? 1 : 0);
#define Z_TYPE_AGRS(N) Z_TYPE_P(&args[N])
#define Z_TYPE_ARGV(Z) Z_TYPE_P(&Z)
#define ZVAL_ARGS_P(N) (&argv[N])
#define ARGV_IS_BOOL(N) (Z_TYPE_ARGV(argv[N]) == IS_TRUE || Z_TYPE_ARGV(argv[N]) == IS_FALSE)
#define RSRC res
#define LIST_FIND(ZVAL) value = (swig_object_wrapper *)Z_RES_P(ZVAL)->ptr;
#define ARGS_IS_TRUE(N) zval_is_true(&args[N])

//Append compatible type and define
typedef zend_resource zend_rsrc_list_entry;
#define Z_DVAL_PP(zv) zv.value.dval
#define Z_LVAL_PP(zv) zv.value.lval
#define Z_STRVAL_PP(zv) Z_STRVAL_P(&zv)

#undef  ZVAL_STRING
#define ZVAL_STRING(z, s, dummy)                                 \
    do {                                                         \
    const char *_s = (s);                                        \
        ZVAL_NEW_STR(z, zend_string_init(_s, strlen(_s), 0));    \
  } while (0)

#undef ZVAL_STRINGL
#define ZVAL_STRINGL(z, s, l, dummy)                             \
    do {                                                         \
    const char *_s = (s);                                        \
      ZVAL_NEW_STR(z, zend_string_init(_s, l, 0));               \
  } while (0)


#else

static ZEND_RSRC_DTOR_FUNC(SWIG_landfill) { (void)rsrc; }
typedef long zend_long;
typedef zval** zval_args_type;
#define ZVAL_ARGS *args
#define ZVAL_ARGS_ARRAY args
#define ZVAL_ARGV_ARRAY argv
#define CONV_to_double_ex(zv) convert_to_double_ex(zv)
#define CONV_to_long_ex(zv) convert_to_long_ex(zv)
#define CONV_to_string_ex(zv) convert_to_string_ex(zv)
#define CONV_to_boolean_ex(zv) convert_to_boolean_ex(zv)
#define Z_TYPE_AGRS(N) (ZVAL_ARGS[N])->type
#define Z_TYPE_ARGV(Z) Z_TYPE_PP((Z))
#define ZVAL_ARGS_P(N) (*(argv[N]))
#define ARGV_IS_BOOL(N) (Z_TYPE_ARGV(argv[N]) == IS_BOOL)
#define RSRC rsrc
#define LIST_FIND(ZVAL) (swig_object_wrapper *)zend_list_find((*ZVAL[0])->value.lval, &type)
#define ARGS_IS_TRUE(N) zval_is_true(*args[N])

#endif

ラッパーコードをマクロで置換する

次に、ラッパーコードを以下のパターンですべて置換します。左側のパターンのコードをの右側のコードにします。nパラメータの実際は整数の値です。_xxxxはクラス名で、残りは実際のラッパーコードの記述そのものです。

zval **args[n]                               --> zval_args_type args[n]
zend_get_parameters_array_ex(n, args)         --> zend_get_parameters_array_ex(n, ZVAL_ARGS_ARRAY)
zend_get_parameters_array_ex(argc,argv)       --> zend_get_parameters_array_ex(argc, ZVAL_ARGV_ARRAY)
SWIG_ConvertPtr(*args[n]                    --> SWIG_ConvertPtr(ZVAL_ARGS[n]
(*args[n])->type                              --> Z_TYPE_AGRS(n)
convert_to_string_ex                          --> CONV_to_string_ex
convert_to_long_ex                            --> CONV_to_long_ex
convert_to_double_ex                          --> CONV_to_double_ex 
convert_to_boolean_ex                         --> CONV_to_boolean_ex
(Z_TYPE_PP(argv[n]) == IS_BOOL)               --> ARGV_IS_BOOL(n)
zend_list_find((*args[n])->value.lval, &type) --> LIST_FIND(args)
__wrap_delete_xxxx(rsrc,                      --> __wrap_delete_xxxx(RSRC,
efree(rsrc->ptr)                              --> efree(RSRC->ptr)
zval_is_true(*args[n])                        --> ARGS_IS_TRUE(n) 

この置換作業は、今後C++のインタフェースに変更があって関数の追加や変更をする際、SWIGで生成したものを使用する場合にも必要です。

組み込み関数の変更

最後に5つのSWIGの組み込み関数を置換します。

SWIG_ZTS_SetPointerZval

static void
SWIG_ZTS_SetPointerZval(zval *z, void *ptr, swig_type_info *type, int newobject TSRMLS_DC) {
  /*
   * First test for Null pointers.  Return those as PHP native NULL
   */
  if (!ptr) {
    ZVAL_NULL(z);
    return;
  }
  if (type->clientdata) {
    swig_object_wrapper *value;
    if (!(*(int *)(type->clientdata)))
      zend_error(E_ERROR, "Type: %s failed to register with zend", type->name);
    value = (swig_object_wrapper *)emalloc(sizeof(swig_object_wrapper));
    value->ptr = ptr;
    value->newobject = (newobject & 1);
    if ((newobject & 2) == 0) {
      /* Just register the pointer as a resource. */
      #ifdef ZEND_ENGINE_3
      ZVAL_RES(z, zend_register_resource(value, *(int *)(type->clientdata)));
      #else
      ZEND_REGISTER_RESOURCE(z, value, *(int *)(type->clientdata));
      #endif
    } else {
      /*
       * Wrap the resource in an object, the resource will be accessible
       * via the "_cPtr" member. This is currently only used by
       * directorin typemaps.
       */
      #ifdef ZEND_ENGINE_3
      zval resource;
      zend_class_entry *ce = NULL;
      #else
      zval *resource;
      zend_class_entry **ce = NULL;
      int result;
      #endif
      const char *type_name = type->name + 3; /* +3 so: _p_Foo -> Foo */
      size_t type_name_len;
      const char * p;
      /* Namespace__Foo -> Foo */
      /* FIXME: ugly and goes wrong for classes with __ in their names. */
      while ((p = strstr(type_name, "__")) != NULL) {
        type_name = p + 2;
      }
      type_name_len = strlen(type_name);
      #ifdef ZEND_ENGINE_3
      zend_string* tn = zend_string_init(type_name, type_name_len, 0);
      ZVAL_RES(&resource, zend_register_resource(value, *(int *)(type->clientdata)));
      #else
      MAKE_STD_ZVAL(resource);
      ZEND_REGISTER_RESOURCE(resource, value, *(int *)(type->clientdata));
      #endif
      if (SWIG_PREFIX_LEN > 0) {
        char * classname = (char*)emalloc(SWIG_PREFIX_LEN + type_name_len + 1);
        strcpy(classname, SWIG_PREFIX);
        strcpy(classname + SWIG_PREFIX_LEN, type_name);
        #ifdef ZEND_ENGINE_3
        zend_string* zp = zend_string_init(classname, SWIG_PREFIX_LEN + type_name_len, 0);
        ce = zend_lookup_class(zp);
        zend_string_release(zp);
        #else
        result = zend_lookup_class(classname, SWIG_PREFIX_LEN + type_name_len, &ce TSRMLS_CC);
        #endif
        efree(classname);
      } else {
        #ifdef ZEND_ENGINE_3
        ce = zend_lookup_class(tn);
        #else
        result = zend_lookup_class((char *)type_name, type_name_len, &ce TSRMLS_CC);
        #endif
      }
      #ifdef ZEND_ENGINE_3
      if (!ce) {
      #else
      if (result != SUCCESS) {
      #endif
        /* class does not exist */
        object_init(z);
      } else {
      #ifdef ZEND_ENGINE_3
        object_init_ex(z, ce);
      #else
        object_init_ex(z, *ce);
      #endif
      }
      Z_SET_REFCOUNT_P(z, 1);
      #ifdef ZEND_ENGINE_3
      ZVAL_MAKE_REF(z);
      zend_hash_str_update(HASH_OF(z),  "_cPtr", sizeof("_cPtr") - 1, &resource);
      zend_string_release(tn);
      #else
      Z_SET_ISREF_P(z);
      zend_hash_update(HASH_OF(z), (char*)"_cPtr", sizeof("_cPtr"), (void*)&resource, sizeof(zval), NULL);
      #endif
    }
    return;
  }
  zend_error(E_ERROR, "Type: %s not registered with zend",type->name);
}

SWIG_ZTS_ConvertResourcePtr

static void *
SWIG_ZTS_ConvertResourcePtr(zval *z, swig_type_info *ty, int flags TSRMLS_DC) {
  swig_object_wrapper *value;
  void *p;
  int type;
  const char *type_name;

#ifdef ZEND_ENGINE_3
  type = Z_RES_P(z)->type;
  value = (swig_object_wrapper *)Z_RES_P(z)->ptr;
#else
  value = (swig_object_wrapper *) zend_list_find(z->value.lval, &type);
#endif

  if (type==-1) return NULL;
  if (flags & SWIG_POINTER_DISOWN) {
    value->newobject = 0;
  }
  p = value->ptr;
#ifdef ZEND_ENGINE_3
  type_name = zend_rsrc_list_get_rsrc_type(Z_RES_P(z));
#else
  type_name = zend_rsrc_list_get_rsrc_type(z->value.lval TSRMLS_CC);
#endif
  return SWIG_ZTS_ConvertResourceData(p, type_name, ty TSRMLS_CC);
}

SWIG_ZTS_ConvertPtr

static int
SWIG_ZTS_ConvertPtr(zval *z, void **ptr, swig_type_info *ty, int flags TSRMLS_DC) {
  if (z == NULL) {
    *ptr = 0;
    return 0;
  }
  switch (Z_TYPE_P(z)) {
    case IS_OBJECT: {
      //find z->_cPtr
#ifdef ZEND_ENGINE_3
    zval* _cPtr;
    if ((_cPtr = zend_hash_str_find(HASH_OF(z), "_cPtr",  sizeof("_cPtr")- 1)) != NULL) {
      if (Z_TYPE_P(_cPtr) == IS_INDIRECT)
        _cPtr = Z_INDIRECT_P(_cPtr);
      if (Z_TYPE_P(_cPtr)==IS_RESOURCE) {
      *ptr = SWIG_ZTS_ConvertResourcePtr(_cPtr, ty, flags TSRMLS_CC);
      return (*ptr == NULL ? -1 : 0);
      }
      }
#else
      zval **_cPtr;
    if (zend_hash_find(HASH_OF(z), (char*)"_cPtr", sizeof("_cPtr"), (void**)&_cPtr) == SUCCESS) {
      if ((*_cPtr)->type == IS_RESOURCE) {
        *ptr = SWIG_ZTS_ConvertResourcePtr(*_cPtr, ty, flags TSRMLS_CC);
        return (*ptr == NULL ? -1 : 0);
      }
    }
#endif
      break;
    }
    case IS_RESOURCE:
      *ptr = SWIG_ZTS_ConvertResourcePtr(z, ty, flags TSRMLS_CC);
      return (*ptr == NULL ? -1 : 0);
    case IS_NULL:
      *ptr = 0;
      return 0;
  }

  return -1;
}

SWIG_Php_GetModule

static swig_module_info *SWIG_Php_GetModule() {
  zval *pointer;
  swig_module_info *ret = 0;
  TSRMLS_FETCH();

#ifdef ZEND_ENGINE_3
  if (pointer = zend_get_constant_str(const_name, sizeof(const_name) - 1)) {
    if (Z_TYPE_P(pointer) == IS_LONG) {
      ret = (swig_module_info *) pointer->value.lval;
    }
  } 
#else
  MAKE_STD_ZVAL(pointer);
  if (zend_get_constant(const_name, sizeof(const_name) - 1, pointer TSRMLS_CC)) {
    if (pointer->type == IS_LONG) {
      ret = (swig_module_info *)pointer->value.lval;
    }
  }
  FREE_ZVAL(pointer);
#endif

  return ret; 
}

SWIG_Php_SetModule

static void SWIG_Php_SetModule(swig_module_info *pointer) {
  TSRMLS_FETCH();
  REGISTER_MAIN_LONG_CONSTANT(const_name, (zend_long) pointer, CONST_PERSISTENT | CONST_CS);
}

Windowsでのビルドエラー

Windowsの場合、PHP7はVisual Studio 2015でのビルドが必要です。
VS2015でstd::stringを使用すると、stringヘッダーのinline namespace literalsコンパイルエラーが発生することがあります。その時は、#include <string>をコードの最初にインクルードすると解消できます。

最後に

Transactdではこの変更でPHP用のテストすべてにパスしています。
Transactd用のラッパーコードは
transactd/tdclphp_wrap.cpp at master · bizstation/transactd · GitHub
で確認できます。ZEND_ENGINE_3で検索すればPHP7用の修正が列挙できます。(ここに記載したコードはRefactoringしてあるので少し異なりますが基本同じです)

マクロでの切替はコードが読みづらく必ずしもベストではありませんが、PHP5とPHP7はExtension構造が基本的に同じなので、2つのコードを別々にして管理するよりは随分楽かと思います。

Transactd Plugin PHP Ruby C# C++のチュートリアル

MySQL用NoSQL Transactd Plugin C++ Transactd MySQL MariaDB

MySQL用NoSQLプラグイン Transactdのチュートリアルで使用するプログラミング言語の種類を拡充しました。
この中に慣れた言語がありましたら、是非試してみてください。SQLに比べてとても高速に処理ができるようになります。

Transactd チュートリアル


今まではJScriptしかありませんでしたが、ようやくPHPRubyC#C++の4つの言語を追加することができました。
また、内容も細かく見直しています。

内容は、このような構成です。

  • プラグインとクライアントの準備
  • プログラム言語ごとの準備
  • データベースとテーブルの操作
  • SQLライクな読取と結果セットの操作
  • データベースとテーブルの作成・削除
  • サンプルコードダウンロード

簡単なハウツー本のような内容になっています。
ダウンロード用のサンプルコードは、エラー処理も含め実用に耐えるコードにしています。

できれば、このチュートリアルを使って、ハンズオンセミナーなどもやりたいなと考えています。関連するセミナーなどで「うちでやって」とか「この時間空いてるよ」などご意見ありましたら是非お寄せください。

MySQL 5.7 + Transactd スループット117万QPSを記録

MySQL Transactd

ようやく24コアのマシンでTransactdのベンチマークを取ることができました。
Xeon E5-2697 V2 2.7GHz 24コア48スレッドの物理マシンです。32または36コアでできれば良かったのですが、お借りできたこのマシン*1ベンチマークを行いました。

結果はタイトルの通りで、パフォーマンスの改善されたMySQL 5.7.7にて驚きの117万QPSを記録しました。
以下はその結果グラフです。横軸がクライアント数、縦軸が1秒間に処理したクエリー(読み取りレコード)数です。

f:id:bizstation:20150430111654p:plain
memcached-pluginなどと基本的な考え方は同じですので、100万QPSを出すには48コア位は必要かと思っていましたが、24コアでこの値には少し驚いています。

MySQL 5.6.20でも87万QPSを出しました。5.7の結果が良いので5.6が少なく見えますが、24コアで87万QPSは好結果です。
48コアマシンだったら、MySQL 5.7とTransactd 2.4で200万QPSも可能ではないかと思います。

このテストのポイントについていくつか説明しておきます。

テスト環境

MachineFujitsu RX300 S8
CPUXeon E5-2697 V2 2.7GHz 24Core
Hyper-ThreadingON
memory24GB
OSWindows Server 2012 R2
Transactd Version2.4(Not yet release)
NetworkLocal pipe (shared memory)

テスト方法

ベンチマークプログラム

ベンチマークプログラムは、シングルプロセスで2~48個のスレッドにてランダムなidのプライマリーキー値でサーバーにアクセスします。それぞれのスレッドは無限ループでアクセスを開始し、5秒間の計測シグナルがONの間にアクセスできた数を返すようになっています。
OSに制御を返す時間がほとんどない連続アクセスのため、論理コア数以下でスループットの劣化が始まります。
(ときどき、数百クライアントまでスケールする結果があったりしますが、それはサーバーの処理スレッドがOSに制御を返す空き時間が多い処理(通信)方法によるもので、空き時間がなければ論理コア以上にスケールすることはありません。)

ネットワーク

他のMySQL用のNoSQLプラグインでもベンチマークはローカルマシン内で行っています。これはネットワークレイテンシと負荷を軽減するためです。Transactdのパケットは数十バイト程度の小さなものが多く、1Gbpsのネットワークでも1Gbpsのスループットを出すことはできません。ネットワークのレイテンシによって、10~30万パケット程度で頭打ち*2になってしまいます。また、ネットワーク処理の負荷も問題になるため、今回のテストも他と同様にサーバーとクライアントは同一マシンで行っています。*3

OSはWindowsで行いました。TransactdのWindows版は共有メモリとイベント送受信を使ったプロセス間通信を行うことができます。これで通信のレイテンシと負荷を最小限にすることができます。

f:id:bizstation:20150430110038p:plain

この方法はTCPのレイヤーを一切介さず、非常に高速に通信します。

TCPでの接続においても、ネットワーク処理を除いたデータアクセスに24コアが割り当てできれば、Linux/Windowsともにほぼ同様な結果がでると思います。それには、低負荷で高速なネットワークカードが必要です。逆に言えば今回の結果はそれを示していると言えます。

Transactdの高速性を真に発揮するには高速なネットワークカードも併せて利用していただけたらと思います。

MySQL 5.7

MySQL 5.7はスループットの改善が顕著でした。Transactdのコードは5.6と5.7のインタ-ーフェースの差異を吸収するだけの違いしかなく、基本的に同じものです。それにもかかわらず大きな性能差が出ています。特に、クライアント数が物理コア数を超えたあたりからの差が大きく、Hyper -Threadingによる論理コアをうまく使えているようです。InnoDBの改善が進んでいるのがわかります。
これだけの差があると、スループットをすぐにでも上げたい場合5.7はお勧めです。

5.7は既にRC版で、間もなくGA版になるかと思います。早期の導入には不安もありますが、TransactdはMySQLのhandlerインターフェース以下の層しか使わないので、潜在的なバグもSQLでのアクセスより少なくなります。また、TransactdのAPIは十分にテストされますのでチェックも2重に働いています。

まとめ

Transactd APIにて、性能劣化を起こすことなくInnoDBのパフォーマンスを引き出せていることが数字で確認できました。

今回のテストはidによる読み取りなのでkey-valueアクセスです。このようなkey-valueアクセス用途はもちろんですが、Transactdはフル機能のAPIを備えています。複雑な集計やトランザクションもすべて高速に行えます。
みなさんも、Transactdで最高パフォーマンスのWebアプリケーションを作ってみてください。今回のテストは開発中のVersion2.4で行いましたが、パフォーマンスに関しては2.3も同様です。

共有メモリを使用したWindowsローカルでの処理は非常に高速です。BizStationでは、大きなデータで時間のかかるデータのマイグレーションなどの際に、RAMディスクと併用して極端に短時間で処理するなどに利用しています。

また、MySQL 5.7の読み取りスループットの改善は本当に大きく、リリースされたらBizStationでも積極的に使っていこうと思います。
MySQL 5.7 + Transactdのこのスピードはちょっとわくわくします。

*1:ダイワボウ様、富士通様ありがとうございます。

*2:100バイト ☓ 30万パケット ≒ 240Mbps

*3:最近の10Gbpsネットワークカードのテストなどを見ると小さなパケットでも100万PPSを超えるようです。これであれば、ネットワークがボトルネックになることを回避できるかと思います。

TransactdでInnoDBロックを自在に操る

MySQL MariaDB Transactd

前回の MySQL/MariaDBとTransactdのInnoDBロック制御詳細 その1 - BizStationブログ では、InnoDBのロックの詳細について説明しました。
今回は、TransactdのトランザクションにおいてInnoDBのロックをどう扱うかを説明します。Transactdは、InnoDBロックを自在に操って同時実行性を高めることができます。

ロック制御は、マルチスレッドプログラミングとmutex lockなどの制御とよく似ています。これらを扱ったことがあるようでしたら、Transactdのロック制御はとてもやり易く感じると思います。

書き込みにおけるパフォーマンスの確保には、ロックを最小限にし同時実行性を高めることが不可欠です。ミッションクリティカルなアプリケーションもパフォーマンスの良いものにしましょう。

Transactdのロック制御

TransactdのAPIは行単位のナビゲーションアクセスが基本です。そのため、InnoDBの行ロックの制御とはとても相性が良く、行単位でロックを解放したり、種類を変えたりといったことができます。ミッションクリティカルなアプリケーションも最小限のロックで安全に処理することが可能です。

トランザクションの種類

Transactdのトランザクションには以下の3つの種類があります。

それぞれ順に説明します。

自動トランザクション

自動トランザクションとは、Transactd内部で自動的に開始されるトランザクションです。その他の2つが明示的に開始を指定するのに対して、自動トランザクションは暗黙のうちに開始されます。
自動トランザクションは、ユーザートランザクションまたはスナップショットが開始されていないときにテーブルに対するアクセスを行うと自動で開始され、1つのオペレーションが終わると終了します。すなわち、テーブルアクセスの通信1回ごとに開始され終了します。

読み取りオペレーション

自動トランザクションの読み取りオペレーションは、常に nonlocking readsでありロックを取得しません。オペレーションごとにトランザクションが開始されるため、その都度最新のスナップショットが使用されます。

更新と行削除

更新nstable::update()と行削除nstable::del()は、直前のオペレーションで読み取られたカレントレコードをサーバー側で再度読み直し、排他ロック(X)を取得してから、更新または削除を行います。
直前のオペレーションで読み取られた値と最新の値が異なる場合は、それを検出してnstable::stat()にSTATUS_CHANGE_CONFLICTエラーが返ります。そのため、このシナリオではロストアップデートは起こりません。また、事前にロックを取得してから更新する方法もあります。それらについては、以降で詳しく説明します。

ユーザートランザクション

ユーザートランザクションは読み書き可能なトランザクションです。 nsdatabase::beginTrn(bias=SINGLELOCK_READ_COMMITED+NOWAIT_WRITE)メソッドで開始し、 nsdatabase::endTrn() または nsdatabase::abortTrn()で終了します。
ユーザートランザクションは開始メソッドのbias値によって複数のロックタイプとロック粒度を選択できます。ロック粒度には、シングルレコードロックとマルチレコードロックの2種類があります。

  • シングルレコードロックは、テーブルごとに最後にアクセスしたレコードのみロックを保持します。
  • マルチレコードロックは、トランザクション中にアクセスしたすべてのレコードのロックを保持します。

以下はbias値に対するロック粒度とロックタイプの一覧表です。

beginTranのbias値 ロック粒度 InnoDB分離レベル 行ロックタイプ
SINGLELOCK_NOGAP
(デフォルト)
シングルレコードロック READ_COMMITED row lock(X)
MULTILOCK_NOGAP マルチレコードロック READ_COMMITED row lock(X)
MULTILOCK_GAP マルチレコードロック REPEATABLE_READ next key lock(X)

SINGLELOCK_NOGAPはbiasのデフォルト値です。SINGLELOCK_NOGAPは、最後に読み取ったレコードの排他ロック(X)のみ保持します。更新や削除を行う際にはその行を確定するために読み取りを必要とします。読み取った値に基づいて更新を行うことでロストアップデートのない処理を行うことができます。また、ロックの範囲が非常に狭いため最も同時実行性の高い処理が行えます
MULTILOCK_NOGAPは、アクセスしたすべてのレコードを排他ロック(X)を取得します。ただし、row lockなので読み取り範囲内への挿入はブロックできません。ロックの範囲は広くなりますので、同時実行性は悪くなります
MULTILOCK_GAPは、アクセスしたすべてのレコードを排他ロック(X)+GAPロックします。これによりアクセスした範囲への挿入もブロックします。InnoDB分離レベルは、REPEATABLE_READ を使用していますが、機能的な分離レベルはSERIALIZABLEになります。同時実行性は最も悪くなりますが 完全な読み取り一貫性を確保した処理が行えます。(以降に説明するunlock()を使用しなかった場合です。)

SINGLELOCK_NOGAPとMULTILOCK_NOGAPの場合は、nstable::unlock() で直前の読み取り行のロックを解放することができます。必要な行のみロックを得たい場合に細かな制御を可能にします。なお、 nstable::find()など1回のオペレーションで複数の行を取得する場合は、ロックの解放はできません。細かくロック・アンロックの制御を行いたい場合は、seek系オペレーションを使用してください。

// ユーザートランザクション unlock()の例
db->beginTrn(MULTILOCK_NOGAP);
tb->setFVN("id", 1);
tb->seek();
if (tb->stat() == 0)
   tb->unlock();
...
db->endTrn();

マルチレコードロックの場合は、読み取りオペレーションの lockBias値にROW_LOCK_Sを指定することで、排他ロック(X)に替えて共有ロック(S)を使用することができます。更新を行わない行の読み取りにこれを使うことで、不要な排他ロック(X)を防止し、必要な行のみ排他ロック(X)にすることができます。lockBias値が指定できる読み取りオペレーションは、nastable::seek系とnastable::step系のオペレーションです。

// ユーザートランザクション 共有ロック(S)を指定する例
db->beginTrn(MULTILOCK_GAP);
tb->setFVN("id", 1);
tb->seek(ROW_LOCK_S); //<-- 共有ロック(S)にする
if (tb->stat() == 0)
...
db->endTrn();

スナップショット

スナップショットは読み取り専用トランザクションです。nsdatabase::beginSnapshot(bias=CONSISTENT_READ)メソッドで開始し、nsdatabase::endSnapshot()で終了します。 スナップショットは開始メソッドのbias値によって複数のロックタイプを選択できます。
以下はbias値に対するロック粒度とロックタイプの一覧表です。

beginSnapshotのbias値 ロック粒度 InnoDB分離レベル 行ロックタイプ
CONSISTENT_READ
(デフォルト)
対象外 REPEATABLE_READ ロックなし (consistent nonlocking reads)
MULTILOCK_NOGAP_SHARE マルチレコードロック READ_COMMITED row lock(S)
MULTILOCK_GAP_SHARE マルチレコードロック REPEATABLE_READ next key lock(S)

CONSISTENT_READはbiasのデフォルト値です。CONSISTENT_READは、REPEATABLE_READによる一貫性のある読み取りが行えます。集計処理などはもちろん、複数の読み取りオペレーションを実行する際にこれを使うと、自動トランザクションに比べて内部処理のオーバーヘッドが減ってより高速に処理できます。
MULTILOCK_NOGAP_SHAREの場合は、nstable::unlock() にて直前の読み取り行のロックを解放することができます。 必要な行のみロックを得たい場合に細かな制御を可能にします。なお、 nstable::find()など1回のオペレーションで複数の行を取得する場合は、ロックの解放はできません。細かくロック・アンロックの制御を行いたい場合は、seek系オペレーションを使用してください。

// スナップショット unlock()の例
db->beginSnapshot(MULTILOCK_NOGAP_SHARE);
tb->setFVN("id", 1);
tb->seek();
if (tb->stat() == 0)
   tb->unlock();
...
db->endSnapshot();

自動トランザクション時の行ロック

自動トランザクションの読み取りはデフォルトで nonlocking readsですが、lockBias値にROW_LOCK_Xを指定することで、排他ロック(X)を取得することができます。単純に1つのレコードを更新したい場合に使用します。

// 行ロックの例
tb->setFV("id", 1);
tb->seek(ROW_LOCK_X);// <-- 排他ロック(X)を取得
if (tb->stat() == 0)
{
    tb->setFV("name", "ABC");
    tb->update();
    if (tb->stat() == 0)
        ;// success!
}
...

自動トランザクションは、通常1つのオペレーションで終了してしまいますが、このロックオペレーションを行った場合に限り、次に続くオペレーションを行ってから終了します。これによりロストアップデートの無い更新・削除が行えます。
また、このロックは、テーブルごとに最後にアクセスしたレコードのみ有効で、次になんらかのオペレーションを行うとそのロックは解放されます。

ロックWait

ロックが競合した場合、InnoDBは自動的に一定時間内リトライを繰り返します。その間にロックが解放されればそのまま処理が進みます。解放されなかった場合は、Transactdが nstable::stat()にSTATUS_LOCK_ERRORを返します。リトライ時間は、サーバー側の my.cnf の、mysqldセクション transactd_lock_wait_timeout に秒で指定します。エントリが無い場合はデフォルト値 1(秒) が使用されます。my.cnfでの設定の詳細はTransactd 運用マニュアルを参照してください。
また、 nsdatabase::lockWaitCount()nsdatabase::lockWaitTime() によってクライアント側でリトライすることもできます。これらは、Transactdへのアクセスにおいてはデフォルトで共にゼロが設定され無効になっています。多くの場合は、InnoDBによるリトライの方が、通信のオーバーヘッドがなく効率的です。

更新処理のレコードアクセスアルゴリズム統一の重要性

同時実行性の向上は、不正な更新が起きるかも知れないであろうシナリオの推測と、更新処理のレコードアクセスアルゴリズムにかかっています。特にアルゴリズムは、処理ごとに統一されることがとても重要です。それができれば無限にある同時実行シナリオを限定的なものにできます。
処理ごとに更新アルゴリズムが1つであれば、ネクストキーロックをせずとも挿入ができなくなることは多々あります。また、マルチレコードロックでなくともシングルレコードロックで済むこともしかりです。
Transactdを使用した場合、レコードアクセスのインデックス・オペレーション・順序などはすべてプログラムコードで記述され、それが変わることはありません。しかしSQLの場合はそれらの多くをオプティマイザが決定しています。このためレコード数の増加やそのほかの条件によって変化する可能性があります。これもまた、ロックを多くしなければならない要因になります。

まとめ

Transactdでミッションクリティカルかつ同時実行性の高いアプリケーションを書く上で大切なことをまとめます。

  • アトミックな操作は、トランザクションで(beginTrn() ... endTrn())で行う。
  • 読み取りで一貫性が必要ならスナップショット(beginSnapshot(CONSISTENT_READ) ... endSnapshot())で行う。また、複数の読み取りオペレーションがある場合に使うと全体の実行速度も向上する。
  • 1行だけの更新・削除は、その読み取りオペレーションのbiasにROW_LOCK_Xを指定しロックして行う。
  • ファントムリードが問題になるトランザクションは beginTrn(MULTILOCK_GAP)で開始する。更新しないレコードの読み取り時は、その読み取りオペレーションのbiasにROW_LOCK_Sを指定して、同時実効性を良くする。
  • ファントムリードが問題にならないトランザクションは beginTrn(MULTILOCK_NOGAP)で開始する。
  • トランザクションの同時実効性を追求するときは、beginTrn(MULTILOCK_NOGAP)で不要なロックをunlock()をするか、beginTrn(SINGLELOCK_NOGAP)を検討する。
  • 更新処理ごとにレコードアクセスアルゴリズムを可能な限り統一する。
  • テーブルを占有したいときは、openTable()のmodeパラメータに、TD_OPEN_EXCLUSIVEかTD_OPEN_READONLY_EXCLUSIVEを指定する。ただし、テーブルロックはテーブルを占有してしまうので、マルチユーザー環境では特別な状況以外使用してはいけない。

いかがだったでしょうか?
Transactdはミッションクリティカルなアプリケーションを書けるのはもちろん、SQLに比べてトランザクションの同時実行性能を向上させることが可能です。
新しいプロダクトには、Transactdを使ってみませんか?

次回は、「実践ミッションクリティカル MySQL/Transactd」です。ロックのことはわかったけれども、実際どういうときにどうすればいいのかわからないという方のために、
いえ、自分のためにまとめてみたいと思います。