SpringBootの悲観ロック/楽観ロックについて調べてみた
悲観ロック
Repositoryのメソッドに@Lock(LockModeType.PESSIMISTIC_WRITE)
を加えることでSpringBootがSQLにselect ~ for update
を発行してくれる
@Repository
を付与しているリポジトリクラスの@Query
を付与しているメソッドには有効という記事がいくつかあり、Query Creationなメソッドには有効にならないのかと思ってたが、試したところ問題なく有効になった
@Repository
interface AuthorRepository : PagingAndSortingRepository<Author, Long?> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // Query Creationでも有効
fun findByName(name: String): List<Author>
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Author a WHERE a.age >= 20")
fun findByAgeIsAdult(orgId: Long?): List<Author>
}
Authorを更新する処理を同時に3回呼び出し、正しく悲観ロックができているか検証する。具体的には、以下のサービスクラスのaddAge
を呼び出しloggerの出力および出力されたSQLを確認してみた
@Service
@Validated
class AuthorService(){
// ...
@Transactional(readOnly = false)
fun addAge() {
logger.warn("addAge")
val yamada = authorRepository.findByName("yamada").first()
yamada.age += 1
authorRepository.save(a)
Thread.sleep(5000)
logger.warn("complete")
}
}
loggerの出力結果は以下のようになった。
2021-04-27 17:15:03.101 WARN 60439 --- [nio-8081-exec-4] c.hoge.bookmanager.service.AuthorService : addAge
2021-04-27 17:15:03.101 WARN 60439 --- [nio-8081-exec-1] c.hoge.bookmanager.service.AuthorService : addAge
2021-04-27 17:15:03.101 WARN 60439 --- [nio-8081-exec-8] c.hoge.bookmanager.service.AuthorService : addAge
2021-04-27 17:15:08.348 WARN 60439 --- [nio-8081-exec-4] c.hoge.bookmanager.service.AuthorService : complete
2021-04-27 17:15:13.840 WARN 60439 --- [nio-8081-exec-1] c.hoge.bookmanager.service.AuthorService : complete
2021-04-27 17:15:19.021 WARN 60439 --- [nio-8081-exec-8] c.hoge.bookmanager.service.AuthorService : complete
また、出力されたSQLは@Lock
を付与する前後でこのように変化した
Hibernate:
select
author0_.id as id1_1_,
author0_.name as name2_1_,
author0_.age as age3_1_
from
- author author0_
+ author author0_ for update
このことから、RDBレベルでトランザクションが扱われ、なおかつauthorRepository.findByName("yamada")
の処理がブロックされていることがわかった
上記で利用していたLockModeType.PESSIMISTIC_WRITE
は占有ロックを行うことで排他制御を行っていたが、他にもPESSIMISTIC_READ
やPESSIMISTIC_FORCE_INCREMENT
やOPTIMISTIC
も存在する
PESSIMISTIC_READ
の場合だと、占有ロックではなく共有ロックになる。テーブル間に正しくリレーションを貼っておけば共有ロックを使うことはない(と思っている)ので試していない。
検証している最中に占有ロックと共有ロックのユースケースが何か混乱してしまったがこちらの記事が簡潔にわかりやすく書かれていて助かった
https://knowledge-capsule.site/select-for-update-select-lock-in-share-mode-example-of-use/
楽観ロック
楽観ロックは、@Entity
を付与したエンティティに、@Version
を付与したカラムを用意するだけ。例えば以下のように設定できる
@Entity
data class Author(
@field:NotNull @field:Length(max=255) var name: String,
@field:NotNull @field:Min(1) @field:Max(100) var age: Int
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
@Version
private val version: Long = 0
}
このAuthor.version
は、更新が入るたびにカウントアップされていく。例えば以下のようにエンティティが更新される処理を、同時に3回呼び出す
@Service
@Validated
class AuthorService(){
@Transactional(readOnly = true)
fun optimisticWrite() {
val author = authorRepository.findByName("yamada").first()
Thread.sleep(5000)
author.age += 1
authorRepository.save(author)
}
}
すると最初の1回のみが成功し、他の2回は org.springframework.orm.ObjectOptimisticLockingFailureException
がthrowされた
この方法はサービスからは全くversionに関する制御を気にしなくて良くなる点がとても良い。また、エンティティの定義をprivate val
にしておけば、誤ってアプリケーションから明示的に変更されることが一切なくなる点も良い。