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 花的时间,比预防多十倍。三条约束值得写进代码规范:

  1. 集合类写入必须带长度上限:LPUSH 配 LTRIM、HSET 前判断 field 数、SADD 超过阈值就告警;
  2. 单 value 超过 1 MB 必须走对象存储,Redis 只存指针;
  3. 上线前跑一次容量评估:估算 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 外置、上线前做容量评估。
查看「数据库与缓存」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。