热点 Key 处理
问题
什么是热点 Key?热点 Key 有什么问题?如何发现和处理?
答案
一、什么是热点 Key
热点 Key 是指在极短时间内被大量并发访问的缓存 Key。比如:
- 秒杀商品的库存 Key
- 热门微博/话题的详情 Key
- 明星出轨相关的搜索 Key
- 首页推荐列表 Key
二、热点 Key 的危害
在 Redis Cluster 中,一个 Key 只属于一个 Slot(一个节点),所以热点 Key 会导致单节点过载,即使集群有很多节点也无济于事。
三、热点 Key 发现
方法一:Redis 命令
# redis-cli 的 hotkeys 功能(需要开启 maxmemory-policy = allkeys-lfu)
redis-cli --hotkeys
# MONITOR 命令(生产环境慎用,性能开销大)
redis-cli MONITOR | head -1000
方法二:业务侧统计
/**
* 基于滑动窗口的热点检测
*/
@Component
public class HotKeyDetector {
// Key -> 滑动窗口计数器
private final ConcurrentHashMap<String, AtomicLong> counterMap =
new ConcurrentHashMap<>();
// 10秒内访问超过 1000 次即为热点 Key
private static final long THRESHOLD = 1000;
private static final long WINDOW_MS = 10_000;
/**
* 记录访问并判断是否为热点
*/
public boolean isHotKey(String key) {
AtomicLong counter = counterMap.computeIfAbsent(key, k -> new AtomicLong(0));
long count = counter.incrementAndGet();
return count > THRESHOLD;
}
// 定时重置计数器
@Scheduled(fixedRate = 10_000)
public void resetCounters() {
counterMap.clear();
}
}
方法三:开源工具
- JD HotKey(京东开源):客户端 SDK + Worker + Etcd 探测热点 Key
- Redis 内置 LFU:
OBJECT FREQ <key>查看访问频率
四、热点 Key 解决方案
方案一:本地缓存(最常用)
将热点数据缓存到应用的 JVM 内存中,不经过 Redis。
@Service
public class HotKeyCacheService {
// L1 本地缓存:容量小,TTL 短
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(3, TimeUnit.SECONDS) // 3秒过期
.build();
@Autowired
private RedisTemplate<String, Object> redis;
@Autowired
private HotKeyDetector hotKeyDetector;
public Object get(String key) {
// 1. 热点 Key 走本地缓存
if (hotKeyDetector.isHotKey(key)) {
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
}
// 2. 查 Redis
Object value = redis.opsForValue().get(key);
if (value != null) {
// 热点 Key 回填本地缓存
if (hotKeyDetector.isHotKey(key)) {
localCache.put(key, value);
}
return value;
}
// 3. 查数据库...
return loadFromDB(key);
}
}
京东的 HotKey 框架通过 Worker 集群实时探测热点 Key,一旦识别,自动将热点数据推送到各应用节点的本地缓存。应用代码几乎无需修改:
// 使用 JD HotKey,自动识别热点并本地缓存
Object value = JdHotKeyStore.getValue(key);
方案二:Key 分片(分散热点)
将一个热点 Key 拆分成多个子 Key,分散到不同 Redis 节点。
/**
* 热点 Key 分片:将一个 Key 拆分成 N 个
*/
public class KeySharding {
private static final int SHARD_COUNT = 10; // 分片数
/**
* 写入时:随机写入一个分片
*/
public void set(String key, Object value) {
for (int i = 0; i < SHARD_COUNT; i++) {
String shardKey = key + ":shard:" + i;
redis.opsForValue().set(shardKey, value, 30, TimeUnit.MINUTES);
}
}
/**
* 读取时:随机读取一个分片
*/
public Object get(String key) {
int shard = ThreadLocalRandom.current().nextInt(SHARD_COUNT);
String shardKey = key + ":shard:" + shard;
return redis.opsForValue().get(shardKey);
}
}
原理:原来所有请求访问同一个 Key(同一个 Redis 节点),现在分成 10 个 Key,分布到 10 个不同的 Slot → 不同的节点,压力分散。
- Key 分片只适合读多写少的热点数据
- 写入时需要同步更新所有分片
- 更新分片之间存在短暂不一致窗口
方案三:读写分离 + 从节点读
利用 Redis 主从复制,让读请求分散到多个从节点:
// Lettuce 配置从节点读取
@Bean
public LettuceClientConfiguration lettuceConfig() {
return LettuceClientConfiguration.builder()
.readFrom(ReadFrom.REPLICA_PREFERRED) // 优先从从节点读取
.build();
}
适合 Redis 大版本中支持的读写分离场景。但要注意主从延迟可能导致从节点读到旧数据。
方案四:限流 + 降级
对热点 Key 的访问进行限流,超出限流阈值的请求直接返回降级数据。
// Guava RateLimiter 限流
private final RateLimiter rateLimiter = RateLimiter.create(10000); // 每秒 10000 次
public Object getWithRateLimit(String key) {
if (!rateLimiter.tryAcquire()) {
return getDefaultValue(key); // 降级:返回兜底数据
}
return cacheService.get(key);
}
五、方案对比
| 方案 | 效果 | 复杂度 | 一致性 | 适用场景 |
|---|---|---|---|---|
| 本地缓存 | ⭐⭐⭐⭐⭐ | 低 | 秒级延迟 | 通用方案,首选 |
| Key 分片 | ⭐⭐⭐⭐ | 中 | 写入延迟 | 读多写少 |
| 从节点读 | ⭐⭐⭐ | 低 | 主从延迟 | Redis 集群 |
| 限流降级 | ⭐⭐⭐ | 低 | 无影响 | 兜底方案 |
组合使用:本地缓存 + 限流降级。本地缓存消化大部分请求,限流保护 Redis 和数据库。
六、热点 Key 在秒杀场景中的应用
秒杀中库存 Key 是典型的热点 Key,但它是写热点(频繁扣减),不能简单用本地缓存。
/**
* 秒杀库存扣减方案:Redis + Lua 原子操作
*/
public boolean deductStock(String itemId) {
String key = "stock:" + itemId;
String luaScript =
"local stock = tonumber(redis.call('get', KEYS[1]))\n" +
"if stock and stock > 0 then\n" +
" redis.call('decr', KEYS[1])\n" +
" return 1\n" +
"end\n" +
"return 0";
Long result = redis.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(key));
return result != null && result == 1;
}
秒杀场景的热点 Key 方案:
- 预热:提前将库存加载到 Redis
- Lua 脚本原子扣减:避免超卖
- 本地缓存售罄标记:库存为 0 后在本地标记,后续请求直接返回
- 消息队列异步:将扣减请求投递到 MQ,削峰填谷
常见面试问题
Q1: 如何发现线上的热点 Key?
答案:
- Redis 监控:
redis-cli --hotkeys(需要 LFU 淘汰策略) - 代理层统计:在 Redis 代理(如 Twemproxy、Codis)中统计 Key 访问频率
- 客户端 SDK:在 Redis 客户端封装统计逻辑
- JD HotKey:开源的热点探测框架,实时探测 + 自动缓存
- 业务预判:秒杀、热搜等场景可以提前预判热点
Q2: 热点 Key 和大 Key 有什么区别?
答案:
| 对比 | 热点 Key | 大 Key |
|---|---|---|
| 问题 | 访问频率过高 | 单个 Value 体积过大 |
| 危害 | 单节点 CPU 打满 | 网络带宽打满,阻塞其他操作 |
| 示例 | 秒杀商品库存 | 10MB 的用户列表 JSON |
| 解决 | 本地缓存、Key 分片 | 拆分 Value、压缩 |
两个问题可能同时出现(又热又大),需要同时处理。
Q3: 本地缓存和 Redis 数据不一致怎么办?
答案:
- 设置短 TTL:本地缓存 3~10 秒过期,定期从 Redis 刷新
- 广播失效:数据更新时通过 Redis Pub/Sub 或 MQ 通知所有节点清除本地缓存
- 业务容忍:大多数热点场景可以容忍秒级不一致(如商品详情)
详见多级缓存架构。
Q4: Redis Cluster 中热点 Key 为什么不能自动负载均衡?
答案:
Redis Cluster 按 Key 的 CRC16 哈希值分配到 16384 个 Slot 中,每个 Key 固定属于一个 Slot,一个 Slot 固定在一个节点上。所以:
- 热点 Key 的所有请求都打到同一个节点
- Redis 不会自动迁移热点 Key 到其他节点
- 增加 Slot 数量无法解决问题(Key 还是只在一个 Slot)
解决思路:要么在客户端分散请求(本地缓存、Key 分片),要么增加从节点走读写分离。
Q5: JD HotKey 的原理是什么?
答案:
JD HotKey 架构:
- 客户端 SDK:嵌入应用,统计每个 Key 的访问频率
- Worker 集群:接收各节点上报的访问统计,聚合判断热点
- Etcd:存储热点 Key 规则和配置
- 探测到热点后:Worker 通过推送通知所有客户端 SDK,SDK 自动将热点 Key 缓存到本地 JVM
整个过程对业务代码透明,只需调用 JdHotKeyStore.getValue(key) 即可。