导读 :线上 Redis 偶尔出现几百毫秒的毛刺,慢日志里一看是某个
DEL或HGETALL,十有八九是大 key。本文在 Redis 7.2.6 上造了 100 万元素的 Hash、Set、ZSet、List,把找大 key、删大 key、大 key 过期这三件事各测了一遍,给出能直接用的命令、配置和 Java 代码。
一、先说清楚:大 key 是什么,为什么要管
大 key 就是一个 key 下面挂的数据太多或太大:一个 Hash 里塞了 100 万个字段,一个 String 存了 10MB 的 JSON,一个 List 当消息队列攒了几百万条没消费。
为什么怕它?Redis 执行命令是单线程的,一条命令没跑完,后面所有请求都得排队。平时只读一两个字段没事,可一旦有人对这个 key 做了整体操作,比如删除它、取出全部内容、它到期被自动清理,整个实例就会卡住几百毫秒。下游的 Java 服务看到的就是一批请求同时超时,连接池被打满,接着可能熔断。
不管它会怎样:不会马上出事,但它像一颗定时炸弹。最常见的引爆点是"业务下线了,顺手删掉这个 key",或者"给它设了过期时间,到点自己没了"。后一种最难查,因为没有任何人发命令。
什么时候该动它:
- 慢日志(
SLOWLOG GET)里出现DEL、HGETALL、LRANGE 0 -1、SMEMBERS这类命令,耗时上百毫秒 - 监控上 Redis 响应时间有规律的毛刺,和某个定时任务、某个缓存过期时间对得上
- 准备删除一批历史 key,或者准备给一个很大的 key 加过期时间
二、实测环境
| 项目 | 版本 / 配置 |
|---|---|
| Redis | 7.2.6(Homebrew,macOS,单独起一个 6390 端口的实例,默认配置,不开持久化) |
| 测试数据 | 4 个 100 万元素的 key(Hash、Set、ZSet、List),1 个 10MB 的 String,20 万个 50 字节的小 String |
| Java | JDK 17.0.12 + Jedis 5.2.0;Spring Data Redis 3.5.3 只用 javap 看了字节码 |
⚠️ 下面的耗时都是在一台 16GB 内存的 Mac 上测的,绝对值会随机器、内存分配器(这个 Homebrew 版本用的是 libc malloc,Linux 发行版默认多是 jemalloc)变化,看数量级和相对差距就好。
三、多大算大 key:别只看元素个数
大 key 没有统一标准。网上流传最广的是一份《阿里云 Redis 开发规范》:String 控制在 10KB 以内,Hash、List、Set、ZSet 元素个数不超过 5000(我只找到了转载版本);腾讯云文档《Key 与 Value 设计原则》给的线宽松得多,String 超过 10MB、Set 和 List 超过 1 万个元素才算。这些标准拿来做代码评审的红线没问题,但它们主要看"个数"和"字节",和"会不会卡"并不完全对应。
我把 4 个都是 100 万元素的 key 分别 DEL 了 3 次(每次删之前用 COPY 复制一份),服务端耗时从 SLOWLOG 读:
| 类型(100 万元素) | 内存(MEMORY USAGE SAMPLES 0) | DEL 耗时(3 次) |
|---|---|---|
| ZSet | 98.0 MB | 289 / 293 / 332 ms |
| Hash | 61.4 MB | 192 / 207 / 302 ms |
| Set | 46.1 MB | 94 / 100 / 104 ms |
| List | 12.4 MB | 1.3 / 1.9 / 2.0 ms |
| String(10MB) | 10.0 MB | 0.18 ms 左右 |
同样 100 万个元素,List 删掉只要 2 毫秒,ZSet 要 0.3 秒,差了一百多倍。原因在 Redis 官方文档对 UNLINK 复杂度的说明里:释放内存的开销和"这个对象由多少块内存分配组成"成正比。ZSet、Hash、Set 每个元素都是单独分配的节点,100 万个元素就要释放 100 万次以上;List 底层是 quicklist,一个节点(listpack)里塞了很多元素,块数少得多。10MB 的 String 只是一整块内存,释放一次就完了。
所以我的判断标准是两条一起看:
- 会不会卡:看元素个数,尤其是 Hash、Set、ZSet,几十万个元素起删除就是百毫秒级
- 会不会撑爆网卡和内存 :看字节数。10MB 的 String 删除很快,但每次
GET都要把 10MB 传给客户端,按千兆网卡算,传一次光理论值就要 80 多毫秒(10MB × 8 ÷ 1000Mbps)
四、怎么找大 key
4.1 redis-cli --bigkeys:按元素个数找,每种类型只报一个
这是最常用的命令,它用 SCAN 把整个库遍历一遍,对每个 key 调 HLEN、SCARD 这类命令统计个数:
bash
# 建议连从节点跑;-i 0.1 表示每扫 100 次 SCAN 歇 0.1 秒,降低对线上的压力
redis-cli -h 127.0.0.1 -p 6390 --bigkeys -i 0.1
20 万个 key 的库,不加 -i 跑了 6.4 秒,输出的 summary 是这样的:
text
Biggest list found '"big:list:a"' has 1000000 items
Biggest hash found '"big:hash:a"' has 1000000 fields
Biggest string found '"big:str"' has 10485760 bytes
Biggest set found '"big:set:a"' has 1000000 members
Biggest zset found '"big:zset:a"' has 1000000 members
坑在这里:每种类型只报最大的那一个。 我又复制了一份 Hash(big:hash:b,多加了一个字段),再跑一次:
text
Biggest hash found '"big:hash:b"' has 1000001 fields
3 hashs with 2020011 fields (00.00% of keys, avg size 673337.00)
summary 里只剩 big:hash:b,另一个 100 万字段的 big:hash:a 不见了,只能从"3 个 Hash 一共 202 万字段"这个汇总数倒推出还有别的大 Hash。扫描过程中打印的 Biggest hash found so far 也只在刷新最大值时才打印。如果线上有 10 个大 Hash,--bigkeys 只会告诉你其中一个。
4.2 redis-cli --memkeys:按内存字节找
bash
# 按 MEMORY USAGE 统计,同样每种类型只报最大的一个
redis-cli -h 127.0.0.1 -p 6390 --memkeys -i 0.1
同一个库跑了 7.5 秒,结果换成了字节数:ZSet 102,789,312 字节,Hash 64,388,720,Set 48,388,720,List 13,627,899。--bigkeys 里看着一样大的 4 个 key,按内存排差了 7 倍多。它和 --bigkeys 一样,每种类型只报一个。
4.3 自己写扫描:把超过阈值的全列出来
想要"所有超过 1MB 的 key",就得自己扫。下面是用 Jedis 写的版本:SCAN 每次取 1000 个 key,一页用 pipeline 批量发 MEMORY USAGE key SAMPLES 0,避免每个 key 一次网络往返:
java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;
import redis.clients.jedis.params.ScanParams;
import redis.clients.jedis.resps.ScanResult;
import java.util.ArrayList;
import java.util.List;
public class BigKeyScanner {
public static void main(String[] args) {
long thresholdBytes = 1024 * 1024; // 超过 1MB 的 key 都列出来
try (Jedis jedis = new Jedis("127.0.0.1", 6390)) {
ScanParams params = new ScanParams().count(1000);
String cursor = ScanParams.SCAN_POINTER_START;
long scanned = 0, start = System.currentTimeMillis();
do {
ScanResult<String> page = jedis.scan(cursor, params);
cursor = page.getCursor();
List<String> keys = page.getResult();
// 一页 key 用 pipeline 批量查内存,避免一个 key 一次网络往返
Pipeline p = jedis.pipelined();
List<Response<Long>> sizes = new ArrayList<>();
for (String k : keys) {
sizes.add(p.memoryUsage(k, 0)); // SAMPLES 0:统计全部元素
}
p.sync();
for (int i = 0; i < keys.size(); i++) {
Long bytes = sizes.get(i).get();
if (bytes != null && bytes >= thresholdBytes) {
System.out.printf("%-20s %8.1f MB %s%n", keys.get(i), bytes / 1048576.0, jedis.type(keys.get(i)));
}
}
scanned += keys.size();
} while (!ScanParams.SCAN_POINTER_START.equals(cursor));
System.out.printf("scanned=%d cost=%dms%n", scanned, System.currentTimeMillis() - start);
}
}
}
本机跑出来两个 Hash 都列出来了,20 万个 key 用了 1.7 秒:
text
big:hash:b 61.4 MB hash
big:zset:a 97.0 MB zset
big:list:a 12.4 MB list
big:set:a 46.1 MB set
big:hash:a 61.4 MB hash
scanned=200007 cost=1687ms
⚠️
MEMORY USAGE ... SAMPLES 0会遍历 key 的全部元素,对一个 100 万字段的 Hash 也是一次 O(N) 操作。线上跑请连从节点,或者在每页之间Thread.sleep一下。
五、读大 key 也会卡
删除不常做,读却天天在做。同一个 100 万字段的 Hash,各种读命令的服务端耗时(SLOWLOG,各跑 3 次):
| 命令 | 耗时 |
|---|---|
HGET big:hash:a field:12345 |
2 μs |
HLEN big:hash:a |
1~2 μs |
LRANGE big:list:a 0 -1(100 万条) |
43~47 ms |
HGETALL big:hash:a |
120~205 ms |
GET big:str(10MB) |
0.85~0.89 ms |
只取一个字段完全没问题,问题出在"整个取出来"。Java 里最容易踩的是 redisTemplate.opsForHash().entries(key),它发的就是 HGETALL;opsForList().range(key, 0, -1) 对应 LRANGE 0 -1。如果真要遍历,换成 HSCAN / SSCAN / ZSCAN 分页取。
10MB 的 String 在服务端只用了不到 1 毫秒,本机回环网络下 redis-cli 整个过程约 10 毫秒。它的代价在网络和客户端:跨机房、千兆网卡、上百 QPS 一起读的时候,带宽先扛不住。
六、怎么删:用 UNLINK,别用 DEL
6.1 DEL 和 UNLINK 实测对比
UNLINK(Redis 4.0 起有)先把 key 从库里摘掉,马上返回,真正释放内存交给后台线程。同样的 100 万元素 key,交替执行 DEL 和 UNLINK 各 3 次:
| 类型 | DEL(服务端耗时) | UNLINK(服务端耗时) |
|---|---|---|
| Hash | 0.20~0.67 s | 7~45 μs |
| ZSet | 0.54~0.91 s | 17~38 μs |
| Set | 0.22~0.33 s | 17~28 μs |
| List | 1.7~1.9 ms | 8~19 μs |
这一轮 DEL 比第三节那一轮慢(第三节是只跑 DEL、每次间隔 0.5 秒),因为前一个 UNLINK 的后台线程还在释放内存,两边抢内存分配器。不管哪一轮,UNLINK 都稳定在几十微秒。
我同时开了一个每毫秒发一次 PING 的探测脚本,看客户端感受到的最大延迟:DEL 100 万字段的 Hash 时 PING 最大等了 199~246 ms,ZSet 是 314~389 ms;换成 UNLINK 后最大 3.6~14.6 ms,基本是探测脚本自身的抖动。
💡 10MB 的 String 用
UNLINK没好处:它只有一块内存。Redis 7.2 源码lazyfree.c里的规则是释放成本(大致等于内存块数)超过 64 才交给后台线程,否则直接在主线程释放,实测DEL和UNLINK都是 0.18 ms 左右。
6.2 Spring 里怎么调
Spring Data Redis 的 RedisTemplate 两个方法都有,我用 javap -c 看了 3.5.3 的字节码:delete(key) 最终调用的是 RedisKeyCommands.del,unlink(key) 调用的是 RedisKeyCommands.unlink。
java
// 删除可能很大的 key:用 unlink,返回 true 表示 key 存在并已摘除
Boolean removed = redisTemplate.unlink("report:daily:20261112");
// 批量删除同理
Long count = redisTemplate.unlink(List.of("cache:a", "cache:b"));
代码里到处都是 delete(),一个个改不现实的话,可以在 Redis 侧把 DEL 也变成异步:
bash
# 让 DEL 的行为和 UNLINK 一样(Redis 6.0 起有这个参数)
redis-cli -p 6390 CONFIG SET lazyfree-lazy-user-del yes
打开后再 DEL 三个 100 万字段的 Hash,服务端耗时分别是 19、13、18 μs,和 UNLINK 一样。
6.3 Redis 4.0 以前:分批删
老版本没有 UNLINK,只能用 HSCAN 每次取一批字段 HDEL,最后删空壳。也适合"只想删掉一部分字段"的场景:
java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.ScanParams;
import redis.clients.jedis.resps.ScanResult;
import java.util.List;
import java.util.Map;
public class BigKeyCleaner {
// 分批删除大 Hash:每次 HSCAN 取 batch 个 field,HDEL 掉,最后 DEL 空壳
static void deleteHashInBatches(Jedis jedis, String key, int batch) {
ScanParams params = new ScanParams().count(batch);
String cursor = ScanParams.SCAN_POINTER_START;
long maxMs = 0, rounds = 0;
long start = System.nanoTime();
do {
ScanResult<Map.Entry<String, String>> page = jedis.hscan(key, cursor, params);
cursor = page.getCursor();
List<Map.Entry<String, String>> entries = page.getResult();
if (!entries.isEmpty()) {
String[] fields = entries.stream().map(Map.Entry::getKey).toArray(String[]::new);
long t = System.nanoTime();
jedis.hdel(key, fields);
maxMs = Math.max(maxMs, (System.nanoTime() - t) / 1_000_000);
rounds++;
}
} while (!ScanParams.SCAN_POINTER_START.equals(cursor));
jedis.del(key);
System.out.printf("batch=%d rounds=%d total=%dms maxHdel=%dms%n",
batch, rounds, (System.nanoTime() - start) / 1_000_000, maxMs);
}
public static void main(String[] args) {
try (Jedis jedis = new Jedis("127.0.0.1", 6390)) {
for (int batch : new int[]{100, 500, 1000}) {
jedis.copy("big:hash:a", "big:hash:p", true);
deleteHashInBatches(jedis, "big:hash:p", batch);
}
System.out.println("exists after: " + jedis.exists("big:hash:p"));
}
}
}
删同一个 100 万字段的 Hash:
| 每批个数 | 轮数 | 总耗时 | 单次 HDEL 最长 |
|---|---|---|---|
| 100 | 9919 | 3.2 s | 4 ms |
| 500 | 1997 | 1.5 s | 4 ms |
| 1000 | 1000 | 1.4 s | 4 ms |
总耗时比一次 DEL 长得多,但每次只占用 Redis 几毫秒,中间的业务请求能插进来。Set 用 SSCAN + SREM,ZSet 可以直接 ZREMRANGEBYRANK key 0 999 每次删前 1000 个,List 用 LTRIM 一段段截。
七、最容易被忽略的:大 key 到期自动删除也会卡
给大 key 设过期时间,到点由 Redis 自己删,没有任何客户端发命令,慢日志里查不到 。我复制了 3 份 100 万字段的 Hash,设置 200 毫秒后过期,同时开着每毫秒一次的 PING 探测,用 LATENCY 监控看:
bash
# 记录超过 10ms 的事件
redis-cli -p 6390 CONFIG SET latency-monitor-threshold 10
redis-cli -p 6390 LATENCY LATEST
| lazyfree-lazy-expire | PING 最大延迟 | LATENCY 记录 |
|---|---|---|
| no(7.2 默认) | 551 ms | expire-cycle 550 ms,expire-del 194 ms |
| yes | 15.8 ms | 无 |
默认配置下,三个大 key 到期那一下,整个实例卡了半秒多。SLOWLOG 里什么都没有,只有 LATENCY 里记了 expire-cycle 和 expire-del。排查毛刺时如果慢日志干干净净,记得看一眼 LATENCY LATEST。
我的建议是 Redis 4.0 以上把这几个参数都打开,写进 redis.conf:
properties
# 内存满了按淘汰策略删 key 时,后台释放
lazyfree-lazy-eviction yes
# key 过期被删除时,后台释放
lazyfree-lazy-expire yes
# RENAME、SET 覆盖等服务端内部删除时,后台释放
lazyfree-lazy-server-del yes
# 客户端执行 DEL 时,按 UNLINK 处理(Redis 6.0+)
lazyfree-lazy-user-del yes
Redis 7.2 自带的 redis.conf 里这几项默认全是 no,本机实例 CONFIG GET 'lazyfree*' 也确认了。云上的托管 Redis 一般不让执行 CONFIG SET,要去控制台的参数设置里改。lazyfree-lazy-eviction 这一项我没有专门测淘汰场景,作用据 redis.conf 里的注释。
八、根治:拆小
找到、删掉只是处理现场,根治还是别让 key 长这么大:
- 按 ID 分桶 :一个存全部用户信息的 Hash,按
userId % 100拆成user:info:0到user:info:99,每个桶 1 万字段 - 按时间拆 :
report:2026拆成report:2026-11-13一天一个,过期时间也能分散开,避免同一时刻一起过期 - List 当队列:消费跟不上就会越攒越大,换成 Stream 或者 RocketMQ 这类真正的消息队列
- 大 JSON:只存需要频繁读的字段,或者压缩后再存,整块大对象放到对象存储
分桶的写法很简单,读写都先算桶号:
java
public final class HashBucket {
private static final int BUCKETS = 100;
// user:info + 用户 ID → user:info:37
public static String key(String prefix, long id) {
return prefix + ":" + Math.floorMod(id, BUCKETS);
}
}
用法是 redisTemplate.opsForHash().put(HashBucket.key("user:info", userId), String.valueOf(userId), json)。代价是不能再用一条 HGETALL 拿全部数据,不过那正是我们想避免的操作。
九、速查表
| 要做的事 | 命令 / 配置 | 注意 |
|---|---|---|
| 找每种类型最大的 key | redis-cli --bigkeys -i 0.1 |
每类只报一个,按元素个数 |
| 按内存找 | redis-cli --memkeys -i 0.1 |
每类只报一个 |
| 列出所有超过阈值的 key | SCAN + pipeline MEMORY USAGE key SAMPLES 0 |
连从节点跑 |
| 看单个 key 多大 | MEMORY USAGE key SAMPLES 0 |
O(N),大 key 上也有开销 |
| 删大 key | UNLINK key / redisTemplate.unlink(key) |
Redis 4.0+ |
| 让 DEL 也异步 | lazyfree-lazy-user-del yes |
Redis 6.0+ |
| 大 key 过期不卡 | lazyfree-lazy-expire yes |
7.2 默认 no |
| 查过期引起的毛刺 | LATENCY LATEST |
慢日志里没有 |
| 老版本分批删 | HSCAN + HDEL / ZREMRANGEBYRANK / LTRIM | 每批 500~1000 |
| 别整取 | HGETALL、LRANGE 0 -1、SMEMBERS 换成 *SCAN |
十、常见问题
Q:线上主节点能直接跑 --bigkeys 吗?
它底层是 SCAN 加 HLEN 这类 O(1) 命令,不会长时间阻塞,但会把整个库扫一遍,QPS 高的实例还是建议在从节点跑并加上 -i 0.1。--memkeys 用的是 MEMORY USAGE,对大 key 本身有开销,更要放在从节点。
Q:集群模式怎么扫?
redis-cli --bigkeys 扫的是连上的那一个节点,集群要对每个主节点(或它的从节点)分别跑一遍。这次我只测了单机实例,集群的行为是按 redis-cli 连接单个节点的机制推断的。
Q:开了 lazyfree,内存是不是马上就降下来?
不是马上。UNLINK 返回时内存还在,后台线程释放完才降。我删完一批大 key 后看 INFO memory 里的 lazyfree_pending_objects 已经是 0、lazyfreed_objects 是 12,说明后台释放了 12 个对象。内存紧张时删大 key,要等这个数回到 0 再判断效果。
Q:Hash 拆到多少个字段以内合适?
Redis 7.2 默认 hash-max-listpack-entries 是 512,字段数在这以内(且值不太长)时用紧凑的 listpack 编码,内存省很多。我补测了不同字段数的 Hash 直接 DEL 的耗时(各 3 次):5000 个字段 0.7~1.5 ms,10 万个 19~32 ms,100 万个 0.19~0.30 s,基本随字段数线性增长。那份流传的开发规范给的 5000 可以直接拿来当分桶上限,删除在毫秒级。
参考链接
- Redis UNLINK 命令文档
- Redis MEMORY USAGE 命令文档
- redis-cli 文档(--bigkeys、--memkeys)
- Redis 延迟监控(LATENCY)
- Redis 7.2 自带 redis.conf(lazyfree 参数说明)
- 腾讯云:Key 与 Value 设计原则
你们线上 Redis 的
lazyfree-lazy-expire现在是 yes 还是 no?有没有碰到过慢日志里查不到、最后发现是大 key 过期引起的毛刺?评论区说说你们的版本和情况,我挑典型的补进 FAQ。觉得有用的话点个赞、收个藏,下次排查 Redis 毛刺时翻得到。