はじめに
今回の記事では、トランザクションの基礎まとめということで、以下の内容をカバーしていきます。
Keyword
トランザクション、ACID、楽観ロック、悲観ロック、排他ロック、共有ロック、同時実行制御
トランザクションとは
トランザクションとは、データの整合性を保つために、関連する複数の処理を一つの作業単位としてまとめて扱う仕組みです。トランザクションは、データベースへの処理が「すべて成功する」か「すべて失敗する」ことを保証し、処理の途中でエラーが発生してもデータの矛盾が生じないようにします。
ACID
トランザクションが満たすべき4つの性質をそれぞれの頭文字をとって、ACIDといいます。
原子性(Atomicity)
トランザクションは、複数の処理を一つの作業単位にまとめたものです。トランザクションに含まれるすべての処理が、成功(Commit) か失敗(Abort, Rollback) になる性質を原子性といいます。
例えば、データベースに対して1000行の更新を行う処理を行なっていて、999行目までの処理が完了していても、1000行目の更新途中にDBサーバがクラッシュした場合、999行目までの更新はロールバックされます。つまり、原子性はトランザクションが中途半端な状態にならないことを保証してくれます。
コミット(Commit)とロールバック(Rollback)
トランザクションが正常に完了した際に、その処理内容をデータベースに確定することをコミットといいます。
一方、トランザクション処理中にエラーの発生などによって、処理が正常に完了しなかった場合は、それまでの更新内容をすべて取り消し、データベースをトランザクション開始前の状態に戻すことをロールバックといいます。
一貫性(Consistency)
一貫性は、トランザクションの実行前と実行後で、データが一貫した状態を保つことを保証する性質です。データの一貫性は、データベース制約による一貫性と、アプリケーション側で管理する一貫性があります。
データベースのテーブルには、NOT NULL制約、一意制約、CHECK制約など、様々な制約が設定されています。一貫性によって、トランザクションの実行後もこれらの制約が満たされることが保証されます。
一方、以下のようなビジネスロジックに基づき、データベース制約では対応できない一貫性をアプリケーションが管理します。
- ECサイトで注文数が在庫数を超える場合、注文を受け付けない
- ある商品は1人10個まで購入することができる
分離性(Isolation)
分離性は、すべてのトランザクションが互いに分離され、同じデータに同時にアクセスすることがないことを保証します。つまり、同時に実行している複数のトランザクションが互いに影響を与えないことを保証します。
耐久性(Durability)
耐久性は、一度成功(Commit)したトランザクションの結果が失われないことを保証します。
データベースがクラッシュした場合でも、再起動後のクラッシュリカバリにより、クラッシュ前の状態までデータを復元することができます。
トランザクションの機能
トランザクションはACIDを保証するために、同時実行制御とクラッシュリカバリという2つの機能を提供します。
これらの機能は、DBサーバに対して多数のクライアントから同時にアクセスが発生した場合や、更新途中にDBサーバがクラッシュした場合でも、データの整合性を保つことを可能にします。
同時実行制御
同じデータに対して同時に読み書きが行われると、データの不整合が発生することがあります。それを防止するための機能が同時実行制御です。
同時実行制御を行わない場合
以下の操作を同時実行制御を使用せずに、実行する場合を考えてみます。
状況
口座の初期残高:10,000円
操作
user1:3,000円を入金
user2:2,000円を引き出し
一連の操作
- user1 が口座の残高を読み取る(10,000円)
- user2 が口座の残高を読み取る(10,000円)
- user1 が3,000円を入金し、残高を更新(10,000 + 3,000 = 13,000円)
- user2 が2,000円を引き出し、残高を更新(10,000 - 2,000 = 8,000円)
この場合、ロストアップデートという問題が発生し、操作3のuser1による残高更新の結果が消失してしまいます。
ロック
同時実行制御を実現するために、ロックという仕組みを使って排他制御を行います。
複数のトランザクションが同じデータに同時にアクセスする際に発生するデータの不整合を防ぐために、トランザクションはデータに対してのアクセス権であるロックを取得します。あるトランザクションが特定のデータに対してロックを取得している場合、他のトランザクションがそのデータにアクセスを試みると、そのトランザクションはブロック(待機状態)されます。
ロックには以下の2種類があります。
楽観ロックと悲観ロック
楽観ロック
楽観ロックは、他のトランザクションとのデータの競合は起こりにくいという楽観的な前提のもとで行う排他制御です。
更新対象のデータが取得時と同じ状態であるかを調べて、他のトランザクションがそのデータを変更していないことを確認してから、データの更新を行うことでデータの整合性を保証する方法です。楽観ロックは、データ自体にロックをかけず、アプリケーション側でデータ取得時のVersion番号と更新時のVersion番号を比較することで、データに対する更新の有無を確認します。
データの更新の有無を確認するために、Version番号の他に、タイムスタンプ等を使用することもできます。これらの値を管理するためのカラム(ロックキー)をテーブルに用意する必要があります。
悲観ロック
悲観ロックは、他のトランザクションとのデータの競合が頻繁に発生するだろう、という悲観的な前提のもとで行う排他制御です。
トランザクション開始直後に更新データをロックし、トランザクションがコミットまたはロールバックされるまで、他のトランザクションからのアクセスを防ぐことで、データの整合性を保証する方法です。
悲観ロックでは、データ更新後は必ずロックの解除を行う必要があります。解除を行わない場合、他のトランザクションが永遠と待機状態になり、デッドロック発生の可能性が高まります。
楽観ロック vs 悲観ロック
楽観ロックのメリット
- ロックを取得するための待ち時間がなく、オーバーヘッドが少ない
楽観ロックのデメリット
- 複数のトランザクションが同時に同じデータを更新しようとした場合、更新の失敗が頻繁に発生する可能性がある
悲観ロックのメリット
- ロック中のデータに対して、他のトランザクションがアクセスできないようにすることで、データの整合性を確保できる
悲観ロックのデメリット
- 他のトランザクションがロックを保持している場合、ロックを取得するための待ち時間が発生する
- ロックを取得するためにSQL文を発行する必要があるため、コストがかかる
競合が多い場合は悲観ロック、競合が少ない場合は楽観ロックが有効です。
悲観ロックは、データベースレベルで行うことが一般的です。トランザクション開始時にSELECT ... FOR UPDATE
文を発行することで、対象のデータにロックをかけ、他のトランザクションからのアクセスを制限します。
楽観ロックは、アプリケーションレベルで行うことが多いです。O/Rマッパーなどのフレームワークを利用することで、楽観ロックを簡単に実装できます。
排他ロックと共有ロック
悲観ロックには、排他ロックと共有ロックが存在します。
排他ロック
データベースに対して、更新処理(UPDATE, INSERT, DELETEなど)を行うときには、排他ロックを取得します。他のトランザクションはロック中のデータに対して、読み込みと書き込みどちらも行うことができません。
排他ロックのイメージが以下です。
T1:「今からこのデータを変更するから、変更が終わるまで読み書きしないでね!」
T2:「了解、終わるまで待ってる!」
以下の図を使用して、具体例を見ていきます。
トランザクション1が1行目のデータに排他ロックをかけると、他のトランザクションはそのレコードに対して読み込みや更新を行うことができなくなります。排他ロックがかかっているデータに対しては、他のトランザクションは排他ロックはもちろん、共有ロックも取得できません。
共有ロック
共有ロックは、トランザクションがデータの読み込み(SELECT)を行う時に使用します。共有ロックがかかっているデータに対して、他のトランザクションは書き込み(更新)はできませんが、読み込みは行うことができます。
共有ロックのイメージは以下です。
T1:「今からこのデータを読み込むから、完了するまで変更しないでね!」
T2:「了解!読み込みはしても大丈夫だよね?」
T1:「うん、大丈夫!」
以下の図を使用して、具体例を見ていきます。
トランザクション1が1行目のデータの共有ロックを取得すると、他のトランザクションはそのレコードに対して、更新を行うことはできませんが、読み込みは行うことができます。共有ロックがかかっているデータに対しては、他のトランザクションは排他ロックを取得することはできませんが、共有ロックを取得することは可能です。
ロックの粒度
ロックは、データベース全体、テーブル、レコード、カラムなど、様々なレベルでかけることができます。
デッドロック
トランザクション1が1行目に対して排他ロックをかけて、更新処理を行います。そしてトランザクション2が2行目への排他ロックを取得して、更新処理を行います。その後、トランザクション1が2行目の排他ロックの取得を試みますが、2行目はトランザクション2によりロック中なので待機になります。トランザクション2が1行目の排他ロックの取得を試みますが、1行目はトランザクション1によりロック中なので待機になります。
このように両方のトランザクションが互いにブロックし合うことで、どちらのトランザクションも先に進めず、デッドロックが発生してしまいました。デッドロックが発生したトランザクションには、ロールバックを行い、更新内容を取り消す必要があります。
同時実行制御を行う場合
先ほどと同じ操作を同時実行制御ありで考えてみます。同時実行制御は、ロックを使用した排他制御により実現することが多いです。
状況
口座の初期残高:10,000円
操作
トランザクション1(T1):3,000円を入金
トランザクション2(T2):2,000円を引き出し
一連の操作
- T1 は口座データの排他ロックを取得して、口座の残高を読み取る(10,000円)
- T2 は口座データの排他ロックの取得を試みるが、T1がすでに口座へのロックを取得しているため、T2はロックの取得を待機する
- T1 が3,000円を入金し、残高を更新(10,000 + 3,000 = 13,000円)
- T1 が口座データのロックを解除する
- T2 が口座データの排他ロックを取得して、口座の残高を読み取る(13,000円)
- T2 が2,000円を引き出し、残高を更新(13,000 - 2,000 = 11,000円)
- T2 が口座データのロックを解除する
クラッシュリカバリ
データベースがクラッシュした場合、クラッシュ直前の状態までデータを復元することが求められます。
ステーブルログというトランザクションの履歴を再生(REDO)することで、クラッシュ発生前の最新の状態までデータベースを復元します。その後、クラッシュ直前に実行されていたコミットが完了していないトランザクションの更新内容を取り消す(UNDO)ことで、クラッシュリカバリが完了し、データベースが整合性の取れた状態に復旧します。
参考
理論から学ぶ データベース実践入門
トランザクションの分離性(isolation)の概要(Qiita)
排他制御(楽観ロック・悲観ロック)の基礎 (Qiita)
アプリケーション開発において重要なロックを掘り下げ、ORM における楽観的ロックの実現例を紹介する(Qiita)
排他制御