マルチノード構成における二重決済問題
深夜のフラッシュセールに1万人のアクティブユーザーが殺到している場面を想像してみてください。最後の1台となったPlayStation 5に対し、2人のユーザーがまったく同じミリ秒で「購入」をクリックしました。上流のNGINXロードバランサーは、ユーザーAをノード1へ、ユーザーBをノード2へと振り分けます。
両方のノードがデータベースに問い合わせると、在庫数は「1」です。両ノードとも在庫の引き落としと決済処理を進めてしまいます。その結果、倉庫には1台しかないにもかかわらず、わずか数ミリ秒の間に2台販売されてしまうことになります。Javaの標準的なsynchronizedブロックやNode.jsのミューテックスは、単一マシン上でメモリを共有するスレッド間しか保護できません。アプリケーションが複数インスタンスにスケールアウトした瞬間、プロセス内同期はまったく役に立たなくなります。
MySQLやPostgreSQLといったデータベースはトランザクション処理に優れていますが、高頻度なアプリケーション間の調停に利用するには大きなコストがかかります。高トラフィックの急増時に多数のノードが同一行のロックを奪い合うと、接続プールは数秒で枯渇し、データベースのレイテンシは急上昇してしまいます。
分散並行処理における選択肢の比較
アプリケーションノードが複数台になった場合、処理をシリアライズするための一般的なアプローチとして次の3つが挙げられます。
1. データベースレベルのロック(悲観的 vs 楽観的)
リレーショナルデータベースでは、SELECT ... FOR UPDATEによる明示的な行ロックや、バージョン番号を用いた楽観的ロックが利用できます。
-- 悲観的行ロック
BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
-- 在庫を確認してデクリメント
UPDATE products SET stock = stock - 1 WHERE id = 42;
COMMIT;
2. SETNXを利用した自作Redisロック
Redisはシングルスレッドのイベントループで動作し、インメモリ操作をミリ秒未満で完了します。初期のロックパターンはアトミックなSETNXコマンドに依存していました:
# 10秒のTTLを設定してアトミックにロックを取得
SET lock:product:42 "client-uuid-123" NX PX 10000
3. Redissonによる本番対応の分散ロック
Redissonは、生のRedisコマンドをRLockのような扱い慣れたJavaの並行処理プリミティブへと橋渡しします。内部では実戦で鍛え上げられたLuaスクリプトを実行し、ウォッチドッグ機構によってリースの自動更新を処理します。
RLock lock = redissonClient.getLock("lock:product:42");
lock.lock();
try {
// クラスタ内の全ノードにわたってシリアライズ実行される
inventoryService.deductStock(42);
} finally {
lock.unlock();
}
トレードオフ:適切なツールの選定
データベースの行ロック
- メリット: 追加のインフラが不要であり、最初から厳密なACID特性が保証される。
- デメリット: 貴重なデータベース接続を長時間占有する。秒間2,000リクエストを超えるピーク負荷下では、行競合によってトランザクションの滞留が発生し、接続プールのタイムアウトが連鎖する。
自作Redis SETNX
- メリット: 非常に軽量であり、1ミリ秒未満でのロック取得が可能。
- デメリット: 安全に実装するのが極めて難しい。GC停止や遅い外部APIによって処理がTTLを超えて遅延した場合、Redisはロックを途中で解放してしまい、別のワーカーがそれを獲得してしまう。安全に解放するには、他プロセスのトークンを誤削除しないよう、カスタムLuaスクリプト内で所有権を確認する必要がある。
Redissonの分散ロック
- メリット: 完全な再入可能(Reentrant)ロックに対応。ノンブロッキングなリトライバックオフをサポート。予期しないGC停止やネットワークの瞬断時でもロック失効を防ぐ自動リースウォッチドッグを搭載。
- デメリット: 新たなクライアントライブラリが追加される。標準的なRedisのマスター・レプリカ構成では、プライマリ障害時にレプリカへの書き込み同期が完了していないと、スプリットブレインによるロック消失のリスクが残る。
推奨アーキテクチャ:ファストパスパターン
高スループットなマイクロサービスでは、調停処理の負荷をコアデータベースから切り離すことでレイテンシを最小限に抑えられます。実績のある設計パターンでは、役割を明確に分離します:
- Redis Cluster / Sentinel: 中央の調停レイヤーとして機能し、有効期限の短い同期トークンをRAM上に保持する。
- Redisson: スレッドのロック獲得、リトライ、リースの自動延長を管理する。
- リレーショナルDB(PostgreSQL / MySQL): Redissonによって一度に1ノードのみのアクセスが許可された後、安全にビジネストランザクションを実行する。
実装:Spring BootにおけるRedissonのセットアップ
ステップ 1: Redisの起動
Dockerを使用してローカルのRedisコンテナを起動します:
docker run -d --name redis-lock -p 6379:6379 redis:7-alpine
ステップ 2: Maven依存関係の追加
pom.xmlにRedissonスターターを追加します:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.34.0</version>
</dependency>
ステップ 3: RedissonClient Beanの設定
接続プールのしきい値を調整する設定クラスを定義します:
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setConnectionPoolSize(64)
.setConnectionMinimumIdleSize(24);
return Redisson.create(config);
}
}
ステップ 4: クリティカルなビジネスロジックの保護
tryLockに明示的な待機タイムアウトを設定し、競合が激しい場合でも呼び出し元スレッドが無期限に停止せずフェイルファストするようにします:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class OrderProcessingService {
private final RedissonClient redissonClient;
public OrderProcessingService(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
public boolean processOrder(Long productId, int quantity) {
String lockKey = "lock:order:product:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 最大3秒間待機。-1を渡すことでウォッチドッグによるリースの動的延長を有効化
boolean acquired = lock.tryLock(3, -1, TimeUnit.SECONDS);
if (!acquired) {
// 別のワーカーがロックを保持しているため、リクエストを拒絶またはキューへ追加
return false;
}
try {
return executeOrderDeduction(productId, quantity);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
private boolean executeOrderDeduction(Long productId, int quantity) {
// データベースの在庫確認および注文作成処理をここで実行
return true;
}
}
ウォッチドッグが早期失効を防ぐ仕組み
リース時間を-1に設定すると、Redisson内部のウォッチドッグが有効になります。デフォルトのウォッチドッグタイムアウト(30秒)の3分の1にあたる10秒ごとに、バックグラウンドタスクがスレッドの実行継続状況をチェックします。もし決済ゲートウェイの応答が遅く15秒かかったとしても、ウォッチドッグが自動的にTTLを30秒へ更新します。
処理の途中でノードがクラッシュした場合はどうなるでしょうか?ウォッチドッグはJVMとともに終了します。その後、Redis内のロックは30秒以内に自然失効し、キーが解放されるため、健全なノードが処理を再開でき、恒久的なデッドロックを防止できます。

