2844 字
约 9 分钟
13
S12-X08 · Redis 大 key 与热 key:识别、拆分与治理

压测报告里 Redis 侧 QPS 看着不高,单实例的出口带宽却先到了瓶颈;线上另一处更怪,一次 DEL 让整个集群的 P99 出现明显尖刺。两个现象的共同点是问题不在总量,在分布:一个 key 太大,或者一个 key 太热。

Redis 是单线程处理命令的(Redis 7 的 IO 多线程只分担读写与协议解析,命令执行仍是单线程),所以"某个 key 特别大或特别热"直接等于"某个操作会独占这条线程"。识别和治理的套路,就是围绕这句话展开的。


1. 大 key 的判定口径

类型 常用阈值 说明
String value > 10 KB 序列化后的 JSON、图片 base64 都在此列
Hash / List / Set / ZSet 元素数 > 5000–10000 元素数比字节数更容易监控
单 key 内存 MEMORY USAGE > 1 MB 集群下按 slot 分布看倾斜更准

口径不必统一,但必须写进团队规范并配告警。没有阈值的"大 key"讨论,最后都会变成"我觉得不算大"。


2. 一个 key 太大,伤在哪

伤害 机制 表现
阻塞单线程 DEL 大集合是 O(N),命令执行期间其他命令排队 一次删除引起 P99 尖刺
带宽打满 单次 GET 传输整个 value 出口带宽先于 QPS 到顶
删除卡顿 DEL + 惰性释放,内存碎片上涨 删除后内存不降、RSS 上涨
集群倾斜 整个 key 落在一个 slot、一个节点 单节点内存/CPU 明显高于同集群其他节点

还有两个次生效应:主从复制时大 key 的写命令会放大复制流量;RDB/AOF 重写时大 key 让 fork 后的 COW 页更多,间接拉长持久化时间。


3. 识别:四条路径,各有盲区

# 1) 抽样找每种类型最大的 key(内部用 SCAN,不阻塞,但只给"最大"不给全貌)
redis-cli -h 127.0.0.1 -p 6379 --bigkeys

# 2) 精确看单个 key 的内存占用(SAMPLES 0 = 全量采样,最准也最慢)
redis-cli MEMORY USAGE user:profile:8848 SAMPLES 0

# 3) 对可疑前缀做抽样:SCAN 游标分批,配合类型命令
redis-cli --scan --pattern 'feed:*' --count 1000 | head -50 | \
  xargs -I{} redis-cli STRLEN {}

# 4) 慢日志里找"单条命令耗时异常"的 key(默认只记超过阈值的命令)
redis-cli CONFIG SET slowlog-log-slower-than 10000   # 单位微秒
redis-cli SLOWLOG GET 20
手段 覆盖范围 盲区
--bigkeys 每种类型 Top1 只给最大值,不给分布;抽样可能漏掉
MEMORY USAGE 单 key 精确 需要先知道 key 名
SCAN + 类型命令 按前缀普查 遍历成本高,只能离线跑
慢日志 已发生的慢命令 只记录超过阈值的,且要看得到具体 key 名
监控面板 节点内存倾斜 只能定位到节点,定位不到 key

--bigkeys 在 Redis 6.2 之后还提供 --memkeys(走 MEMORY USAGE 估算),比 --bigkeys 更贴近真实内存。生产上不要用 KEYS * 排查:它一次性遍历整个 keyspace 并构造返回数组,阻塞时间与 key 数量成正比。


4. 删除:别用 DEL

# 异步删除:把释放内存的动作交给后台线程(Redis 4.0+)
UNLINK feed:8848

# 让所有 DEL 都表现得像 UNLINK(谨慎,会改变全局语义)
CONFIG SET lazyfree-lazy-user-del yes

集合类不能一把 UNLINK 就完事的时候(比如要保留部分字段),用分批删除:

-- 每次删 500 个 field,返回下一个游标,由客户端循环调用直到返回 0
local cursor = tonumber(ARGV[1])
local batch = redis.call('HSCAN', KEYS[1], cursor, 'COUNT', 500)
local fields, next = batch[1], batch[2]
for i = 1, #fields, 2 do
  redis.call('HDEL', KEYS[1], fields[i])
end
return next

要点:每批控制在毫秒级(500–1000 个元素量级),批次之间 sleep 一下,把一次 O(N) 拆成 N 次 O(1)。删除脚本同样要避开业务高峰——它只是把尖峰摊平,不是消除成本。


5. 拆分:四种策略

策略 做法 适用
按业务维度拆 feed:uid:8848 代替全站 feed:all 天然有归属维度(用户、店铺、租户)
按哈希分桶 feed:8848:{0..15},读写时 hash(uid) % 16 单 key 元素数过大,但需要整体读
集合拆成多个小集合 1 万条的分页列表拆成 100 个 100 条的 key 分页读取,天然按页命中
大 value 拆字段 JSON 拆成 Hash 的多个 field,按需 HGET 只读部分字段、value 体积大

分桶后有个必然的代价:要拿全量数据就得聚合多次 GET/MGET,聚合逻辑一旦写错(桶数变了、旧数据没迁移)就会出现"少了几个桶"的诡异缺数据。桶数变更必须走双写 + 回填,不能直接改模数。


6. 热 key:判定与识别

判定维度 口径
单 key QPS 占比 单 key QPS / 实例总 QPS,超过 10% 量级即需关注
集群集中度 某 key 落在单一 slot,而该 slot 承载的 QPS 远超均值
带宽占比 单 key 的出流量 / 实例出带宽
访问突增 短窗口内同一 key 的 QPS 涨 10 倍以上(活动、热点事件)

识别手段的取舍:

// 客户端埋点:滑动窗口计数,采样上报(对性能影响可控)
private final Cache<String, LongAdder> counter = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofSeconds(10)).maximumSize(10_000).build();

public String get(String key) {
    counter.get(key, k -> new LongAdder()).increment();
    return redis.opsForValue().get(key);
}
手段 精度 开销 能否上生产
客户端埋点 高(只看本应用) 低(本地计数) 可以
代理层统计 高(全集群视角) 中(需代理) 可以(有代理时首选)
redis-cli --hotkeys 中 需 LFU 淘汰策略 可以,但会全量扫描
MONITOR 最全 极高,输出量巨大 不可以

MONITOR 会把每条命令回传给客户端,QPS 上万时客户端先被输出拖死;它的正确位置是测试环境复现,不是线上排查。


7. 热 key 的治理

# 读副本打散:同一份数据写 N 个副本 key,读时随机取一个
SET hot:item:8848:0 '{"id":8848,...}' EX 300
SET hot:item:8848:1 '{"id":8848,...}' EX 300
SET hot:item:8848:2 '{"id":8848,...}' EX 300
手段 作用 代价
本地缓存 + 短 TTL(1–5 秒) 把绝大部分读挡在进程内 多实例间短暂不一致
多副本 key + 随机读 把单 key 压力摊到多个 slot/节点 写放大 N 倍;副本需同步失效
key 加随机后缀打散 + 聚合 突破单 slot 上限 聚合逻辑复杂,MGET 次数上升
读写分离 读流量分散到从库 复制延迟导致读到旧值

加后缀的方案必须同时想清楚聚合:读侧要 MGET 所有分片再合并,分片数与分片规则要版本化。实践中最常见的 bug 是"写侧用了 16 个分片,读侧按 8 个聚合",表现为数据随机缺失,且只在流量高峰才明显。


8. 分层与集群分布

层 承担什么 不承担什么
本地缓存(Caffeine) 极热 key、秒级容忍旧值 一致性要求高的判定
Redis 主库 写与强一致读 大 key 的全量读
Redis 从库 读扩展 写后立刻读(有复制延迟)
DB 真相源 承接缓存击穿流量

集群模式下 key 的归属由 CRC16(key) mod 16384 决定,slot 固定绑定数据。改名不能迁移数据:想让一个 key 换 slot,只能用 MIGRATE 搬,或双写新 key 再删旧 key。用 {} hash tag 可以强制多个 key 落同一 slot(便于 MGET/Lua 原子操作),但这恰好是热点集中的成因之一——{8848}:a、{8848}:b 会一起压在同一个节点上。


9. 常见坑

坑 后果 正确做法
用 KEYS * 排查 阻塞主线程数秒 SCAN 游标分批
热 key 加后缀后聚合写错 数据随机缺失 分片数版本化,读写共用同一份配置
只盯内存不盯带宽 内存正常但网络先满 监控出口带宽与单 key 出流量
DEL 删大集合 P99 尖刺、内存碎片 UNLINK 或分批 HDEL
大 key 放 Hash 后整取 HGETALL 仍是 O(N) 按 field 精确 HGET/HMGET
以为 UNLINK 立刻释放 内存延迟下降,误判为泄漏 看 used_memory 与 mem_fragmentation_ratio 趋势

10. 排错

现象 可能原因 处理
删除后内存不降 惰性释放尚未回收 / 碎片 看 lazyfree_pending_objects,必要时重启或 MEMORY PURGE
单节点内存远高于其他 大 key 或 hash tag 集中 定位大 key 后拆分,去掉强制 tag
带宽跑满但 QPS 不高 大 value 传输 拆字段、压缩、改按需读
--bigkeys 结果与实际不符 抽样 + 只取 Top1 用 --memkeys 或离线 SCAN 普查
热 key 副本读到的值不一致 副本写未同步 / TTL 不一致 副本同时写、同一 TTL,或改读主
集群迁移后访问变慢 大量 key 跨 slot 需要 MOVED 重定向 用 hash tag 收敛同族 key,减少重定向

11. 边界

场景 更合适的方案
单 value 超过几十 MB 放对象存储,Redis 只存引用
需要按多维度范围查询 换用支持索引的存储,Redis 只做缓存
计数类超热 key(如秒杀库存) Lua 原子扣减 + 本地预扣,不要靠多副本读
需要强一致的热点写 分段锁 / 队列串行化,Redis 单 key 就是串行点

Redis 的强项是"单 key 极快",弱项是"单 key 很大或很热"。所有治理手段本质上都在做同一件事:把一个大或热的点,拆成许多小而温的点。


12. 自测

  1. 为什么 DEL 一个百万元素的 Set 会造成 P99 尖刺,而 UNLINK 不会?
  2. redis-cli --bigkeys 为什么可能漏掉真正的大 key?
  3. 集群里为什么给 key 改名不能让它换节点?换节点要怎么做?
  4. 热 key 加后缀打散后,读侧必须补上什么逻辑?写错会有什么表现?
  5. MONITOR 为什么不适合生产环境排查热 key?
  6. 本地缓存、从库读、多副本这三种手段,各自牺牲了什么?
S12-X08 · Redis 大 key 与热 key:识别、拆分与治理
https://www.clxhxhhr.top/posts/5819/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。