Spring Bootを利用した高負荷在庫管理システムにおけるトランザクション分離レベルと排他制御の最適化
概要
ユーザー数1,000名/分を想定した在庫管理システムにおいて、在庫テーブルと決済テーブルの整合性を保ちつつ高スループットを実現するためには、トランザクション分離レベルと排他制御の適切な組み合わせが不可欠です。本稿では、Spring Boot環境での実装例を交えながら、以下の観点で詳細に解説します。
- トランザクション分離レベルの選択基準
- 悲観的ロックと楽観的ロックの使い分け
- デッドロック回避戦略
- Spring Data JPAを活用した実装パターン
- パフォーマンスチューニングのポイント
トランザクション分離レベルの選定基準
各分離レベルの特性比較
分離レベル | ダーティリード | ノンリピータブルリード | ファントムリード | スループット | 適用ケース |
---|---|---|---|---|---|
READ UNCOMMITTED | 発生 | 発生 | 発生 | 最高 | 集計処理等の非クリティカル処理 |
READ COMMITTED | 防止 | 発生 | 発生 | 高 | デフォルト推奨 |
REPEATABLE READ | 防止 | 防止 | 発生* | 中 | 財務関連トランザクション |
SERIALIZABLE | 防止 | 防止 | 防止 | 低 | 厳密な一貫性が求められる処理 |
※MySQLのInnoDBはREPEATABLE READでもファントムリードを防止[33]
推奨構成
// 在庫更新処理
@Transactional(isolation = Isolation.READ_COMMITTED)
public void updateInventory(Long productId, int quantity) {
// 在庫更新ロジック
}
// 決済処理
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processPayment(PaymentRequest request) {
// 決済処理ロジック
}
選定理由:
- 在庫更新: 高スループットを実現しつつダーティリードを防止[1][2]
- 決済処理: 金額計算の再現性を保証[12][13]
排他制御戦略
悲観的ロック vs 楽観的ロック
方式 | ロック取得タイミング | 競合検出タイミング | スループット | 適用ケース |
---|---|---|---|---|
悲観的ロック | データ取得時 | 即時 | 中 | 高競合率(例: 人気商品) |
楽観的ロック | 更新時 | コミット時 | 高 | 低競合率(通常商品) |
実装例
悲観的ロック(行ロック)
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Inventory i WHERE i.productId = :productId")
Inventory findByProductIdForUpdate(@Param("productId") Long productId);
}
// 在庫更新処理
public void updateStock(Long productId, int quantity) {
Inventory inventory = inventoryRepository.findByProductIdForUpdate(productId);
if (inventory.getStock() >= quantity) {
inventory.setStock(inventory.getStock() - quantity);
} else {
throw new InsufficientStockException();
}
}
楽観的ロック(バージョン管理)
@Entity
public class Inventory {
@Id
private Long productId;
@Column(nullable = false)
private Integer stock;
@Version
private Long version;
}
// 更新処理
@Transactional
public void updateStockOptimistic(Long productId, int quantity) {
Inventory inventory = inventoryRepository.findById(productId)
.orElseThrow(ProductNotFoundException::new);
if (inventory.getStock() < quantity) {
throw new InsufficientStockException();
}
inventory.setStock(inventory.getStock() - quantity);
inventoryRepository.save(inventory); // バージョンチェック自動実行
}
デッドロック回避策
発生要因と対策
-
ロック順序の不整合
- 解決策: テーブル/行へのアクセス順序を統一[19][22]
-
ロック粒度の不適切
- 解決策: 行ロックを基本とし、インデックス設計を最適化[1][6]
-
トランザクション時間の長期化
- 解決策: タイムアウト設定の導入
@Transactional(timeout = 5) // 5秒タイムアウト public void processOrder(Order order) { // 処理ロジック }
高負荷環境でのチューニング
接続プール設定(HikariCP推奨)
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 30000
max-lifetime: 1800000
最適化ポイント:
- コネクションプールサイズ = (コア数 * 2) + スピンドル数[25]
- 監視指標: プール使用率、待機時間、タイムアウト率
バッチ処理最適化
@Scheduled(fixedDelay = 5000)
@SchedulerLock(name = "InventorySync", lockAtMostFor = "10m")
public void syncInventory() {
// 分散ロックを利用したバッチ処理
}
ShedLockによる分散ロックの活用[27]
実装パターン比較表
シナリオ | 方式 | 分離レベル | ロック種別 | 想定スループット |
---|---|---|---|---|
通常商品在庫更新 | 楽観的ロック | READ COMMITTED | 行ロック | 500 TPS |
人気商品在庫更新 | 悲観的ロック | REPEATABLE READ | 行ロック | 200 TPS |
決済処理 | 悲観的ロック | SERIALIZABLE | テーブルロック | 100 TPS |
在庫集計 | 非ロック | READ UNCOMMITTED | N/A | 1,000 TPS |
障害発生時の挙動設計
リトライメカニズム
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 100))
public void updateStockWithRetry(Long productId, int quantity) {
try {
updateStock(productId, quantity);
} catch (ObjectOptimisticLockingFailureException e) {
log.warn("Optimistic lock conflict detected, retrying...");
throw e;
}
}
監視指標
- デッドロック発生率(目標: <0.1%)
- ロック待ち時間(P95 < 50ms)
- トランザクションタイムアウト率(<0.5%)
- 楽観ロック競合率(<5%)