Redis 大 key 与热 key:从库延迟 40 秒,主库 CPU 却只有 30%
告警说从库落后 40 秒,但主库 QPS 和 CPU 都没涨。真正的原因是一个 380 MB 的 hash:大 key 不让请求变多,只让单次操作变慢,再把这些慢操作放大到每一个从库。
监控告警响的是「从库延迟 40 秒」,但主库 CPU 只有 30%,QPS 也没涨。
真正的原因是有人往 Redis 里塞了一个 380 MB 的 hash——大 key 不会让你的 QPS 变高,它只会让单次操作变慢,然后把这些慢操作复制给所有从库。
一、现象:延迟在从库,根因在主库
监控面板上的三个数:
| 指标 | 正常 | 故障时 |
|---|---|---|
| 主库 QPS | 8k | 8k(没变) |
| 主库 CPU | 25% | 32% |
| 从库延迟(master_repl_offset 差值) | < 1s | 40s 且持续增长 |
QPS 和 CPU 都不高,说明不是「请求太多」,而是「单次请求太重」。
SLOWLOG GET 10 一秒就定位了:
1) 1) (integer) 128 # 序号
2) (integer) 1730845121 # 时间戳
3) (integer) 2100341 # 耗时 2.1 秒
4) 1) "HGETALL"
2) "user:profile:all" # ← 一个 hash 装了 120 万个 field
HGETALL 一个 380 MB 的 hash,耗时 2.1 秒;这条命令在 AOF/RDB 复制流里同样要传 380 MB 给每个从库,于是从库光接收就落后了 40 秒。
二、先把两个概念分清
运维常把「大 key」和「热 key」混着说,但它们的表现和治理手段完全不同:
| 大 key | 热 key | |
|---|---|---|
| 定义 | 单个 key 的 value 体积大(如 > 10 MB)或元素多(如 > 5000) | 单个 key 的 QPS 极高(如 > 5000/s) |
| 表现 | 单次操作慢、网络打满、复制延迟、删除阻塞 | 单节点 CPU 打满、集群倾斜 |
| 危害范围 | 所有读写这个 key 的请求 + 全量从库 | 持有这个 key 的那个节点 |
| 主要手段 | 拆分、压缩、换结构 | 本地缓存、打散、限流 |
一个 key 可以同时是大 key 和热 key,但排查时一定要先判断是哪一类,否则会用错药:对大 key 加本地缓存没用,对热 key 拆分反而增加复杂度。
三、大 key 的三个危害
1. 阻塞式操作会卡住整个实例
Redis 是单线程的。HGETALL、LRANGE 0 -1、SMEMBERS、DEL 一个超大 key,都会在这段时间里阻塞所有其他请求。
DEL 尤其危险:释放 380 MB 的内存需要遍历并释放所有元素,实测能卡 1.5 秒。Redis 4.0 之后要用 UNLINK(后台线程渐进释放):
# 危险:同步释放,阻塞主线程
DEL user:profile:all
# 正确:把 key 从 keyspace 摘掉,内存由后台线程慢慢回收
UNLINK user:profile:all
2. 主从复制被放大
写一个大 key 的成本,在复制链路上是「一次操作 × N 个从库 × 数据量」。上面的 380 MB hash 每次更新,都要往每个从库传一遍。
3. 集群模式下的数据倾斜
Cluster 按 slot 分片,一个大 key 只能落在一个节点上。单个 380 MB 的 key 会让那个节点的内存比邻居高一倍,CLUSTER COUNTKEYSINSLOT 看起来很健康,但内存已经倾斜了。
四、怎么把大 key 找出来
1. --bigkeys:适合首次普查
redis-cli -h 127.0.0.1 --bigkeys
它按类型扫描并给出每种类型最大的 key。缺点是只采样不精确,而且会对线上造成压力(本质是全量 SCAN),低峰期跑。
2. MEMORY USAGE:精确量一个 key
redis-cli MEMORY USAGE user:profile:all SAMPLES 0
SAMPLES 0 表示全量计算,慢但准确;不指定则抽样估算。
3. SCAN 自己采样:可控且能落库统计
redis-cli --scan --pattern 'user:*' --count 1000 | head -1000 | while read k; do
echo "$k $(redis-cli MEMORY USAGE "$k" SAMPLES 5)"
done | sort -k2 -nr | head -20
注意:永远不要用 KEYS *,它会一次性阻塞整个实例,生产环境等同于故障。
4. 把阈值写进巡检
# 每天低峰跑一次,超过 5 MB 的记录下来
redis-cli --bigkeys 2>/dev/null | awk '/Biggest/ {print}'
redis-cli INFO memory | grep used_memory_human
五、大 key 怎么拆
1. hash 按业务维度分桶
# 改造前:一个 hash 装所有用户画像
user:profile:all → 120 万 field,380 MB
# 改造后:按用户 id 取模分桶
user:profile:0 ~ user:profile:63 → 每桶约 1.9 万 field,约 6 MB
代码层加一行分桶函数即可,读的时候只取一桶:
def bucket(uid: int, n: int = 64) -> str:
return f"user:profile:{uid % n}"
2. list 只保留最近 N 条
消息、日志、动态列表这类数据,绝大多数只需要「最近一页」。用 LTRIM 兜住上限:
LPUSH feed:123 "<item>"
LTRIM feed:123 0 199 # 只保留最新 200 条
3. value 先压缩再存
JSON 文本压缩率通常有 4–8 倍。1 MB 的 JSON 压完 150 KB,序列化成本远低于网络与内存的收益:
raw = json.dumps(obj).encode()
redis.set(key, zlib.compress(raw, 6))
4. 统计类数据换结构
「今天有多少人去重访问过」用 SET 会随用户数线性增长,换成 HyperLogLog 固定 12 KB,误差 0.81%;「用户是否签到」用 Bitmap,100 万用户只占 125 KB。
六、热 key 怎么处理
热 key 的问题在「单节点扛不住」,思路是把请求分散开:
| 手段 | 做法 | 代价 |
|---|---|---|
| 本地缓存 | 进程内 LRU 缓存 1–5 秒 | 有短暂不一致 |
| key 打散 | hotkey:{0..9} 十个副本,随机读 |
写要写十份 |
| 读写分离 | 热 key 读走从库 | 从库也可能被打满 |
| 限流兜底 | 单 key 维度限流,超了走降级 | 功能降级 |
最常见的组合是本地缓存 + 短过期:热点数据 1 秒的偏差通常可以接受,但 QPS 能从 8 万直接降到 800。
七、把它挡在写入侧
排查一次大 key 花的时间,比预防多十倍。三条约束值得写进代码规范:
- 集合类写入必须带长度上限:
LPUSH配LTRIM、HSET前判断 field 数、SADD超过阈值就告警; - 单 value 超过 1 MB 必须走对象存储,Redis 只存指针;
- 上线前跑一次容量评估:估算 key 数量 × 平均大小,超过实例内存 60% 就不许上。
监控侧加两条阈值就够发现 90% 的问题:
# 单个 key 超过 10 MB 告警
redis-cli --bigkeys | grep -E '([0-9]{2,}) MB'
# 从库延迟超过 5 秒告警
redis-cli INFO replication | grep lag
小结
- 从库延迟高但主库 CPU 不高,先怀疑大 key,而不是「请求太多」;
- 大 key 与热 key 是两类问题:前者靠拆分压缩,后者靠本地缓存与打散;
DEL大 key 会阻塞主线程,一律用UNLINK;- 找大 key 用
--bigkeys普查 +MEMORY USAGE精确定位,绝不用KEYS *; - 真正省事的是写入侧约束:集合有上限、大 value 外置、上线前做容量评估。
正在加载评论…