トランザクション分離レベルとは何か

データベーストランザクションの ACID 特性とか分離レベルってややこしい。そんなある日、データベースの "状態" という概念があるのを知った。この概念を軸にすると、これまでややこしいと思っていたものが素直に理解できる気がしたので整理してみる。例によって、勉強しながらまとめているので記事の内容に誤りがある可能性が大いにある。

準備:データベースの状態

本記事ではデータベースの状態(database state)という概念を用いる。とはいっても簡単な話で、単にデータベース中に含まれるあらゆるデータをまとめて1つの状態を見なすだけである。たとえば、データの一部に更新があった場合、データベースは現在の状態から別の状態に遷移すると見なせる。広い範囲のデータが更新された場合でも、ごく一部のデータが更新された場合でも、データベースの状態としては今の状態から別の状態に遷移したと解釈するだけである。

"状態" という考え方はコンピュータサイエンスにおいて超重要な概念であり、状態の遷移を示す 状態遷移図コンピュータサイエンスのあらゆる分野で頻出する。ここではデータベースの状態に対して以下の状態遷移図を考えよう。

f:id:t-keita:20211123233531p:plain:w400

図中の s1 が現在の状態である。データベースの内容が具体的にどうなっているのかはどうでもよい。とにかく現在のデータベースの状態を s1 としている。ここで3種類の操作「C/D」「U」「R」によって状態が遷移する。これらはデータに対する操作として一般的な CRUD に対応する。

  • 「C/D」はデータに対する作成または削除を示しており、SQL の場合は INSERT/DELETE 文に対応する。レコードを作成したり削除する。この例では、状態 s1 に対してなんらかの作成または削除の操作が行われたとき状態 s2 に遷移する。
  • 「U」は既存のデータに対する更新であり、SQL の場合は UPDATE 文に対応する。レコードの作成や削除は行わず、既存レコードの内容を更新する。状態 s1 に対してなんらかの更新が行われたとき状態 s3 に遷移する。
  • 「R」はデータの読み取りであり SQL の場合は SELECT 文に対応する。読み取りなので状態の遷移は起こらない。

この状態遷移図のポイントは、具体的なデータの内容や具体的なデータの操作は考えていないという点である。これまでのトランザクションに関する概念の解説は「飛行機のチケットを予約するトランザクション」みたいな具体的なシチュエーションが想定されていて、その例を追うのだけでお腹いっぱいになってしまうという短所があった。今回の解説では具体的なデータや操作は考えないので、トランザクションをありのまま理解できるようにする狙いがある。上手くいくか知らんけど。

トランザクションとは?

トランザクションは、データベースに対するまとまった操作の列である。データベースの状態を遷移させてゆく一連の操作列と見なすこともできる。たとえば、さきほどの状態遷移図を用いると以下はトランザクションの例である。

f:id:t-keita:20211124001833p:plain:w600

この例ではデータベースの状態 s1 に対して [C/D, R, U, R, U, C/D] という操作を行う例である。読み取り操作である「R」では状態は遷移しないが、他の操作では状態が遷移している。

ACID 特性とは?

ACID 特性は、トランザクションが満たすことが望ましいとされている性質である。ACID は Atomicity, Consistency, Isolation, Durability の4つの頭文字を取ったものである。ただし「ACID 特性」という言葉の使われ方には曖昧性があり、ACID 特性を満たしていると謳ってるデータベースであってもその実装は異なる場合がある。以下、それぞれの具体的な意味について以下で述べるが、必ずしもこれが唯一の説明ではないと思ったほうがよい。なお、説明する順番は durability, consistency, atomicity, isolation とする。

durability(永続性)

durability は、トランザクションの結果が永続化されるという性質である。トランザクションの実行に成功したのに結果がどこにも残っていない、みたいな状況は悲しいので durability は満たすべきである。とはいっても、あらゆる障害に対して永続化を保証することはできないので、保証される基準はシステムに依存する。

consistency(一貫性)

consistency は、トランザクションの実行が完了したときにデータベースに課された "制約" が常に満たされているという性質である。この "制約" はデータベースが外部から課されるものである。たとえば「口座残高が負ではない」は制約の例である。この場合は、トランザクションの実行前後で「口座残高が負はない」を満たしていなければならない。この制約自体は、データベースが管理しているというより、データベースを利用するアプリケーション側でトランザクションを組み立てるときに考慮されるものである。

consistency はトランザクションの実行前後における性質であるため、トランザクションの実行途中においては必ずしも満たされている必要はない。この制約を満たしていない状態を「汚れた状態」と呼ぶことにする。状態遷移図としては以下のように点線で図示されるものとする。

f:id:t-keita:20211124004338p:plain:w600

状態 s4 が汚れた状態である。最終的には汚れていない状態 s5 に遷移するので、トランザクションとしては consistency を保証できている。

atomicity(原子性)

atomicity は、トランザクションの実行に成功した場合はすべての操作が実行され、失敗した場合は元の状態にロールバックされるという性質である。トランザクションの実行が中途半端なところ終わってしまうとデータベースの内容が壊れる可能性がある。トランザクションを実行するたびにデータベースが壊れてゆき、やがて無秩序なデータの蓄積になってしまう。このような状況を避けるため、実行に失敗したときは元の状態へロールバックすることは重要である。

さきほどの状態遷移図を再掲する。

f:id:t-keita:20211124001833p:plain:w600

atomicity が保証されている場合、このトランザクションの実行に成功した場合は状態 s5 に遷移し、失敗した場合は状態 s1 に戻る。たとえば、状態 s4 に対する操作に失敗したからといって状態 s4 のまま実行が終了することはない。状態 s1 まで巻き戻される。

isolation(分離性)

isolation は、トランザクションの実行が他のトランザクションの影響を受けないという性質である。一般に、同一のデータベースに対して同時に複数のトランザクションが実行されるため、排他制御のようなことをしないとデータが壊れてしまう可能性がある。isolation が保証されたもとでは、そのような排他制御が自動的に行われるため、ユーザは安心してトランザクションを実行できる。

SQL の分離レベル(isolation levels)とは

isolation を実現するためにはすべてのトランザクションを順番に1つずつ処理すればよい。しかし、これは実行時の性能が非効率になることが知られている。よく考えれてみれば、読み込み「R」しか持たないトランザクションをいくつ同時に実行してもデータベースが壊れることはない。つまり、トランザクションの性質によっては同時に実行しても問題ない場合がある。

このような背景から isolation のレベルを決めたものが SQL の分離レベルである。isolation のレベルを上げると、同時に実行できるトランザクション数は減るが、別のトランザクションの影響を受けることが少なくなる。逆にレベルを下げると、トランザクション数は増加するが、別のトランザクションの影響をより大きく受けることになる。分離レベルはデータベースの用途に応じて設計され、トランザクションを実行する前に指定される。

具体的には、分離レベルには以下の表の4段階が存在する。

Isolation levels Dirty reads Non-repeatable reads Phantoms
Serializable 起きない 起きない 起きない
Repeatable Read 起きない 起きない 起こりうる
Read Committed 起きない 起こりうる 起こりうる
Read Uncommitted 起こりうる 起こりうる 起こりうる

Dirty reads、Non-repeatable reads、Phantoms がトランザクションの同時実行時に起きうる異常(anomaly)であり、分離レベルを上げるにつれて起こるものが限られてゆく。ただし、データベースごとに分離レベルの実装が異なる可能性があるので、実際に指定するときはその仕様を確認すべきである。

Serializable レベル

Serializable は最も高い分離レベルであり、複数のトランザクションを1つずつ順番に実行したときと同じ挙動をすることを保証するものである。

例として以下の2つのトランザクション T1, T2 を同時に実行することを考える。トランザクション T1 は U1 という更新操作を行うものであり、T2 は [U2, U3] という2つの更新処理を行う。

f:id:t-keita:20211125001135p:plain:w600

現在のデータベースの状態を s1 とすると、Serializable のもとでの状態遷移は以下の2つのいずれかになる。

f:id:t-keita:20211125001307p:plain:w600

トランザクション T1, T2 は1つずつ実行されるので、[U2, U1, U3] のような操作列になることはない。

これに対して、以下の3つの異常のうちどれを許すかによって分離レベルが決まる。

Dirty reads

Dirty reads という異常は、複数のトランザクションを同時に実行したときに "汚れた状態" が読み込まれてしまうものである。

この異常が発生する例として、以下の2つのトランザクション T1, T2 を同時に実行することを考える。トランザクション T1 は現在の状態を読み込む「R」の操作だけを行う.。T2 は [U1, U2] という2つの更新処理を行うが、U1 の操作後は "汚れた状態" になっている。

f:id:t-keita:20211125002655p:plain:w600

Read Uncommitted のような弱い分離レベルでは、これらを同時実行したとき以下のような状態遷移が発生する可能性がある。

f:id:t-keita:20211125003134p:plain:w400

操作列は [U1, R, U2] であるが、R によって汚れた状態が読み込まれてしまう。たとえば、銀行のアプリで「口座残高: -20,000円」みたいな表示が起こりうるのである。実際はこれらの実行が完了するころには汚れていない状態 s3 に遷移するので、あくまでユーザに表示する値がおかしくなるに過ぎないのだが、ユーザからのクレームは止まないだろう。

Non-repeatable reads

Non-repeatable reads という異常は、同じ状態の既存のレコードに対して値を2度読むはずが異なる値を読み込んでしまうものである。

この異常が発生する例として、以下の2つのトランザクション T1, T2 を同時に実行することを考える。トランザクション T1 は [R, R] という操作列であり、同じ状態を2度読むものである。Serializable のもとではまったく同じ状態を2度読むことが保証される。トランザクション T2 は既存のレコードに対して更新操作をするものである。

f:id:t-keita:20211125005049p:plain:w500

Read Committed より弱い分離レベルでは、これらを同時実行したとき以下のような状態遷移が発生する可能性がある。

f:id:t-keita:20211125005120p:plain:w300

操作列は [R, U, R] であるが、トランザクション T1 から見ると2度の R で異なる値を読み込んでしまう可能性がある。dirty reads のように状態遷移の中に汚れた状態は存在しないものの、T1 としては同じ状態を2度読むことを期待していたために異常と言えるわけである。

たとえば、トランザクション T1 が口座Aと口座Bを持つユーザのアプリ画面に総資産額を表示するためのものであるとする。最初の R によって口座Aの残高を取得し、次の R によって口座Bの残高を取得する。そして、トランザクション T2 は口座Aから口座Bに送金するためのものであるとしよう。上記の異常な状態遷移では、送金前の口座Aと送金後の口座Bの残高を取得することになり、結果として総額が実際より多く表示されてしまう可能性がある。

Phantoms

Phantoms という異常は、同じ範囲のレコード集合を2度読むはずが、レコードが追加または削除されたため異なる範囲のレコード集合を読んでしまうものである。

この異常が発生する例として、以下の2つのトランザクション T1, T2 を同時に実行することを考える。

f:id:t-keita:20211125011401p:plain:w500

Repeatable Read より弱い分離レベルでは、これらを同時実行したとき以下のような状態遷移が発生する可能性がある。

f:id:t-keita:20211125011450p:plain:w300

操作列は [R, C/D, R] であり、Non-repeatable reads で異常が発生するケースとの違いは U か C/D の違いのみである。Non-repeatable reads は「既存のレコードに対して同じの値を2度読む」ときに発生する異常であるため、今回のようにレコードごと追加/削除された場合は該当しない。

Phantoms はかなり微妙な異常で、ユーザを想定したアプリみたいな文脈で分かりやすい例を出すのはなかなか難しそう。

おしまい。

参考