プログラミングメモ

ソフトウェア開発に関する技術メモ。

データベース利用時に自前で排他制御が必要か

並行処理がある環境では、Webアプリに限らず排他制御の考慮が必要。必要になる典型例は次のパターン。

パターン1

  1. 値を取得してから
  2. その値に基づく計算結果を書き出し

パターン2

  1. 前提条件を確認してから
  2. 条件を満たしていれば処理を実行

1を取得・確認してから2を実行するまでの間に別のスレッド・プロセスがデータを書き換えてしまうと、1で得た情報がもはや正しくないまま2を実行してしまう可能性がある。このため、これらの処理が完了するまでの間に1の結果を変更してしまうような処理が割り込まないよう排他制御が必要になる。

例1

  1. 預金残高を取得してから
  2. それに100円を加えた金額を預金残高として記録

例2

  1. 会員資格が停止されていないことを確認してから
  2. その会員にサービスを提供

プログラミング言語レベルの制御なら

Javaの場合、同一VM内であればオブジェクトのロックを利用して排他制御が実現できる。

  • あるオブジェクトのロックは1スレッドからしか取得できない。
  • あるスレッドがロック保持中に別スレッドがロックを取得しようとすると、開放されるまで待たされる。

ロックはsynchronizedブロック/メソッドに入るときに取得され、出るときに開放される。

排他制御を実現するには以下のどちらも、ロックを取得してから処理するようにする。

  • 値を取得する側、前提条件を確認する側
  • 値を書き換える側、確認結果を変えてしまうような変更を行う側

例えば、
(a) 値を取得する側

synchronized (foo) { // ←ロックを取得
  int balance = 預金残高を取得();
  if (balance >= 100) {
    預金残高を記録(balance - 100);
  }
}                    // ←ロックを開放

(b) 変更を実施する側 (無理のある例だけれど)

synchronized (foo) {
  預金残高を記録(0); // 無条件に残高を0円に変更。
}

変更を実施する側もロックを取得する必要がある点に注意する。ロックを取得しなければ、(a)の実行途中で(b)が実行される可能性がある。

トランザクション排他制御

トランザクションを使えば排他制御される、というわけでは必ずしもない。
例えばPostgreSQLの場合、トランザクション分離レベル(隔離レベル)によって状況が変わってくる。

  • 「シリアライザブル分離レベル*1」を利用した場合は、複数トランザクションを順次直列に実行したのと同じ結果が得られるとのことなので、排他制御されていることになる。ただし、トランザクションが失敗することがあるため、失敗時にリトライするようなプログラムを書く必要がある。また、ホットスタンバイレプリケーションでは利用できないとのこと。
  • 「リピータブルリード分離レベル」の場合、シリアライザブル分離レベルと同様に順次直列に実行したのとほぼ同様の結果が得られるがいくつかの例外もある(まだよく理解できていない)。トランザクションが失敗することがあるため、失敗時にリトライ処理が必要。
  • 「リードコミッティド分離レベル(デフォルトの分離レベル)」の場合、あるトランザクションの実行中に別のトランザクションが実行できるし、トランザクションがコミットすれば、その結果は、実行中の他のトランザクションから見えるようになる。排他はされない。

リアライザブル分離レベルやリピータブルリード分離レベルの利用に伴う制約やオーバーヘッド(リトライの作成、性能劣化)を許容できない場合はリードコミッティド分離レベルを使うことになる。

リードコミッティド分離レベルでは、

  • 各問い合わせが実行を開始した時点のデータに基づいて実行する。
    なおこの「問い合わせ」がSQL文1個を指すのか、1文中にサブクエリを含むような場合にそれぞれのSELECTを指すのかは、PostgreSQLのマニュアルからは読み取れない。
  • 他のトランザクションの変更内容は、それがコミットされれば、見えるようになる。

少なくとも、トランザクション内でSQL文が2個以上実行される場合は、その途中で別トランザクションが割り込んで、参照しているデータを書き換える可能性がある。したがって、自前で排他制御する必要がある。

では、SQL文1文の場合はどうなるか。

UPDATE 同士の衝突

PostgreSQLデフォルトのリードコミッティド分離レベルの場合で考える。

先の例の「預金残高を取得して100円を加えて記録する」はUPDATEコマンド1個で書けてしまう。

UPDATE account SET balance = balance + 100 WHERE account.id = 12345;

すなわち、UPDATE 1個の実行中に値取得、計算、記録という一連の処理が含まれるが、値取得後、計算前に別のトランザクションが割り込み、預金残高を書き換えてしまうことはないのか。

この場合は、次の2つの働きにより、割り込まれる問題はない。順次実行したのと同じ結果が得られる。

  • 行レベルロック
    行レベルロックにより、同じ行に対する書き込みは、一方のトランザクションが完了するまで他方が待たされる。
  • ロック解除後の再読み込み
    行ロックにより待たされた場合、相手のトランザクションが完了後、その更新結果に対して処理が適用される*2

このため、

UPDATEとINSERTの衝突

UPDATEがロックしている行をINSERTが書き換えるわけではなく、ロックによる排他は働かない。
基本的にこの2つはお互い待たされずに並行動作する。

次のケースを検討してみる。

  • UPDATEの実行途中にINSERTが実行された場合、追加された行の影響を受けるか。
    受けない。リードコミッティド分離レベルでは、UPDATEの実行開始時点でコミット済みのデータを利用する。
  • INSERTが複数の行を追加中にUPDATEが実行開始した場合、どうなるか。
    UPDATEからはINSERTが追加中の行は見えない。INSERT側が明示的にトランザクションのBEGINを実行していなくても、各文ごとにトランザクションが開始され、文の実行終了時に自動的にコミットされる*3。リードコミッティド分離レベルでは、コミットされるまで別のトランザクションからは見えない。

ここまでの範囲では、UPDATEとINSERTが衝突しても、UPDATE→INSERTの順に順番に実行したのと同じ結果が得られる。

INSERTする行が定数として与えられておらず、サブクエリに基づいて内容が決まる場合はどうなるか。非実用的だが次の例を考える。

INSERT INTO account(balance) SELECT balance * 2 FROM account;

UPDATEの実行中にINSERTが実行開始したとする。INSERTが追加するレコードは、INSERT実行開始時点のaccountテーブルに基づいて決定される。まだUPDATEがコミットしていないため、UPDATE実行前の内容に基づいてINSERTされる。一方、UPDATE側も、INSERT実行前のデータに基づいてUPDATEを実施する。UPDATE→INSERTの順、INSERT→UPDATEの順、どちらの順番で実行した場合とも異なる結果が得られる。

UPDATEとDELETEの衝突

DELETEも行レベルロックを取得するので*4、一方の実行中は他方の実行も待たされる。

UPDATE→DELETEの順に実行された場合は、更新した行がすぐ削除される。
DELETE→UPDATEの順に実行された場合、UPDATEは対象から、その削除された行は除外される(仕組みは後述)。

WHEREによる検索結果が変わるような変更との衝突

UPDATEは更新対象行の行レベルロックを取得するので、事前にWHEREに該当する行を検索して、行を特定してから、行レベルロックを取得し、場合によっては待ちに入ることになる。待っている間に別トランザクションが変更を実施し、WHEREによる検索結果が変わってしまう場合はどうなるか。別トランザクションで次のような変更を実施した場合が該当する。

  • 行を追加
  • UPDATEが更新しようとしていた行を削除
  • WHEREで参照しているカラムの値を変更

UPDATEはロックが開放された後、

  • 既に検索して特定した行については、再度WHEREを評価して、条件に該当しなくなった場合は更新対象から除外
  • それ以外の行については、再検索は実施しない

このため、

  • 更新される行は、WHEREの条件を満たしている

しかし、事前に検索しておいた行以外は再検索しないため、

  • WHEREの条件を満たす行であっても、更新されない場合がある

ここで問題なのは、UPDATEが待たされることにより「別トランザクション」→「UPDATE」の順に実行されたかと思いきや、実際に得られる結果はそれとは異なる、ということである。もちろん、「UPDATE」→「別トランザクション」の順に実行した結果とも一致しない。「UPDATE」「別トランザクション」を順に実行した場合には決して発生しない結果が生じてしまう。

まとめ

  • 「シリアライザブル分離レベル」のような、順列実行との等価性を保証する仕組みを使わない限りは、自前で排他制御が必要。
  • トランザクション中にSQL文1個の場合は、自動的に働く行レベルロックが基本的には排他制御として機能するものの、以下の要因により条件によっては順列実行時と同じ結果は得られない。(PostgreSQLのデフォルトの「リードコミッティド分離レベル」の場合の例)
    • INSERTは行レベルロックを取得しないため、UPDATE/DELETEとは排他にならない。INSERTで追加される行の内容がテーブル内容から計算されている場合には(サブクエリの利用)、UPDATE前のテーブル内容に基づく内容が、UPDATE後に、UPDATEによる更新を経ないまま残る可能性がある。
    • UPDATE/DELETEのWHEREによる検索は、ロックの前に実施される。ロックしてから検索ではない。ロック解放待ちに入った場合、別トランザクションでの変更に伴い、実行再開後はWHEREの検索結果は変わる可能性があるが、再検索はロック前に該当した行のみに限定して実施され、テーブル全体に対しては実施されない。

*1:完全な対応はPostgreSQL 9.1で追加された模様。

*2:http://www.postgresql.jp/document/9.3/html/transaction-iso.html#XACT-READ-COMMITTEDに説明がある。

*3:マニュアルではhttp://www.postgresql.jp/document/9.3/html/sql-begin.htmlの説明中に記載あり。

*4:http://www.postgresql.jp/document/9.3/html/explicit-locking.html#LOCKING-ROWSの「他のトランザクションが共有ロックを保持している行に対して、更新、削除、排他ロックの獲得を行うことができるトランザクションはありません」という説明から判断して。