データベース上で行われる一続きの操作を論理的な単位にまとめたものがトランザクションです。
コミット直前に実データとは別にクラッシュリカバリ用のログ(WriteAheadLog)を記録することで、トランザクションの最中にマシンがクラッシュしたり規約違反が生じた場合でも、全体として更新を適応するか破棄するかというアトミックな更新ができるようになっています。
この仕組みはデータベースノード内のデータベースエンジンとログによって提供されますが、更新を適応するデータベースのノードが複数に渡る場合、ネットワーク越しにトランザクションの管理が必要になり幾らか勝手が異なります。
例えばサービスの拡大に合せてコネクションやディスクデータを分散させるためにデータベースを分割する場合を考えてみます。
一つのユーザリクエストの中で分割された複数のデータベースノードにアクセスするシチュエーションでは、それぞれのノードの間でもデータの一貫性を保たなければなりません。
観測される実装例はいくつかありますが、一つにトランザクションをネストさせるというものがあります。
大抵はアプリケーション中のライブラリを使い回すことで実装できるので、各々の接続先のデータベースに独立したトランザクションを張って一つずつ処理していくのですが、そのうち一つのトランザクションで規約違反やクラッシュなどのエラーでロールバックが発生しても、直前のトランザクションはコミットを終えているためロールバックできないという危険が伴います。
あるいはトランザクション用のサービスを自作して独立したトランザクションを管理するという例もあります。
トランザクションの処理のシーケンスをトランザクションサービス上のデータベースに記録し、途中で処理に失敗した場合は記録したシーケンスを遡って補償コミットで管理先のデータベースの状態を戻していくというものです。
しかし、ここで実行されるのはデータベース上は個別のトランザクションであり、各データベース上のデータが正しいことの責務はアプリケーションが担当することになってしまうため限界があります。
2相コミット
複数のデータベースノードに跨って作用するトランザクションに2相コミット(2Phase-Commit)というものがあります。
具体的にはXAやWA-AtomicTransactionのようなプロトコルが存在します。
WA-AtomicTransactionの実装例をあまり聞かないのでXAを例にあげると、トランザクションに参加するノードはCordinator(調整者)とCohort(参加者)という役割に分割され、PrepareとCommitと2つのフェーズに別れてトランザクションを実施します。
Cohortは自身のデータベースにトランザクションを実施するノード、Cordinatorは各Cohortのトランザクションの進行を管理するノードで、これはトランザクションに参加するデータベースノード群の代表が管理する場合もあれば、トランザクションを実施するアプリケーション中のトランザクションマネージャが管理する場合もあります。
SQL Statement上の具体的な流れは以下のようになります。
XA START (global transaction id) -- ...⑴
~ do something ~
XA END (global transaction id)
XA PREPARE (global transaction id) -- ...⑵
XA COMMIT (global transaction id) -- ...⑶
⑴ トランザクションの開始
コーディネータはトランザクション参加ノード間でユニークなトランザクションのID(グローバルトランザクションID)を添えてトランザクションを開始する
並行する可能性のあるトランザクションのIDと見分ける必要があるので、uuidやtimestamp等実施するアプリケーションが実施するトランザクションの粒度に合わせてユニークであれば良いとされています。
⑵ Prepare
2-1. coordinatorはグローバルトランザクションIDを含むPrepareリクエストをcohortに送信
2-2. Prepareを受信したcohortはローカルノード内でコミットできることを確認し、coordinatorにレスポンスを返す
2-3. Prepareのレスポンスを受け取ったcoordinatorはトランザクションをCommitするかRollbackするか判断
2-4. coordinatorはCommit/Rollbackの判断を自身のログに書き込む
⑶ Commit(Rollback)
3-1. coordinatorはCommit(Rollback)のリクエストをcohortに送信
3-2. cohortはCommit(Rollback)のリクエストを受け取りローカルノード内で実施
実装
JavaTransactionAPIに代表される内部で2PCをサポートしているアプリケーションフレームワークもありますが、多くは内部的に2PCをサポートしていない為、都度自身で実装することになります。
命令自体は以下のようにアプリケーションが発行するSQLステートメントを書き換えれば作用するので、適宜アプリケーションに付属しているトランザクションマネージャをラップすることで実装できます。
しかしながら、参加者がクラッシュした場合のリトライの戦略や進行中・復旧時のログの管理、トランザクション分離レベルの管理などアプリケーションサーバが一種のデータベースの役割を担うため、実装には注意を払う必要があります。
$connections = [
new PDO('DB_TYPE:host=HOST;dbname=DBNAME', DB_USER, DB_PASS),
new PDO('DB_TYPE:host=HOST;dbname=DBNAME', DB_USER, DB_PASS),
];
public function XAStart($connections, $gtxid)
{
foreach($connections as $conn) {
$conn->exec("XA START '$gtxid'");
}
}
public function XAEnd($connections, $gtxid)
{
foreach($connections as $conn) {
$conn->exec("XA END '$gtxid'");
}
}
public function XAPrepare($connections, $gtxid)
{
foreach($connections as $conn) {
$conn->exec("XA PREPARE '$gtxid'");
}
}
public function XACommit($connections, $gtxid)
{
foreach($connections as $conn) {
$conn->exec("XA COMMIT '$gtxid'");
}
}
public function XARollBack($connections, $gtxid)
{
foreach($connections as $conn) {
$conn->exec("XA ROLLBACK '$gtxid'");
}
}
public function Begin($connections, $gtxid)
{
$this->XAStart($connections, $gtxid);
}
public function Commit($connections, $gtxid)
{
$this->XAEnd($connections, $gtxid);
$this->XAPrepare($connections, $gtxid);
$this->XACommit($connections, $gtxid);
}
public function RollBack($connections, $gtxid)
{
$this->XAEnd($connections, $gtxid);
$this->XAPrepare($connections, $gtxid);
$this->XARollback($connections, $gtxid);
}
2PCの課題
加筆中
参考
https://www.slideshare.net/takezoe/ss-35337478
https://www.slideshare.net/kumagi/ss-78765920
https://www.slideshare.net/yugoshimizu/ss-38626214
2019/11/30 諸々修正