Redis 热 Key 问题:如何发现和解决
问题
某天凌晨,线上告警响了:Redis 节点 CPU 100%,响应延迟从 1ms 飙升到 500ms,上游业务开始大面积超时。查了一圈,发现罪魁祸首是一个 Key——某热门直播间的人数统计,每秒被客户端请求了几十万次。这就是典型的热 Key(Hot Key)打穿单节点。
热 Key 是指某个 Key 被极高频率访问,导致承载该 Key 的 Redis 节点成为整个集群的瓶颈。不解决的话,故障会沿着调用链向上传导:Redis 超时 → 客户端连接池打满 → 业务线程阻塞 → 上游服务雪崩。
热 Key 的典型伤害量化
| 影响维度 | 典型表现 | 真实案例数据 |
|---|---|---|
| CPU 打满 | 单节点 CPU 100%,其他节点空闲 | 某直播平台 10w QPS 的直播间在线人数 Key,压满 8C 节点 |
| 网络带宽打满 | 出网带宽打满,同节点其他 Key 也被拖慢 | 1KB 的 Key 在 50w QPS 下需要 400Mbps 带宽 |
| 请求排队 | 单线程模型下请求排队,延迟从 1ms 到 5s | 某电商秒杀 Key 导致同节点延迟飙升 5000 倍 |
| 连接池耗尽 | 客户端连接池被阻塞请求占满,无法复用 | 业务线程 blocked,引发上游链路级联超时 |
如何发现热 Key
发现热 Key 是第一步。线上环境不能直接上 MONITOR(会吃掉 10 倍 CPU,官方建议生产禁止使用),需要区分场景选择方案。
方案一:客户端侧统计(生产推荐)
在 Redis 客户端 SDK 层(Jedis / Lettuce / Redisson)拦截请求,统计每个 Key 的访问频次。
// 基于 Jedis 的拦截器示例
public class HotKeyMonitor extends JedisMonitor {
// 用 LongAdder 替代 AtomicLong,高并发下 CAS 竞争减少 60%+
private static final ConcurrentHashMap<String, LongAdder> COUNTER = new ConcurrentHashMap<>();
private static final int WARN_THRESHOLD = 5000; // 每秒 5000 次 → WARN
private static final int CRIT_THRESHOLD = 20000; // 每秒 20000 次 → CRITICAL
@Override
public void onCommand(String command) {
String key = parseKeyFromCommand(command);
if (key != null) {
LongAdder counter = COUNTER.computeIfAbsent(key, k -> new LongAdder());
counter.increment();
long count = counter.sum();
if (count > CRIT_THRESHOLD) {
reportHotKey(key, count, "CRITICAL");
} else if (count > WARN_THRESHOLD) {
reportHotKey(key, count, "WARN");
}
}
}
// 每 1 秒由调度器触发,清理计数并上报
@Scheduled(fixedDelay = 1000)
public void flushAndReset() {
COUNTER.forEach((key, adder) -> {
long count = adder.sumThenReset();
if (count > WARN_THRESHOLD) {
// 上报到 Prometheus / 日志平台
Metrics.gauge("redis.hotkey.qps", count, "key", key);
}
});
}
}关键要点:
- 阈值设定:一般每秒超过集群单节点 QPS 的 10% 就算热 Key。例如单节点承载 5w QPS,超过 5000 就报警。
- 两级阈值:WARN(5000/s)和 CRITICAL(20000/s),WARN 只记录日志,CRITICAL 触发钉钉/电话告警。
- 性能开销:
LongAdder在高并发下比AtomicLong吞吐量高 3-5 倍(JDK 8 源码:LongAdder 内部维护 Cell 数组,分散 CAS 竞争点)。 - 上报去重:同一 Key 触发 WARN 后 1 分钟内不再重复报,用本地
Cache<String, Long>做去重。
方案二:Redis 服务端扫描
Redis 4.0+ 提供了 redis-cli --hotkeys 命令,基于 LFU(Least Frequently Used)策略统计访问频率。
# 低峰期执行,-i 为每次扫描间隔 0.1s
redis-cli --hotkeys -i 0.1输出示例:
# Scan the keyspace for hot keys, threshold is 0.5
[0.12%] Key: user:profile:10001 (1.0k accesses)
[0.15%] Key: live:room:9527 (580.0k accesses) ← 热 Key原理:LFU 在 Redis 中的实现是莫定计数法(Redis 源码 evict.c 中 LFUDecrAndReturn 函数)。核心逻辑:
- 每个对象维护一个 24-bit 的 counter(不是简单的频率,而是经过对数压缩的计数)
- 访问时 counter 递增,但递增幅度递减(避免高频 Key 溢出)
- 空闲时 counter 按时间衰减,衰减速率由
lfu-decay-time控制(默认 1 分钟) --hotkeys命令扫描所有 key → 取 counter 最高的 N 个 → 排序输出
注意:
--hotkeys需要开启 LFU 淘汰策略(maxmemory-policy allkeys-lfu或volatile-lfu)- 如果线上用的是
allkeys-lru或noeviction,--hotkeys不可用,必须切方案 - 扫描本身是 O(N) 的,N=1000 万 key 时耗时约 30-60 秒,建议低峰期运行
- 生产经验:数据量超过 1 亿 key 时,
--hotkeys扫描会触发主线程阻塞,不适合用
方案三:代理层统计
如果架构中使用了 Redis Proxy(Twemproxy / Codis / 自研 Proxy),在代理层做统计是侵入性最低的方案。
客户端 → Proxy(统计热点)→ Redis 节点Proxy 在转发命令时记录 Key 访问频率,当某个 Key 超过阈值时,直接推送到配置中心或告警系统。好处是业务代码零改动。
缺点:Proxy 本身会成为瓶颈。如果 Proxy 转发 10w QPS,统计逻辑不能阻塞主路径。业界方案:
- 用 Ring Buffer 做异步写入(Disruptor 模式)
- 每 1 秒聚合一次,避免频繁锁竞争
- 内存中维护 Top-K 堆(容量 1000),超过容量时淘汰低频率 Key
方案四:Redis 7.0 新增的 Hot Key 自动发现
Redis 7.0 引入了 CLUSTER HOTKEYS 命令(实验性),不需要手动扫描,节点自动统计访问频率最高的 Key。
redis-cli CLUSTER HOTKEYS 10输出:
1) "live:room:9527" (frequency: 580231)
2) "flash:item:10086" (frequency: 320100)
...原理:每个 Redis 节点在命令处理路径中维护一个采样计数器,每处理 1000 个命令采样一次,记录被访问的 Key。采样窗口内频率最高的 Key 聚合后返回。因为是采样,精度有限但足够定位热 Key。
注意:Redis 7.0 功能,需要升级集群到 7.0+。且如果集群节点数多(>100),聚合开销不可忽略。
如何解决热 Key
发现热 Key 之后,选择哪种治理方案取决于业务场景。
方案一:本地缓存兜底(最常用)
在业务进程内用 Caffeine / Guava Cache 缓存热 Key 的值,缓存时间 1-5 秒,QPS 可以降到 1/10 甚至更低。
public class HotKeyCache {
// 本地缓存,容量 1000,过期 3 秒
Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(3, TimeUnit.SECONDS)
.build();
public Object get(String key) {
Object val = localCache.getIfPresent(key);
if (val != null) {
return val;
}
// 本地没有,查 Redis
val = redis.get(key);
if (val != null) {
localCache.put(key, val);
}
return val;
}
}本地缓存击穿问题:上面代码有隐患。假设 3s 过期时,1000 个请求同时发现本地缓存失效,会同时回源 Redis,瞬间又打满。这就是缓存击穿。
正确解法:用 expireAfterWrite 和 refreshAfterWrite 配合,实现异步刷新。
LoadingCache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.SECONDS) // 5 秒后过期
.refreshAfterWrite(3, TimeUnit.SECONDS) // 3 秒后开始异步刷新
.build(key -> {
// 只有第一个请求会触发这里的加载,其他请求返回旧值
return redis.get(key);
});原理:缓存过期后,第一个请求触发回源(同步阻塞),其他并发请求直接返回旧值(不阻塞)。3-5 秒的窗口期内,最多只有一个线程回源 Redis,QPS 从 10w 降到 1/s。
方案二:Key 副本分散
将热 Key 拆成 N 个副本,分散到不同 Redis 节点。读请求随机选一个副本,写请求同步写所有副本。
原始 Key: live:room:9527
副本: live:room:9527:1, live:room:9527:2, live:room:9527:3public class HotKeySharding {
private static final int REPLICA_COUNT = 3;
public String get(String rawKey) {
// 读请求:随机选一个副本
int replica = ThreadLocalRandom.current().nextInt(REPLICA_COUNT);
String shardKey = rawKey + ":" + replica;
return redis.get(shardKey);
}
public void set(String rawKey, String value) {
// 写请求:写入所有副本
for (int i = 0; i < REPLICA_COUNT; i++) {
redis.set(rawKey + ":" + i, value);
}
}
}适用场景:读多写极少(如直播间在线人数、商品库存快照)。写频繁的场景下,副本一致性会成为新问题。
副本数量怎么定? 假设单节点 QPS 上限 5w,热 Key QPS 是 20w,需要至少 4 个副本。但考虑到 hash 分布不均匀,建议 N = 预期 QPS / 单节点上限 × 1.5(安全系数)。
副本数与集群节点数关系:如果集群只有 3 个节点,副本数设 3 足以,因为每个节点分配一个 hash slot。副本数超过节点数没意义(多个副本落到同一个节点,仍然打穿)。
方案三:热 Key 对集群 rehash 的影响
一个容易被忽略的问题:热 Key 导致集群 slot 迁移时数据倾斜治理失效。
Redis Cluster 的 rebalance 根据 slot 的 key 数量和内存大小做迁移,不关注访问频率。所以可能出现:
- 热 Key 分布在同一个 slot 内(不可分割)
- 即使迁移到新节点,所有流量全部跟过去
- 新节点瞬间被打满,rebalance 白做
解法:把热 Key 拆成多副本(方案二),让副本分散到不同 slot,rebalance 时才能均匀分布。
方案四:自动发现 + 动态降级(闭环方案)
把方案一和方案二组合成闭环:自动发现热 Key → 推送配置中心 → 自动激活本地缓存 → 恢复正常后自动降级。
时序图(文字描述):
时间轴 →
[客户端统计] 每 1s 扫描 Top-N 热 Key
↓ 超过阈值
[上报配置中心] Nacos/Apollo 推送热 Key 列表
↓
[业务进程监听配置] 收到热 Key 配置 → 激活本地缓存
↓
[本地缓存生效] QPS 从 20w 降到 200/s
↓
[持续监控] 30s 后热 Key QPS 降到阈值以下
↓
[自动移除] 配置中心删除该热 Key,本地缓存关闭// 热 Key 检测 + 推送配置的定时任务
@Scheduled(fixedDelay = 30000) // 每 30 秒
public void detectAndPushHotKeys() {
List<HotKeyEntry> topN = hotKeyCounter.getTopN(10);
for (HotKeyEntry entry : topN) {
if (entry.getQps() > HOT_KEY_THRESHOLD) {
configCenter.push("hotkey.enable", entry.getKey(), "true");
configCenter.push("hotkey.ttl", entry.getKey(), "3");
}
}
// 清理已恢复的热 Key
for (String key : configCenter.getKeys("hotkey.enable")) {
if (hotKeyCounter.getQps(key) < RECOVER_THRESHOLD) {
configCenter.remove("hotkey.enable", key);
}
}
}这个方案具备自愈能力,是 P7 级面试中能拿分的架构设计。面试官追问"热 Key 自动治理怎么落地"时,能给出这个闭环就是加分项。
总结
| 环节 | 推荐方案 | 注意事项 | 适用场景 |
|---|---|---|---|
| 发现 | 客户端侧统计 + 两级阈值 | 不要用 MONITOR,用 LongAdder 减少竞争 | 通用,生产标准做法 |
| 扫描 | redis-cli --hotkeys(低峰期) | 需开启 LFU 策略,>1亿 key 慎用 | 小规模集群、临时排查 |
| 发现 | Proxy 层统计 | 零侵入,但 Proxy 本身可能成瓶颈 | 已有 Proxy 架构 |
| 发现 | Redis 7.0 CLUSTER HOTKEYS | 采样精度有限,需升级 7.0 | 已升级 7.0 的集群 |
| 治理 | 本地缓存(Caffeine) | 注意缓存击穿,配合 refreshAfterWrite 异步刷新 | 通用,第一道防线 |
| 治理 | Key 副本分散 | 读多写极少,写频繁导致一致性问题 | 直播间在线人数、库存快照 |
| 治理 | 自动发现 + 配置中心降级 | 需要自愈逻辑,恢复后自动移除 | P7 级架构设计 |
热 Key 问题的本质是单点打穿的容灾。线上治理不是选一个方案,而是多层组合:客户端统计做发现,本地缓存做第一道防线,Key 副本做第二道,监控告警兜底。能画出"热 Key 自动发现 → 推配置 → 本地缓存激活 → 自动恢复"的闭环,才算有架构思维。
面试高频追问:热 Key 和 Big Key 有什么区别?前者是访问频率高,后者是数据量大。但两者常同时出现(比如一个 1MB 的热 Key 同时打满 CPU 和带宽),需要分别治理。面试时能说出这个区别,说明理解到位。