Skip to content

问题

线上 Redis 突然"卡"了——平时 1ms 的 GET 请求变成了 5 秒超时,客户端连接池被打满,上游服务雪崩式报错。重启 Redis 后恢复,但几小时后再次复现。翻开排查日志,发现罪魁祸首是一个 List 类型的 key,里面塞了 300 多万个元素。

这就是典型的 Redis 大 Key 问题。大 Key 是指单个 key 存储的 value 过大——String 超过 10MB、Hash 有数百万 field、List/Set/ZSet 有数百万元素。它不像缓存穿透、雪崩那样有"漂亮的解决方案",而是实实在在的运维事故,修复手段往往只有"切流量、停服务、删数据"三板斧。

分析

大 Key 的四大危害

1. 阻塞 Redis 主线程

Redis 事件循环是单线程的,O(N) 复杂度的命令会直接卡住所有后续请求。以 DEL 为例:

bash
# 删除一个包含 300 万成员的 Set
DEL hot_set
# 这条命令执行可能需要 10-30 秒
# 期间所有其他请求全部排队,全部超时

不仅仅是 DELHGETALL(返回大 Hash 全部 field)、SMEMBERS(返回大 Set 全部成员)、LRANGE 0 -1(返回大 List 全部元素)都会导致类似问题。

2. 网络带宽打满

大 Value 传输时,一次 GET 响应体可能达到 10MB+。如果并发 100 个请求,瞬时网络带宽需求就是 1GB/s。Redis 的 client-output-buffer-limit(默认 256MB 普通客户端、1GB 发布订阅客户端)在遇到大 Key 时很容易被打满,触发 client kill

3. 内存不均(Cluster 场景)

Redis Cluster 的 16384 个 Hash Slot 是按 key 的 CRC16 取模分配的。如果某个大 Key 落在一个 Slot 上,该 Slot 所在节点的内存使用率会远高于其他节点,导致"负载不均"——有的节点内存用了 80%,有的才 20%。

4. 数据迁移阻塞

Cluster 扩缩容时,需要用 MIGRATE 命令迁移 key。大 Key 的迁移是单线程阻塞的,一个大 Key 迁移可能需要几秒甚至几十秒,期间该节点无法处理其他请求。

产生大 Key 的常见原因

  • 业务代码没做限制:List 不断 RPUSH 尾部追加,数月后变成百万级
  • 日志/行为数据误存 Redis:应该用消息队列/ES 存储的行为日志,直接塞进了 Redis List
  • 缓存未设置 TTL:某些 key 理论上应该过期,但忘记设置 TTL,不断累积
  • Hash 做无限聚合:用 Hash 做计数器聚合,field 不断增长
  • 序列化膨胀:Java 默认 JDK 序列化比 JSON 大 3-5 倍,对象存进去再取出来体积翻番

代码示例

排查工具全家桶

1. redis-cli --bigkeys(最常用,适合低峰期扫描)

bash
# 扫描所有 key,按类型统计 Top 大 Key
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1

# 输出示例
# -------- summary -------
# Biggest string found 'session:user:10000' has 25165824 bytes
# Biggest   list found 'history:ops:log' has 3150000 items
# Biggest    set found 'online:device:set' has 1200000 members
# Biggest   hash found 'user:tag:stat' has 850000 fields
# Biggest   zset found 'hot:rank:202607' has 520000 members

关键参数说明:

  • -i 0.1:每次 SCAN 扫描间隔 0.1 秒,避免线上抖动
  • --bigkeys:本质是 SCAN 0 COUNT 100 + 对每个 key 调 TYPESTRLEN/LLEN/HLEN/SCARD/ZCARD 取大小

2. MEMORY USAGE(精确内存占用,比 --bigkeys 准)

bash
# 精确计算 key 占用内存(含数据结构开销)
MEMORY USAGE user:profile:10001
# 返回字节数,例如 1024576

# 批量扫描大 Key(配合 SCAN)
SCAN 0 COUNT 1000
# 对返回的 key 逐个 MEMORY USAGE,过滤出 > 1MB 的

3. DEBUG OBJECT(查看序列化后长度,更轻量)

bash
DEBUG OBJECT history:ops:log
# Value at:0x7f1234 refcount:1 encoding:quicklist serializedlength:3056789 ...
# serializedlength 是 RDB 序列化后的字节数,比实际内存小

4. 离线分析 RDB 文件

bash
# 安装 redis-rdb-tools
pip install rdbtools python-lzf

# 分析 RDB 文件,输出 Top 大 Key
rdb -c memory /var/lib/redis/dump.rdb --bytes 1024 > memory_report.csv

# CSV 中按 bytes 排序,找出最大 key
sort -t, -k4 -rn memory_report.csv | head -20

治理方案

1. 拆分大 Key

bash
# 原方案:一个 List 存所有操作日志
LPUSH history:ops:log "{\"op\":\"create\",\"time\":\"2026-07-20\"}"

# 拆分方案:按时间分桶
LPUSH history:ops:log:20260720 "{\"op\":\"create\",\"time\":\"2026-07-20\"}"
# 每天一个 List,单个 List 元素不超过 10 万
java
// Hash 拆分示例:按用户 ID 取模
public class BigHashSplitter {
    private static final int BUCKET_SIZE = 100;
    private final Jedis jedis;

    public BigHashSplitter(Jedis jedis) {
        this.jedis = jedis;
    }

    public void hsetSplitted(String baseKey, String field, String value) {
        // 按 field 哈希取模,分散到多个小 Hash
        int bucket = Math.abs(field.hashCode() % BUCKET_SIZE);
        String realKey = baseKey + ":" + bucket;
        jedis.hset(realKey, field, value);
    }

    public String hgetSplitted(String baseKey, String field) {
        int bucket = Math.abs(field.hashCode() % BUCKET_SIZE);
        String realKey = baseKey + ":" + bucket;
        return jedis.hget(realKey, field);
    }
}

2. 分批裁剪大 Key

bash
# 大 List 分批裁剪(保留最新 1000 条)
LTRIM history:ops:log -1000 -1

# 大 Set 分批删除指定成员
# 先取出部分成员,再逐个 SREM
SREM online:device:set "device:10001" "device:10002" "device:10003"

# 大 ZSet 按分数区间删除
ZREMRANGEBYSCORE hot:rank:202607 0 1000

# 大 Hash 批量删除字段
HDEL user:tag:stat field1 field2 field3

3. 异步删除——UNLINK 代替 DEL

bash
# 同步删除(阻塞,慎用)
DEL huge_key

# 异步删除(4.0+ 推荐)
UNLINK huge_key
# UNLINK 在后台线程回收内存,主线程立即返回 O(1)

4. 全局懒删除配置(6.0+)

bash
# redis.conf 配置
# 让所有 DEL 命令自动走异步删除
lazyfree-lazy-user-del yes

# 让 FLUSHALL/FLUSHDB 异步
lazyfree-lazy-flush yes

# 让 AOF rewrite 时异步释放 key
lazyfree-lazy-server-del yes

# 从节点复制时的异步删除
replica-lazy-flush yes

线上监控脚本

bash
#!/bin/bash
# redis-bigkey-monitor.sh
# 定时扫描大 Key 并告警

REDIS_HOST="127.0.0.1"
REDIS_PORT="6379"
THRESHOLD_BYTES=$((10 * 1024 * 1024))  # 10MB
THRESHOLD_COUNT=1000000                # 100万

# 使用 --bigkeys 扫描
redis-cli -h $REDIS_HOST -p $REDIS_PORT --bigkeys -i 0.1 > /tmp/bigkeys_scan.txt

# 解析结果,检查是否超过阈值
if grep -q "Biggest" /tmp/bigkeys_scan.txt; then
    # 找出超过阈值的 key 并告警
    echo "=== 大 Key 告警 $(date) ===" >> /var/log/redis/bigkey_alert.log
    grep "Biggest" /tmp/bigkeys_scan.txt >> /var/log/redis/bigkey_alert.log
    
    # 发送告警到企业微信/PagerDuty
    # curl -X POST -d "{\"msg\":\"Redis 大 Key 告警\"}" https://alert.service
fi

总结

  1. 大 Key 的根因是"无限制的数据增长",不是"set 了一个大 value"这么简单——业务代码没有做容量上限检查、没有设置 TTL、没有按时间分桶,才是真正的元凶

  2. 排查工具链要完整--bigkeys 做快速扫描,MEMORY USAGE 做精确确认,DEBUG OBJECT 做轻量快捷检查,rdb -c memory 做离线兜底分析

  3. 治理三板斧:拆(按时间/ID 分桶)、删(UNLINK/LTRIM 分批)、限(代码层容量上限 + TTL)

  4. 预防比治疗重要:在代码里写死集合类型的最大成员数,超过时报错或截断;在 Redis 配置里开 lazyfree-lazy-user-del yes 作为兜底

  5. 面试重点:能说清楚大 Key 的完整故障链路——"慢命令 → 主线程阻塞 → 客户端超时 → 连接池打满 → 上游服务雪崩 → 全链路降级"——比背出 5 条危害更有说服力。同时要能给出具体的监控指标(latency latestused_memory_dataset_perc 趋势)和参数配置(repl-backlog-sizeclient-output-buffer-limit

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。