Redis キャッシュ問題の詳細解説

キャッシュ貫通
キャッシュ貫通とは、クライアントがリクエストしたデータがキャッシュにもデータベースにも存在しないため、キャッシュが一切機能せず、リクエストがすべてデータベースに到達する問題を指す。
悪意のあるユーザーが多数のスレッドで存在しないデータに同時にアクセスすると、すべてのリクエストがデータベースに到達し、データベースがクラッシュする可能性が高い。
解決策
空オブジェクトのキャッシュ
考え方:ユーザーがリクエストした id が Redis にもデータベースにも存在しない場合、その id に対応する null 値を直接 Redis にキャッシュする。これにより、次回同じ id が繰り返しリクエストされた際に Redis でヒットし(null にヒット)、データベースにクエリが飛ばない。
メリット:実装がシンプルで保守が容易
デメリット:

追加のメモリ消費が発生する(TTL の設定で対応可能)


- 短期的な不整合が発生する可能性がある(TTL の調整である程度緩和可能):null をキャッシュした直後にデータベースに値が設定された場合、ユーザーのクエリ結果は null だが、実際にはデータベースにデータが存在するため不整合が生じる(データ挿入時にキャッシュ内の null を自動上書きすることで対応可能)
ブルームフィルター
クライアントと Redis の間にブルームフィルターの層を追加する。ユーザーがアクセスする際、ブルームフィルターがデータの存在を判定する。存在しない場合は直接拒否し、存在する場合は通常のプロセスで処理する。
ブルームフィルターはどのようにしてデータの存在を判定するか?

ブルームフィルターはバイト配列として理解でき、バイナリビットを格納する。データベース内のデータの存在を判定する際、データを直接ブルームフィルターに格納するのではなく、ハッシュアルゴリズムでハッシュ値を計算し、これらのハッシュ値をバイナリビットに変換してブルームフィルターに格納する。データの存在を判定する際は、対応する位置の 0/1 を確認するだけでよい(この判定は確率的な統計であり、100% 正確ではない。つまり「存在しない」は正確だが「存在する」は必ずしも正確ではないため、貫通のリスクは依然として存在する)
メリット:メモリ使用量が少なく、冗長なキーがない(バイナリ形式)
デメリット:

実装が複雑
誤判定の可能性がある(必ずしも正確ではない)


空オブジェクトキャッシュの Java 実装
/**
* キャッシュ貫通
*
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Redis からショップキャッシュをクエリ
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 存在するかどうかを判定
if (StrUtil.isNotBlank(shopJson)) {
// 3. 存在する場合、直接返す
return JSONUtil.toBean(shopJson, Shop.class);
}
// ヒットした値が null 値かどうかを判定
if (shopJson 。= null) {
// エラーメッセージを返す
return null;
}
// 4. 存在しない場合、id に基づいてデータベースをクエリ
Shop shop = getById(id);
// 5. 存在しない場合、エラーを返す
if (shop == null) {
// null 値を Redis に書き込む(キャッシュ貫通対策)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. 存在する場合、Redis に書き込む
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. 返す
return shop;
}
キャッシュアバランチ
キャッシュアバランチとは、大量のキャッシュキーが同時に無効化される、または Redis サービスが同時にダウンすることで、大量のリクエストがデータベースに到達し、データベースに大きな負荷がかかる問題である。


解決策

各キーの TTL に乱数を加える(同時無効化の解決):キャッシュウォームアップ時、データベースのデータを事前に一括でキャッシュにインポートする必要がある。同時にインポートされたデータは同じ TTL 値になり、ある時点で一斉に期限切れとなりアバランチが発生する可能性がある。この問題を解決するため、インポート時に TTL に乱数を加える(例:TTL = 30±1〜5)。これにより、各キーの有効期限が期間内に分散され、同時に無効化されることがなくなり、アバランチを回避できる。
Redis クラスターを利用してサービス可用性を向上させる(Redis ダウンの解決):Redis センチネル機構により、あるマシンがダウンした際、センチネルが自動的に代替マシンを選択してダウンしたマシンと置き換え、マスターとスレーブがデータを同期することで、Redis の高可用性を確保する。
キャッシュを利用するビジネスにダウングレードと流量制御の戦略を追加する:たとえば、即時失敗、サービス拒否、リクエストがデータベースに流れ込むのを防ぐ仕組みなど。
マルチレベルキャッシュをビジネスに追加する:ブラウザにキャッシュを追加(主に静的リソース)、リバースプロキシの Nginx にキャッシュを追加、Nginx でキャッシュミスの場合は Redis にリクエスト、Redis でキャッシュミスの場合は JVM に到達、JVM 内部にもローカルキャッシュを設けることができ、最終的にデータベースに到達する。
キャッシュブレークダウン
キャッシュブレークダウン問題はホットキー問題とも呼ばれ、高並列でアクセスされるキーであり、かつキャッシュ再構築のビジネスが複雑なキーが突然失効する問題である。無数のリクエストが瞬時にデータベースに大きな影響を与える。
キャッシュ再構築:Redis のキャッシュは有効期限切れ後に失効するため、期限切れ後にデータベースから再クエリして Redis に書き込む必要がある。データベースからのクエリとデータ構築のプロセスは複雑な場合があり、複数テーブルの結合クエリなどが必要で、最終的に結果をキャッシュする。この処理には長い時間がかかる可能性がある(数十ミリ秒から数百ミリ秒)。この期間、Redis にはキャッシュが存在しないため、受信したリクエストはすべてデータベースにアクセスしてしまう。


解決策
ミューテックス
スレッドのリクエストがキャッシュミスを検知した場合、データベースをクエリする前にロックを取得し、キャッシュへの書き込み完了後にロックを解放する。このようにして、他のスレッドがキャッシュミスした場合も、データベースをクエリする際にミューテックスロックの取得を試みる。取得に失敗した場合は一定時間スリープしてから再クエリする。
他のスレッドはキャッシュへの書き込み完了後にデータを取得できる。整合性は保証されるが、パフォーマンスは比較的劣り、デッドロックを引き起こす可能性がある。


Java 実装


/**
* ロックを取得
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}


/**
* ロックを解放
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}


/**
* ミューテックス
*
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Redis からショップキャッシュをクエリ
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 存在するかどうかを判定
if (StrUtil.isNotBlank(shopJson)) {
// 3. 存在する場合、直接返す
return JSONUtil.toBean(shopJson, Shop.class);
}
// ヒットした値が null 値かどうかを判定
if (shopJson 。= null) {
// エラーメッセージを返す
return null;
}

// 4. キャッシュ再構築を実装
// 4.1 ミューテックスを取得
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 取得成功かどうかを判定
if (。isLock) {
// 4.3 失敗、スリープして再試行
Thread.sleep(50);
// 再帰
return queryWithMutex(id);
}
// 4.4 成功、id に基づいてデータベースをクエリ
shop = getById(id);
// 再構築遅延をシミュレート
Thread.sleep(200);
// 5. 存在しない場合、エラーを返す
if (shop == null) {
// null 値を Redis に書き込む(キャッシュ貫通対策)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. 存在する場合、Redis に書き込む
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. ミューテックスを解放
unlock(lockKey);
}
// 8. 返す
return shop;
}
jmeter でテストしてみよう。1000 件のリクエストを送信すると、すべてのリクエストが正常に処理され、データベースへのクエリは 1 回のみであることが確認できる。



論理有効期限
その名の通り、実際には有効期限切れにならない方法で、永続的に有効と見なせる。Redis にデータをキャッシュする際、TTL を設定せず、データ格納時に有効期限フィールド(TTL ではなく、現在時刻 + 有効期限に基づく論理的な時刻)を追加する。これにより、どのスレッドもキャッシュにヒットでき、論理的に有効期限切れかどうかを判定するだけでよい。
下図のように、スレッド 1 がキャッシュをクエリして論理的な有効期限切れを検知した場合、キャッシュの再構築が必要になり、ミューテックスを取得して別のスレッドを起動して再構築を行う(スレッド 1 自身が再構築するのではなく)。キャッシュ再構築完了後にロックを解放し、スレッド 1 は期限切れデータを直接返す。他のスレッドも同様にキャッシュミスの場合、ミューテックスの取得に失敗して期限切れデータを直接返す。パフォーマンスは保証されるが、データ整合性は保証されない。


Java 実装
/**
* キャッシュウォームアップ
*
* @param id
* @param expireSeconds 論理有効期限(秒)
*/
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. ショップデータをクエリ
Shop shop = getById(id);
Thread.sleep(200);
// 2. 論理有効期限をカプセル化
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. Redis に書き込む
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

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

/**
* 論理有効期限
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Redis からショップキャッシュをクエリ
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 存在するかどうかを判定
if (StrUtil.isBlank(shopJson)) {
// 3. キャッシュミス、直接返す
return null;
}
// 4. ヒット、まず JSON をオブジェクトに逆シリアル化
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 有効期限切れかどうかを判定
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 期限切れでない場合、ショップ情報を直接返す
return shop;
}
// 5.2 期限切れ、再構築が必要
// 6. キャッシュ再構築
// 6.1 ミューテックスを取得
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 ロック取得成功かどうかを判定
if (isLock) {
// 6.3 成功、独立スレッドを起動してキャッシュ再構築を実行
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// キャッシュを再構築
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// ロックを解放
unlock(lockKey);
}
});
}
// 6.4 期限切れのショップ情報を返す
return shop;
}

関連記事

特別オファーをもっと見る

  1. Short Message Service (SMS) とメールサービス

    5 万通のメールパックが USD 1.99 から、120 通の SMS が USD 1.00 から

phone お問い合わせ
Hi, I'm Alibaba Cloud AI Assistant!
I can help with questions and solutions.