Redis 大 Key 与热 Key 治理实战:基于 MySQL 高并发读场景的内存分析与拆分方案

一、问题场景:一场大促引发的缓存危机

某电商系统大促期间,商品详情接口 QPS 从日常 500 飙升到 2 万。为了扛住高并发读,团队在 MySQL 前加了 Redis 缓存。起初效果显著,数据库压力骤降,但活动开始一小时后,Redis 延迟出现周期性毛刺,部分请求响应从 2ms 劣化到 200ms,甚至出现超时。监控显示 Redis 实例 CPU 使用率接近 100%,但整体 QPS 并不高。

排查发现两个典型问题:

  • 某个商品详情缓存 value 是一个完整的 JSON,包含商品基础信息、SKU 列表、评论摘要、推荐列表等,序列化后超过 5MB,属于典型的大 Key。
  • 该商品正好是活动主推款,单 key 的 QPS 超过 8000,属于典型的热 Key。

大 Key 导致 Redis 单线程在处理该 key 的读写时占用大量 CPU 时间,热 Key 又使这个操作被高频触发,最终阻塞其他请求。这引发了我们对 Redis 大 Key 与热 Key 的系统性治理。

二、大 Key 与热 Key 的本质与影响

2.1 定义

  • 大 Key:单个 key 的 value 过大。对于 String 类型,通常认为 value 超过 10KB 就算大 Key;对于 Hash/List/Set/ZSet 等集合类型,元素数量超过 1 万或整体内存超过 1MB 即应警惕。问题本质是单次操作的时间复杂度和网络传输量过大。
  • 热 Key:单个 key 被访问的频率远超其他 key。例如一个 key 的 QPS 占实例总 QPS 的 10% 以上,或绝对 QPS 超过 1000。问题本质是访问倾斜,导致单点资源竞争。

2.2 影响对比

维度 大 Key 的影响 热 Key 的影响
内存 内存占用不均,单个 key 占用过大,可能触发内存淘汰或 OOM 通常内存占用不大,但如果热 Key 也是大 Key,则问题叠加
CPU 序列化/反序列化、数据结构遍历、内存分配等操作耗时高 单 key 高 QPS 使 Redis 单线程 CPU 集中处理该 key
网络 单次请求返回大量数据,占用带宽,容易打满网卡 高频请求本身造成大量网络小包,消耗连接和带宽
阻塞 删除大 Key 会阻塞主线程(Redis 6.0 之前 DEL 是同步的),导致整个实例不可用 热 Key 操作频繁,若每个操作都很重,会阻塞其他请求
可用性 大 Key 在 RDB 持久化或主从同步时可能造成延迟和内存压力 热 Key 容易成为缓存击穿点,一旦失效,大量请求穿透到 MySQL

下面这张 ASCII 图展示了 Redis 单线程处理请求时,一个大 Key 操作阻塞后续所有请求的场景:

1
2
3
4
5
6
7
8
9
10
11
12
客户端请求队列


┌─────────────────────────────┐
│ Redis 主线程(单线程) │
│ │
│ 处理请求1(正常) │
│ 处理请求2(大 Key 读) │ ← 耗时 5ms,阻塞后续请求
│ 处理请求3(正常) │ ← 等待中
│ 处理请求4(热 Key 写) │ ← 等待中
│ ... │
└─────────────────────────────┘

三、定位大 Key 与热 Key:从命令行到监控

3.1 redis-cli –bigkeys 快速扫描

redis-cli --bigkeys 会扫描整个实例,统计每种数据类型中最大的 key,并给出 top 1 的 key 名称和大小。该命令使用 SCAN 而非 KEYS,不会阻塞主线程,但会消耗一定 CPU 和内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 运行环境:Redis 7.0.11,Linux x86_64
redis-cli -h 127.0.0.1 -p 6379 --bigkeys

# 输出示例
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).

[00.00%] Biggest string found so far '"product:detail:10001"' with 5242880 bytes
[00.00%] Biggest hash found so far '"user:cart:888"' with 12000 fields
...
-------- summary -------
Sampled 100000 keys in the keyspace!
Total key length in bytes is 4800000 (avg len 48.00)

Biggest string found '"product:detail:10001"' has 5242880 bytes
Biggest hash found '"user:cart:888"' has 12000 fields
...

注意:--bigkeys 只能给出 key 的 value 大小或元素个数,不能直接给出内存占用,且只给出 top 1,对于大 Key 数量较多的情况需结合其他工具。

3.2 memory usage 精确分析内存

memory usage key [samples] 返回指定 key 及其子元素实际占用的内存字节数。对于 Hash、List 等集合类型,可以指定采样数量(默认 5),采样越多结果越精确但越耗时。该命令在 Redis 4.0+ 可用。

1
2
3
4
5
6
7
# 运行环境:Redis 7.0.11
redis-cli -h 127.0.0.1 -p 6379 memory usage product:detail:10001
# (integer) 5242880

# 对 hash 类型指定采样数量
redis-cli -h 127.0.0.1 -p 6379 memory usage user:cart:888 samples 100
# (integer) 1048576

3.3 object freq 识别热 Key

object freq key 返回 key 的访问频率计数,但只有在 Redis 配置了 LFU 淘汰策略(maxmemory-policyallkeys-lfuvolatile-lfu)时才有效。LFU 内部使用对数计数器,返回值是一个 0-255 的近似值,并非精确访问次数,但足以反映相对热度。

1
2
3
4
5
6
7
8
9
10
11
12
# 先确认淘汰策略
redis-cli -h 127.0.0.1 -p 6379 config get maxmemory-policy
# 1) "maxmemory-policy"
# 2) "allkeys-lfu"

# 查看热 Key 的访问频率
redis-cli -h 127.0.0.1 -p 6379 object freq product:detail:10001
# (integer) 220

# 对比普通 key
redis-cli -h 127.0.0.1 -p 6379 object freq product:detail:10002
# (integer) 15

另外,Redis 6.0+ 提供了 redis-cli --hotkeys 命令,可以扫描整个 keyspace 并输出访问频率最高的 key。使用前提同样是启用 LFU 策略。

1
2
3
4
5
6
7
8
redis-cli -h 127.0.0.1 -p 6379 --hotkeys

# 输出示例
# Scanning the entire keyspace to find hot keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
[00.00%] Hot key '"product:detail:10001"' found with freq 220
...

3.4 监控指标与日志

生产环境不能让开发者频繁执行命令,需要建立监控体系。关键指标包括:

  • **instantaneous_ops_per_sec**:整体 QPS,异常升高说明可能存在热 Key。
  • **keyspace_hits / keyspace_misses**:缓存命中率,命中率突然下降可能有大 Key 被淘汰或热 Key 失效。
  • latency 监控redis-cli --latencyredis-cli --latency-history 可观察延迟毛刺,结合大 Key 操作时间。
  • 慢查询日志slowlog get 可发现执行时间超过阈值的命令,大 Key 的 HGETALLSMEMBERSDEL 等常见于慢查询。
1
2
3
4
5
6
7
8
9
# 查看慢查询日志
redis-cli -h 127.0.0.1 -p 6379 slowlog get 10
# 输出示例
# 1) 1) (integer) 15
# 2) (integer) 1693372800
# 3) (integer) 5230 # 执行耗时 5.23 毫秒
# 4) 1) "GET"
# 2) "product:detail:10001"
# 5) "127.0.0.1:54321"

四、治理实战一:大 Key 拆分

4.1 拆分策略选择

大 Key 拆分的核心思路是将单个大 value 分解为多个小 value,常见策略如下:

原 Key 类型 拆分策略 适用场景
String(大 JSON) 拆成 Hash,字段拆分 对象属性较多且读取常按需取字段
Hash(元素多) 按业务维度分桶,如 key:{hash} 用户数据、订单数据等可哈希分片
List / Set / ZSet 按时间或业务键拆成多个集合 消息队列、排行榜等

对于 String 类型的大 JSON,最直接的是将其拆成 Hash,每个字段存储 JSON 中的一部分。这样读取时可按需获取字段,减少网络传输和内存拷贝。如果 Hash 的字段也很多,可以进一步哈希分桶,例如 product:detail:10001 拆成多个 Hash:product:detail:10001:baseproduct:detail:10001:skuproduct:detail:10001:comments

4.2 代码示例:商品详情大 Key 拆分为 Hash 字段

以下示例使用 Spring Boot 3 + RedisTemplate,将原本 5MB 的 JSON 大 Key 拆分为多个 Hash 字段存储。假设商品详情包含 baseInfoskuListcommentSummaryrecommendList 四个部分。

依赖配置(pom.xml 片段)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>

application.yml

1
2
3
4
5
6
7
8
9
10
spring:
redis:
host: 127.0.0.1
port: 6379
timeout: 3s
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5

Java 服务类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Service
public class ProductDetailCacheService {

private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;

public ProductDetailCacheService(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}

/**
* 保存商品详情,拆分为多个 Hash 字段
*/
public void saveProductDetail(String productId, ProductDetail detail) {
String hashKey = "product:detail:" + productId;
Map<String, String> fields = new HashMap<>();
try {
fields.put("baseInfo", objectMapper.writeValueAsString(detail.getBaseInfo()));
fields.put("skuList", objectMapper.writeValueAsString(detail.getSkuList()));
fields.put("commentSummary", objectMapper.writeValueAsString(detail.getCommentSummary()));
fields.put("recommendList", objectMapper.writeValueAsString(detail.getRecommendList()));
} catch (Exception e) {
throw new RuntimeException("序列化商品详情失败", e);
}
// 使用 hMSet 一次写入多个字段(Redis 7 推荐 HSET 多字段形式)
redisTemplate.opsForHash().putAll(hashKey, fields);
}

/**
* 按需读取商品详情的某一部分,避免一次取出全部 5MB 数据
*/
public String getProductDetailPart(String productId, String field) {
String hashKey = "product:detail:" + productId;
Object value = redisTemplate.opsForHash().get(hashKey, field);
return value != null ? value.toString() : null;
}

/**
* 读取整个商品详情(仅在确实需要全部数据时使用)
*/
public Map<Object, Object> getFullProductDetail(String productId) {
String hashKey = "product:detail:" + productId;
return redisTemplate.opsForHash().entries(hashKey);
}
}

对应的 ProductDetail 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.List;

public class ProductDetail {
private BaseInfo baseInfo;
private List<Sku> skuList;
private CommentSummary commentSummary;
private List<Product> recommendList;
// getter/setter 省略
}

class BaseInfo { /* ... */ }
class Sku { /* ... */ }
class CommentSummary { /* ... */ }
class Product { /* ... */ }

运行环境:JDK 17,Spring Boot 3.2.5,Redis 7.0.11。验证日期:2026-08-30。

拆分后,商品详情接口只需读取 baseInfocommentSummary 两个字段,单次网络传输从 5MB 降到几十 KB,Redis 操作耗时显著下降。

五、治理实战二:热 Key 本地缓存与多级缓存

5.1 多级缓存架构

对于热 Key,最有效的办法是让请求尽量不落到 Redis。在应用服务器本地增加一层缓存(如 Caffeine),将热点数据缓存到 JVM 堆内,读取路径变为:

1
2
用户请求 → 本地缓存(Caffeine) → Redis → MySQL
↓ 命中返回 ↓ 命中返回 ↓ 回源

本地缓存访问延迟在纳秒级,且不占用 Redis 连接和网络带宽。但需注意数据一致性问题:本地缓存可能滞后。对于商品详情这类读多写少、容忍秒级延迟的场景非常合适。

5.2 代码示例:Caffeine + Redis 两级缓存

以下示例使用 Caffeine 作为本地缓存,Redis 作为二级缓存,MySQL 作为最终数据源。

添加 Caffeine 依赖

1
2
3
4
5
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>

Caffeine 配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class CacheConfig {

@Bean
public Cache<String, ProductDetail> localCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.SECONDS)
.recordStats()
.build();
}
}

商品服务类(两级缓存读取)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import com.github.benmanes.caffeine.cache.Cache;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class ProductService {

private final Cache<String, ProductDetail> localCache;
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final ProductRepository productRepository; // 假设存在 MySQL 访问层

public ProductService(Cache<String, ProductDetail> localCache,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
ProductRepository productRepository) {
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.productRepository = productRepository;
}

public ProductDetail getProductDetail(String productId) {
String cacheKey = "product:detail:" + productId;
String localKey = productId;

// 1. 查本地缓存
ProductDetail localValue = localCache.getIfPresent(localKey);
if (localValue != null) {
return localValue;
}

// 2. 查 Redis
String redisValue = redisTemplate.opsForValue().get(cacheKey);
if (redisValue != null) {
try {
ProductDetail detail = objectMapper.readValue(redisValue, ProductDetail.class);
localCache.put(localKey, detail);
return detail;
} catch (Exception e) {
// 反序列化失败,继续回源
}
}

// 3. 查 MySQL 并回填缓存
ProductDetail detail = productRepository.findById(productId);
if (detail != null) {
try {
String json = objectMapper.writeValueAsString(detail);
redisTemplate.opsForValue().set(cacheKey, json, 60, TimeUnit.SECONDS);
localCache.put(localKey, detail);
} catch (Exception e) {
// 缓存写入失败不影响主流程
}
}
return detail;
}
}

运行环境:JDK 17,Spring Boot 3.2.5,Redis 7.0.11,Caffeine 3.1.8。

此方案将热 Key 的 QPS 压力从 Redis 转移到应用服务器本地,Redis 侧 QPS 可降低 90% 以上。需要注意本地缓存的内存占用,避免引入新的内存问题。

六、治理实战三:访问聚合(Singleflight 模式)

6.1 热 Key 访问聚合原理

即使有本地缓存,在缓存失效或未命中的瞬间,大量并发请求可能同时打到 Redis 甚至 MySQL。访问聚合的思路是:对于同一个 key 的并发请求,只允许一个请求真正向后端发起加载,其他请求等待并共享结果。这种模式在 Go 语言中叫 singleflight,在 Java 中可以用 CompletableFuture 实现。

1
2
3
4
5
请求1 ──┐
请求2 ──┼──> 合并为一个加载请求 ──> Redis / MySQL
请求3 ──┘ │

所有请求共享同一个结果

6.2 代码示例:基于 CompletableFuture 的请求合并

以下工具类实现了一个简单的 singleflight 机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.function.Function;

public class SingleFlight<K, V> {

private final ConcurrentHashMap<K, CompletableFuture<V>> flights = new ConcurrentHashMap<>();

/**
* 对于同一个 key,同时只有一个 doLoad 会被执行,其他调用者共享结果。
*/
public V doOnce(K key, Function<K, V> loader) throws ExecutionException, InterruptedException {
CompletableFuture<V> future = flights.get(key);
if (future == null) {
CompletableFuture<V> newFuture = new CompletableFuture<>();
CompletableFuture<V> oldFuture = flights.putIfAbsent(key, newFuture);
if (oldFuture == null) {
// 当前线程是第一个发起者,负责执行加载
future = newFuture;
try {
V result = loader.apply(key);
newFuture.complete(result);
} catch (Throwable t) {
newFuture.completeExceptionally(t);
} finally {
flights.remove(key);
}
} else {
future = oldFuture;
}
}
return future.get();
}
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class HotKeyCacheService {

private final SingleFlight<String, String> singleFlight = new SingleFlight<>();
private final RedisCache redisCache; // 假设存在 Redis 访问
private final MysqlRepository mysqlRepository; // 假设存在 MySQL 访问

public String getHotData(String key) throws ExecutionException, InterruptedException {
return singleFlight.doOnce(key, k -> {
// 只有第一个请求会进入这里
String value = redisCache.get(k);
if (value == null) {
value = mysqlRepository.query(k);
redisCache.set(k, value, 60);
}
return value;
});
}
}

运行环境:JDK 17。验证日期:2026-08-30。

在实际生产环境中,还需要实现超时控制、失败重试和并发上限,但上述代码已展示核心思想:合并并发请求,避免缓存击穿。

七、RDB/AOF 与内存参数调优建议

治理大 Key 和热 Key 的同时,也需要调整 Redis 持久化和内存相关参数,以降低整体资源消耗和阻塞风险。

7.1 RDB 持久化调优

1
2
3
4
5
6
7
8
9
# redis.conf
# 开启 RDB 压缩,牺牲少量 CPU 换取更小的磁盘文件
rdbcompression yes
# 调整 save 触发条件,避免频繁 fork 子进程
save 900 1
save 300 10
save 60 10000
# 设置子进程内存共享,避免写时复制过多
rdb-del-sync-files no

大 Key 会导致 RDB 持久化时 fork 子进程时间变长,以及复制时内存翻倍。拆分后 RDB 文件更小,fork 更快。

7.2 AOF 持久化调优

1
2
3
4
5
6
7
# 使用 RDB 前导 + AOF 追加,减少重写开销
aof-use-rdb-preamble yes
# 每秒同步一次,平衡性能与数据安全
appendfsync everysec
# 自动重写触发条件
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

热 Key 会导致 AOF 记录大量重复命令,通过多