排他ロックを使用したキャッシュスルー防止の実装方法

キャッシュスルー現象とは

キャッシュスルーはホットキー問題とも呼ばれ、高頻度でアクセスされる複雑な再構築ロジックを持つ特定のキーが突然無効化された場合に発生します。これにより、多数のリクエストが瞬時にデータベースに集中し、大きな負荷をかけることになります。

対応策

  1. 排他ロック方式(時間換算スペース)
    • 利点:メモリ使用量が少なく、整合性が高く、実装がシンプル
    • 欠点:パフォーマンスが低下しやすく、デッドロックのリスクあり
  2. 論理的有効期限方式(スペース換算時間)
    • 利点:パフォーマンスが高い
    • 欠点:メモリ消費が大きく、ダーティリードが発生しやすい

比較すると、排他ロック方式の方が実装は容易ですが、デッドロックの可能性があり、並列処理が直列化されることでシステム全体のパフォーマンスが低下します。一方、論理的有効期限方式は実装が複雑になり、追加メモリが必要ですが、サブスレッドによるキャッシュ再構築により同期ブロッキングを非同期化し、システム応答性を向上させます。

フローチャート

リクエスト -> Redis検索 -> データあり? -> あり:返却
                    ↓
               データなし -> 空文字列? -> はい:null返却
                                    ↓
                           いいえ:ロック取得 -> ロック失敗:待機再試行
                                           ↓
                                     ロック成功 -> DB照会
                                               ↓
                                    データ空 -> 空文字列キャッシュ
                                    データあり -> 結果キャッシュ
                                               ↓
                                          データ返却

実装コード


/**
 * キャッシュスルー対策:排他ロック方式
 * 排他ロックを導入して、DB照会とキャッシュ再構築を1つのスレッドのみ実行し、他のスレッドは待機または再試行
 */
public Shop fetchWithExclusiveLock(Long shopId) {
    // 1. Redisから取得(固定プレフィックス+店舗ID)
    String cachedData = stringRedisTemplate.opsForValue().get(CACHE_SHOP_PREFIX + shopId);
    
    // 2. キャッシュヒット判定
    if (StrUtil.isNotBlank(cachedData)) {
        // 2.1 データがキャッシュされている場合
        Shop shopEntity = JSONUtil.toBean(cachedData, Shop.class);
        return shopEntity;
    }

    // 2.2 空文字列の場合はキャッシュされた空データと判断
    if (cachedData != null) {
        return null;
    }
    
    // 2.3 キャッシュミス時のロック付き再構築
    Shop targetShop = null;
    try {
        boolean lockAcquired = acquireExclusiveLock(LOCK_SHOP_PREFIX + shopId);
        
        // 2.4 ロック取得失敗時は待機後再試行
        while (!lockAcquired) {
            Thread.sleep(60); // 60ms待機
            return fetchWithExclusiveLock(shopId);
        }
        
        // 2.5 ロック取得成功後の処理
        targetShop = retrieveById(shopId);
        
        // 2.6 DBにデータがない場合は空文字列をキャッシュ
        if (targetShop == null) {
            stringRedisTemplate.opsForValue()
                .set(CACHE_SHOP_PREFIX + shopId, "", NULL_CACHE_DURATION, TimeUnit.MINUTES);
            return null;
        }
        
        // 2.7 DBデータをJSONに変換しRedisに保存
        String jsonData = JSONUtil.toJsonStr(targetShop);
        stringRedisTemplate.opsForValue()
            .set(CACHE_SHOP_PREFIX + shopId, jsonData, SHOP_CACHE_DURATION, TimeUnit.MINUTES);
            
    } catch (InterruptedException interruptException) {
        throw new RuntimeException(interruptException);
    } finally {
        // 2.8 必ずロック解放
        releaseLock(LOCK_SHOP_PREFIX + shopId);
    }
    return targetShop;
}

実装ポイント

  1. RedisのSETNXコマンドを使用して排他ロックを実現し、値が存在しない場合のみ設定可能
  2. ロックの有効期間は具体的なビジネスロジックに基づき柔軟に調整する必要があり、通常は業務処理時間の10~20倍程度
  3. スレッドがロックを取得した後も再度キャッシュを確認する(いわゆるダブルチェック)ことで、キャッシュスルーを真正に防止できる

タグ: redis cache-busting mutex-lock distributed-lock spring-boot

6月22日 16:35 投稿