はじめに
在庫数が10件の商品を、二つの処理がほぼ同時に更新する場面を考えます。一方は7件へ、もう一方は8件へ変更します。先に7件への更新が確定しているなら、後から古い情報を持つ処理が8件へ上書きしてはいけません。
しかし、Spring Data JPAのエンティティにバージョン属性がない状態では、二つ目の更新も成功し、最終在庫数が8件になります。先行更新がエラーなく失われるため、ログに例外がないことだけでは発見しにくい不具合です。
この記事では、二つの古い在庫スナップショットを順に保存する小さなSpring Bootプロジェクトを使います。失敗テスト、SQLログ、最終DB状態を別々に確認し、@Version による楽観ロックで古い更新を拒否するまでを扱います。再現コードは spring-jpa-optimistic-lock-debug-lab に置いています。
先に結論
更新競合を検出したいJPAエンティティには、永続属性として @Version を付けます。Jakarta Persistenceでは、バージョン属性を持つエンティティは楽観的同時実行制御の対象になります。更新時に読み込み時点のバージョンと照合し、他のトランザクションが先に変更していれば競合として扱います。実装上は、多くの場合に flush やコミット時に OptimisticLockException や ObjectOptimisticLockingFailureException が発生します。
今回の最小修正は次のとおりです。
@Version
private Long version;
| 状態 | バージョン列なし |
@Version あり |
|---|---|---|
| 二つ目の古い更新 | 成功する | 競合例外になる |
| 最終在庫数 | 8 | 7 |
| 先行更新 | 気付かないまま失われる | 維持される |
再現する更新競合
初期在庫を10件とします。二つの処理がどちらも在庫10件を読んだ後、それぞれ異なる値へ更新します。
| 処理 | 読み込んだ在庫 | 保存しようとする在庫 |
|---|---|---|
| 処理A | 10 | 7 |
| 処理B | 10 | 8 |
この例では処理Aの更新を先に保存します。その後で、同じく在庫10件を見ていた処理Bを保存します。処理Bは古いスナップショットなので、商品仕様としては拒否することにします。
バグ状態のエンティティには、IDと在庫数だけがあります。
@Entity
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private int availableQuantity;
}
この再現コードでは、バージョン属性がないため、更新SQLの条件は典型的に ID だけになります。二つ目の更新は「今もIDが存在する」ため成功し、先行更新の値を上書きします。
最初に失敗するテストを書く
再現テストでは、二つのスナップショットを別トランザクションで読み込みます。処理Aを保存した後、処理Bを保存します。テストは、二つ目の保存で競合が検出され、最終在庫数が7件であることを期待します。実装によっては、競合の検出が save 呼び出し直後ではなく flush やコミット時に発生することがありますが、どちらも古いスナップショットが保存されないことを確認する目的は同じです。
@Test
void 古い在庫スナップショットの保存は競合として拒否し先行更新を残す() {
InventoryItem firstSnapshot = inventoryService.load(itemId);
InventoryItem secondSnapshot = inventoryService.load(itemId);
firstSnapshot.setAvailableQuantity(7);
inventoryService.save(firstSnapshot);
secondSnapshot.setAvailableQuantity(8);
Throwable thrown = catchThrowable(() -> inventoryService.save(secondSnapshot));
InventoryItem currentItem = inventoryService.current(itemId);
assertAll(
() -> assertThat(thrown).isInstanceOf(ObjectOptimisticLockingFailureException.class),
() -> assertThat(currentItem.getAvailableQuantity()).isEqualTo(7)
);
}
修正前には、二つの条件がともに失敗します。
Expecting actual not to be null
expected: 7
but was: 8
最初の失敗は、二つ目の保存で例外が起きなかったことを示します。二つ目の失敗は、最終的にDBに残った在庫数が8件であることを示します。例外の有無だけでなく、DBを読み直して最終状態を確認している点が重要です。
SQLログで確認したこと
バージョン列がない状態では、二つの更新SQLが同じ条件で実行されます。
update inventory_item
set available_quantity = ?, sku = ?
where id = ?
ログでは、まず在庫7件の更新、その後に在庫8件の更新が実行されます。
Saved itemId=1, availableQuantity=7
Saved itemId=1, availableQuantity=8
SQLが二回実行された事実だけでは不具合とは断定できません。今回の要件は「古いスナップショットで先行更新を上書きしない」です。そのため、テストで二つ目の保存結果と最終在庫数を確認しました。
原因は更新条件にバージョンがなかったこと
Jakarta Persistenceの楽観ロックは、エンティティの @Version 属性で有効にできます。プロバイダはエンティティの更新時にバージョン値を更新し、読み込み後に他のトランザクションが変更していた場合は OptimisticLockException を発生させます。
Hibernate ORMでも、@Version を永続属性に付けることで、バージョン番号またはタイムスタンプを使う楽観ロックを有効にできると説明されています。実際には、更新時に古いバージョン値を含む条件が一致しない場合に競合が検出されます。
今回の再現コードにはバージョン属性がないため、JPAは処理Bが読んだ時点で情報が古くなったことを判断できません。IDが一致する行に対して、後から保存した値をそのまま書き込みます。
修正
在庫エンティティへ数値型のバージョン属性を追加します。
@Entity
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private int availableQuantity;
@Version
private Long version;
}
修正後の更新SQLには、バージョンの照合が加わります。
update inventory_item
set available_quantity = ?, sku = ?, version = ?
where id = ? and version = ?
処理Aが保存されると、バージョンは0から1へ進みます。処理Bはバージョン0の古いスナップショットを持っているため、where id = ? and version = 0 に一致する行を更新できません。この再現プロジェクトでは、Springが ObjectOptimisticLockingFailureException として競合を通知します。
再発防止テスト
楽観ロックを追加しただけでは、実際に競合を検出できるか分かりません。回帰テストは次の二点を同時に確認します。
- 二つ目の古い更新が
ObjectOptimisticLockingFailureExceptionになること - DBを再読込した最終在庫数が7件であること
二つ目の確認がないと、例外を出した後の独自処理やトランザクション境界の変更により、先行更新が残らない問題を見逃す可能性があります。
楽観ロックだけで決めないこと
@Version は古い更新を検出して止める仕組みです。競合が起きた後にどうするかは、業務ごとに決めます。
在庫のようにユーザーが最新値を見て操作をやり直すべき場合は、競合をHTTP 409などの扱いに変換し、再取得を促す設計が考えられます。短時間の競合が頻繁で、再試行しても安全な更新なら、読み直しと再計算を含むリトライを検討します。ただし、古い値をそのまま再送するだけでは、先行更新の意図を失うため注意が必要です。
また、在庫を「7件に設定する」のではなく「3件減らす」という業務操作なら、更新APIと同時実行制御の設計を別に検討します。@Version を付けることは競合を隠す解決策ではなく、競合を検出して業務上の判断へ渡すための入口です。
バグを自分で再現する
再現プロジェクトの初期コミットには、バージョン列がない状態を残しています。次のコマンドで失敗を確認した後、mainブランチに戻して修正後のテストを実行できます。
git checkout d90de01
mvn test
git switch main
mvn test
まとめ
同時更新の不具合は、例外が出ないまま先行更新が失われる形で現れることがあります。今回の再現では、二つの古いスナップショットを保存し、SQLログ、競合例外、DBの最終状態を分けて確認しました。
JPAで古い更新を検出したいエンティティには @Version を付けます。そして、競合例外が起きることだけでなく、先行更新がDBに残ることまでテストで固定すると、更新競合を安全に扱いやすくなります。