Skip to content

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 的访问频次。

java
// 基于 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)策略统计访问频率。

bash
# 低峰期执行,-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.cLFUDecrAndReturn 函数)。核心逻辑:

  • 每个对象维护一个 24-bit 的 counter(不是简单的频率,而是经过对数压缩的计数)
  • 访问时 counter 递增,但递增幅度递减(避免高频 Key 溢出)
  • 空闲时 counter 按时间衰减,衰减速率由 lfu-decay-time 控制(默认 1 分钟)
  • --hotkeys 命令扫描所有 key → 取 counter 最高的 N 个 → 排序输出

注意

  • --hotkeys 需要开启 LFU 淘汰策略(maxmemory-policy allkeys-lfuvolatile-lfu
  • 如果线上用的是 allkeys-lrunoeviction--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。

bash
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 甚至更低。

java
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,瞬间又打满。这就是缓存击穿

正确解法:用 expireAfterWriterefreshAfterWrite 配合,实现异步刷新。

java
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:3
java
public 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,本地缓存关闭
java
// 热 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 和带宽),需要分别治理。面试时能说出这个区别,说明理解到位。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。