Redis の Hot Key と Big Key: 検出と処理
1. 概要
Redis はコマンド処理をメインスレッド 1 つで行います。そのため、1 つの巨大キー(big key)や 1 つのアクセス頻度が高いキー(hot key)だけでも、インスタンス全体が遅くなります。この記事では、Redis に備わったツールと Java コードを使ってこの 2 つのキーを検出する方法を学びます。その後、Spring Boot 3.x アプリケーション内で、キーシャーディング、安全な削除、ローカルキャッシング、キーレプリケーションといった技術を使って対応します。
2. Hot key と big key とは
Big key は大量のメモリを占有する、または要素が非常に多いキーです。標準的なしきい値はありませんが、多くのチームは以下の指標を使用しています。
- String が約 10 KB 以上。
- Hash、List、Set、ZSet が約 5,000 個以上の要素を持つ、またはサイズが数 MB を超える。
Hot key は他のキーと比べて異常に高い割合のリクエストを受けるキーです。例えば、フラッシュセール情報、ホームページ、または共有設定を含むキーです。
これら 2 つのキーは以下の問題を引き起こします。
- メインスレッドをブロック:100 万フィールドの hash に対する
HGETALL、または巨大な ZSet のDELは、Redis を数百ミリ秒ブロックします。他のすべてのクライアントは待たなければなりません。 - ネットワーク帯域幅の瓶頸:2 MB の値を秒 5,000 リクエストで読むのは、約 10 GB/秒に相当します。
- Redis Cluster での負荷不均衡:1 つのキーは常に 1 つのスロットに存在し、つまり 1 つのノードに留まります。hot key でも big key でも、クラスタにいくつノードがあっても、そのノードに負荷が集中します。
- レプリケーションとフェイルオーバーの遅延:big key は同期と RDB ロードに時間がかかります。
3. Big key の検出
3.1. redis-cli --bigkeys と --memkeys を使用する
最速の方法は redis-cli の組み込みオプションを使うことです。
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01
このコマンドは SCAN を使ってキースペース全体を走査するため、KEYS * のようなサーバーのブロックは起こりません。-i 0.01 パラメータは 100 回の SCAN コマンドごとに 10 ms スリープしており、本番サーバーの負荷軽減に役立ちます。結果は以下のようになります。
[00.00%] Biggest hash found so far '"cart:10023"' with 48211 fields
[12.35%] Biggest string found so far '"product:detail:88"' with 1048576 bytes
[45.10%] Biggest zset found so far '"leaderboard:global"' with 920144 members
-------- summary -------
Biggest hash found '"cart:10023"' has 48211 fields
Biggest string found '"product:detail:88"' has 1048576 bytes
Biggest zset found '"leaderboard:global"' has 920144 members
--bigkeys は要素数で測定します。各データ型の最大キーのみを報告します。実際のメモリに関心がある場合は、--memkeys を使用してください。
redis-cli --memkeys --memkeys-samples 0
--memkeys-samples 0 は Redis に全要素を測定するよう要求します(サンプリングではなく)。より正確ですが遅くなります。
3.2. Java で MEMORY USAGE を使ってスキャンする
redis-cli はそれぞれのデータ型の最大キーのみを返します。しきい値を超えるすべてのキーを一覧表示するには、自分でスキャンできます。まず依存関係を追加してください。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
次に、キースペースをスキャンするコンポーネントを作成します。
@Component
public class BigKeyScanner {
private final StringRedisTemplate redis;
public BigKeyScanner(StringRedisTemplate redis) {
this.redis = redis;
}
public Map<String, Long> findKeysLargerThan(long thresholdBytes) {
Map<String, Long> result = new LinkedHashMap<>();
ScanOptions options = ScanOptions.scanOptions().match("*").count(500).build();
try (Cursor<String> cursor = redis.scan(options)) {
while (cursor.hasNext()) {
String key = cursor.next();
Long bytes = redis.execute((RedisCallback<Long>) conn ->
(Long) conn.execute("MEMORY",
"USAGE".getBytes(), key.getBytes(), "SAMPLES".getBytes(), "0".getBytes()));
if (bytes != null && bytes > thresholdBytes) {
result.put(key, bytes);
}
}
}
return result;
}
}
コード内の重要ポイント:
redis.scan(options)はキースペースを 500 キーのバッチで走査します。メインスレッドが長時間ロックされません。Cursorを閉じる必要があるため、try-with-resources を使用します。MEMORY USAGE key SAMPLES 0はキーが占有するバイト数(オーバーヘッド含む)を返します。Spring Data Redis にはこのコマンド用の専用 API がないため、conn.execute経由で呼び出します。
findKeysLargerThan(1_000_000) を呼び出した場合の例:
{cart:10023=3145812, product:detail:88=1048648, leaderboard:global=89231560}
注意: スキャナーはレプリカではなくマスターで実行してください。トラフィックの低い時間帯に実行してください。別の方法として、RDB 読み込みツールを使ってオフライン RDB ファイルを解析する方法もあります。この方法は稼働中のサーバーに全く影響を与えません。
4. Hot key の検出
4.1. redis-cli --hotkeys を使用する
Redis は LFU タイプのエビクションポリシーを有効にした場合のみ、アクセス頻度を追跡します。
# redis.conf
maxmemory-policy allkeys-lfu
LFU を有効にした後、以下を実行します。
redis-cli --hotkeys
[00.00%] Hot key '"config:homepage"' found so far with counter 255
[37.21%] Hot key '"product:stock:5001"' found so far with counter 201
-------- summary -------
hot key found with counter: 255 keyname: "config:homepage"
hot key found with counter: 201 keyname: "product:stock:5001"
LFU カウンタは対数値で、最大 255 です。実際のリクエスト数ではありません。キー間の相対的な比較用としてのみ使用すべきです。
特定のキーを疑う場合は、直接確認します。
redis-cli OBJECT FREQ config:homepage
(integer) 255
注意: hot key を見つけるため、本番環境で
MONITORを使用しないでください。MONITORはすべてのコマンドをクライアントに送信するため、スループットが半分以上低下する可能性があります。
4.2. アプリケーション側でアクセスをカウントする
maxmemory-policy を変更できない場合、アプリケーション内で直接カウントできます。以下のコードは、時間ウィンドウ内で各キーの読み取りアクセス回数をカウントします。
@Component
public class HotKeyDetector {
private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
public void record(String key) {
counters.computeIfAbsent(key, k -> new LongAdder()).increment();
}
public List<Map.Entry<String, Long>> topAndReset(int n) {
Map<String, Long> snapshot = new HashMap<>();
counters.forEach((k, v) -> snapshot.put(k, v.sumThenReset()));
counters.clear();
return snapshot.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(n)
.toList();
}
}
Redis を読むたびに record(key) を呼び出します。その後、10 秒ごとに topAndReset(10) を呼び出す @Scheduled ジョブを使い、結果をログまたはメトリクスに出力します。LongAdder は複数スレッドが同じカウンタをインクリメントする場合の競合を減らします。したがって、このシナリオでは AtomicLong より適切です。
5. Big key の処理
5.1. キーシャーディング(分割)
すべての商品のビュー数を product:views という 1 つの hash に格納するとします。この hash は数百万フィールドまで膨らみます。対策は複数の小さい hash に分割することです。
public final class ShardRouter {
private ShardRouter() {}
public static String shardKey(String baseKey, String field, int shards) {
int shard = Math.floorMod(field.hashCode(), shards);
return baseKey + ":" + shard;
}
}
@Service
public class ProductViewService {
private static final int SHARDS = 64;
private final StringRedisTemplate redis;
public ProductViewService(StringRedisTemplate redis) {
this.redis = redis;
}
public long increment(String productId) {
String key = ShardRouter.shardKey("product:views", productId, SHARDS);
return redis.opsForHash().increment(key, productId, 1);
}
public long get(String productId) {
String key = ShardRouter.shardKey("product:views", productId, SHARDS);
Object value = redis.opsForHash().get(key, productId);
return value == null ? 0 : Long.parseLong(value.toString());
}
}
動作方法:
Math.floorModはhashCode()が負の場合でも常に非負の数を返します。多くの人は%を使用して負のインデックスエラーが発生してしまいます。- 商品
"P-1001"は常に同じシャード(例:product:views:17)に属します。これにより、読み取りと書き込みが常に一貫しています。 - 各 hash は現在、フィールド数が約 1/64 になります。Redis Cluster では、これら 64 キーは異なるスロットに属するため、負荷は複数ノードに分散されます。
注意: 負荷を分散させるのが目的の場合、
product:views:{all}:17のようなハッシュタグを追加しないでください。ハッシュタグはすべてのシャードを同じスロットに強制します。個々のキーサイズは縮小しますが、負荷は 1 つのノードに集中したままです。
1 MB の商品詳細 JSON 文字列のような big key の場合、変更頻度の低い部分と変更頻度の高い部分を別キーに分割します。値を圧縮することもできますし、実際に必要なフィールドのみをキャッシュすることもできます。
5.2. Big key の安全な削除
DEL はメモリを同期的に解放します。100 万要素の ZSet を削除すると、Redis が数百ミリ秒ブロックされる可能性があります。代わりに UNLINK を使用します。
public void removeLeaderboard() {
redis.unlink("leaderboard:global");
}
UNLINK はキーをキースペースから即座に削除します。メモリ解放はバックグラウンドスレッドに移されます。Redis の暗黙的な削除操作もメインスレッドをブロックしないよう、設定で lazy free も有効にしましょう。
# redis.conf (Redis 6+)
lazyfree-lazy-user-del yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes
lazyfree-lazy-user-del yes により、DEL コマンドも UNLINK のように動作します。この設定は、まだ delete() を呼び出している古いコードから保護します。
古い Redis で実行する必要がある場合、または負荷をより厳密に制御したい場合は、段階的なバッチで削除します。
public void deleteHashGradually(String key) {
ScanOptions options = ScanOptions.scanOptions().count(500).build();
try (Cursor<Map.Entry<Object, Object>> cursor = redis.opsForHash().scan(key, options)) {
List<Object> batch = new ArrayList<>(500);
while (cursor.hasNext()) {
batch.add(cursor.next().getKey());
if (batch.size() == 500) {
redis.opsForHash().delete(key, batch.toArray());
batch.clear();
}
}
if (!batch.isEmpty()) {
redis.opsForHash().delete(key, batch.toArray());
}
}
redis.unlink(key);
}
各 HDEL は 500 フィールドのみを削除するため、各コマンドのブロック時間は非常に短くなります。Redis は HSCAN 実行中の hash 変更を許可しています。削除されていないフィールドは依然として返されます。最後の unlink コマンドは残りのフィールドをクリーンアップします。
6. Hot key の処理
6.1. Redis の前にローカルキャッシュを配置する
最も効果的な方法は、ほとんどのリクエストが Redis に到達しないようにすることです。Caffeine を追加してください。
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
その後、Redis の前に短期ローカルキャッシュを配置します。
@Service
public class HomepageConfigService {
private final StringRedisTemplate redis;
private final LoadingCache<String, String> localCache;
public HomepageConfigService(StringRedisTemplate redis) {
this.redis = redis;
this.localCache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofSeconds(2))
.build(key -> redis.opsForValue().get(key));
}
public String getConfig() {
return localCache.get("config:homepage");
}
}
アプリケーションインスタンスが 20 個あると仮定します。各インスタンスはこのキーに対して約 2 秒ごとに最大 1 回 Redis を呼び出すため、Redis は数万ではなく約 10 リクエスト/秒を受け取ります。LoadingCache は同じキーの同時ロード試行も統合します。これにより、キャッシュが期限切れになったときに何百ものスレッドが Redis に殺到することはありません。
代償として、データは最大 2 秒古い可能性があります。ホームページ設定または商品カテゴリの場合、このレイテンシは通常許容されます。
6.2. キーレプリケーション
データをより新しく保つ必要がある場合、または 1 つのノードが過負荷になっている Redis Cluster で実行している場合、値を複数のレプリカに書き込んで、1 つからランダムに読み取ることができます。
@Service
public class FlashSaleService {
private static final int REPLICAS = 8;
private final StringRedisTemplate redis;
public FlashSaleService(StringRedisTemplate redis) {
this.redis = redis;
}
public void publish(String saleId, String json) {
for (int i = 0; i < REPLICAS; i++) {
redis.opsForValue().set("flashsale:" + saleId + ":r" + i, json, Duration.ofMinutes(10));
}
}
public String read(String saleId) {
int i = ThreadLocalRandom.current().nextInt(REPLICAS);
return redis.opsForValue().get("flashsale:" + saleId + ":r" + i);
}
}
8 つのキー flashsale:42:r0 から flashsale:42:r7 は通常、異なるスロットに属しています。これにより、読み取り負荷が複数ノードに分散されます。このアプローチは読み取り多重、書き込み少なめのデータに適しています。
注意: 複数レプリカへの書き込みはアトミックではありません。短い間、レプリカは異なる可能性があります。口座残高など絶対的一貫性を必要とするデータには使用しないでください。ホットな在庫カウンタの場合、在庫を複数の独立した「バケット」に分割することを検討してください。各バケットは同じ値のレプリカを含むのではなく、数量の一部を保持します。
これら 2 つの方法の他に、レプリカから読み取ることもできます。Lettuce の場合、ReadFrom.REPLICA_PREFERRED を設定して、master–replica セットアップで読み取り負荷を分散させます。
7. キーシャーディングロジックのテスト
キー定向ロジックは最も間違いやすい部分なので、そのテストを作成しましょう。
class ShardRouterTest {
@Test
void sameFieldAlwaysMapsToSameShard() {
String first = ShardRouter.shardKey("product:views", "P-1001", 64);
String second = ShardRouter.shardKey("product:views", "P-1001", 64);
assertThat(first).isEqualTo(second).startsWith("product:views:");
}
@Test
void shardIndexIsNeverNegative() {
// "polygenelubricants" の hashCode() == Integer.MIN_VALUE
String key = ShardRouter.shardKey("product:views", "polygenelubricants", 64);
int index = Integer.parseInt(key.substring(key.lastIndexOf(':') + 1));
assertThat(index).isBetween(0, 63);
}
@Test
void fieldsAreSpreadAcrossShards() {
Map<String, Long> distribution = IntStream.range(0, 100_000)
.mapToObj(i -> ShardRouter.shardKey("product:views", "P-" + i, 64))
.collect(Collectors.groupingBy(k -> k, Collectors.counting()));
assertThat(distribution).hasSize(64);
assertThat(distribution.values()).allSatisfy(count ->
assertThat(count).isBetween(1_000L, 2_200L));
}
}
2 番目のテストは Integer.MIN_VALUE を使った境界ケースをチェックします。Math.abs(hash) % n を使用すると、Math.abs(Integer.MIN_VALUE) が依然として負の数であるため、このテストは失敗します。3 番目のテストは、100,000 フィールドが相対的に均等に分散していることを検証します。平均してシャード当たり約 1,560 フィールドです。
8. よくあるミスと不適切な使用
- big key を見つけるために
KEYS *を実行する:このコマンドはスキャンが終わるまで Redis をブロックします。常にSCANを使用してください。 - キーを分割したが、すべてを読む必要がある:業務ロジックが頻繁に全フィールドに対する
HGETALLが必要な場合、分割は 1 つの遅いコマンドを 64 個のコマンドに変えるだけです。業務ロジックを先に見直してください。ページネーション追加、または結果の事前計算などを検討してください。 - 実行中にシャード数を変更する:
SHARDSを 64 から 128 に変更すると、大多数のフィールドが移動します。マイグレーション計画が必要か、最初から十分に大きなシャード数を選択してください。 - 急速に変わるデータをローカルキャッシュする:在庫、現在の入札価格…など数秒の TTL には適していません。どうしても使用したい場合は、Pub/Sub または Redis 6+ のクライアント側キャッシング(
CLIENT TRACKING)と組み合わせて、キャッシュを無効化してください。 - 早すぎる最適化:2,000 フィールドの hash はまだ問題ではありません。決める前に
--bigkeys、--hotkeys、slowlog(SLOWLOG GET)を使って測定してください。
9. 結論
この記事では、hot key と big key が何故 Redis(単一スレッドでコマンド処理)にとって危険であるかを学びました。検出するには redis-cli --bigkeys、--memkeys、--hotkeys、MEMORY USAGE、OBJECT FREQ、またはアプリケーション側でのカウントを使用できます。Big key を処理するには、Math.floorMod を使用したシャーディング、UNLINK を使用した削除、および lazy free を有効にしてください。Hot key の負荷を軽減するには、Caffeine のローカルキャッシング、またはキーレプリケーションを使用してください。定期的なスキャンを運用手順に組み込んでください。問題を早期に発見することは、Redis がボトルネックになってから対応するより常に安く上がります。