SpringBootの悲観ロック/楽観ロックについて調べてみた件

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_READPESSIMISTIC_FORCE_INCREMENTOPTIMISTICも存在する

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にしておけば、誤ってアプリケーションから明示的に変更されることが一切なくなる点も良い。


See also