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 已经出现瓶颈后才处理要便宜。