Skip to content

悲観ロックと楽観ロック

データベースでの同時実行制御において、**悲観ロック(Pessimistic Locking)楽観ロック(Optimistic Locking)**は、データの整合性を保つための重要な手法です。この章では、Spring Data JPAでの実装方法について詳しく解説します。

複数のトランザクションが同じデータに同時にアクセスする際に、データの整合性を保つためのメカニズムです。

問題例:

トランザクションA: 在庫数を読み取り(100個)
トランザクションB: 在庫数を読み取り(100個)
トランザクションA: 10個購入 → 90個に更新
トランザクションB: 20個購入 → 80個に更新(本来は90個であるべき)

このような問題を防ぐために、ロックを使用します。

楽観ロックは、競合が発生しないことを前提としたロック方式です。バージョン番号やタイムスタンプを使用して、データが変更されていないことを確認します。

  • パフォーマンス: ロックを取得しないため、読み取り性能が高い
  • 競合が少ない場合に適している: 更新頻度が低いデータに適している
  • デッドロックのリスクが低い: ロックを保持しないため

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
@Transactional
public 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
@Transactional
public 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);
}
}

悲観ロックは、競合が発生することを前提としたロック方式です。データを読み取る際にロックを取得し、他のトランザクションからのアクセスをブロックします。

  • データの整合性: 確実にデータの整合性を保つ
  • 競合が多い場合に適している: 更新頻度が高いデータに適している
  • パフォーマンス: ロックを保持するため、読み取り性能が低下する可能性がある
  • デッドロックのリスク: 複数のロックを取得する場合、デッドロックが発生する可能性がある

1. @Lockアノテーションを使用

@Repository
public 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
@Transactional
public 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. タイムアウトの設定

@Repository
public 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 “楽観ロックと悲観ロックの比較”
項目楽観ロック悲観ロック
前提競合が発生しない競合が発生する
実装方法バージョン番号データベースロック
パフォーマンス高い(ロックなし)低い(ロックあり)
適用場面読み取りが多い、更新が少ない更新が多い、競合が多い
例外OptimisticLockExceptionLockTimeoutException
リトライ必要不要(ロックで待機)

在庫管理システムでの楽観ロック

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
@Transactional
public 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が発生
}
}
@Entity
@Table(name = "accounts")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String accountNumber;
private BigDecimal balance;
// getter/setter
}
@Repository
public 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
@Transactional
public 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);
// トランザクションがコミットされるまで、両方の口座がロックされる
}
}

複数のロックを取得する際は、デッドロックを防ぐために常に同じ順序でロックを取得します。

@Service
@Transactional
public 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;
// 残りの処理...
}
}
@Service
@Transactional
public 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);
}
}
@Repository
public 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を使用
    • タイムアウトを設定

適切なロック戦略を選択することで、データの整合性を保ちながら、パフォーマンスも最適化できます。