JPAロック(Lock)を理解する

JPAによるロックを使用中に思っていた挙動と異なる場合がありまして調べてまとめました。

ロックの種類

楽観的ロック(Optimisstic Lock)

楽観的ロックは、現実的に、データ更新時の競合が発生しないだろうと楽観的に見て、ロックをかける技法です。例えば、会員情報の更新は、通常、当該会員によって行われるため、同時に複数の修正要求が発生する可能性が低くなります。したがって、同時に修正が行われた場合を検出して、例外を発生させても、実際に例外が発生する可能性が低いと楽観的に見ることができます。これは厳密な意味では、ロックというより、一種の衝突検出(Conflict detection)に見方もあります。

悲観的ロック(Pessimistic Lock)

同じデータを同時に変更する可能性が高いという悲観的な前提でロックをかける技法です。例えば、商品の在庫は、同時に同一商品を注文することができますので、データの変更による競合が発生する可能性が高くなります。この場合、衝突検出を介してロックを発生させると、衝突発生による例外が頻繁に発生するようになり、ユーザー経験が悪くなります。この場合、悲観的ロックを使って、例外を発生させずに整合性を確保することができます。ただし、パフォーマンス的に損失を及ぼす可能性があります。主にデータベースで提供される排他ロック(Exclusive Lock)を使用します。

暗黙的ロック(Implicit Lock)

暗黙的なロックは、プログラムで明示的に指定しなくても、ロックがかかることを意味します。JPAでは、エンティティに@Versionがついたフィールドが存在するか、@ OptimisticLockingアノテーションが設定されている場合は、自動的に衝突を検出するためのロックが実行されます。なお、データベースの場合、メジャーなデータベースは更新、削除クエリ実行時に暗黙的にその行に排他ロック(Row Exclusive Lock)がかかります。JPAの衝突検出が役割を果たされるのも、このようなデータベースの暗黙のロックが存在するからです。データベースの暗黙的なロックがない場合は、衝突検出を通過した後、コミット(Commit)が実行されている間に隙間が生じるので、衝突検出をしても整合性を保証することができないでしょう。

明示的ロック(Explicit Lock)

プログラムを介して意図的にロックを実行することを明示的ロックと言います。JPAでエンティティを参照するとき、LockModeを指定したり、select for updateクエリを使って直接ロックを指定することができます。

楽観的ロックの使い方

JPA(Hibernate)で楽観的ロックを使用する方法について説明します。

@Version

JPAで楽観的ロックを使用するためには、 @Versionアノテーションを付けたフィールドを追加すると、簡単に適用することができます。

@Entity
public class Member implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long memberNo;
    //...
    @Version
    private int version;
    //...

}

特定のフィールドに@Versionが付いたフィールドを追加すると、自動的に楽観的ロックが適用されます。@Versionを適用することができるタイプは以下の通りです。

  • int
  • Integer
  • short
  • Short
  • long
  • Long
  • java.sql.Timestamp

実際にクエリを実行してみると以下のように更新クエリの条件節にバージョン情報が設定されます。現在のエンティティが持っているバージョン情報が条件節に適用され、updateステートメントには+1した値が適用されます。他のトランザクションによってすでにバージョン情報が変わった状態とすると、更新ロー(Row)の数が0が返されます。その結果、衝突が検出されて例外(OptimisticLockException)が発生することになります。一度updateステートメントが実行されると、上述した暗黙的なロックが実行され、同時に実行された同じエンティティに対するクエリは、先に実行されたupdateクエリがコミットされるまで待機することになって整合性を確実に保証することができます。

f:id:reiphiel:20190605141259p:plain
@Versionによる楽観的ロック

@OptimisticLocking

JPA標準仕様ではないが、Hibernateで提供される楽観的ロックを設定する方法です。

ロックの種類 説明
NONE 楽観的ロックを使用しない。
VERSION @Versionアノテーションが付いているフィールドを条件に楽観的ロック。
DIRTY 変更されたフィールドによって楽観的ロックの使用。
ALL すべてのフィールドを衝突検出の条件として使用する楽観的ロック。

DIRTYとALLは、バージョンフィールドがなくても楽観的ロック(Version less optimistic lock)を使用する方法です。

@Entity
@OptimisticLocking(type = OptimisticLockType.ALL)
@DynamicUpdate
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_no")
    private Long memberNo;

    @Column(name = "member_id", nullable = false)
    private String memberId;

    @Column(name = "member_name")
    private String memberName;
    //...
}

f:id:reiphiel:20190605141755p:plain
@OptimisticLocking ALLによる楽観的ロック

上記の@Versionフィールドによるロックとは異なり、条件節にカラム全体がかかっていることがわかります。条件節には、更新前の値がバインドされています。このようにカラム全体の変更を確認することでバージョンない楽観的ロックが可能になります。ALLを使用する場合には、@DynamicUpdateを使用が必須になることに気をつけてください。 @DynamicUpdateが必要な理由は、フィールド単位でDirty確認を動作させるためです。

When using OptimisticLockType.ALL, you should also use @DynamicUpdate because the UPDATE statement must take into consideration all the entity property values.

@Entity
@OptimisticLocking(type = OptimisticLockType.DIRTY)
@DynamicUpdate
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_no")
    private Long memberNo;

    @Column(name = "member_id", nullable = false)
    private String memberId;

    @Column(name = "member_name")
    private String memberName;
    //...
}

f:id:reiphiel:20190605141900p:plain
@OptimisticLocking DIRTYによる楽観的ロック

DIRTYに指定した場合は、上記のように更新されるカラムの更新前の値に条件節にバインドされます。特定のカラムのみの衝突チェックに使用するためALL@Versionを使用したときに比べて衝突する可能性を下げることができます。つまり、特定のエンティティの異なる部分を更新するプログラムがある場合は、競合することなく、実行が可能となります。

The main advantage of OptimisticLockType.DIRTY over OptimisticLockType.ALL and the default OptimisticLockType.VERSION used implicitly along with the @Version mapping, is that it allows you to minimize the risk of OptimisticLockException across non-overlapping entity property changes.

When using OptimisticLockType.DIRTY, you should also use @DynamicUpdate because the UPDATE statement must take into consideration all the dirty entity property values, and also the @SelectBeforeUpdate annotation so that detached entities are properly handled by the Session#update(entity) operation.

@DynamicUpdateを使用しない場合、org.hibernate.MappingException:optimistic-lock= all| dirty requires dynamic-update="true"のような例外が発生します。

明示的な楽観的ロック

プログラムによって明示的に楽観的ロックを使用することができます。

public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo, LockModeType.OPTIMISTIC);
        //do something
    }
}
public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo);
        //do something
        this.entityManager.lock(member, LockModeType.OPTIMISTIC);
    }
}

上記のように、エンティティマネージャが提供する ÈntityManager#find(Class<T> entityClass, Object primaryKey, LockModeType lockMode)を使用するか ÈntityManager#lock(Object entity, LockModeType lockMode)を使えます。

findはエンティティを永続コンテキストから照会、存在しない場合はデーバーベースから照会しながら同時にロックをかけるとき使用します。lockはすでに永続コンテキストにロードされているエンティティを対象にロックをかけるとき使用します。

OPTIMISTIC

ロックモードをOPTIMISTICに指定した場合には、バージョンフィールドの更新とは関係なく、コミット直前にバージョンを確認するクエリをもう一度発行します。

OPTIMISTICロックモードによるバージョン確認
OPTIMISTICロックモードによるバージョン確認

そのエンティティに変更があった場合には、updateクエリによってすでに衝突検出が動作するため、事実上、不必要なクエリが発行されることになります。ただし、エンティティに変更することなく、そのエンティティの処理を実行する場合に有用に使えます。エンティティの変更がないため暗示的な排他ロック(Row Exclusive Lock)がかかりません。そういうわけで完璧なロックと見るのが難しい側面があります。したがって、子エンティティの修正を目的とし、ロックを使用する場合には、使用してはいけません。その場合には、子エンティティの修正時に変更するフィールドを追加したり、(例えば、子エンティティの変更日付など)下記するOPTIMISTIC_FORCE_INCREMENTロックモードを使用してください。

JPA(Hibernate)では、子エンティティのみ変更する場合、親エンティティは変更があると判定されません。

OPTIMISTIC_FORCE_INCREMENT

OPTIMISTICとは異なり、バージョンを強制的に増加させるロックです。コミット直前に、次のようにバージョンのみ増加させるクエリが常に発行されます。そのため、エンティティに変更があった場合には、二度バージョンが増加されます。つまりエンティティ自体に変更があった場合には、要らないUPDATEクエリが発行されるので、注意する必要があります。該当するエンティティーの変更がなくても暗黙的な行排他ロック(Row Exclusive Lock)が発生して整合性を保証することはできますので、子エンティティを変更するときに、子エンティティ全体のロックの目的で使用することができます。

OPTIMISTIC_FORCE_INCREMENTによるバーション確認
](understanding-jpa-lock_5.png)

悲観的ロック

PESSIMISTIC_READ

データベースで提供される共有ロック(複数のトランザクションで同時に読み取ることができますが変更できないロック、for share)を利用して、ロックを取得します。ただし、共有ロックを提供していない場合、PESSIMISTIC_WRITEと同じように動作します。デーバーベースのサーポート可否を確認して、ご使用ください。

public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo, LockModeType.PESSIMISTIC_READ);
        //do something
    }
}

PESSIMISTIC_WRITE

データベースで提供される行排他ロック(Row Exclusive Lock)を利用して、ロックを取得します。

public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo, LockModeType.PESSIMISTIC_WRITE);
    }
}

PESSIMISTIC_FORCE_INCREMENT

データベースで提供される行排他ロック(Row Exclusive Lock)を利用したロックと同時にバージョンを増加させます。該当するエンティティに変更はないが、子エンティティを更新のためにロックが必要な場合に使用することができます。

public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo, LockModeType.PESSIMISTIC_FORCE_INCREMENT);
    }
}

f:id:reiphiel:20190605142223p:plain
行排他ロックとバーション増加

上記のように、行排他ロックとバージョンの増加が連続的に発生することがわかります。

注意事項

分離レベル(Isolation Level)

一般的に使用されるデータベースは、READ COMMITTEDに対応する分離レベルをデフォルト値として適用される場合が多いです。しかし、JPAを使用した場合、一度永続コンテキストに積載されたエンティティを再照会する場合は、データベースを照会せずに永続コンテキストでエンティティを取得するので、REPEATABLE READ分離レベルと同じように動作するようになります。

public class SomeService {
    @Transactional
    public void someOperation(Long memberNo) {
        Member member = this.entityManager.find(Member.class, memberNo);
        //do something
        this.entityManager.find(Member.class, memberNo, LockModeType.PESSIMISTIC_WRITE);
        //do something
    }
}

上記の例を見ると、同じエンティティを2回照会し、2回目には悲観的ロックを使用しています。実行結果は以下の通りになります。

f:id:reiphiel:20190605142312p:plain
永続コンテキストに存在するエンティティにロックをかける

バージョンフィールドが存在しないエンティティの場合、上記のよう最初の照会時に永続コンテキストに積載されたエンティティの状態は変わらず、単にselect for updateによる行排他ロックが実行されます。つまりREPEATABLE READ分離レベルと同じように動作します。最初にエンティティが照会され、ロックが実行される前に、他のトランザクションによる変更事項が反映されず、現在のトランザクションのエンティティの状態が維持されることに注意してください。この場合、前のトランザクションによって変更された事項を失ってしまうという問題(Lost update problem)が発生することがあります。

すなわち、ロックは動作したが、整合性に問題が生じる可能性があります。たとえばポイントを使用する場合、以前のトランザクションから差し引かれたポイントが反映されないため、二重使用の問題が発生する可能性があります。永続コンテキストにエンティティが存在することが確実な場合には、EntityManger#refreshやJPQLを利用して、データベースからエンティティを再度読み込ませる必要があります。

f:id:reiphiel:20190605142333p:plain
永続コンテキストに存在するエンティティーにヴァーションフィールドが存在する場合

逆にバージョンフィールドが存在するエンティティの場合には、排他ロックを実行する条件にバージョン情報が含まれることになります。これにより、クエリの実行後、排他ロックを取得する前に、別のトランザクションによってバージョンが増加することになったら、ロック獲得に失敗します。したがって悲観的ロックを利用して、順次的処理を期待した場合は、期待どおりに動作しませんので注意する必要があります。

クエリ直接使用

@Versionフィールドが存在するエンティティにJPQLやネイティブクエリを使用している場合は注意する必要があります。JPQLやネイティブクエリの実行時にバージョン情報を増加しないと楽観的ロックに期待通り動作しない可能性があります。

Timeout

悲観的ロックによってデータベースに行排他ロックがかかった場合、つつくリクエストによって行の排他ロック要求がロックを持つリクエストが終了し、ロックが解除されるまで待機することになります。このような時にロックが持っているリクエストの処理が長くなると、コネクションプールのコネクションが足りなくなって、アプリケーション全体が影響を及ぼす可能性があります。このような場合は、ロック獲得待機時間を設定するTimeoutを使用して、データベースのロックがアプリケーション全体の障害に拡散されることを防ぐことができます。

public class SomeService {
    @Transactional
    public void someOperation(Long memberNo) {
        Member member = this.entityManager.find(
            Member.class, memberNo,
            LockModeType.PESSIMISTIC_WRITE,
            Map.of("javax.persistence.lock.timeout", 0L)
        );
        //do something
    }
}

上記のように設定すると、select for updateクエリにnowaitが追加され、ロックを取得することができない場合は、直ちにLockTimeoutExceptionのような例外が発生されます。ミリ秒単位で時間を指定することも可能です。ただし、注意することはデータベースによってサーポートしない場合があり、対応していない場合は無視されます。よく使い分けて使用する必要あることです。たとえば、H2は、クエリにTimeoutを指定することができず、PostgreSQLの場合には、NOWAIT(0に設定)は指定可能が、時間の設定は無視されるので、注意する必要があります。出来る限りコーネックションごとの設定を利用したり、サーキットブレーカー(circuit breaker)などを用いることがより望ましく見えます。

まとめ

楽観的ロック、悲観ロック、明示的ロックなどいくつかの種類のロックの中、何を使用するかは、そのドメインのビジネスロジックによって異なり、ほとんどのドメインでは、必要がないかも知れません。しかし、状況に応じてロックを選択できるように特性についてよく知っておかないと必要なとき対応できないでしょう。

参考

Hibernate ORM 5.4.2.Final User Guide

Spring Security ユニットテスト

概要

Spring Securityを使用する場合、ユニットテスト(単体テスト)をする方法について調べて見ました。

※本記事で紹介する内容はSpring Security 4.1から追加されました。

既存のテスト方法

Spring SecurityはSecurityContextを通して、現在実行しているスレッドと関連している認証情報を管理します。したがって、テスト実行時にSecurityContextHolderを介して認証情報を設定すると、ログインした状態などの認証に関するテストができます。

class SomeTest {
    //...
    @Before
    public void setUp() {
        SecurityContextHolder.getContext()
            .setAuthentication(new UsernamePasswordAuthenticationToken("username", "password", Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))));
    }
    //...
}

※ 上記のようにユニットテストを実行する前に認証情報を設定すると、ログインした状態でテストが可能です。しかし、上記のようなコードは、やはりSpringらしくないですね。

新しく追加された機能

Spring Security 4.1からアノテーション(Annotation)を通した宣言的なテスティングフィチャーが追加されました。

アノテーション 説明
@WithAnonymousUser 匿名ユーザの認証情報を設定するためのアノテーション
@WithUserDetails UserDetailsServiceからユーザー情報を取得して認証情報を設定するためのアノテーション(4.0から追加され、4.1からはSpring Bean名も指定できるようになりました)
@WithMockUser 別のUserDetailsServiceのスタブを作成しなくても簡単に認証情報の設定ができるアノテーション(4.0から追加、4.1から動作する)

使い方

設定

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    runtimeOnly 'org.springframework.boot:spring-boot-devtools'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

まず、GradleなどでSpring Securityに関する依存性を設定する必要があります。テストスコープでorg.springframework.security:spring-security-testモジュールを追加します。

@WithAnonymousUser

認証されていない状態を設定するためのアノテーションです。認証していない場合を示すアノテーションがなぜ必要かと思われますが、前述のようにSpring Securityは、現在実行中のスレッドに関連づけて、認証情報を管理するため、一度の認証情報を設定してテストを行った場合に、認証状態を初期化させる必要があります。そうしないと次のテストで以前のテストの認証情報がそのまま使われてしまいます。

class SomeTest {

    //...
    @Test
    @WithAnonymousUser
    public void some_test() {
        ...
    }
    //...

}

@WithUserDetails

Spring Securityでユーザーの情報を照会するために使われるUserDetailsServiceを使って認証情報を設定するアノテーションです。

class SomeTestConfiguration {
    //...
    @Bean
    @Profile("test")
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                return User
                        .withUsername(username)
                        .password("password")
                        .authorities(new SimpleGrantedAuthority("ROLE_USER"))
                        .build();
            }
        };
    }
    //...
}
class SomeTest {
   //...
    @Test
    @WithUserDetails("test_user")
    public void some_test() {
        ...
    }
    //...
}

上記のようにテスト用のUserDetailsServiceのBeanを登録して使います。

@WithMockUser

単にusernameとroleくらいの記述するこてで、認証情報を設定してくれるアノテーションです。別の設定がいらないので簡単に使えます。

class SomeTest {
    //...
    @Test
    @WithMockUser(username = "username", roles = "USER")
    public void some_test() {
        ...
    }
    //...
}

カスタムアノテーション

ほとんどの場合、上のコードで紹介したアノテーション(Annotation)を使用すると、テストが可能になると思われるが、認証主体(AuthenticationPrincipal)に関する情報をUserDetailsを使用していない場合には、別のカスタムアノテーションを作成することができます。

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithCustomMockUser {

    String userNo() default "1";

    String userId() default "user";

    String name() default "name";

}
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithCustomMockUser> {
    @Override
    public SecurityContext createSecurityContext(WithCustomMockUser user) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        CustomUser principal = new CustomUser(Long.valueOf(user.memberNo()), user.userId(), user.name());
        Authentication auth = new UsernamePasswordAuthenticationToken(principal, "password", Arrays.asList(new SimpleGrantedAuthority("ROLE_MEMBER")));
        context.setAuthentication(auth);
        return context;
    }
}

上記のようにカスタムアノテーションを作成し、他のテストアノテーションと同様に使用すると、カスタム認証情報が設定された状態でテストができます。

class SomeTest {
    //...
    @Test
    @WithCustomMockUser
    public void some_test() {
        //...
    }
    //...
}

合成アノテーション(Composition Annotation)

また、合成アノテーションを作成し、繰り返されるコードを省略することができます。

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value="admin",roles="ADMIN")
public @interface WithCustomMockAdmin {
}
class SomeTest {
    //...
    @Test
    @WithCustomMockAdmin
    public void some_test() {
        ...
    }
    //...
}

まとめ

今まではログインなどの認証状態をテストする場合、面倒なコードを書かなければなりませんでしたが、Spring Securityで提供するテスティングフィチャーを使用することで、簡単にテストすることができますので、より安全なアプリケーションを作成することができます。

参考

Spring Security Reference