データベースを利用するWebアプリケーションでは、複数のユーザーやプロセスが同時に同じデータを更新しようとする「同時更新」問題が発生します。Spring JPAのロック戦略は、このような並行アクセス時のデータ整合性を保証するための重要な機能です。本記事では、@Versionアノテーションを活用した楽観的ロック(Optimistic Locking)の仕組みとOptimisticLockExceptionのハンドリング、@LockアノテーションとLockModeType(PESSIMISTIC_READ、PESSIMISTIC_WRITE)を使った悲観的ロック(Pessimistic Locking)の実装、デッドロック対策、そしてロック戦略の選定基準まで、実務で必要となる知識を体系的に解説します。
実行環境と前提条件
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 | バージョン・要件 |
|---|---|
| Java | 17以上 |
| Spring Boot | 3.4.x |
| Spring Data JPA | 3.4.x(Spring Boot Starterに含まれる) |
| Hibernate | 6.6.x(Spring Data JPAに含まれる) |
| データベース | PostgreSQL 16.x または H2 Database(開発用) |
| ビルドツール | Maven または Gradle |
事前に以下の準備を完了してください。
- JDK 17以上のインストール
- Spring Boot + Spring Data JPAプロジェクトの基本構成
spring-boot-starter-data-jpa依存関係の追加- トランザクション管理の基礎知識(@Transactional完全ガイドを参照)
なぜロック戦略が必要なのか
まず、ロック戦略が必要となるシナリオを具体的に見ていきましょう。以下は、在庫管理システムにおける典型的な同時更新問題です。
sequenceDiagram
participant User1 as ユーザーA
participant User2 as ユーザーB
participant DB as データベース
Note over DB: 在庫数: 10個
User1->>DB: 在庫を読み取り(10個)
User2->>DB: 在庫を読み取り(10個)
Note over User1: 2個注文 → 残り8個と計算
Note over User2: 3個注文 → 残り7個と計算
User1->>DB: 在庫を8個に更新
User2->>DB: 在庫を7個に更新
Note over DB: 在庫数: 7個<br/>(本来は5個であるべき)この「ロストアップデート」問題では、ユーザーAの更新がユーザーBの更新によって上書きされ、在庫が正しく管理されません。本来、両者の注文後は10 - 2 - 3 = 5個になるべきところ、7個として記録されてしまいます。
ロック戦略は、このような問題を防ぐために2つのアプローチを提供します。
| 戦略 | 概要 | 適用シーン |
|---|---|---|
| 楽観的ロック | 更新時に競合を検出し、競合があれば例外を発生 | 競合が少ない環境、読み取りが多いアプリケーション |
| 悲観的ロック | 読み取り時点でレコードをロックし、他のトランザクションをブロック | 競合が頻繁に発生する環境、データの即座の整合性が必須 |
楽観的ロック(Optimistic Locking)の仕組み
楽観的ロックは「競合はめったに起きない」という前提に立ち、データの読み取り時にはロックを取得せず、更新時にのみ競合を検出する方式です。JPAでは@Versionアノテーションを使用して実装します。
@Versionアノテーションの基本
@Versionアノテーションをエンティティのフィールドに付与すると、Hibernateが自動的にバージョン管理を行います。
|
|
@Versionで使用可能な型
@Versionアノテーションは以下の型をサポートしています。
| 型 | 説明 | 推奨度 |
|---|---|---|
int / Integer |
整数型(32ビット) | 中 |
long / Long |
整数型(64ビット) | 高(オーバーフロー対策) |
short / Short |
整数型(16ビット) | 低 |
Timestamp |
タイムスタンプ型 | 低(精度問題のリスク) |
Instant |
Java 8日時API | 中 |
実務ではLong型の使用を推奨します。Timestamp型は同一ミリ秒内に複数の更新が発生した場合に競合を検出できないリスクがあります。
楽観的ロックの動作原理
@Versionを使用した場合、HibernateはUPDATE文のWHERE句にバージョン条件を自動的に追加します。
|
|
更新対象のレコードが見つからない(=WHERE句のバージョン条件に一致しない)場合、HibernateはOptimisticLockExceptionをスローします。
sequenceDiagram
participant User1 as ユーザーA
participant User2 as ユーザーB
participant DB as データベース
Note over DB: id=1, stock=10, version=0
User1->>DB: SELECT(version=0を取得)
User2->>DB: SELECT(version=0を取得)
Note over User1: stock=8に変更
Note over User2: stock=7に変更
User1->>DB: UPDATE SET version=1<br/>WHERE version=0
Note over DB: 更新成功<br/>version=1に変更
User2->>DB: UPDATE SET version=1<br/>WHERE version=0
Note over DB: WHERE条件不一致<br/>更新対象0件
DB-->>User2: OptimisticLockExceptionサービス層での楽観的ロックの活用
|
|
OptimisticLockExceptionのハンドリング
楽観的ロックで競合が発生した場合、適切なエラーハンドリングが必要です。Spring Data JPAでは、ObjectOptimisticLockingFailureException(Springのラッパー例外)またはOptimisticLockException(JPAの例外)がスローされます。
グローバル例外ハンドラーでの処理
|
|
期待されるレスポンス:
|
|
リトライ機構の実装
競合が発生した場合に自動的にリトライする機構を実装することで、ユーザー体験を向上させることができます。
|
|
Spring Retryを使用するには、以下の依存関係を追加してください。
|
|
また、メインクラスに@EnableRetryを追加する必要があります。
|
|
悲観的ロック(Pessimistic Locking)の仕組み
悲観的ロックは「競合は頻繁に起きる」という前提に立ち、データの読み取り時点でレコードをロックする方式です。他のトランザクションは、ロックが解放されるまで待機またはエラーとなります。
LockModeTypeの種類
Spring Data JPAでは、@LockアノテーションとLockModeType列挙型を使用して悲観的ロックを指定します。
| LockModeType | 説明 | 生成されるSQL(PostgreSQL) |
|---|---|---|
PESSIMISTIC_READ |
共有ロック。読み取りは許可、書き込みはブロック | SELECT ... FOR SHARE |
PESSIMISTIC_WRITE |
排他ロック。読み取り・書き込み両方をブロック | SELECT ... FOR UPDATE |
PESSIMISTIC_FORCE_INCREMENT |
排他ロック + バージョンインクリメント | SELECT ... FOR UPDATE + version更新 |
リポジトリでの@Lockアノテーションの使用
|
|
サービス層での悲観的ロックの活用
|
|
EntityManagerを使った悲観的ロック
より細かい制御が必要な場合は、EntityManagerを直接使用することもできます。
|
|
デッドロック対策とパフォーマンスへの影響
悲観的ロックを使用する際は、デッドロックのリスクとパフォーマンスへの影響を十分に考慮する必要があります。
デッドロックの発生パターン
sequenceDiagram
participant TxA as トランザクションA
participant TxB as トランザクションB
participant P1 as Product 1
participant P2 as Product 2
TxA->>P1: ロック取得
TxB->>P2: ロック取得
TxA->>P2: ロック取得を試行(待機)
TxB->>P1: ロック取得を試行(待機)
Note over TxA,TxB: デッドロック発生デッドロック対策
- ロック取得順序の統一
複数のリソースをロックする場合は、常に同じ順序でロックを取得します。
|
|
- ロックタイムアウトの設定
ロック取得に制限時間を設けることで、デッドロック状態からの回復を可能にします。
|
|
- NOWAIT オプションの使用
待機せずに即座にエラーを返す場合は、データベース固有の機能を活用します。
|
|
パフォーマンスへの影響
| 観点 | 楽観的ロック | 悲観的ロック |
|---|---|---|
| 読み取り性能 | 高(ロック不要) | 低(ロック取得のオーバーヘッド) |
| 書き込み競合時 | 例外発生後リトライが必要 | 待機後に処理継続 |
| スケーラビリティ | 高(同時実行可能) | 低(直列化される) |
| デッドロックリスク | なし | あり |
| コネクション保持時間 | 短い | 長い(ロック保持中) |
ロック戦略の選定基準
適切なロック戦略を選択するためのフローチャートを示します。
flowchart TD
A[同時更新の対策が必要] --> B{競合頻度は?}
B -->|低い| C{リトライ可能?}
B -->|高い| D{即座の整合性が必須?}
C -->|Yes| E[楽観的ロック + リトライ]
C -->|No| F[楽観的ロック + エラー通知]
D -->|Yes| G[悲観的ロック]
D -->|No| H{長時間トランザクション?}
H -->|Yes| I[楽観的ロック推奨]
H -->|No| G
E --> J[(@Version)]
F --> J
G --> K[(@Lock + PESSIMISTIC_WRITE)]
I --> J
style E fill:#90EE90
style F fill:#90EE90
style G fill:#FFB6C1
style I fill:#90EE90
~~~
### 楽観的ロックが適している場合
- 読み取りが多く、更新が少ないアプリケーション
- 競合が発生しても業務フローで再試行が可能
- Webアプリケーションのフォーム送信
- 複数ユーザーが同じドキュメントを編集するシナリオ
### 悲観的ロックが適している場合
- 金融取引など、データの即座の整合性が必須
- 在庫管理で「売り越し」を絶対に防ぎたい場合
- バッチ処理での大量データ更新
- 短いトランザクションで処理が完了する場合
### 実践例:ECサイトの注文処理
~~~java
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
public OrderService(OrderRepository orderRepository,
ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}
/**
* 通常の注文処理(楽観的ロック)
* - 競合発生時はユーザーに再試行を促す
*/
@Transactional
public Order createOrder(OrderRequest request) {
Product product = productRepository.findById(request.getProductId())
.orElseThrow(() -> new ProductNotFoundException(request.getProductId()));
if (product.getStock() < request.getQuantity()) {
throw new InsufficientStockException(
request.getProductId(),
product.getStock(),
request.getQuantity()
);
}
product.setStock(product.getStock() - request.getQuantity());
productRepository.save(product);
Order order = new Order(request.getCustomerId(), product, request.getQuantity());
return orderRepository.save(order);
}
/**
* タイムセール注文処理(悲観的ロック)
* - 在庫が少なく競合が頻発する状況
* - 「売り越し」を絶対に防ぎたい
*/
@Transactional
public Order createTimeSaleOrder(OrderRequest request) {
Product product = productRepository.findWithLockById(request.getProductId())
.orElseThrow(() -> new ProductNotFoundException(request.getProductId()));
if (product.getStock() < request.getQuantity()) {
throw new InsufficientStockException(
request.getProductId(),
product.getStock(),
request.getQuantity()
);
}
product.setStock(product.getStock() - request.getQuantity());
Order order = new Order(request.getCustomerId(), product, request.getQuantity());
return orderRepository.save(order);
}
}
~~~
## よくある誤解とアンチパターン
### 誤解1:@Versionを手動で更新してはいけない
`@Version`フィールドの値はHibernateが自動的に管理します。手動で設定すると意図しない動作を引き起こす可能性があります。
~~~java
// アンチパターン:バージョンを手動で設定
product.setVersion(product.getVersion() + 1); // 絶対にやってはいけない
// 正しいアプローチ:Hibernateに任せる
product.setStock(newStock);
productRepository.save(product); // バージョンは自動でインクリメント
~~~
### 誤解2:悲観的ロックは常に安全
悲観的ロックでもトランザクション境界外でのデータ変更は防げません。
~~~java
// アンチパターン:ロックの外で処理
@Transactional
public Product getLockedProduct(Long id) {
return productRepository.findWithLockById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
// 別メソッドで呼び出し(トランザクション外=ロック解放済み)
public void updateProduct(Long id, String newName) {
Product product = getLockedProduct(id); // ロックは既に解放
product.setName(newName);
productRepository.save(product); // 競合の可能性あり
}
~~~
### 誤解3:楽観的ロックと悲観的ロックは排他的
両方を組み合わせることも可能です。`PESSIMISTIC_FORCE_INCREMENT`は、悲観的ロックを取得しつつバージョンもインクリメントします。
~~~java
@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithForceIncrement(@Param("id") Long id);
~~~
### 誤解4:長いトランザクションでの悲観的ロック
悲観的ロックを長時間保持すると、他のトランザクションが待機し続け、システム全体のスループットが低下します。
~~~java
// アンチパターン:外部API呼び出しを含むトランザクション
@Transactional
public void processWithExternalCall(Long productId) {
Product product = productRepository.findWithLockById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 外部API呼び出し(数秒かかる可能性)
ExternalResponse response = externalApi.call(); // ロック保持中
product.setPrice(response.getNewPrice());
}
// 正しいアプローチ:外部呼び出しをトランザクション外に
public void processWithExternalCall(Long productId) {
// 1. 外部API呼び出し(トランザクション外)
ExternalResponse response = externalApi.call();
// 2. データ更新(短いトランザクション)
updatePrice(productId, response.getNewPrice());
}
@Transactional
public void updatePrice(Long productId, Integer newPrice) {
Product product = productRepository.findWithLockById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
product.setPrice(newPrice);
}
~~~
### 誤解5:バージョンフィールドのNULL
`@Version`フィールドが`null`の場合、エンティティは新規として扱われ、競合検出が機能しません。
~~~java
// アンチパターン:プリミティブ型でNULL不可にしているつもりでも...
@Version
private long version; // 初期値0、問題なし
// 注意:ラッパー型の場合
@Version
private Long version; // NULLの可能性あり
// Hibernate 6.x以降ではNULLは新規エンティティとして扱われる
~~~
## まとめと実践Tips
Spring JPAのロック戦略について、楽観的ロックと悲観的ロックの実装方法を解説しました。
### 実装のポイント
1. **楽観的ロック**
- `@Version`アノテーションをエンティティに追加するだけで有効化
- `Long`型の使用を推奨
- `OptimisticLockingFailureException`を適切にハンドリング
- リトライ機構の実装を検討
2. **悲観的ロック**
- `@Lock`アノテーションと`LockModeType`でロック種別を指定
- `PESSIMISTIC_WRITE`が最も一般的
- ロックタイムアウトの設定を忘れずに
- ロック取得順序を統一してデッドロックを防止
3. **選定基準**
- 競合頻度が低い → 楽観的ロック
- 競合頻度が高い、または即座の整合性が必須 → 悲観的ロック
- 長時間トランザクション → 楽観的ロック推奨
### チェックリスト
| 項目 | 確認内容 |
|------|----------|
| バージョンフィールド | `@Version`を適切な型で定義しているか |
| 例外ハンドリング | `OptimisticLockingFailureException`を捕捉しているか |
| ロックタイムアウト | 悲観的ロック使用時にタイムアウトを設定しているか |
| デッドロック対策 | 複数リソースのロック順序が統一されているか |
| トランザクション境界 | ロックがトランザクション内で完結しているか |
| パフォーマンステスト | 負荷テストで競合発生時の動作を確認したか |
## 参考リンク
- [Spring Data JPA Reference - Locking](https://docs.spring.io/spring-data/jpa/reference/jpa/locking.html)
- [Hibernate ORM User Guide - Locking](https://docs.hibernate.org/orm/6.6/userguide/html_single/Hibernate_User_Guide.html#locking)
- [Jakarta Persistence 3.2 Specification](https://jakarta.ee/specifications/persistence/3.2/)
- [Spring Retry Reference](https://docs.spring.io/spring-retry/reference/)
- [PostgreSQL LOCK Documentation](https://www.postgresql.org/docs/current/explicit-locking.html)