悲観ロックと楽観ロック
悲観ロックと楽観ロック
Section titled “悲観ロックと楽観ロック”データベースでの同時実行制御において、**悲観ロック(Pessimistic Locking)と楽観ロック(Optimistic Locking)**は、データの整合性を保つための重要な手法です。この章では、Spring Data JPAでの実装方法について詳しく解説します。
複数のトランザクションが同じデータに同時にアクセスする際に、データの整合性を保つためのメカニズムです。
問題例:
トランザクションA: 在庫数を読み取り(100個)トランザクションB: 在庫数を読み取り(100個)トランザクションA: 10個購入 → 90個に更新トランザクションB: 20個購入 → 80個に更新(本来は90個であるべき)このような問題を防ぐために、ロックを使用します。
楽観ロック(Optimistic Locking)
Section titled “楽観ロック(Optimistic Locking)”楽観ロックは、競合が発生しないことを前提としたロック方式です。バージョン番号やタイムスタンプを使用して、データが変更されていないことを確認します。
- パフォーマンス: ロックを取得しないため、読み取り性能が高い
- 競合が少ない場合に適している: 更新頻度が低いデータに適している
- デッドロックのリスクが低い: ロックを保持しないため
1. @Versionアノテーションを使用
@Entity@Table(name = "products")public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name; private int stock;
@Version private Long version; // 楽観ロック用のバージョン番号
// コンストラクタ、getter/setter public Product() {}
public Product(String name, int stock) { this.name = name; this.stock = stock; }
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public String getName() { return name; } public void setName(String name) { this.name = name; }
public int getStock() { return stock; } public void setStock(int stock) { this.stock = stock; }
public Long getVersion() { return version; } public void setVersion(Long version) { this.version = version; }}2. サービスクラスでの使用
@Service@Transactionalpublic class ProductService {
@Autowired private ProductRepository productRepository;
public void purchaseProduct(Long productId, int quantity) { // 楽観ロックが自動的に適用される Product product = productRepository.findById(productId) .orElseThrow(() -> new ResourceNotFoundException("Product", productId));
if (product.getStock() < quantity) { throw new InsufficientStockException("Insufficient stock"); }
product.setStock(product.getStock() - quantity); productRepository.save(product);
// 保存時にバージョン番号が自動的にインクリメントされる // 他のトランザクションが同じバージョンで更新していた場合、 // OptimisticLockExceptionがスローされる }}3. 例外処理
@Service@Transactionalpublic class ProductService {
@Autowired private ProductRepository productRepository;
public void purchaseProduct(Long productId, int quantity) { int maxRetries = 3; int retryCount = 0;
while (retryCount < maxRetries) { try { Product product = productRepository.findById(productId) .orElseThrow(() -> new ResourceNotFoundException("Product", productId));
if (product.getStock() < quantity) { throw new InsufficientStockException("Insufficient stock"); }
product.setStock(product.getStock() - quantity); productRepository.save(product);
return; // 成功したら終了
} catch (OptimisticLockException e) { retryCount++; if (retryCount >= maxRetries) { throw new ConcurrentUpdateException("Failed to update after retries", e); } // 少し待ってからリトライ try { Thread.sleep(100 * retryCount); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new ConcurrentUpdateException("Interrupted during retry", ie); } } } }}4. カスタム例外クラス
public class ConcurrentUpdateException extends RuntimeException { public ConcurrentUpdateException(String message, Throwable cause) { super(message, cause); }}
public class InsufficientStockException extends RuntimeException { public InsufficientStockException(String message) { super(message); }}悲観ロック(Pessimistic Locking)
Section titled “悲観ロック(Pessimistic Locking)”悲観ロックは、競合が発生することを前提としたロック方式です。データを読み取る際にロックを取得し、他のトランザクションからのアクセスをブロックします。
- データの整合性: 確実にデータの整合性を保つ
- 競合が多い場合に適している: 更新頻度が高いデータに適している
- パフォーマンス: ロックを保持するため、読み取り性能が低下する可能性がある
- デッドロックのリスク: 複数のロックを取得する場合、デッドロックが発生する可能性がある
1. @Lockアノテーションを使用
@Repositorypublic interface ProductRepository extends JpaRepository<Product, Long> {
// PESSIMISTIC_WRITE: 排他ロック(他のトランザクションは読み取りも書き込みもできない) @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
// PESSIMISTIC_READ: 共有ロック(他のトランザクションは読み取り可能、書き込み不可) @Lock(LockModeType.PESSIMISTIC_READ) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdWithPessimisticReadLock(@Param("id") Long id);}2. EntityManagerを使用
@Service@Transactionalpublic class ProductService {
@PersistenceContext private EntityManager entityManager;
public void purchaseProduct(Long productId, int quantity) { // 悲観ロックを取得してエンティティを読み込む Product product = entityManager.find( Product.class, productId, LockModeType.PESSIMISTIC_WRITE );
if (product == null) { throw new ResourceNotFoundException("Product", productId); }
if (product.getStock() < quantity) { throw new InsufficientStockException("Insufficient stock"); }
product.setStock(product.getStock() - quantity); entityManager.merge(product);
// トランザクションがコミットされるまで、ロックが保持される }}3. ロックモードの種類
public enum LockModeType { // 楽観ロック OPTIMISTIC, // 楽観ロック(バージョンチェック) OPTIMISTIC_FORCE_INCREMENT, // 楽観ロック(強制的にバージョンをインクリメント)
// 悲観ロック PESSIMISTIC_READ, // 共有ロック(SELECT ... FOR SHARE) PESSIMISTIC_WRITE, // 排他ロック(SELECT ... FOR UPDATE) PESSIMISTIC_FORCE_INCREMENT, // 悲観ロック + バージョンインクリメント
// なし NONE // ロックなし}4. タイムアウトの設定
@Repositorypublic interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints({ @QueryHint(name = "javax.persistence.lock.timeout", value = "5000") // 5秒でタイムアウト }) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdWithTimeout(@Param("id") Long id);}楽観ロックと悲観ロックの比較
Section titled “楽観ロックと悲観ロックの比較”| 項目 | 楽観ロック | 悲観ロック |
|---|---|---|
| 前提 | 競合が発生しない | 競合が発生する |
| 実装方法 | バージョン番号 | データベースロック |
| パフォーマンス | 高い(ロックなし) | 低い(ロックあり) |
| 適用場面 | 読み取りが多い、更新が少ない | 更新が多い、競合が多い |
| 例外 | OptimisticLockException | LockTimeoutException |
| リトライ | 必要 | 不要(ロックで待機) |
実践的な使用例
Section titled “実践的な使用例”在庫管理システムでの楽観ロック
Section titled “在庫管理システムでの楽観ロック”@Entity@Table(name = "inventory")public class Inventory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String productCode; private int quantity;
@Version private Long version;
// getter/setter}
@Service@Transactionalpublic class InventoryService {
@Autowired private InventoryRepository inventoryRepository;
public void reduceInventory(String productCode, int quantity) { Inventory inventory = inventoryRepository.findByProductCode(productCode) .orElseThrow(() -> new ResourceNotFoundException("Inventory", productCode));
if (inventory.getQuantity() < quantity) { throw new InsufficientStockException( String.format("Insufficient stock: available=%d, requested=%d", inventory.getQuantity(), quantity) ); }
inventory.setQuantity(inventory.getQuantity() - quantity); inventoryRepository.save(inventory);
// バージョン番号が自動的にインクリメントされる // 他のトランザクションが同時に更新していた場合、OptimisticLockExceptionが発生 }}銀行口座での悲観ロック
Section titled “銀行口座での悲観ロック”@Entity@Table(name = "accounts")public class Account { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String accountNumber; private BigDecimal balance;
// getter/setter}
@Repositorypublic interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT a FROM Account a WHERE a.accountNumber = :accountNumber") Optional<Account> findByAccountNumberWithLock(@Param("accountNumber") String accountNumber);}
@Service@Transactionalpublic class AccountService {
@Autowired private AccountRepository accountRepository;
public void transfer(String fromAccountNumber, String toAccountNumber, BigDecimal amount) { // 送金元口座を悲観ロックで取得 Account fromAccount = accountRepository.findByAccountNumberWithLock(fromAccountNumber) .orElseThrow(() -> new ResourceNotFoundException("Account", fromAccountNumber));
// 送金先口座を悲観ロックで取得 Account toAccount = accountRepository.findByAccountNumberWithLock(toAccountNumber) .orElseThrow(() -> new ResourceNotFoundException("Account", toAccountNumber));
if (fromAccount.getBalance().compareTo(amount) < 0) { throw new InsufficientBalanceException("Insufficient balance"); }
fromAccount.setBalance(fromAccount.getBalance().subtract(amount)); toAccount.setBalance(toAccount.getBalance().add(amount));
accountRepository.save(fromAccount); accountRepository.save(toAccount);
// トランザクションがコミットされるまで、両方の口座がロックされる }}デッドロックの回避
Section titled “デッドロックの回避”複数のロックを取得する際は、デッドロックを防ぐために常に同じ順序でロックを取得します。
@Service@Transactionalpublic class AccountService {
@Autowired private AccountRepository accountRepository;
public void transfer(String fromAccountNumber, String toAccountNumber, BigDecimal amount) { // アカウント番号でソートして、常に同じ順序でロックを取得 List<String> accountNumbers = Arrays.asList(fromAccountNumber, toAccountNumber); Collections.sort(accountNumbers);
Account account1 = accountRepository.findByAccountNumberWithLock(accountNumbers.get(0)) .orElseThrow(() -> new ResourceNotFoundException("Account", accountNumbers.get(0)));
Account account2 = accountRepository.findByAccountNumberWithLock(accountNumbers.get(1)) .orElseThrow(() -> new ResourceNotFoundException("Account", accountNumbers.get(1)));
Account fromAccount = account1.getAccountNumber().equals(fromAccountNumber) ? account1 : account2; Account toAccount = account1.getAccountNumber().equals(toAccountNumber) ? account1 : account2;
// 残りの処理... }}パフォーマンスの考慮
Section titled “パフォーマンスの考慮”楽観ロックの最適化
Section titled “楽観ロックの最適化”@Service@Transactionalpublic class ProductService {
@Autowired private ProductRepository productRepository;
public void updateProduct(Long productId, ProductUpdateRequest request) { // バージョン番号を明示的に指定して更新 Product product = productRepository.findById(productId) .orElseThrow(() -> new ResourceNotFoundException("Product", productId));
// バージョンチェックを明示的に行う if (!product.getVersion().equals(request.getVersion())) { throw new OptimisticLockException("Product has been modified by another transaction"); }
product.setName(request.getName()); product.setPrice(request.getPrice()); productRepository.save(product); }}悲観ロックの最適化
Section titled “悲観ロックの最適化”@Repositorypublic interface ProductRepository extends JpaRepository<Product, Long> {
// NOWAITオプション: ロックが取得できない場合は即座にエラー @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints({ @QueryHint(name = "javax.persistence.lock.timeout", value = "0") // NOWAIT }) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdWithPessimisticLockNowait(@Param("id") Long id);}楽観ロックと悲観ロックの使い分け:
-
楽観ロック:
- 読み取りが多い、更新が少ない場合
- パフォーマンスを重視する場合
@Versionアノテーションを使用OptimisticLockExceptionを処理
-
悲観ロック:
- 更新が多い、競合が多い場合
- データの整合性を最優先する場合
@LockアノテーションまたはEntityManagerを使用- タイムアウトを設定
適切なロック戦略を選択することで、データの整合性を保ちながら、パフォーマンスも最適化できます。