トランザクション分離レベルの理解を言語化した

ソフトウェアエンジニアであれば一度はトランザクション処理という言葉を聞いたことがあるだろう。 トランザクション処理とは、互いに依存関係を持つある一連の操作が、すべて完了される、あるいはすべて破棄(アボード)されることを保証する処理を指す。

多くのRDBMSはトランザクション処理を備えている。 たとえば銀行口座間の送金操作にはデータベース上でトランザクション処理が要求され、複数の銀行口座の情報を矛盾なく変更することができる。

TL; DR

  • トランザクション分離レベルには4段階ある
  • 読み取りスキューを防ぐにはRepeatableを、書き込みスキューを避けるにはSerializalbleの分離レベルが必要
  • 行ロックをうまく利用することでRepeatable分離レベルで書き込みスキューを防げる

トランザクション処理を考えるとき「トランザクション分離レベル」の考え方が必要になる。 一貫性のあるデータ操作を行うにあたり、すべてのトランザクションをひとつずつ順に処理していたのでは時間がかかりすぎて現実的な用途にたえない。 そこで、トランザクション処理の分離の度合いを「分離レベル」として段階的に定義し、処理時間とのトレードオフを選択可能とする。

一般的なトランザクション分離レベルとして次の4つが定義されている。 (a)から(b)につれて分離レベルが高くなる一方で、要求される処理時間も長くなる。

  • (a) Read-uncommited (確定していない情報まで読取り可能)
  • (b) Read-commited (確定した最新情報のみ読取り可能)
  • (c) Repeatable (繰り返し読取り可能)
  • (d) Serializalble (直列化可能)

(a) Read-uncommitted は同時に実行される他のトランザクション処理の書きかけ(完了も破棄もされていない)状態のデータ読み取りを許容する。 一方、(b) Read-committed は完了した(committed)データだけが読み取り可能となる。
(b) Read-commitedさえ保証されれば、トランザクション処理に必要となる懸念はすべて解決されたように思えるかもしれない。 しかし、読み取りのタイミングが悪ければ容易に一貫性のない(矛盾した)データの受信が発生する。

例えば、$100ずつ預金された2つの口座α,βがあり、αからβに$50を送金するトランザクション処理T1を想定しよう。 このとき、別のトランザクション処理T2として口座の持ち主が口座α,βの預金額を確認する状況を考える。口座αはトランザクションT1が実施される前にデータを読み込み$100の預金を確認した。βはトランザクション処理が終わった時点で読み込みを行い$150の預金を確認した。すなわち口座の持ち主は合計で$250の預金を確認したことになる。しかし、その後もう一度口座αを確認すると預金額は$50に変化している。

このように、トランザクション処理の最中で読み取りのタイミングによってデータが変化する状況を読み取りスキューと呼ぶ。 これを防ぐためには、(c) Repeatable の分離レベルが必要だ。(c) Repeatableにおけるデータ読み込みはトランザクション開始時点のスナップショットを用いて行われる。 上の例で説明すれば、持ち主の預金額確認(T2)が送金処理(T1)の前に開始されれば、送金処理の最中でも読み取るデータの値は変化しない。

(c) Repeatable を提供するための最も一般的な実装として「スナップショット分離」がある。 スナップショット分離では、それぞれのトランザクションがデータベースの一貫性のあるスナップショットから読み取りを行う。詳しくは各RDBMSのドキュメントを参照されたい。

スナップショット分離によってトランザクション処理に必要となる懸念はすべて解決されただろうか。 じつはまだ不十分だ。読み取りスキューと対になる概念に「書き込みスキュー」がある。 書き込みをスキューは、データを書き込む操作において過去に読み込んだ値との一貫性欠いてしまう状況をさす。

例えば、ある口座に紐づく出納記録を保存する取引テーブルを考える。口座の残金が負の値になるような操作は無効として破棄されるものとする。 いま口座Aには10万円が預金されており、トランザクション処理T3によって8万円が引き出されようとしている。 このとき、口座Aの預金額を確認した時刻tと、引き出しトランザクションT3を完了する時刻t’の時間間隔t'-tの間に、別のトランザクション処理T4が5万円の引き出を完了したとしよう。 T3はT4による預金額の変化を認知しないまま引き出し処理を試みるため、口座の残金は-3万円となり非負制約に違反する。

これを防ぐためには高次の分離レベル(d) Serializalbleが必要となる。 (d) Serializalble は読んで字の如く、複数のトランザクション処理に対して直列で処理した場合と同じ結果を保証する分離レベルだ。この分離レベルは非常に頑健な一方で、深刻なパフォーマンス悪化を招きかねない。利用する場合にはくれぐれも注意されたい。

じつは、書き込みスキューを防ぐ方法として(分離レベル変えないまま)行のロックを利用する方法が存在する。 しかし単純に複数行をロックする方法では書き込みスキューを防ぐことはできない。なぜなら、書き込みスキューを引き起こす可能性のある行はまだ存在していないからだ。存在しない行にたいしてロックを取得することはできない。

存在しない行に対してロックをかける操作は「衝突の実体化(materializing conflicts)」と呼ばれる。 先の例では、T3開始時にT4で生成される行(=5万円の引き出し)は存在していないため、取引テーブル上でロックを設けることができなかった。 しかし、親テーブル(=口座テーブル)を用いることでT3とT4の衝突を実体化できる。 つまりT3、T4はいずれもある口座Aに対する操作を扱っているのだから、口座Aの行ロックを用いて排他処理を実現するのである。 衝突の実体化を用いることで(c) Repeatableの分離レベルのまま書き込みスキューを防止できる。

しかしながら、本来であればデータベース上で完結するはずの衝突管理をアプリケーションレイヤで扱うことは望ましい選択ではない。 RDBMSの選択によらず、同じアプリケーションコードで動作することが理想である。