Redis 大key怎么排查和删除:--bigkeys 每类只报一个,DEL 百万字段的 Hash 卡 0.3 秒(Redis 7.2 实测)

导读 :线上 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

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 的 lazyfree-lazy-expire 现在是 yes 还是 no?有没有碰到过慢日志里查不到、最后发现是大 key 过期引起的毛刺?评论区说说你们的版本和情况,我挑典型的补进 FAQ。

觉得有用的话点个赞、收个藏,下次排查 Redis 毛刺时翻得到。

相关推荐
羑悻的小杀马特4 小时前
Redis持久化三剑客:RDB二进制快照、AOF文本日志、混合模式如何守护你的数据安全?
数据库·redis·缓存·aof·持久化存储·rdb
时间的拾荒人5 小时前
Redis 进阶实战:渐进式遍历、持久化机制与事务详解
数据库·redis·缓存
码哥字节21 小时前
缓存一致性:先删缓存还是先更新库?3 种方案对比 + 实战
redis
2501_933923251 天前
解读Redis:单线程模型
数据库·redis·缓存
我不会起名字3221 天前
seq 空洞排查:Mongo 的 51 只是预留上界,Redis CURR 才是已消耗边界
redis·后端·mongodb·go·即时通讯·im·故障排查
时间的拾荒人1 天前
Redis 集合类型详解:Set 与 Zset
redis·缓存·面试
鶴哥只手遮天1 天前
《Redis 8.10.3 深度剖析》系列 ·第 17 章. 一个迟到十六年的类型
redis·后端·开源