Redisキャッシュ実装チュートリアル:プロジェクト実践

店舗キャッシュの実装

キャッシュ利用する理由

キャッシュ利用の役割とモデルについて説明します。

キャッシュフロー

基本的なキャッシュの流れは以下の通りです。Redisから 먼저データを取得し、存在すればそのまま返し、存在しなければデータベースから取得してRedisに書き込むという流れです。

以下に実際のコードを記載します:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result getShopById(Long id) {
        String cacheKey = CACHE_SHOP_PREFIX + id;
        // Redisから店舗データを取得
        String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
        
        if (StrUtil.isNotBlank(cachedData)) {
            // キャッシュ存在の場合はそのまま返す
            Shop shop = JSONUtil.toBean(cachedData, Shop.class);
            return Result.success(shop);
        }

        // キャッシュ不存在の場合はデータベースから取得
        Shop shop = getById(id);

        // データベースにも存在しない場合
        if (shop == null) {
            return Result.error("店舗が存在しません");
        }
        
        // キャッシュに書き込む
        stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop));
        
        return Result.success(shop);
    }
}

店舗種別へのRedisキャッシュ追加

店舗種別データのキャッシュも実装する必要があります。リクエストURL:http://localhost:8080/api/shop-type/list

店铺データのキャッシュと同様のアプローチで、List型の店舗種別データを保存するためにString型を使用します。

@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result getShopTypes() {
        String cacheKey = "cache:type";
        // 最初にRedisを確認
        String cachedTypes = stringRedisTemplate.opsForValue().get(cacheKey);
        
        if (StrUtil.isNotBlank(cachedTypes)) {
            // 存在する場合はリストに変換して返す
            List<ShopType> types = JSONUtil.toList(cachedTypes, ShopType.class);
            return Result.success(types);
        }
        
        // 存在しない場合はデータベースから取得
        List<ShopType> types = query().orderByAsc("sort").list();
        
        // データベースにも存在しない場合
        if (types == null || types.isEmpty()) {
            return Result.error("店舗種別を取得できません");
        }
        
        // キャッシュに書き込む
        stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(types));

        return Result.success(types);
    }
}

キャッシュ更新戦略

戦略選択:Cache Aside Pattern

キャッシュの呼び出し側で、データベース更新と同時にキャッシュも更新するパターンです。

キャッシュとデータベース操作時に考慮すべき3つの問題点:

  1. キャッシュを削除還是更新?
    • 更新:データベース更新ごとにキャッシュを更新するため、無効な書き込みが多い
    • 削除:データベース更新時にキャッシュを失效させ、取得時に再構築する ✓
  2. キャッシュとデータベース操作の同時成功/失敗保証
    • 単一システム:キャッシュとデータベース操作を同一トランザクション内に配置
    • 分散システム:TCCなどの分散トランザクション方案を採用
  3. キャッシュとデータベース操作の順序
    • 方案A:キャッシュ削除 → データベース操作
    • 方案B:データベース操作 → キャッシュ削除

操作順序の違いによる影響(異常情况的分析):

スレッド1がキャッシュ削除を実行した場合、これは高速操作ですが、データベース更新は低速操作です。その間にスレッド2が割込み、キャッシュが削除されているため、キャッシュ miss となり、データベースから取得してキャッシュに書き込みます。これらは両方とも高速操作であるため、データベースとキャッシュのデータ不整合が発生する可能性があります。この状況は発生確率が高いです。

たまたまスレッド1の実行時にキャッシュが失效していた場合、データベースからある値aを取得します。たまたまスレッド1がキャッシュに書き込む前に、スレッド2がデータベースを更新して新しい値bにし、キャッシュ削除を実行すると(キャッシュは元空)、スレッド1がキャッシュに書き込みます。しかしスレッド1が書き込む内容はaであり、データベースの値はb、キャッシュの内容はaとなり、データ不整合が発生します。ただし、この状況は発生確率が低いです。理由はキャッシュへの読み書きの速度が速く、別のスレッドがその間に割込んでデータベース更新とキャッシュ削除を完了することが難しいためです。

したがって、一般的にはデータベース操作を先行させ、その後にキャッシュを削除する方案を採用します。

キャッシュ穿透

キャッシュ穿透とは、ユーザーリクエストされたデータがキャッシュにもデータベースにも存在しない情况を指します。これによりキャッシュが永不に有効にならず、すべてのリクエストがデータベースに送信されます。この样的なリクエストが大量発生すると、データベースが瘫痪します。

一般的な解決策は2種類あります:

1. キャッシュnullオブジェクト

ユーザーリクエストしたデータがキャッシュにもデータベースにも存在しない場合、null 값을 설정합니다。

しかし、存在しないデータのリクエストし続けると、キャッシュのnull値が徐々に増加し、メモリ消費が大きくなります。因此、TTL(有効期限)を設定する必要があります。

また、null値がキャッシュされた場合、データがデータベースで更新されると、データ不整合が発生する可能性があります。この問題を緩和するために、过期時間を短く設定することができます。

优点:実装が简单、维护が方便
缺点:追加のメモリ消費、短期的な不整合の可能性

2. ブルームフィルター

优点:メモリ消費较少、余分なkeyがない
缺点:実装が複雑、误判の可能性あり

基本的なセキュリティ対策

  • IDの複雑さを上げて推測を困難にする
  • データ形式のバリデーションを強化
  • ユーザ権限のバリデーションを強化
  • ホットパラメータのレート制限を実施

穿透問題を解決するために、業務コードを修正します。ここではnull値キャッシュ方案を採用します:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result getShopById(Long id) {
        String cacheKey = CACHE_SHOP_PREFIX + id;
        // Redisから店舗データを取得
        String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
        
        if (StrUtil.isNotBlank(cachedData)) {
            // 存在する場合はそのまま返す
            Shop shop = JSONUtil.toBean(cachedData, Shop.class);
            return Result.success(shop);
        }
        
        // null値がヒットしたかチェック
        if (cachedData != null) {
            // エラーメッセージを返す
            return Result.error("店舗情報が存在しません");
        }

        // キャッシュにない場合はデータベースから取得
        Shop shop = getById(id);

        // データベースにも存在しない場合
        if (shop == null) {
            // null値をRedisに書き込む
            stringRedisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.error("店舗が存在しません");
        }
        
        // 存在する場合はキャッシュに書き込む
        stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        
        return Result.success(shop);
    }

    @Override
    @Transactional
    public Result modifyShop(Shop shop) {
        Long id = shop.getId();
        if (id == null) {
            return Result.error("店舗IDを入力してください");
        }
        
        // まずデータベースを更新
        updateById(shop);
        // その後にキャッシュを削除
        stringRedisTemplate.delete(CACHE_SHOP_PREFIX + shop.getId());

        return Result.success(shop);
    }
}

キャッシュ雪崩

キャッシュ雪崩とは、同時に大量のキャッシュkeyが失效하거나、Redisサービスが停止したりすることで大量の 요청がデータベースに到達し、巨大な压力をもたらす現象です。

解決策:

  • 異なるKeyのTTLにランダム値を追加する
  • Redisクラスタでサービスの可用性を向上させる
  • キャッシュ業務に降級・レート制限策略を追加する
  • 業務にマルチレベルキャッシュを追加する

キャッシュ击穿

キャッシュ击穿問題はホットキー問題とも呼ばれ、高并发でアクセスされ、キャッシュ再構築业务が複雑なkeyが突然失效した場合に、数多くのリクエストが瞬间的にデータベースに巨大な冲击をもたらす问题です。

一般的な解決策は2種類あります:

  • 相互ロック(互斥锁)
  • 論理过期(ロジカル到期)
解決策优点缺点
相互ロック追加メモリ消費なし
一貫性を保証
実装が简单
スレッドが等待必要
性能が影响
デッドロックのリスクあり
論理过期スレッドが等待不要
性能が良い
一貫性を保証しない
追加メモリ消費あり
実装が複雑

相互ロックでキャッシュ击穿を解決

IDによる店舗検索の业务を修正します。RedisのStringのsetnxを使用してロックを模擬します。setnxは値が空の場合のみ値を修正できるため、相互ロックを模擬できます。大量のキャッシュリクエストがある場合、1つのリクエストのみがデータベース查询、Redis書き込み、ロック解除などの дальней进行处理できます。他のスレッドは現在のスレッドがロックを解除するまで休眠します。

public Shop fetchWithLock(Long id) {
    String cacheKey = CACHE_SHOP_PREFIX + id;
    // Redisからキャッシュを取得
    String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
    
    if (StrUtil.isNotBlank(cachedData)) {
        // 存在する場合はそのまま返す
        return JSONUtil.toBean(cachedData, Shop.class);
    }
    
    // null値がヒットしたかチェック
    if (cachedData != null) {
        return null;
    }
    
    // キャッシュ再構築の実装
    // 相互ロックを取得
    String lockKey = LOCK_SHOP_PREFIX + id;
    Shop shop = null;
    
    try {
        boolean lockAcquired = acquireLock(lockKey);
        
        // ロック取得成功可否を判断
        if (!lockAcquired) {
            // 失敗した場合は休眠して再試行
            Thread.sleep(50);
            return fetchWithLock(id);
        }
        
        // 成功した場合、IDでデータベースから取得
        shop = getById(id);
        // 再構築遅延をシミュレート
        Thread.sleep(200);

        // データベースに存在しない場合
        if (shop == null) {
            // null値をRedisに書き込む
            stringRedisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        
        // 存在する場合はキャッシュに書き込む
        stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 相互ロックを解除
        releaseLock(lockKey);
    }
    
    return shop;
}

private boolean acquireLock(String key) {
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(result);
}

private void releaseLock(String key) {
    stringRedisTemplate.delete(key);
}

キャッシュ再構築メソッド

private void rebuildCache(Long id, Long expireSeconds) {
    // 1.店舗データを取得
    Shop shop = getById(id);
    // 2.論理过期時間を封装
    RedisData cacheData = new RedisData();
    cacheData.setData(shop);
    cacheData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // 3.Redisに書き込む
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_PREFIX + id, JSONUtil.toJsonStr(cacheData));
}

業務ロジック実装では、スレッドプールを使用してスレッドの频繁な作成と破棄による性能オーバーヘッドを回避します。

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public Shop fetchWithLogicalExpire(Long id) {
    String cacheKey = CACHE_SHOP_PREFIX + id;
    // Redisからキャッシュを取得
    String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
    
    if (StrUtil.isBlank(cachedData)) {
        // 存在しない場合はnullを返す
        return null;
    }
    
    // ヒットした場合、Jsonを逆シリアライズ
    RedisData cacheData = JSONUtil.toBean(cachedData, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) cacheData.getData(), Shop.class);
    LocalDateTime expireTime = cacheData.getExpireTime();
    
    // 过期かどうか判断
    if (expireTime.isAfter(LocalDateTime.now())) {
        // 未过期の場合は店舗情報を返す
        return shop;
    }
    
    // 过期した場合はキャッシュ再構築が必要
    // キャッシュ再構築
    // 相互ロックを取得
    String lockKey = LOCK_SHOP_PREFIX + id;
    boolean lockAcquired = acquireLock(lockKey);
    
    // ロック取得成功可否を判断
    if (lockAcquired) {
        // 成功した場合は独立スレッドを開いてキャッシュ再構築
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                // キャッシュ再構築
                this.rebuildCache(id, 20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                // ロックを解除
                releaseLock(lockKey);
            }
        });
    }

    // 失敗した場合は过期した店舗情報を返す
    return shop;
}

キャッシュユーティリティクラスの封装

CacheClientユーティリティクラスをStringRedisTemplateに基づいて封装します。以下の要件を満たします:

  • 機能1:任意のJavaオブジェクトをjsonにシリアライズし、string型のkeyに保存し、TTL过期時間を設定可能
  • 機能2:任意のJavaオブジェクトをjsonにシリアライズし、string型のkeyに保存し、論理过期時間を設定可能(キャッシュ击穿問題处理用)
  • 機能3:指定されたkeyからキャッシュを取得し、逆シリアライズして指定タイプに変換、null値キャッシュ方式でキャッシュ穿透問題を解決
  • 機能4:指定されたkeyからキャッシュを取得し、逆シリアライズして指定タイプに変換、論理过期方式来キャッシュ击穿問題を解決
@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        // 論理过期を設定
        RedisData cacheData = new RedisData();
        cacheData.setData(value);
        cacheData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // Redisに書き込む
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(cacheData));
    }

    public <R, ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.Redisからキャッシュを取得
        String json = stringRedisTemplate.opsForValue().get(key);
        
        // 2.存在するかを判断
        if (StrUtil.isNotBlank(json)) {
            // 3.存在する場合はそのまま返す
            return JSONUtil.toBean(json, type);
        }
        
        // null値がヒットしたか判断
        if (json != null) {
            return null;
        }

        // 4.存在しない場合はIDでデータベースから取得
        R result = dbFallback.apply(id);
        
        // 5.存在しない場合はエラーを返す
        if (result == null) {
            // null値をRedisに書き込む
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        
        // 6.存在する場合はRedisに書き込む
        this.set(key, result, time, unit);
        return result;
    }

    public <R, ID> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.Redisからキャッシュを取得
        String json = stringRedisTemplate.opsForValue().get(key);
        
        // 2.存在するかを判断
        if (StrUtil.isBlank(json)) {
            return null;
        }
        
        // 4.ヒットした場合はまずjsonを逆シリアライズ
        RedisData cacheData = JSONUtil.toBean(json, RedisData.class);
        R result = JSONUtil.toBean((JSONObject) cacheData.getData(), type);
        LocalDateTime expireTime = cacheData.getExpireTime();
        
        // 5.过期かどうか判断
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1 未过期の場合はそのまま店舗情報を返す
            return result;
        }
        
        // 5.2 过期した場合はキャッシュ再構築が必要
        // 6.キャッシュ再構築
        // 6.1 相互ロックを取得
        String lockKey = LOCK_SHOP_PREFIX + id;
        boolean lockAcquired = acquireLock(lockKey);
        
        // 6.2 ロック取得成功可否を判断
        if (lockAcquired) {
            // 6.3 成功した場合は独立スレッドを開いてキャッシュ再構築
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // データベースから取得
                    R newResult = dbFallback.apply(id);
                    // キャッシュ再構築
                    this.setWithLogicalExpire(key, newResult, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // ロックを解除
                    releaseLock(lockKey);
                }
            });
        }
        
        // 6.4 过期した店舗情報を返す
        return result;
    }

    public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.Redisからキャッシュを取得
        String cachedData = stringRedisTemplate.opsForValue().get(key);
        
        // 2.存在するかを判断
        if (StrUtil.isNotBlank(cachedData)) {
            return JSONUtil.toBean(cachedData, type);
        }
        
        // null値がヒットしたか判断
        if (cachedData != null) {
            return null;
        }

        // 4.キャッシュ再構築の実装
        // 4.1 相互ロックを取得
        String lockKey = LOCK_SHOP_PREFIX + id;
        R result = null;
        
        try {
            boolean lockAcquired = acquireLock(lockKey);
            
            // 4.2 ロック取得成功可否を判断
            if (!lockAcquired) {
                // 4.3 ロック取得失敗時は休眠して再試行
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            
            // 4.4 ロック取得成功時はIDでデータベースから取得
            result = dbFallback.apply(id);
            
            // 5.存在しない場合はエラーを返す
            if (result == null) {
                // null値をRedisに書き込む
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            
            // 6.存在する場合はRedisに書き込む
            this.set(key, result, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 7.ロックを解除
            releaseLock(lockKey);
        }
        
        // 8.返す
        return result;
    }

    private boolean acquireLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    private void releaseLock(String key) {
        stringRedisTemplate.delete(key);
    }
}

ShopServiceImplでの使用

@Resource
private CacheClient cacheClient;

@Override
public Result getShopById(Long id) {
    // キャッシュ穿透問題を解決
    Shop shop = cacheClient
            .queryWithPassThrough(CACHE_SHOP_PREFIX, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 相互ロックでキャッシュ击穿を解決
    // Shop shop = cacheClient
    //         .queryWithMutex(CACHE_SHOP_PREFIX, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 論理过期でキャッシュ击穿を解決
    // Shop shop = cacheClient
    //         .queryWithLogicalExpire(CACHE_SHOP_PREFIX, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

    if (shop == null) {
        return Result.error("店舗が存在しません");
    }
    
    return Result.success(shop);
}

タグ: redis Java Spring Boot キャッシュ パフォーマンス最適化

6月1日 18:57 投稿