はじめに
自分自身が実務経験1年の初心者エンジニアのため、この記事は同じく初心者向けに執筆しています。
もし記載内容に誤りがあった場合はコメント欄でご指摘いただけますと幸いです。
楽観ロック・悲観ロックとは?
ある処理を行っている最中に、DBのレコードを取得/更新した際、
データの整合性が保たれるように、レコードに対してロックを行う方法です。
複数スレッドで処理を行うシステムなど、並行してDBへのアクセスを行う可能性があるときに役立ちます。
後述しますが、システム的にレコードに対してロックが行われるのは悲観ロックのみです(ややこしい)。
楽観ロックはどっちかというと「更新時のチェック」という感じに近いです。
楽観ロックと悲観ロックの違い
楽観ロックを行うのか悲観ロックを行うのかは、
取得・更新対象のテーブルで競合が発生しうる頻度によって選択します。
楽観ロック
競合が発生しうる頻度が低い場合は、楽観ロックを採用します。
【方法】
①レコードを取得
②取得したレコードの更新処理を行う ※まだDBに保存しない
③「取得したレコードとテーブルにある更新対象のレコードが同一かどうか」をチェックする
④チェック結果によって分岐する
同一だった場合
→レコードを更新して保存
異なっていた場合(=他の処理がそのレコードを更新してしまって競合した場合)
→更新処理をキャンセルしたり、エラーとして処理したりする
③「取得したレコードとテーブルにある更新対象のレコードが同一かどうか」をチェックする
の具体的な方法ですが、
そのためには対象となるテーブルに、「更新したよ~」ということ分かる目印のようなカラム(最終更新日時やバージョン番号など)が必要です。
※更新処理の際には必ず目印カラムの値も更新するようにしておきます。
取得時の目印カラムの値と、更新時点でのテーブルの目印カラムの値とを比較し、
差異があった場合には他の処理がそのレコードを更新していた(=競合があった)ことが分かります。
悲観ロック
競合が発生しうる頻度が高い場合は、悲観ロックを採用します。
【方法】
①トランザクションを開始
②レコードを取得(このときにロックをかける)
③取得したレコードの更新処理を行う ※まだDBには保存しない
・・・この間、他の処理は取得したレコードを更新できない
④レコードを更新して保存(更新完了後にロックを解除し、トランザクションを終了)
ロックをかける前にトランザクションを開始するのは、
ロック後の処理中にエラーが発生すると、
ロックが解除されないままになってしまい、他の処理に影響が出てしまう可能性があるためです。
悲観ロックを行う場合と行わない場合の違いは以下です。
○悲観ロックを行わない場合
処理A:レコード1を取得したい
↓アクセス
hogehogeテーブル.レコード1
↓
処理A:レコード1の取得完了
↓
処理A:レコード1の更新処理中・・・
↓
処理B:レコード1を取得したい
↓アクセス
hogehogeテーブル.レコード1
↓
処理B:レコード1の取得完了
→処理Aで更新する予定の内容が処理Bでは反映されていない!
↓
処理A:レコード1の更新完了
○悲観ロックを行う場合
処理A:レコード1を取得したい
↓アクセス(ロックを行う)
hogehogeテーブル.レコード1
↓
処理A:レコード1の取得完了
↓
処理A:レコード1の更新処理中・・・
↓
処理B:レコード1を取得したい
↓アクセス
hogehogeテーブル.レコード1
→処理Aによりロック中のため、処理Bは待機
↓
処理A:レコード1の更新完了
↓
ロック解除
↓
処理B:レコード1の取得完了
→処理Aで更新した内容が処理Bにも反映されている!(データの整合性が保たれる)
悲観ロックによってロック中のレコードを取得したい場合は、ロックが解除されるまで待つ必要があります。
データの整合性が保たれるため安心できる反面、
競合が発生する頻度が低いのに悲観ロックばかり採用してしまうと、
他の処理(レコードの取得のみを行いたい処理など)が無駄に待たされてしまうこともあるため注意が必要です。
ロックの種類
ロックには、
①共有ロック
②占有ロック(排他ロックとも言う)
の2種類があります。
共有ロックと占有ロックの違い
複数の処理:処理Aと処理Bがあるとして、
処理Aが共有ロックをかけた場合は、
処理Aも処理Bも読み取りしか行うことができません。
処理Aが占有ロックをかけた場合は、
処理Aのみ読み取りと更新が可能で、処理Bは読み取りも更新もできません。
注意点:デッドロックとは?
悲観ロックの場合、デッドロックに注意する必要があります。
以下がデッドロックの例です。
- トランザクション処理Aでテーブル1を占有ロック、テーブル2も占有ロックしたい
- トランザクション処理Bでテーブル2を占有ロック、テーブル1も占有ロックしたい
→どちらかのトランザクションが完了しない限りロックは解除されないため、
処理A(テーブル2のロック解除待ち)と処理B(テーブル1のロック解除待ち)の両方が永遠に終わらない(=デッドロック)。
悲観ロックを採用する場合は、デッドロックが起きないように注意して設計する必要があります。
※共有ロックのみ利用する場合、デッドロックは発生しません。
楽観ロック・悲観ロック/デッドロックに関するざっくりとした説明は以上です。
ここから先は補足として、自分と同じくpostgreSQL/SpringBootを利用している方向けの解説をしていきます。
補足:postgreSQL利用者向けの解説
postgreSQLの場合、共有ロックと占有ロックは以下の手順で行うことができます。
①begin;でトランザクション開始
②レコードの取得と同時にロックをかける
占有ロック:SELECT * FROM hogehoge FOR UPDATE;
共有ロック:SELECT * FROM hogehoge FOR SHARE;
③commit;でコミット もしくは rollback;でロールバック でロック解除される
楽観ロックには特に専用のコマンドはありません。
下記のようにversion(目印カラム)を指定してUPDATEを行い、更新行が0行であれば競合が発生したと分かります。
UPDATE hogehoge
SET data = 'updated data', version = version + 1
WHERE id = 1 AND version = 1;
補足:SpringBoot利用者向けの解説
SpringBootでは、下記のように楽観ロック・悲観ロックを実現できます。
楽観ロック
エンティティのフィールドのうち、目印カラムと対応するフィールドに
@Versionアノテーションを付与します。
public class Entity implements Serializable {
private static final long serialVersionUID = 1L;
@Column(name = "\"column1\"")
private String column1 = value1;
@Version
@Column(name = "\"marked\"")
private Long marked;
}
@Repositoryを付与したクラスのsaveAndFlushメソッドでエンティティをDBに保存する際、
@Versionアノテーションを付けたカラムを参照して、自動的に楽観ロックを実現してくれます。
競合が発生した場合、ObjectOptimisticLockingFailureExceptionが投げられます。
悲観ロック
下記のアノテーションを、DBにアクセスを行うメソッドに付与します。
@Lock(LockModeType.PESSIMISTIC_READ):共有ロック
@Lock(LockModeType.PESSIMISTIC_WRITE):占有ロック
LockModeTypeは他にも色々種類があり、
同じロックの種類でも細かい違いがあったり、楽観ロックもこのアノテーションで実現できたりするようです。