华为云DCS-Redis大key与热key治理
华为云 DCS Redis 大 key 与热 key 治理:加了两个节点,集群反而更慢
内存告警、延迟尖刺、偶尔一次主从切换。第一反应是扩容,从 3 主 3 从扩到 4 主 4 从,结果延迟没降、内存没降,还多付了一台机器的钱。问题不在容量,在于两类被混为一谈的 key——大 key 是空间与阻塞问题,热 key 是单核问题,药方完全不同,用错方向会白花预算。
一、现象:扩容失效
集群规格 3 主 3 从,单节点 16 GB,总内存 48 GB。告警与异常表现:
| 观察项 | 现象 |
|---|---|
| 内存使用率 | 单节点 82%~88%,长期高位 |
| 延迟 P99 | 平时 2 ms,偶发 200~800 ms 尖刺 |
| 尖刺时间点 | 与定时任务、报表导出时间高度重合 |
| 主从切换 | 一个月内发生 1 次,切换期间请求大量失败 |
| 扩容后 | 延迟、内存均无改善 |
最后一条是关键:扩容无效,说明瓶颈不是"总量不够",而是"某几个点特别重"。
二、先分清两种病:大 key 与热 key
这两个词经常混着说,但它们是两个正交的问题:
| 维度 | 大 key | 热 key |
|---|---|---|
| 本质 | 单个 key 的数据量过大 | 单个 key 的访问频率过高 |
| 消耗什么 | 内存 + 单次操作耗时 | 单个 CPU 核心 |
| 症状 | 内存不均、偶发阻塞、网络带宽打满 | 延迟尖刺、QPS 上不去 |
| 扩容有用吗 | 有用(分摊内存) | 完全没用(一个 key 只在一个 slot) |
| 主要手段 | 拆分 key、异步删除、限制读取量 | 本地缓存、key 拆分、读写分离 |
扩容对热 key 完全无效,这是本次踩的第一个坑。
三、大 key:DEL 为什么会阻塞整个实例
Redis 是单线程处理命令的。删除一个大 key 时,需要逐个释放其内部所有元素的内存——这个释放过程是同步的,在这期间整个实例不处理任何其他命令。
一个存了 500 万元素的 Hash,DEL 可能阻塞数秒。而线上用的是同步删除:
DEL big_hash ← 阻塞,期间所有请求排队
Redis 4.0 之后提供了异步删除,把释放动作交给后台线程:
UNLINK big_hash ← 立即返回,后台线程慢慢释放
同时还有几个 lazyfree 相关配置,控制哪些场景走异步:
lazyfree-lazy-eviction yes # 淘汰时异步释放
lazyfree-lazy-expire yes # 过期时异步释放
lazyfree-lazy-server-del yes # RENAME 等隐式删除时异步
lazyfree-lazy-user-del yes # 用户执行 DEL 时也异步(等效 UNLINK)
注意 lazyfree-lazy-expire yes 这条尤其重要:如果大量 key 设置在同一时刻过期,过期删除会在那个时间点集中爆发,形成周期性尖刺——这和本文"尖刺与定时任务重合"的现象完全吻合。
大 key 还有两个隐性成本
① 读取时的网络与内存翻倍。 HGETALL 一个 500 万元素的 Hash,返回的数据要完整序列化、网络传输、再在客户端反序列化。服务端内存峰值和客户端内存峰值同时翻倍,这个成本在压测里往往测不出来(压测数据量小)。
② 集群内存分布不均。 Redis Cluster 按 slot 分片,一个大 key 只能落在一个节点上。如果几个大 key 恰好落在同一个节点,该节点内存远高于其他节点,而其他节点还有大量空闲——总内存看着够,单节点先 OOM。
四、热 key:为什么加节点完全无效
Redis Cluster 的 key 分布规则是:
slot = CRC16(key) mod 16384
一个 key 只对应一个 slot,一个 slot 只在一个主节点上。所以:
无论加多少节点,同一个 key 的读写永远落在同一台机器上。
而 Redis 处理命令是单线程的。一个 QPS 10 万的热 key,会把这个节点的单核彻底打满,其他节点再空闲也帮不上忙。这就是扩容后延迟毫无改善的原因。
热 key 的三种解法,按侵入性排序:
1. 本地缓存(首选)
在客户端加一层进程内缓存,短 TTL,挡掉绝大部分读请求:
// 伪代码:本地缓存 + 短过期,兜住热 key
private final Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(500, TimeUnit.MILLISECONDS) // 关键:短 TTL
.build();
public String getHotValue(String key) {
return localCache.get(key, k -> redis.get(k));
}
代价是一致性:500 ms 内可能读到旧值。所以本地缓存只适合"允许短暂不一致"的场景(商品详情、配置项、榜单),不适合余额、库存这类强一致数据。
2. key 拆分(写扩散)
把 hot:item:1001 复制成 hot:item:1001:0 ~ hot:item:1001:9 十份,读的时候随机取一个:
int shard = ThreadLocalRandom.current().nextInt(10);
String realKey = "hot:item:1001:" + shard;
这十份会分布到不同 slot,从而分散到不同节点,真正用上了集群的多核能力。
代价是写扩散:更新时要写十份,且存在短暂不一致窗口。适合读远多于写的场景(比如热点商品),不适合写频繁的数据。
3. 读写分离
华为云 DCS 支持读写分离实例,把读请求分摊到从节点。这是成本最低的方案,但要注意主从复制是异步的,从节点数据可能有毫秒级延迟。
五、怎么把这两类 key 找出来
1. 扫大 key
绝对不要用 KEYS *——它是全量遍历,在百万级 key 的实例上会阻塞数秒,线上等同于一次故障。
正确工具是 --bigkeys,它内部用 SCAN 增量遍历,不阻塞:
redis-cli -h <host> -p 6379 --bigkeys
输出会按类型列出最大的 key。但它只看元素数量,不看实际内存。要精确判断内存占用,用:
# 对可疑 key 逐个查内存(Redis 4.0+)
redis-cli MEMORY USAGE big_hash
如果要批量评估,用 SCAN 游标遍历 + MEMORY USAGE 采样:
redis-cli --scan --pattern 'item:*' --count 100 \
| while read k; do
echo "$(redis-cli MEMORY USAGE "$k") $k"
done | sort -rn | head -20
注意 SCAN 的语义:它是增量遍历,遍历期间新增/删除的 key 可能漏掉或重复,不保证完整快照。对排查来说够用,但别拿它当精确统计。
2. 找热 key
--hotkeys 依赖 LFU 计数器,必须先把淘汰策略设成 LFU 系列,否则命令直接报错:
maxmemory-policy allkeys-lfu # 或 volatile-lfu
然后:
redis-cli --hotkeys
如果线上策略是 LRU 不能改,退化方案是看 SLOWLOG 和 INFO commandstats,或者用 MONITOR 短时间采样(MONITOR 会显著影响性能,只能开几秒,严禁长时间挂)。
六、淘汰策略:一个容易引发事故的默认值
maxmemory-policy 的选择直接影响"内存满时会发生什么":
| 策略 | 内存满时的行为 | 适用 |
|---|---|---|
noeviction |
写入直接报错 | 缓存不能丢的场景(但会写失败) |
allkeys-lru |
淘汰最久未用的 key | 纯缓存,最常用 |
allkeys-lfu |
淘汰访问频率最低的 key | 有明显热点,找热 key 需要它 |
volatile-lru/lfu |
只在设置了过期时间的 key 里淘汰 | 混合用途 |
allkeys-random |
随机淘汰 | 基本不用 |
两个坑:
noeviction在内存满时会返回 OOM 错误给客户端,业务表现为"写入突然全部失败",但监控上内存只是"到顶了",很容易误判成网络问题。- 淘汰动作本身消耗 CPU。内存长期贴着上限跑,淘汰会持续触发,进一步拉高延迟——这也是为什么内存使用率 85% 就该处理,而不是等到 100%。
七、治理方案与优先级
| 优先级 | 动作 | 风险 | 见效 |
|---|---|---|---|
| 1 | 开启 lazyfree 系列配置(含 lazyfree-lazy-expire) |
极低 | 立即消除删除/过期阻塞 |
| 2 | 大 key 拆分,按业务维度切分 | 中(需改代码) | 1~2 周 |
| 3 | 热 key 加本地缓存或 key 拆分 | 中(需评估一致性) | 1~2 周 |
| 4 | 调整 maxmemory-policy 为 LFU |
低 | 立即 |
| 5 | 把 KEYS、HGETALL 大集合替换为 SCAN / HSCAN 分批 |
中(需改代码) | 2~4 周 |
| 6 | 读写分离 / 扩容 | 低 | 立即(但可能无效) |
第 1 项几乎零成本、零风险,应该立刻做。 而第 6 项要放在最后——因为前面已经证明,方向错了的话扩容纯属浪费。
八、效果
| 指标 | 治理前 | 治理后 |
|---|---|---|
| 延迟 P99 | 200~800 ms(尖刺) | 4 ms |
| 单节点内存使用率 | 82%~88% | 55%~62% |
| 内存分布偏差(最高/最低) | 2.3 倍 | 1.1 倍 |
| 主从切换 | 1 次/月 | 0 |
| 节点数 | 4 主 4 从(扩容后仍慢) | 回退到 3 主 3 从 |
最后一行值得注意:治理完成后,节点数比扩容前还少,性能和内存却都改善了。这说明当初的扩容是完全错误的方向。
九、小结
四句话:
- 先分清大 key 和热 key:前者是空间与阻塞问题,后者是单核问题,扩容只对前者有效。
- 删除大 key 必须异步,
UNLINK加 lazyfree 配置,否则一次DEL就能阻塞整个实例数秒。 - 热 key 加节点无效,因为一个 key 只落在一个 slot 上;要靠本地缓存、key 拆分或读写分离。
- 别用
KEYS排查,用--bigkeys和SCAN;--hotkeys需要先把淘汰策略设为 LFU。
如果只记一句:Redis 变慢的时候,先问是"哪个 key 太大"还是"哪个 key 太热",这两个答案通向完全不同的修法。
- 点赞
- 收藏
- 关注作者
评论(0)