Skip to content
Go back

Redis 大 key 处理:定义、排查与删除的完整指南

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

原理:--bigkeysSCAN 命令遍历整个 keyspace,对每个 key 用 TYPE 判断类型,再用对应的 STRLENLLENHLENSCARDZCARD 获取大小,记录最大值。

优点:零侵入、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 USAGESTRLEN 更准确,因为 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 big:hash:key

UNLINKDEL 的区别:

DEL:  立即释放内存,主线程阻塞,直到释放完成
UNLINK: 将 key 从 keyspace 中移除(O(1)),
        内存释放交给后台线程(lazy-free)异步完成
        主线程立即返回,不阻塞

原理UNLINK 使用了 Redis 4.0 引入的 lazyfree 机制。调用 UNLINK 时:

  1. 从全局 dict 中移除 key 的引用(O(1))。
  2. 把 value 的内存释放任务放入 bio 后台线程池。
  3. 主线程立即返回 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:profileuser:100:order
Hash 分桶Hash 的 field 太多cart:user:123cart:user:123:0cart: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 就能把线上服务打崩。

处理流程

  1. 发现redis-cli --bigkeys 定期扫描,MEMORY USAGE 确认大小。
  2. 删除:Redis 4.0+ 优先用 UNLINK 异步删除;老版本用 SCAN + 分批删除(hscan+hdel / ltrim / sscan+srem / zremrangebyrank)。
  3. 预防:设计阶段就做 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 根本长不出来,而不是等它长出来再删。


Share this post on:

Previous Post
Redis 淘汰策略详解:LRU 与 LFU
Next Post
Redis Sentinel选主——三轮打分机制详解