Redis 中的 hot key 和 big key:检测与处理

1. 概述

Redis 在主线程上处理命令。因此,即使只是一个过大的 key(big key)或一个被访问过于频繁的 key(hot key),也会导致整个 instance 变慢。在本文中,你将学习如何使用 Redis 自带的工具和 Java 代码来检测这两类 key。接着,我们将在 Spring Boot 3.x 应用中使用 key sharding、安全删除、本地缓存和 key 复制等技术来处理它们。

2. Hot key 和 big key 是什么?

Big key 是指占用大量内存或包含过多元素的 key。虽然没有标准的阈值,但许多团队使用以下参考值:

  • String 大于约 10 KB。
  • Hash、List、Set、ZSet 有超过约 5,000 个元素,或总容量超过几 MB。

Hot key 是指相比其他 key 而言,接收到异常高比例的请求的 key。例如:包含闪购信息、首页或共享配置的 key。

这两类 key 会导致以下问题:

  • 阻塞主线程:对有 100 万个字段的 hash 执行 HGETALL,或删除一个巨大的 ZSet,可能会阻塞 Redis 数百毫秒。所有其他客户端都必须等待。
  • 网络带宽瓶颈:读取一个 2 MB 的 value,每秒 5,000 个请求,相当于每秒约 10 GB 的流量。
  • Redis Cluster 中的负载不均衡:一个 key 总是位于一个 slot,即一个 node。Hot key 或 big key 都会将负载集中在该 node,无论 cluster 有多少个 node。
  • 复制和故障转移变慢: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 遍历整个 keyspace,因此不会像 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 按元素数量测量。它只报告每种数据类型中最大的 key。如果你关心实际的内存用量,请使用 --memkeys:

redis-cli --memkeys --memkeys-samples 0

--memkeys-samples 0 要求 Redis 测量所有元素而不是抽样。这种方法更准确但更慢。

3.2. 使用 Java 和 MEMORY USAGE 进行扫描

redis-cli 只返回每种类型中最大的 key。如果要列出所有超过阈值的 key,你可以自己扫描。首先,添加依赖项:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

接下来,编写一个 component 来扫描 keyspace:

@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 个 key 的批次遍历 keyspace。主线程不会被长时间锁定。
  • Cursor 必须关闭,因此我们使用 try-with-resources。
  • MEMORY USAGE key SAMPLES 0 返回 key 占用的字节数,包括开销。Spring Data Redis 没有专门的 API 来执行这个命令,所以我们通过 conn.execute 调用。

调用 findKeysLargerThan(1_000_000) 时的示例:

{cart:10023=3145812, product:detail:88=1048648, leaderboard:global=89231560}

注意: 在副本上而不是主副本上运行 scanner,并在低峰时段运行。另一种方法是使用 RDB 读取工具离线分析 RDB 文件。这种方法对运行中的服务器完全没有影响。

4. 检测 hot key

4.1. 使用 redis-cli --hotkeys

Redis 只在启用 LFU 类型的 eviction 策略时才能跟踪访问频率:

# 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 counter 是一个对数值,最大值为 255。它不是实际的请求数。你应该只使用它来相对比较 key 之间的热度。

如果你已经怀疑一个特定的 key,请直接检查:

redis-cli OBJECT FREQ config:homepage
(integer) 255

注意: 不要在生产环境使用 MONITOR 来查找 hot key。MONITOR 将所有命令发送给客户端,可能会使吞吐量降低一半以上。

4.2. 在应用端计算访问次数

如果你无法更改 maxmemory-policy,可以在应用程序中直接计数。以下代码在时间窗口内计算每个 key 被读取的次数:

@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)。然后,使用一个 @Scheduled job 每 10 秒调用一次 topAndReset(10),然后将结果写入日志或指标。当多个线程同时增加一个计数器时,LongAdder 可以减少争用。因此,在这种情况下,它比 AtomicLong 更合适。

5. 处理 big key

5.1. Key 分片 (sharding)

假设你将所有产品的查看次数存储在一个名为 product:views 的 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" 总是落入同一个 shard,例如 product:views:17。因此,读写始终是一致的。
  • 现在每个 hash 只有大约 1/64 的字段。在 Redis Cluster 中,这 64 个 key 位于不同的 slot,因此负载在多个 node 之间分散。

注意: 如果目的是分散负载,不要添加像 product:views:{all}:17 这样的 hash tag。Hash tag 会强制所有 shard 进入同一个 slot。虽然每个 key 的大小减少了,但负载仍然集中在一个 node。

对于 String 类型的 big key,例如 1 MB 的产品详情 JSON,请将变化不频繁的部分和变化频繁的部分分解为单独的 key。你也可以压缩 value,或者只缓存真正需要的字段。

5.2. 安全删除 big key

DEL 同步释放内存。删除一个有 100 万个元素的 ZSet 可能会阻塞 Redis 数百毫秒。请改用 UNLINK:

public void removeLeaderboard() {
    redis.unlink("leaderboard:global");
}

UNLINK 立即从 keyspace 中删除 key。内存释放被推送到后台线程。你还应该在配置中启用 lazy free,以便 Redis 的隐式删除操作也不会阻塞主线程:

# 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 个应用程序实例。每个实例对该 key 每 2 秒最多调用一次 Redis,这意味着 Redis 每秒接收约 10 个请求,而不是数万个。LoadingCache 也会为同一个 key 合并并发的加载。因此,当缓存过期时,不会有数百个线程同时冲向 Redis。

代价是数据最多可能已经过时 2 秒。对于主页或产品分类配置,这种延迟通常是可以接受的。

6.2. Key 复制

当数据需要更新,或在 Redis Cluster 中某个 node 过载时,你可以将 value 写入多个副本并随机读取其中之一:

@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 个 key flashsale:42:r0 到 flashsale:42:r7 通常会落入不同的 slot。因此,读负载被分散到多个 node。这种方法适合读多写少的数据。

注意: 写入多个副本的操作不是原子性的。在短时间内,副本之间可能出现偏差。不要将此方法用于需要绝对一致性的数据,如账户余额。对于库存 hot counter,请考虑将库存分解为多个独立的“桶”(bucket)。每个桶保存一部分数量,而不是保存相同值的副本。

除了上述两种方法外,你还可以从副本中读取。使用 Lettuce,配置 ReadFrom.REPLICA_PREFERRED 以在 master–replica 模型中分散读负载。

7. 测试 Key 分片逻辑

Shard 路由逻辑是最容易出错的部分,因此请为其编写测试:

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));
    }
}

第二个测试检查 Integer.MIN_VALUE 的边界情况。如果你使用 Math.abs(hash) % n,这个测试会失败,因为 Math.abs(Integer.MIN_VALUE) 仍然是负数。第三个测试确认 100,000 个字段相对均匀分布:平均每个 shard 约 1,560 个字段。

8. 常见错误和不适用的场景

  • 运行 KEYS * 查找 big key:此命令会阻塞 Redis 直到扫描完毕。始终使用 SCAN。
  • 分片 key 但仍需读取全部:如果业务经常需要 HGETALL 所有字段,分片只会将 1 条慢命令变成 64 条。请先重新审视业务,例如使用分页或预先计算聚合结果。
  • 运行时更改 shard 数量:将 SHARDS 从 64 更改为 128 会导致大多数字段更改位置。你需要有迁移计划,或从一开始就选择足够大的 shard 数量。
  • 为频繁变化的数据设置本地缓存:库存、当前竞价价格…不适合几秒的 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 本地缓存或 key 复制。将定期扫描纳入运维流程,因为早期检测总是比 Redis 已经出现瓶颈后才处理要便宜。

redisspring-bootperformancecachingjava