Redis 问题排查:大 Key 与过期时间设计
Published in:2025-10-20 | category: 数据库

本文梳理 Redis 大 Key 的定义、危害、产生原因、处理方案,以及 Key 过期时间的设计原则与实践。

一、大 Key

1.1 大 Key 的定义

大 Key 实际上是”大 value“,指的是一个 KV 键值对的 value 特别大。

数据类型判断标准
Stringvalue 一般 >10KB 就可以看做是大 Key,而 >100KB 甚至 >1MB 的就是危险的大 Key 了
集合类型(List、Hash、Set、ZSet 等)一般根据元素数量判断,**>5000** 时可以看做是大 Key

1.2 大 Key 的危害

  1. OOM:内存占用过大,可能直接导致内存溢出。
  2. 客户端延迟很大甚至超时:例如读取一个 Hash 类型的大 Key(购物车有几万件商品),Redis 要一次性把所有数据序列化并返回给客户端;由于 Redis 执行核心任务是单线程的,这个读操作会长时间占用主线程,期间所有客户端请求都必须等待,延迟变得很大甚至超时。
  3. 占用大量网络带宽,拖慢同机器上的其他服务:大 Key 一次响应传输的数据量巨大,比如某个大 Key 占了几十 MB,读一次就要占用几十 MB 的网络带宽,会导致系统整体吞吐量下降,甚至影响同一台机器上的其他服务。
  4. 产生数据倾斜,内存分配不均,导致集群容量下降:如果集群中某个节点上有几个非常大的 Key,该节点内存占用会远高于其他节点,容易率先达到内存上限——此时该节点上的 Key 写入/修改会失败(因为该节点内存已满),但整个 Redis 集群其实仍有大量可用内存,造成资源浪费。

集群数据倾斜示意图

如图所示:大量请求被发到实例 1,导致大量数据集中在实例 1,而实例 2、3 相对空闲,集群容量没有被均衡利用。

1.3 大 Key 产生的原因 & 如何避免

  1. 缓存大文件:比如将商品图片、视频的二进制数据直接放入缓存。Redis 是内存数据库,用其存储大型文件成本极高,且会严重浪费带宽和内存。

  2. 将数据库查询出的数据不加筛选直接加入缓存

    • 要思考是否所有字段都需要缓存。
    • 相关联的数据应做到分开存储,比如商品关联的视频或图片应单独存储。
  3. 滥用 String 类型

    • 比如有一个用户信息对象,包含多个字段,要存入缓存时把整个对象序列化(还要包含括号、引号等字符),每次读取或修改某一个字段都要读取并反序列化整个对象,耗时又耗内存。
    • 因此应该用 Hash 类型存储复杂对象,支持按 field 读取想要的值;同时数据较小时 Hash 底层会选用 ziplist 存储,ziplist 是一段连续的内存(类似数组),非常节省内存。
  4. 缺乏清理操作的集合增长:比如用一个 Set 记录用户的商品搜索历史,方便存储和去重,但代码里没有做数量上限控制——搜索又是高频操作,随着时间积累这个 Key 会越来越大,每个用户对应一个 Key,Redis 中就会出现一堆大 Key。

  5. 超大集合未进行分片处理:比如缓存一个供销商所有的商品 ID,或微博缓存某个明星的所有粉丝——商品数、粉丝数可能达到百万、千万级规模,如果只用一个 Key 存储,这个 Key 必然会成为(或逐渐成为)大 Key。因此对于将来可能存储大数据量的集合 Key,应该提前进行分片处理,把一个大 Key 拆分成多个小 Key,同时也能在物理上让 Key 均匀分布在不同分片中。

  6. 为几乎所有的 Key 设置过期时间,除非是永久的核心数据,避免历史数据无限堆积。

分片写入/查询示例代码:

1
2
3
4
5
6
7
8
9
10
11
// 计算 goods_id 的哈希值,模以分片数,决定放到哪个子Key里
int shardNum = 100; // 分100个片
int shardIndex = hashCode(goods_id) % shardNum;
String shardKey = "shop:goods:A:shard_" + shardIndex;
redis.sadd(shardKey, goods_id);

// 查询时,需要查询所有分片
for (int i = 0; i < shardNum; i++) {
shardKey = "shop:goods:A:shard_" + i;
redis.smembers(shardKey);
}

1.4 如何处理大 Key

① 重建

  • 可以重建:直接删除,或设置一个随机的过期时间让其自然失效。

    异步删除:使用 UNLINK 命令而非 DEL 命令。

    为什么不能用 DEL 命令?

    • DEL 命令是同步的,由 Redis 主线程执行。
    • Redis 处理核心操作(网络 IO、键值对读写)时是单线程的,如果用 DEL 直接删除一个大 Key,会阻塞主线程。

    UNLINK 命令的原理:

    • 由主线程负责断开 Key 与键空间之间的连接,此时 Key 相当于已被删除,无法被访问到。
    • Redis 4.0 之后引入后台线程,负责处理一些耗时、非核心的阻塞任务。
    • 实际的内存回收操作,交由后台线程异步处理,不阻塞主线程。
  • 不可以重建,且数据很重要:进行数据迁移,转存到 OSS 等外部存储中去。

② 渐进式删除

对于集合类型(Hash、List、Set、ZSet)的超大 Key(例如元素数超过几万甚至百万),即使使用 UNLINK,后台线程释放巨大内存时仍可能短暂影响服务。此时应采用渐进式删除:先给 Key 改名(逻辑删除,避免应用继续访问),然后分批删除元素。

  • 大 Hash / Set / ZSet:使用 *SCAN 命令(如 HSCANSSCANZSCAN)分批获取部分元素,再用 *DEL / *REM 命令(如 HDELSREMZREM)删除。
  • 大 List:使用 LTRIM 命令,每次从列表两端修剪掉部分元素。

渐进式删除 Hash 大 Key 示例代码:

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
private void deleteHashKey(String key) {
String cursor = "0";
int deletedCount = 0;

do {
// 使用HSCAN分批获取字段
ScanResult<Map.Entry<String, String>> scanResult =
jedis.hscan(key, cursor, new ScanParams().count(batchSize));

List<Map.Entry<String, String>> entries = scanResult.getResult();
if (!entries.isEmpty()) {
// 提取字段名
String[] fields = entries.stream()
.map(Map.Entry::getKey)
.toArray(String[]::new);

// 批量删除字段
jedis.hdel(key, fields);
deletedCount += fields.length;
System.out.println("Deleted " + fields.length + " fields from hash " + key +
", total: " + deletedCount);

// 短暂休眠,避免过度占用Redis
if (sleepMillis > 0) {
try {
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}

cursor = scanResult.getCursor();
} while (!"0".equals(cursor)); // 当游标返回"0"时表示迭代结束

// 删除空的Hash Key
jedis.unlink(key);
System.out.println("Hash key completely deleted: " + key);
}

③ 提前拆分

对于集合类型,从源头将大 Key 进行拆分,分成多个小 Key(即上文”如何避免产生大Key”中的分片思路),从根本上避免单个 Key 过大。


二、Key 的过期时间

2.1 基本设计原则

设置 TTL(过期时间)时需要综合考虑:

  1. 数据的访问频率:高频访问的数据可以设置稍长一些的过期时间。
  2. 数据的更新频率:数据变更越频繁,TTL 应该尽量短,以保证较高的数据一致性(例如库存数据)。
  3. 数据的重要性和一致性要求
    • 强一致性要求:如库存数据,需要更短的 TTL 或更严格的更新策略。
    • 弱一致性要求:如用户基本信息,可以容忍稍长时间的不一致。

2.2 实践

  1. 过期时间随机化:避免大量 Key 在同一时刻集中过期,造成缓存雪崩
  2. 按业务差异化设置 TTL:根据不同业务场景的数据特点,为不同业务的 Key 设置不同的过期时间。
  3. 数据更新时删除缓存而非直接更新:可以避免并发更新导致的数据不一致问题(即经典的”Cache Aside”删除策略)。
  4. 谨慎使用永不过期的 Key:仅限于真正的核心、永久性数据,否则容易演变为无人清理的大 Key。

总结

大 Key 问题的本质是”单点数据过度集中“——无论是内存占用、网络带宽还是集群负载均衡,都会因为某个 Key 过大而出现单点瓶颈。预防胜于治理:从设计阶段就规划好数据类型选择、分片策略和过期时间,比事后用 UNLINK、渐进式删除去补救要稳妥得多。

Prev:
线上 OOM 分析实战:JProfiler 与 Heap Dump 排查指南
Next:
RocketMQ 重平衡机制与消费者组跨环境冲突问题