キャッシュペネトレーション
キャッシュペネトレーションは、存在しないデータを照会する状況を指します。キャッシュはヒットしない場合に書き込まれるため、またエラー処理の観点からストレージ層からデータが見つからない場合にはキャッシュに書き込まないため、このような存在しないデータのリクエストは毎回ストレージ層に到達してしまいます。これによりキャッシュの意味が失われます。トラフィックが多い場合、データベースが停止する可能性があります。存在しないキーを頻繁に利用してアプリケーションを攻撃する場合は、この脆弱性を悪用することができます。例えば、「-1」のIDや非常に大きく存在しないIDのデータを要求する場合などです。このようなユーザーは攻撃者である可能性が高く、攻撃はデータベースに過大な負荷をかけます。
解決策:
- ブルームフィルター
すべての存在しうるデータを十分に大きなビットマップにハッシュ化し、存在しないデータはこのビットマップによってブロックされ、ストレージシステムへのクエリ圧力を回避します。 - 空の結果をキャッシュ
シンプルで直接的な方法(私たちが採用しているもの)。クエリが返すデータが空の場合(データが存在しない場合でも、システム障害の場合でも)、依然としてこの空の結果をキャッシュしますが、その有効期間は非常に短く、最長でも5分を超えません。
キャッシュ雪崩
キャッシュ雪崩は、キャッシュ設定時に同じ有効期限が使用され、ある時点でキャッシュが同時に失効する状況を指します。これにより、すべてのリクエストがデータベースに転送され、データベースが瞬時に過大な負荷を受け、雪崩が発生します。
解決策:
- ロックまたはキューによるシングルスレッド(プロセス)書き込みの保証
これにより、失効時に大量の並行リクエストがストレージシステムに落ちるのを回避します。 - キャッシュ失効時間の分散
既存の失効時間にランダムな値(例えば1-5分のランダム)を追加することで、各キャッシュの有効期限の重複率を低下させ、集団的な失効イベントの発生を防ぎます。
キャッシュブレークダウン
一部の有効期限が設定されたキーは、特定の時点で超高同時にアクセスされる可能性があり、非常に「ホット」なデータです。この場合、キャッシュが「ブレークダウン」する問題を考慮する必要があります。これはキャッシュ雪崩との違いは、ここでは特定のキーに焦点を当てており、前者は多くのキーに対するものです。
キャッシュが特定の時点で期限切れになると、その時点でそのキーに大量の並行リクエストが到着します。これらのリクエストはキャッシュが期限切れであると検知すると、通常はバックエンドデータベースからデータをロードしキャッシュに設定し直します。この時点で大量の並行リクエストが瞬間的にバックエンドデータベースを圧倒する可能性があります。
解決策:
- 相互排他ロック(mutex key)の使用
この解決策の考え方は比較的シンプルで、1つのスレッドのみがキャッシュを構築し、他のスレッドはキャッシュ構築スレッドの実行を待ち、完了後にキャッシュからデータを再取得するだけです。
単一マシンの場合、synchronizedまたはlockを使用して処理できます。分散環境の場合は分散ロックを使用できます(分散ロックには、memcacheのadd、redisのsetnx、zookeeperのノード追加操作などを使用できます)。
以下はRedisを使用した実装例です:
public String getData(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(keyMutex, "1")) {
// クラッシュを避けるため3分のタイムアウト
redis.expire(keyMutex, 3 * 60);
value = database.getData(key);
redis.set(key, value);
redis.delete(keyMutex);
} else {
// 他のスレッドは50ミリ秒待って再試行
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getData(key);
}
}
return value;
}
value内部にタイムアウト値(timeout1)を設定し、timeout1は実際のmemcacheタイムアウト(timeout2)より小さくします。キャッシュから読み取ったtimeout1が期限切れであると検知した場合、すぐにtimeout1を延長し、キャッシュに再設定します。その後、データベースからデータをロードしキャッシュに設定します。
ここでの「永遠に期限切れにしない」は2つの意味を含みます:
(1) Redisから見ると、実際に有効期限が設定されていないため、ホットキーの期限切れ問題が発生せず、「物理的」に期限切れになりません。
(2) 機能的には、期限切れにしないと静的になってしまいます。したがって、有効期限をキーに対応するvalueに保存し、期限切れになると検知した場合は、バックグラウンドの非同期スレッドを使用してキャッシュを構築します。これが「論理的」な期限切れです。
public String getData(final String key) {
CacheValue v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
if (timeout <= System.currentTimeMillis()) {
// 非同期でバックグラウンドで更新
executorService.execute(() -> {
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1")) {
// クラッシュを避けるため3分のタイムアウト
redis.expire(lockKey, 3 * 60);
String dbValue = database.getData(key);
redis.set(key, dbValue);
redis.delete(lockKey);
}
});
}
return value;
}
NetflixのHystrixを使用してリソースを隔離し、メインスレッドプールを保護することができます。これをキャッシュ構築に応用することも可能です。
4つのソリューションの比較:
| ソリューション | 利点 | 欠点 |
|---|---|---|
| 単純な分散ロック | 1. 思考がシンプル 2. 一貫性を保証 |
1. コードの複雑さが増加 2. デッドロックのリスクがある 3. スレッドプールブロックのリスクがある |
| 追加の有効期間 | 1. 一貫性を保証 | 上記と同様 |
| 期限切れにしない | 1. 非同期でキャッシュを構築し、スレッドプールをブロックしない | 1. 一貫性を保証しない 2. コードの複雑さが増加(各valueにtimekeyを維持する必要がある) 3. 一定のメモリスペースを占有(各valueにtimekeyを維持する必要がある) |
| リソース隔離コンポーネントHystrix | 1. Hystrixは技術が成熟しており、バックエンドを効果的に保証する 2. Hystrixの監視は強力 |
1. 一部のアクセスにはデグレード戦略が存在する |
まとめ
ビジネスシステムに対しては、常に具体的な状況に応じて分析を行い、最適なものを選択する必要があります。最適というものは存在せず、最適というものだけが存在します。
最後に、キャッシュシステムで一般的に発生するキャッシュ満杯とデータ消失の問題については、具体的なビジネスに基づいて分析する必要があります。通常、オーバーフローを処理するためにLRU戦略を採用し、RedisのRDBとAOF永続化戦略を使用して特定の状況でのデータセキュリティを保証します。