Redis 大 key 处理:定义、排查与删除的完整指南
一句话结论(30s)
大 key 的本质是 Redis 单线程下 O(n) 命令阻塞主线程的风险治理:因为一条 DEL 释放百万元素集合可能卡几百毫秒到秒级、拖垮所有请求甚至雪崩,所以删除用 UNLINK 异步释放或 SCAN 分批删,设计上靠拆分 key / Hash 分桶从源头避免——心法一句话:O(1) 永远比 O(n) 安全。
核心原理(2min)
大 key 指 String > 10KB 或 List/Set/Hash/ZSet 元素 > 5000。排查用 redis-cli --bigkeys(SCAN 遍历 + TYPE/STRLEN/LLEN 等取最大值,加 -i 控制扫描间隔)、MEMORY USAGE(含对象 overhead 更精确)、rdb-tools 离线分析 RDB。删除优先 UNLINK(从全局 dict O(1) 摘除引用、内存释放交给 bio 后台线程 lazy-free,主线程立即返回);老版本用 hscan+hdel / ltrim / sscan+srem / zremrangebyrank 分批删,控制单次耗时。预防靠拆分 key、Hash 分桶(itemId.hashCode()%10)、压缩数据、设置 TTL 和监控告警。
底层深入(5-10min)
什么是大 key?
大 key 不是指 key 名字长,而是指 key 对应的 value 数据量过大。业界通用标准:
| 类型 | 大 key 阈值 |
|---|---|
| String | > 10 KB |
| List / Set / Hash / ZSet | 元素数量 > 5,000 |
为什么 10KB 就算大?因为 Redis 是单线程处理命令,如果一条 GET 操作需要传输 10KB 数据,还勉强可以接受;但如果一条 DEL 操作需要释放一个包含 10 万个元素的 List,Redis 主线程就卡死了——用户请求全部阻塞,直到删除完成。
思考:为什么 10KB 就算「大」?关键不是 10KB 这个数字本身,而是「单线程 + O(n) 命令」的组合——GET 传输 10KB 只是慢一点点,但 DEL 要遍历并释放 value 里的每一个元素,复杂度 O(n),一个 100 万元素的 Set 释放可能卡几百毫秒到秒级。主线程被占住期间,所有客户端请求都得排队。所以「大 key」的真正风险不是占内存,而是「一条 O(n) 命令把单线程卡死」。
大 key 的危害
1. 阻塞主线程(最严重)
┌──────────────────────────────────────────┐
│ 正常:每命令 1μs~100μs │
│ DEL 大key:500ms~2s │
│ (这段时间内所有其他请求排队等待) │
└──────────────────────────────────────────┘
Redis 删除大 key 时,需要遍历所有元素释放内存,这个操作是 O(n)。一个 100 万元素的 Set,DEL 可能需要几百毫秒。在高并发场景下,这足以导致 CPU 飙升、连接超时、雪崩。
2. 网络带宽压力
一个 1MB 的 String key,每次 GET 都要传输 1MB。如果 QPS 是 100,网络带宽就需要 100MB/s。加上序列化/反序列化开销,客户端也可能 OOM。
3. 不均匀的内存使用
在 Redis Cluster 中,某个 slot 落了一个超大 key,导致该节点内存使用率远高于其他节点——热节点成为瓶颈。
4. 迁移困难
Redis Cluster 的 MIGRATE 命令迁移 slot 时,大 key 会阻塞迁移过程。如果迁移超时,集群可能反复重试,影响稳定性。
如何排查大 key?
方法一:redis-cli —bigkeys(内置工具)
redis-cli --bigkeys
# 输出示例:
# Biggest string found: "user:session:10086" has 20480 bytes
# Biggest list found: "queue:pending" has 50000 items
# Biggest hash found: "config:system" has 12000 fields
# Biggest set found: "blacklist:ip" has 80000 members
# Biggest zset found: "rank:daily" has 150000 members
原理:--bigkeys 用 SCAN 命令遍历整个 keyspace,对每个 key 用 TYPE 判断类型,再用对应的 STRLEN、LLEN、HLEN、SCARD、ZCARD 获取大小,记录最大值。
优点:零侵入、Redis 自带。缺点:只能找”最大的那个”,不是找”所有大 key”。
思考:为什么排查和删除都用 SCAN 而不是 KEYS?因为 KEYS 是一次性全量匹配并返回、O(N) 且会阻塞主线程,生产环境是红线;SCAN 用游标分批遍历、每次只返回一小批、还能加 -i 休眠,把「全量扫描」拆成「无数次小步」,对线上影响可控。这个「化整为零」的思路贯穿大 key 排查和删除的全过程——也是为什么分批删(hscan+hdel / ltrim / sscan+srem / zremrangebyrank)能控制单次耗时的原因。
方法二:redis-cli —bigkeys -i 0.1
加 -i 参数控制扫描间隔,每 100 条 SCAN 休眠 0.1 秒,减少对生产的影响。
redis-cli --bigkeys -i 0.1
方法三:MEMORY USAGE 命令(Redis 4.0+)
MEMORY USAGE user:session:10086
# (integer) 21504 ← 实际占用内存(包含内部数据结构开销)
MEMORY USAGE 比 STRLEN 更准确,因为 Redis 对象本身有 overhead(如 dictEntry、redisObject、SDS 头等)。
方法四:RDB 离线分析(推荐用于深度排查)
使用开源工具 redis-rdb-tools 分析 RDB 文件:
pip install rdbtools
rdb dump.rdb -c memory --bytes 10240 -f memory.csv
生成 CSV 报告,列出所有 >10KB 的 key,包含内存占用、过期时间、类型等。
优点:不影响线上 Redis(离线分析)。缺点:需要生成 RDB 文件(BGSAVE 会 fork 子进程,大内存实例 fork 可能耗时长)。
如何安全删除大 key?
核心原则:不要用 DEL,用分批删除或异步删除
方法一:UNLINK 异步删除(Redis 4.0+,最推荐)
UNLINK big:hash:key
UNLINK 和 DEL 的区别:
DEL: 立即释放内存,主线程阻塞,直到释放完成
UNLINK: 将 key 从 keyspace 中移除(O(1)),
内存释放交给后台线程(lazy-free)异步完成
主线程立即返回,不阻塞
原理:UNLINK 使用了 Redis 4.0 引入的 lazyfree 机制。调用 UNLINK 时:
- 从全局 dict 中移除 key 的引用(O(1))。
- 把 value 的内存释放任务放入
bio后台线程池。 - 主线程立即返回 OK,后续请求不受影响。
思考:UNLINK 为什么能让主线程立即返回?追两步——第一步从全局 dict 里摘掉 key 的引用是 O(1),主线程自己干、很快;第二步真正释放 value 那块大内存(遍历元素、free)是 O(n),交给 bio 后台线程 lazy-free 慢慢做。主线程只做「摘引用」这 O(1) 的活,重活甩给后台线程,所以不阻塞。想通「引用摘除」和「内存释放」可以分离,就理解了 lazy-free 的精髓。
配置相关参数:
lazyfree-lazy-eviction no # 淘汰时是否用 lazy free
lazyfree-lazy-expire no # 过期删除时是否用 lazy free
lazyfree-lazy-server-del no # 内部删除(如 rename)时是否用 lazy free
replica-lazy-flush no # replica 全量同步时异步 flush
方法二:Hash 分批删除(hscan + hdel)
public void deleteBigHash(String key, int batchSize) {
String cursor = "0";
do {
ScanResult<Map.Entry<String, String>> result =
jedis.hscan(key, cursor, new ScanParams().count(batchSize));
cursor = result.getCursor();
if (!result.getResult().isEmpty()) {
String[] fields = result.getResult().stream()
.map(Map.Entry::getKey)
.toArray(String[]::new);
jedis.hdel(key, fields); // 一次删一批 field
}
} while (!"0".equals(cursor));
}
方法三:List 分批裁剪(ltrim)
public void deleteBigList(String key) {
while (true) {
// 每次只保留前 100 个元素,其余删除
jedis.ltrim(key, 0, 99);
if (jedis.llen(key) == 0) break;
}
}
ltrim 是 O(n),n 是删除的元素数。每次裁剪到固定长度,控制单次操作耗时。
方法四:Set 分批删除(sscan + srem)
public void deleteBigSet(String key, int batchSize) {
String cursor = "0";
do {
ScanResult<String> result =
jedis.sscan(key, cursor, new ScanParams().count(batchSize));
cursor = result.getCursor();
if (!result.getResult().isEmpty()) {
jedis.srem(key, result.getResult().toArray(new String[0]));
}
} while (!"0".equals(cursor));
}
方法五:ZSet 按排名分批删除(zremrangebyrank)
public void deleteBigZSet(String key) {
while (true) {
// 每次删除排名前 100 的元素
jedis.zremrangeByRank(key, 0, 99);
if (jedis.zcard(key) == 0) break;
}
}
ZSet 的 Scan 系列命令(zscan)也可用,但 zremrangebyrank 更加稳定——它直接按排名范围删除,不需要先扫描取出 member 再传回去。
如何从根本上避免大 key?
| 方案 | 适用场景 | 示例 |
|---|---|---|
| 拆分 key | 单个 key 存太多东西 | user:100 的 info 字段太多 → 拆为 user:100:profile、user:100:order |
| Hash 分桶 | Hash 的 field 太多 | cart:user:123 → cart:user:123:0 到 cart:user:123:9,key 哈希到 10 个桶 |
| 压缩数据 | String 类型的 JSON 太大 | 用 MessagePack、Protobuf 替代 JSON;或者 SNAPPY 压缩后存 binary |
| 设置过期时间 | 临时数据 | 避免无限增长的 List(如消息队列),定期清理过期数据 |
| 监控告警 | 预防 | 定期用 MEMORY USAGE 或 --bigkeys 检测,超过阈值告警 |
Hash 分桶示例
// 不要这样:一个 Hash 存所有购物车商品
HSET cart:user:123 item_001 1 item_002 2 ... item_100000 1
// 改成:
int bucketId = itemId.hashCode() % 10;
HSET cart:user:123:bucketId item_xxx quantity
总结
大 key 是 Redis 的慢性毒药——初期没有症状,量变积累到质变时,一次 DEL 就能把线上服务打崩。
处理流程:
- 发现:
redis-cli --bigkeys定期扫描,MEMORY USAGE确认大小。 - 删除:Redis 4.0+ 优先用
UNLINK异步删除;老版本用 SCAN + 分批删除(hscan+hdel / ltrim / sscan+srem / zremrangebyrank)。 - 预防:设计阶段就做 key 拆分或分桶,配合过期时间和监控告警。
记住这条原则:在 Redis 中,O(1) 永远比 O(n) 安全。
章末提问
Q1:为什么大 key 删除会阻塞 Redis?UNLINK 是怎么解决的?
回答思路:结论——大 key 删除是 O(n) 操作,在 Redis 单线程下遍历释放元素会卡住主线程几百毫秒到秒级、拖垮所有请求;UNLINK 靠 lazy-free 解决。因为:UNLINK 先 O(1) 从全局 dict 摘掉 key 引用、主线程立即返回,真正释放内存交给 bio 后台线程异步做;老版本则用 SCAN + 分批删,把大 O(n) 拆成多次小 O(n) 控制单次耗时。
Q2:redis-cli —bigkeys 的原理是什么?它有什么局限?
回答思路:结论———bigkeys 用 SCAN 遍历 keyspace,对每个 key 用 TYPE 判断类型、再用 STRLEN/LLEN/HLEN/SCARD/ZCARD 取大小,记录每类最大值。因为:它零侵入、Redis 自带,但只能找「每类最大的一个」,不能列全所有大 key;要深度排查还得用 MEMORY USAGE(含对象 overhead 更准)或 rdb-tools 离线分析 RDB 文件。
Q3:如何从设计上避免大 key?Hash 分桶的思路是什么?
回答思路:结论——从设计上避免:拆分 key、Hash 分桶、压缩数据、设 TTL、监控告警。因为:大 key 的根源是「单个 key 存太多」,Hash 分桶如 itemId.hashCode()%10 把一个大 Hash 拆成 10 个小 Hash,从源头保证每个 key 都在可控规模;再配合过期时间和监控,让大 key 根本长不出来,而不是等它长出来再删。