Webレイヤーキャッシュはアプリケーションのパフォーマンスを向上させるために不可欠であり、繰り返しのデータ処理やデータベースクエリを減らすことで応答時間を短縮します。たとえば、ユーザーが要求するデータがキャッシュに存在している場合、サーバーはデータベースにアクセスすることなくキャッシュから結果を直接返却できます。これにより、アプリケーションの応答速度が向上し、バックエンドシステムの負荷も軽減されます。
Redisはメモリ上にデータ構造を保存する人気のあるシステムで、効率的なキャッシュ層を実現するためによく使用されます。文字列、ハッシュ、リスト、セットなどのさまざまなデータ構造をサポートしており、高速なデータ読み書きが可能です。一般的なデータをRedisにキャッシュすることで、データベースの負担を大幅に減らすことができ、ユーザー体験も向上します。
キャッシュの問題について詳しく
この章では、Redisの基本的なキャッシュメカニズムではなく、Redisの失敗がもたらす不必要な損失を防ぐ方法に焦点を当てます。キャッシュの空き(キャッシュパネット)、キャッシュの破壊(キャッシュブレイク)、キャッシュの雪崩(キャッシュスラム)といった問題の原因と解決策について詳細に説明します。
キャッシュパネット
キャッシュパネットとは、存在しないデータを検索した際にキャッシュ層とストレージ層の両方でヒットしなくなることを指します。これは通常、容錯性の観点から、ストレージ層でデータが見つからない場合、システムがキャッシュ層に記録しないためです。その結果、存在しないデータのすべてのリクエストに対してストレージ層に直接アクセスする必要があり、キャッシュがバックエンドストレージを保護する本来の意味を失います。これはストレージ層の負荷を増加させ、システム全体のパフォーマンスを低下させます。
キャッシュパネットの主な原因は次の2つです:
- 自身のビジネスコードまたはデータの問題:このような問題は通常、ビジネスロジックの欠陥やデータの不一致によるものです。例えば、ビジネスコードが特定のデータ検索を適切に処理していなかったり、データソース自体に欠陥がある(データの喪失、データの誤りなど)場合、検索要求がキャッシュやストレージ層でデータを見つけることはできません。このような状況では、キャッシュ層が検索結果を適切に保存・返却できず、すべてのリクエストがストレージ層に直接アクセスする必要があります。
- 悪意のある攻撃やクローラー行為:悪意のある攻撃者や自動化されたクローラーは、大量の存在しないデータを検索するリクエストを送信することがあります。これらのリクエストがキャッシュとストレージ層を次々に攻撃することで、大量の空ヒット(検索結果が常に空)が発生し、システムリソースを多く消費し、キャッシュ層とストレージ層の負荷を著しく増加させ、システム全体のパフォーマンスと安定性に影響を与えます。
解決策——空オブジェクトのキャッシュ
キャッシュパネットを解決する効果的な方法の一つは、キャッシュ層に「空」のマーカーオブジェクトをキャッシュすることです。この方法では、特定のデータが存在しないことを示すマーカーまたはオブジェクトをキャッシュ層に保存します。これにより、後続の同じデータを検索するリクエストはキャッシュ層から「空オブジェクト」を取得し、ストレージ層に再びアクセスする必要がなくなるため、ストレージ層への頻繁なアクセスが減少し、システム全体のパフォーマンスと応答速度が向上します。
String get(String key) {
// キャッシュからデータを取得
String cacheValue = cache.get(key);
// キャッシュがヒット
if (cacheValue != null) {
return cacheValue;
}
// キャッシュがヒットせず、ストレージからデータを取得
String storageValue = storage.get(key);
// ストレージにデータが空の場合、キャッシュに設定し、有効期限を設定
if (storageValue == null) {
cache.set(key, ""); // 空オブジェクトマーカーを格納
cache.expire(key, 60 * 5); // 有効期限を設定(300秒)
} else {
// ストレージにデータが存在する場合、キャッシュに格納
cache.set(key, storageValue);
}
return storageValue;
}
解決策——ブームフィルタ
悪意のある攻撃によって大量の存在しないデータを検索するリクエストが発生する場合、ブームフィルタを使用して初期フィルタリングを行うことができます。ブームフィルタは空間効率が高く、確率的なデータ構造であり、ある要素が集合に含まれているかどうかを効率的に判断できます。具体的には、ブームフィルタが特定の値が存在する可能性があると判断した場合、実際にその値が存在する可能性もありますが、それ以外の場合は、ブームフィルタの誤検出が起こる可能性があります。一方、ブームフィルタが特定の値が存在しないと判断した場合は、その値が実際に存在しないことが保証されます。
ブームフィルタは、大きなビット配列と複数の独立した無偏位ハッシュ関数で構成される確率的なデータ構造です。無偏位ハッシュ関数は、入力要素のハッシュ値をビット配列内で均等に分布させ、ハッシュ衝突を最小限に抑える特徴を持っています。キーをブームフィルタに追加する際、これらのハッシュ関数を使ってキーをハッシュ演算し、それぞれのハッシュ関数は整数のインデックス値を生成します。これらのインデックス値はビット配列の長さで割った余りを計算し、ビット配列内の特定の位置を決定します。その後、これらの位置の値を1に設定し、キーの存在をマークします。
ブームフィルタで特定のキーが存在するかを検索する際には、キーをハッシュ関数でハッシュ演算し、複数の位置インデックスを取得します。その後、これらのインデックスに対応するビット配列の位置をチェックします。すべての関連する位置の値が1であれば、キーが存在する可能性があると推測できますが、いずれかの位置の値が0であれば、キーが存在しないことが確定します。ただし、すべての関連する位置の値が1であっても、そのキーが「存在する」ということを絶対に確認することはできないため、他のキーがこれらの位置を1に設定している可能性があります。ビット配列のサイズやハッシュ関数の数を調整することで、ブームフィルタの性能を最適化し、精度と効率のバランスを取ることができます。
この方法は、データのヒット率が低く、データセットが比較的固定され、リアルタイム性がそれほど要求されていないアプリケーションにおいて特に効果的です。特にデータセットが大きい場合、ブームフィルタはキャッシュスペースの使用量を顕著に削減します。ただし、ブームフィルタの実装はコードの保守性を複雑にする可能性がありますが、メモリ効率と検索速度の恩恵は通常それを補う価値があります。
ブームフィルタは、大規模なデータセットを扱いながら少ないメモリ空間で動作できるため、このシナリオでの効果を高めています。ブームフィルタを実装するには、RedissonというJavaクライアントを使用することができます。プロジェクトにRedissonを導入するには、以下の依存関係を追加します:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.2</version> <!-- 必要に応じて適切なバージョンを選択してください -->
</dependency>
例としての疑似コード:
package com.redisson;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonBloomFilter {
public static void main(String[] args) {
// Redissonクライアントを構成し、Redisサーバーに接続
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
// Redissonクライアントを作成
RedissonClient redisson = Redisson.create(config);
// ブームフィルタのインスタンスを取得、名前は "nameList"
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
// ブームフィルタを初期化し、予想される要素数と誤差率を設定
bloomFilter.tryInit(100_000_000L, 0.03);
// 要素 "zhuge" をブームフィルタに挿入
bloomFilter.add("xiaoyu");
// ブームフィルタを検索し、要素が存在するかを確認
System.out.println("Contains 'huahua': " + bloomFilter.contains("huahua")); // falseであるべき
System.out.println("Contains 'lin': " + bloomFilter.contains("lin")); // falseであるべき
System.out.println("Contains 'xiaoyu': " + bloomFilter.contains("xiaoyu")); // trueであるべき
// Redissonクライアントを終了
redisson.shutdown();
}
}
ブームフィルタを使用する際には、あらかじめすべての予期されるデータ要素をブームフィルタに挿入しておく必要があります。これにより、ビット配列構造とハッシュ関数を通じて要素の存在性を効率的に検出できます。データの挿入時には、リアルタイムでブームフィルタを更新し、そのデータの正確性を保証する必要があります。
以下は、ブームフィルタキャッシュフィルタリングの疑似コード例です。これは初期化およびデータ追加のプロセスでの操作を示しています。
// ブームフィルタを初期化
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
// ブームフィルタの予定要素数と誤差率を設定
bloomFilter.tryInit(100_000_000L, 0.03);
// すべてのデータをブームフィルタに挿入
void init(List<String> keys) {
for (String key : keys) {
bloomFilter.add(key);
}
}
// キャッシュからデータを取得
String get(String key) {
// ブームフィルタにkeyが存在するかを確認
if (!bloomFilter.contains(key)) {
return ""; // ブームフィルタに存在しない場合、空文字列を返す
}
// キャッシュからデータを取得
String cacheValue = cache.get(key);
// キャッシュ値が空の場合、ストレージから取得
if (StringUtils.isBlank(cacheValue)) {
String storageValue = storage.get(key);
if (storageValue != null) {
cache.set(key, storageValue); // 非空データをキャッシュに格納
} else {
cache.expire(key, 300); // 有効時間を300秒に設定
}
return storageValue;
} else {
// キャッシュ値が非空、直接返す
return cacheValue;
}
}
注意:ブームフィルタはデータを削除できません。削除が必要な場合は、データを再初期化する必要があります。
キャッシュの失敗(ブレイク)
同一時間に多くのキャッシュが失効すると、多くのリクエストがキャッシュを通過し、データベースに直接アクセスする可能性があります。この場合、データベースが一時的に大きな負荷を受けることになり、場合によってはデータベースがダウンする可能性があります。
解決策——ランダムな有効期間
この問題を緩和するためには、キャッシュを一括で追加する際に、各キャッシュ項目の有効期間を時間帯内の異なる時間に設定する戦略を採用できます。具体的には、各キャッシュ項目に異なる有効期間を設定し、すべてのキャッシュ項目が同時に失効しないようにすることで、データベースへの瞬時のリクエストの圧力を減らすことができます。
以下の具体的な疑似コード例をご覧ください:
String get(String key) {
// キャッシュからデータを取得
String cacheValue = cache.get(key);
// キャッシュが空の場合
if (StringUtils.isBlank(cacheValue)) {
// ストレージからデータを取得
String storageValue = storage.get(key);
// ストレージにデータが存在する場合
if (storageValue != null) {
cache.set(key, storageValue);
// 有効期間を設定(300〜600秒のランダム値)
int expireTime = 300 + new Random().nextInt(301); // ランダム範囲: 300〜600
cache.expire(key, expireTime);
} else {
// ストレージにデータがない場合、キャッシュのデフォルト有効期間を設定(300秒)
cache.expire(key, 300);
}
return storageValue;
} else {
// キャッシュからデータを返す
return cacheValue;
}
}
キャッシュスラム
キャッシュスラムとは、キャッシュ層に障害が発生したり、負荷が高くなった場合、大量のリクエストが後端のストレージ層に直接受け渡されることがあり、これによりストレージ層が過負荷になるまたはダウンする現象を指します。通常、キャッシュ層はリクエスト流量を効率的に処理し、バックエンドストレージ層を保護する役割を果たします。
しかし、キャッシュ層がいくつかの理由でサービスを提供できなくなった場合、例えば、大きな并发の衝撃やキャッシュ設計が不適切(例えば、大きなキャッシュ項目 bigkey がキャッシュ性能を急激に低下させる)など、多くのリクエストがストレージ層に送信されます。このとき、ストレージ層のリクエスト量が急激に増加し、ストレージ層も過負荷やダウンになる可能性があります。この現象は「キャッシュスラム」と呼ばれます。
解決策
キャッシュスラムを効果的に防止および解決するためには、以下の3つの面から取り組む必要があります。
- キャッシュ層サービスの高可用性を確保する:
キャッシュ層の高可用性はキャッシュスラムを回避するための重要な手段です。Redis SentinelやRedis Clusterなどのツールを活用してキャッシュの高可用性を実現できます。Redis Sentinelは自動的なフェールオーバーと監視機能を提供し、メインノードに問題が発生したときに自動的にセカンダリノードを新しいメインノードに昇格させ、サービスの継続性を維持します。Redis Clusterはデータシャーディングとノード間のコピーを通じて、システムの可用性と拡張性をさらに高めます。こうすることで、一部のノードが障害を起こしても、システムは正常に動作し、リクエストを処理し続けることができます。 - 制限、断線、降格の依存コンポーネントを利用する:
限流や断線メカニズムを利用して、バックエンドサービスが突然のリクエストの衝撃を受けることを防ぎ、キャッシュスラムによる圧力を効果的に緩和できます。例えば、SentinelやHystrixなどの限流と断線コンポーネントを用いて、トラフィック制御とサービス降格を実施します。異なる種類のデータに対しては、異なる処理戦略を採用することができます:- 非コアデータ:例えば、ECサイトの商品属性やユーザ情報。キャッシュ中のこれらのデータが失われても、アプリケーションは予め定義されたデフォルトの降格情報を、空値やエラー表示として返すだけで、直接バックエンドストレージを参照する必要はありません。この方法により、バックエンドストレージの負荷を減らすことができ、ユーザーに一定のフィードバックを提供できます。
- コアデータ:例えば、ECサイトの商品在庫。このような重要なデータについては、キャッシュから検索を試みるだけでなく、キャッシュが欠如していた場合でも、データベースから読み取る必要があります。これにより、キャッシュスラムが発生した場合でも、コアデータの読み取りが保証され、システム機能の喪失を防ぐことができます。
- 事前テストと予案の策定:
プロジェクトのリリース前に、十分なテストとシミュレーションを行い、キャッシュ層のダウン後のアプリケーションとバックエンドの負荷状況を模擬し、潜在的な問題を特定し、対応する予案を策定します。これはキャッシュの失敗やバックエンドサービスの過負荷などの状況をシミュレートし、システムの振る舞いを観察し、テスト結果に基づいてシステム構成や戦略を調整するものです。これらのテストを通じて、システムの弱点を発見し、実際の製品環境での突発的な状況に対する緊急対応策を策定することができます。これにより、システムの頑健性を向上させ、キャッシュスラムが発生した際にシステムが迅速に復旧できるようにすることができます。これは、システムの安定性とパフォーマンスを向上させるだけでなく、高同時アクセス環境下でのシステムの効率的かつ安定した運用を保証するための重要なステップです。
これらの措置を総合的に活用することで、キャッシュスラムによるリスクを顕著に低下させ、システムの安定性とパフォーマンスを向上させることができます。
まとめ
Webレイヤーキャッシュは、繰り返しのデータ処理やデータベースクエリを減らすことで、アプリケーションのパフォーマンスを大きく向上させます。Redisは効率的なメモリデータ構造ストレージシステムとして、キャッシュ層の実現において重要な役割を果たします。文字列、ハッシュ、リスト、セットなどのさまざまなデータ構造をサポートし、高速なデータ読み書きが可能で、データベースの負担を減らし、ユーザー体験を向上させます。
しかし、キャッシュメカニズムには課題もあり、キャッシュパネット、キャッシュブレイク、キャッシュスラムなどの問題があります。キャッシュパネットは空オブジェクトのキャッシュやブームフィルタで解決され、前者は毎回データベースにアクセスすることを避け、後者は悪意のあるリクエストの影響を効果的に減らします。キャッシュブレイクはランダムな有効期間を設定することで緩和され、これにより大量のリクエストがデータベースに集中するのを防ぎます。キャッシュスラムに関しては、キャッシュ層の高可用性を確保し、限流と断線メカニズムを採用し、十分な予案を策定することが重要です。
効果的なキャッシュ管理はシステムのパフォーマンスを向上させ、システムの安定性を強化します。これらのキャッシュ問題を理解し、解決することで、高同時アクセス環境下でもシステムが効率的で安定した動作を維持できます。キャッシュ戦略を丁寧に設計・実施することは、アプリケーションパフォーマンスの最適化の基礎であり、これらの戦略を継続的に注視・調整することで、システムがさまざまな課題に対応し、良好なユーザー体験を維持できるようになります。
私は努力する小雨で、Javaサービス側のプログラマーです。AI技術の奥深さを研究しています。私は技術交流と共有を熱心に好み、オープンソースコミュニティに情熱を注いでいます。また、掘金優秀な執筆者、騰訊クラウドコンテンツコラボレーター、アリババクラウドエキスパートブロガー、华为云雲享エキスパートです。
💡 私は技術の道での個人的な探求と経験を惜しみなく共有し、あなたの学習と成長に少しでも啓発と助けになることを願っています。
🌟 努力する小雨をフォローしてください!🌟