ひらめの日常

日常のメモをつらつらと

『データ指向アプリケーションデザイン』を読んだ ー 第II部 分散データ 7章

こちらの続きです hiramekun.hatenablog.com

本はこちら

7章は個人的に重要なことが多いと感じたので、内容多めにまとめます。

第7章 トランザクション

ACID

トランザクションが提供する安全性の保証は、ACID で示される。

  • 原子性(Atomicity)
  • 一貫性(Consistency)
    • データについて何らかの不変性があるということ。
    • 一貫性を保つようにトランザクションを適切に定義することはアプリケーションの責任であり、これはデータベースの責務ではない。
  • 分離性(Isolation)
    • 並行して実行されたトランザクションがお互いから分離されているという意味。
    • 直列化可能性(serializability)とも呼ばれる
      • 複数のトランザクションが並行に実行されていても、コミットされた後の結果はそれが直列に実行されたことと同じようになる。
    • 実際にはスナップショット分離が実装されることもある。
  • 永続性(Durability)
    • トランザクションのコミットが成功したら、ハードウェアの障害やクラッシュがあったとしても、トランザクションで書き込まれた全てのデータは失われないことを保証する。
    • とはいえ、完全な永続性は存在しない。

トランザクションのエラーと中断の処理

トランザクションの重要な機能は、エラーが生じた際に中断でき、安全にリトライできること。

リーダーレスレプリケーションを行うようなシステムなど、ベストエフォートで動作するシステムもある。つまり、データベースはできる限り処理を進め、エラーが起きた場合でもそれまでに終わった処理は取り消さない。エラーからのリカバリはアプリケーション側で担当する。

このようなエラーからのリカバリ、リトライでは特に以下の点に注意が必要

  • トランザクションが成功していても、ネットワークエラーにより失敗したとみなされる可能性がある。アプリケーション側でトランザクションの重複排除の仕組みが必要。
  • 過負荷によるエラーが発生した場合、トランザクションをリトライすると状況をむしろ悪化させる。リトライの回数を制限し、指数的バックオフを行うなどが必要。
  • リトライするべきなのは一時的なエラー(デッドロック・一時的なネットワーク障害など)の場合のみであり、恒久的なエラーの場合はリトライしない。
  • トランザクションがデータベース外に副作用を持っている場合、トランザクションが中断されても副作用は生じているかもしれない。
  • クライアントのプロセスがリトライ中に落ちた場合、そのプロセスがデータベースに書き込もうとしたデータは全て失われる。

弱い分離レベル

並行性の問題(レース条件)が問題となるのは、

実際に利用されている弱い分離レベルにおいて、どのようなレース条件が生じるのかをみていく。

Read Committed

トランザクションの分離性において最も基本的なレベルであり、2つのことを保証する。

  • ダーティリードは生じない
    • データベースから読み取りを行なった際に見えるデータは、コミットされたもののみ
  • ダーティライトは生じない
    • データベースへの書き込みを行う場合、上書きするのはコミットされたものに対してのみ

トランザクションがあるオブジェクトを更新したい場合、トランザクションはまずはそのオブジェクトのロックを獲得する。そしてトランザクションが終わるか中断するまで保持する。

トランザクション進行中でまだコミットされていないオブジェクトを読み取ろうとした場合、データベースは以前にコミットされている古い値を返す。

スナップショット分離とリピータブルリード

例としてあげられていたのは以下のようなもの。

  1. 下図のように、Aliceが口座1と口座2にそれぞれ残高が500円あるとする。
  2. Aliceがそれぞれの口座に対して読み取りのトランザクションを開始する
  3. Aliceが読み取りのトランザクションをしている最中に、別の送金トランザクションが100円を口座1から口座2に移し、コミットする。
  4. Aliceが読み取りのトランザクションをコミットする。
  5. するとAliceは「送金前の口座1の値」と「送金後の口座2の値」の両方を参照してしまい、500円 + 400円 となり、不整合が生じる。
sequenceDiagram autonumber actor Alice participant 口座1 participant 口座2 Note over 口座1, 口座2: それぞれ残高500 actor 送金 Alice ->> 口座1: select balance from accounts where id = 1 口座1 -->> Alice: 500 送金 ->> 口座1: update accounts set balance = balance + 100 where id = 1 口座1 -->> 送金: success 送金 ->> 口座2: updaate accounts set balance = balance - 100 where id = 2 口座2 -->> 送金: success Note left of 送金: コミット Alice ->> 口座2: select balance from accounts where id = 2 口座2 -->> Alice: 400 Note right of Alice: コミット

このような現象を nonrepeatable read もしくは読み取りスキューと呼ぶ。この読み取りスキューは、read committed 分離レベルの下では許容されるものとみなされている。

スナップショット分離 を用いると、読み取りスキューを解決できる。スナップショット分離の考え方は、トランザクションが読み取るデータは、すべてそのトランザクション開始時点でデータベースにコミットされたものだけ、というもの。PostgreSQLMySQLなどでサポートされている(これらでは リピータブルリード と呼ばれている)。特徴としては、

  • 読み取りが書き込みをブロックしない
  • 書き込みが読み取りをブロックしない

スナップショット分離の実装

データベースは、あるオブジェクトについてコミットされた状態を保持する必要がある。これは、マルチバージョン並行性制御, MVCC と呼ばれる。

昔のバージョンを参照するため、長時間実行されるトランザクションはずっと昔のバージョンを参照し続けている可能性がある。

更新のロストの回避

更新のロストの問題は、二つ以上の並行するトランザクションが、アプリケーションが何らかの値をデータベースから読み取り、その値を変更して書き戻す場合に生じることがある。2つ目の変更は1つ目の変更を踏まえていないため、2つ目の変更が書き込まれる際は1つ目の変更が失われることになる。

アトミックな書き込み操作

多くのデータベースではアトミックな更新処理が提供されている。アトミックな操作は、次のようにして実装される。

  1. オブジェクトからの読み取りの際に排他ロックを取る
  2. 更新の適用が終わるまで、他のトランザクションがそのオブジェクトを読み込めないようにする

ORマッパーを使用する際、このアトミックな操作を実行しない時があるので注意すること。
https://nateware.com/2010/02/18/an-atomic-rant/

明示的なロック

アプリケーション側で明示的にロックをかけるのも方法の一つ。 FOR UPDATE によってデータベースが、すべての行に対するロックを取得するようにする MySQL :: MySQL 5.6 リファレンスマニュアル :: 14.2.5 ロック読み取り (SELECT ... FOR UPDATE および SELECT ... LOCK IN SHARE MODE)

衝突の解決とレプリケーション

レプリケーションされているデータベースでは、データが別々のノード上で並行して変更される可能性がある。この場合、並行する書き込みによって1つの値について衝突する複数バージョンの生成を許し、その後アプリケーションのコードなどによってそれらのバージョンの衝突解決やマージを行うのが一般的なアプローチ。

書き込みスキューとファントム

これまでは同じオブジェクトに対する書き込みや読み込みによる競合を扱ったが、それ以外の場合も発生する。それが 書き込みスキュー で、次の状態で発生しうる。

  1. 2つのトランザクションが同じオブジェクト群から読み取りを行い、
  2. それらのいくつかを更新する。トランザクションごとに更新するオブジェクトが異なっていても発生しうる。

より具体的には、

  1. SELECT クエリによって特定の条件を満たす行を検索し、要求が満たされているかをアプリケーション側で確認する
  2. このクエリの結果に応じてアプリケーションは振る舞いを変える
  3. 処理を続ける場合、データベースに書き込みを行い、トランザクションをコミットする

このように、あるトランザクションでの書き込みが他のトランザクション中の検索クエリの結果をへkなさせてしまう効果は ファントム と呼ばれる。

何かデータにおける制約がある場合は、データベースのユニーク制約や外部キー制約などで解決できる場合がある。

直列化可能性

直列化可能分離レベルは、最も強い分離レベルとされている。これは、トランザクションが並行して実行されていても、最終的にはそれぞれが1つずつ順番に実行された場合と同じになることを保証する

それを実現するための方法をいくつか紹介する。

完全な順次実行

シングルスレッドで順次実行している方法は、Redisなどで実装されている。ただし、書き込みスキューを解決するためには、ネットワーク越しにクエリを複数回実行し、アプリケーション側で条件分岐をする必要があり、ここにコストがかかっている。そこで、このようなシステムでは外部とのやりとりで複数文を含むトランザクションを禁止し、ストア°プロシージャ を利用する。

ツーフェーズロック(2PL)

2PLでは、

  • writer がブロックするのは他の writer だけではなく、他の reader もブロックする
  • reader も write をブロックする

2PLは、MySQLSQLServer の直列化可能分離レベルなどで利用されている。Reader, Writer のブロックは、データベース内の各オブジェクトにロックを持たせることによって実装される。このロックは二つのモードがあり、shared モードexclusive モード である。

  • 読み取りを行う場合、トランザクションはまず shared モードのロックを取得する。他のトランザクションが exclusive モードのロックを取得していた場合、その解放を待つ必要がある。
  • 書き込みを行う場合、トランザクションはまず exclusive モードのロックを取得する。他のトランザクションが同時に shared, exclusive モードともにロックを取得することは許可されず、どちらかのロックがすでに保持されている場合、その解放を待つ必要がある。

トランザクション開始時にロックを取得し、終了時にロックを解放することから、ツーフェーズロックと呼ばれている。

このツーフェーズロックは、ロックの取得が頻繁に発生するため、パフォーマンス面が大きな欠点となっている。

インデックス範囲ロック

2PLを持つほとんどのデータベースが実装しているのが、インデックス範囲ロック、別名 next-key ロック である。

インデックス範囲ロックでは、 SELECT 文を実行する際に検索条件となっているインデックスのデータに共有ロックを持たせるというもの。これは条件が範囲であればその範囲のデータをロックするし、単独の値であればその値をロックする。

このロックを与えるのに適したインデックスがない場合、データベースはテーブル全体に対する共有ロックを取得する。 その際はこのテーブルに書き込もうとしているすべてのトランザクションを止めるため、パフォーマンスが低下する。

直列化可能なスナップショット分離(SSI)

これは、完全な直列化可能性を提供しながら、パフォーマンス面での低下を抑えられる方法として期待されている。

2PLは、悲観的な並行性制御の仕組みで、状況が安全になるのを待ってから処理を進める方法。

その一方で直列化可能スナップショット分離は楽観的な仕組み。何か競合が起きた場合、そのままトランザクションを実行して問題がなかったことを期待する。もし問題があれば、トランザクションを一度中止し、リトライを行う。