压测报告里 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. 自测
- 为什么
DEL一个百万元素的 Set 会造成 P99 尖刺,而UNLINK不会? redis-cli --bigkeys为什么可能漏掉真正的大 key?- 集群里为什么给 key 改名不能让它换节点?换节点要怎么做?
- 热 key 加后缀打散后,读侧必须补上什么逻辑?写错会有什么表现?
MONITOR为什么不适合生产环境排查热 key?- 本地缓存、从库读、多副本这三种手段,各自牺牲了什么?