大促期间某热门商品 Key 被 80% 请求集中访问,单 Redis 节点 CPU 100%,拖垮整个集群。排查路径:监控告警 → 流量分析 → Redis slowlog → redis-cli --hotkeys 定位 → 发现 maxmemory-policy 配置错误加剧了问题。最终方案:Caffeine 本地缓存 + Redis 二级架构 + 热 Key 自动探测多副本分散
🚨 00:00 故障爆发
大促零点刚过,监控大屏突然变红。
[00:00:12] ALERT: Redis-Node-03 CPU使用率达99.8%
[00:00:15] ALERT: API响应超时率突破50%
[00:00:18] ALERT: Redis集群内存使用量快速下降
[00:00:22] ALERT: 订单服务POD重启
客服开始收到用户反馈"页面加载慢"、"下单失败"。运营同学在群里问:"怎么回事?刚上线就崩了?"
🩹 00:15 应急止血
值班团队立刻响应:
- 限流降级:对热门商品接口实施临时限流,QPS从5000降到1000
- 缓存预热:紧急将热门商品数据加载到本地内存
- 节点隔离:将 Redis-Node-03 从集群中摘除,避免影响其他节点
15分钟后,系统恢复正常。但问题根因还没找到,大促还在继续,随时可能再次爆发。
🔍 01:00 根因追踪
第一步:流量分析
查看网关日志,发现一个惊人的现象:
# 统计Top10访问Key
cat gateway.log | grep "product:detail" | awk -F'=' '{print $2}' | sort | uniq -c | sort -nr | head -10
823456 product:detail:10086
34521 product:detail:10087
28901 product:detail:10088
5621 product:detail:10089
...
80%以上的请求集中在同一个商品ID:10086——这是本次大促的爆款商品!
第二步:Redis slowlog 分析
redis-cli -h redis-node-03 -p 6379 slowlog get 50
1) 1) (integer) 123456
2) (integer) 1699996812
3) (integer) 25000
4) 1) "GET"
2) "product:detail:10086"
5) "127.0.0.1:54321"
单个 GET 请求耗时 25ms,正常应该在 0.1ms 以内。
第三步:redis-cli --hotkeys
redis-cli -h redis-node-03 -p 6379 --hotkeys
# Scanning the entire keyspace to find hot keys...
# [00.00%] Hot key found! Key 'product:detail:10086' with counter 823456
# [00.00%] Hot key found! Key 'product:detail:10087' with counter 34521
# ...
确认了:product:detail:10086 是热 Key,每秒被访问超过 10000 次。
💥 03:00 隐藏陷阱
找到热 Key 后,我们以为问题就这么简单。但为什么一个 GET 请求会导致 CPU 100%?
关键发现:maxmemory-policy 配置错误
redis-cli -h redis-node-03 -p 6379 config get maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"
问题就在这里!
我们的配置是 allkeys-lru,意味着:
- 当内存达到上限时,Redis 会从所有 Key中淘汰最近最少使用的
- 热 Key 被频繁访问,永远不会被淘汰
- 但问题是:LRU 淘汰需要遍历所有 Key 来找出"最近最少使用"的
当热 Key 占满请求时,Redis 还在后台不断执行 LRU 淘汰扫描,这直接导致了 CPU 飙升!
正确配置应该是什么?
# 推荐配置:只淘汰带过期时间的Key
redis-cli config set maxmemory-policy volatile-lru
或者更好的选择:
# 推荐配置:volatile-ttl 优先淘汰即将过期的Key
redis-cli config set maxmemory-policy volatile-ttl
| 策略 | 适用场景 | 风险 |
|---|---|---|
allkeys-lru | 所有Key都可能被访问,内存有限 | 热Key场景下淘汰扫描消耗大量CPU |
volatile-lru | 大部分Key有过期时间 | 无过期时间的Key永远不会被淘汰 |
volatile-ttl | Key有明确的过期时间 | 需要合理设置TTL |
noeviction | 不淘汰任何Key,内存满时拒绝写入 | 可能导致写入失败 |
🛠️ 06:00 最终方案
方案一:Caffeine 本地缓存 + Redis 二级架构
@Service
public class ProductCacheService {
private final Cache<String, ProductDetail> localCache;
private final StringRedisTemplate redisTemplate;
public ProductCacheService() {
this.localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build();
}
public ProductDetail getProductDetail(Long productId) {
String key = "product:detail:" + productId;
// 一级缓存:Caffeine本地缓存
ProductDetail cached = localCache.getIfPresent(key);
if (cached != null) {
return cached;
}
// 二级缓存:Redis
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
ProductDetail product = JsonUtil.parseObject(json, ProductDetail.class);
localCache.put(key, product);
return product;
}
// 三级缓存:数据库
ProductDetail product = productService.getById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, JsonUtil.toJsonString(product),
30, TimeUnit.MINUTES);
localCache.put(key, product);
}
return product;
}
}
方案二:热 Key 自动探测与多副本分散
@Component
public class HotKeyDetector {
private final StringRedisTemplate redisTemplate;
private final HotKeyRepository hotKeyRepository;
@Value("${hotkey.threshold:1000}")
private int threshold;
@Scheduled(fixedRate = 60000)
public void detectHotKeys() {
Set<String> currentHotKeys = new HashSet<>();
Cursor<Map.Entry<Object, Object>> cursor = redisTemplate.execute((RedisCallback<Cursor<Map.Entry<Object, Object>>>) connection ->
connection.scan(new ScanOptions.Builder().match("product:detail:*").build()));
while (cursor.hasNext()) {
Map.Entry<Object, Object> entry = cursor.next();
String key = new String((byte[]) entry.getKey());
Long ttl = redisTemplate.getExpire(key);
if (ttl > 0) {
String countKey = "hotkey:count:" + key;
Long count = redisTemplate.opsForValue().increment(countKey, 0);
if (count != null && count > threshold) {
currentHotKeys.add(key);
}
}
}
hotKeyRepository.saveAll(currentHotKeys.stream()
.map(k -> HotKeyRecord.builder()
.key(k)
.count(threshold)
.status("ACTIVE")
.build())
.collect(Collectors.toList()));
}
}
方案三:热 Key 多副本分散写入
@Component
public class HotKeyReplicator {
private final List<String> redisNodes = Arrays.asList(
"redis-node-01:6379",
"redis-node-02:6379",
"redis-node-03:6379",
"redis-node-04:6379"
);
public void setHotKey(String key, String value, long timeout) {
int replicas = Math.min(redisNodes.size(), 4);
for (int i = 0; i < replicas; i++) {
String replicaKey = key + ":replica:" + i;
String node = redisNodes.get(i % redisNodes.size());
try (Jedis jedis = new Jedis(node)) {
jedis.setex(replicaKey, (int) timeout, value);
}
}
}
public String getHotKey(String key) {
int randomNode = ThreadLocalRandom.current().nextInt(redisNodes.size());
String replicaKey = key + ":replica:" + randomNode;
String node = redisNodes.get(randomNode);
try (Jedis jedis = new Jedis(node)) {
String value = jedis.get(replicaKey);
if (value != null) {
return value;
}
}
return null;
}
}
📊 效果对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| Redis CPU 使用率 | 99.8% | 15% | -85% |
| 单Key请求耗时 | 25ms | 0.1ms | -99.6% |
| API响应超时率 | 50% | 0.1% | -99.8% |
| 系统吞吐量 | 1000 QPS | 100000 QPS | +9900% |
📝 踩坑总结
- 热 Key 是大促的常态:爆款商品、热点新闻、节假日活动都会产生热 Key
- maxmemory-policy 配置不可忽视:
allkeys-lru在热 Key 场景下会导致严重的 CPU 消耗 - 二级缓存架构是标配:本地缓存 + 分布式缓存的组合能有效分担压力
- 热 Key 探测需要自动化:手动排查太慢,需要建立自动探测和治理机制
- 多副本分散是终极方案:将热 Key 的压力分散到多个节点,避免单点瓶颈
💬 互动话题
你在生产环境中遇到过热 Key 问题吗?是怎么解决的?欢迎在评论区分享你的经验!
