痛点
线上 Redis Cluster 突然出现单节点 CPU 飙升到 90%+,其他节点负载却很低。业务端大量超时报错,监控告警不断。排查发现:某个 Key 的 QPS 占该节点总请求的 60% 以上 —— 典型的热 Key(Hot Key)问题。
热 Key 是 Redis Cluster 架构下最常见的性能瓶颈之一。由于 Cluster 按 slot 分片,同一个 Key 只会落在一个节点上,无论集群规模多大,单 Key 的流量天花板就是单节点的处理能力。秒杀活动的库存 Key、热门商品详情缓存、全局计数器 —— 这些场景都是热 Key 的重灾区。
方案概述
解决热 Key 问题的核心思路是 发现 → 拆分 → 兜底:
- 快速定位热 Key:利用 Redis 内置命令 + 客户端监控双管齐下
- 读热 Key 拆分:本地缓存 + Key 分片打散
- 写热 Key 优化:聚合写入 + Lua 原子操作
- 兜底保护:限流熔断,防止单点击穿
实操步骤
第 1 步:快速定位热 Key
方法一:redis-cli --hotkeys(Redis 4.0+,需开启 LFU)
确保 maxmemory-policy 设为 LFU 策略(如 allkeys-lfu 或 volatile-lfu):
# 查看当前淘汰策略
redis-cli -h <node-ip> -p 6379 CONFIG GET maxmemory-policy
# 如需修改为 LFU(线上建议灰度验证后再改)
redis-cli -h <node-ip> -p 6379 CONFIG SET maxmemory-policy allkeys-lfu
# 扫描热 Key(基于 LFU 计数器排序)
redis-cli -h <node-ip> -p 6379 --hotkeys
输出示例:
Summary of hot keys found with frequency counter:
hot:product:detail:10086 (counter=255)
hot:seckill:stock:2024080301 (counter=248)
hot:user:session:global_config (counter=230)
方法二:MONITOR + 实时统计(短时间采样,生产慎用)
# 采样 10 秒,统计 Top 10 Key
redis-cli -h <node-ip> -p 6379 MONITOR | head -n 50000 | \
awk '{print $NF}' | sort | uniq -c | sort -rn | head -10
⚠️
MONITOR会消耗额外 CPU,生产环境只在紧急排查时短暂开启,采样后立即关闭。
方法三:客户端侧统计(推荐长期方案)
在 Redis 客户端 SDK(如 Jedis、Lettuce、redis-py)中嵌入 Key 访问计数,通过 Prometheus 指标暴露:
# redis-py wrapper 示例
import redis
from prometheus_client import Counter
redis_key_access = Counter(
'redis_key_access_total',
'Redis key access counter',
['key_prefix']
)
class MonitoredRedis:
def __init__(self, **kwargs):
self._client = redis.Redis(**kwargs)
def get(self, key):
# 按前缀聚合,避免高基数
prefix = key.split(':')[0] if ':' in key else key
redis_key_access.labels(key_prefix=prefix).inc()
return self._client.get(key)
第 2 步:读热 Key 优化 —— 本地缓存 + Key 分片
方案 A:本地缓存(L1 Cache)
对于读多写少的热 Key,在应用层增加本地缓存,大幅削减 Redis 请求量:
import time
from functools import lru_cache
from threading import Lock
class LocalCache:
"""简单的 TTL 本地缓存,热 Key 专用"""
def __init__(self, ttl_seconds=1):
self._cache = {}
self._lock = Lock()
self._ttl = ttl_seconds
def get_or_load(self, key, loader_fn):
now = time.time()
with self._lock:
if key in self._cache:
value, expire_at = self._cache[key]
if now < expire_at:
return value
# 缓存未命中,从 Redis 加载
value = loader_fn(key)
with self._lock:
self._cache[key] = (value, now + self._ttl)
return value
# 使用
local_cache = LocalCache(ttl_seconds=2) # 2秒 TTL,容忍短暂不一致
def get_product_detail(product_id):
key = f"product:detail:{product_id}"
return local_cache.get_or_load(
key,
lambda k: redis_client.get(k)
)
本地缓存 TTL 建议 1-5 秒,太长会导致数据不一致,太短效果不明显。
方案 B:Key 分片打散(适合超高 QPS 场景)
将单个热 Key 拆分成 N 个子 Key,读取时随机选一个:
import random
import redis
SHARD_COUNT = 16 # 分片数,根据热度调整
def set_hot_key(redis_client: redis.Redis, key: str, value: str, ex: int = 60):
"""写入时同步写所有分片"""
pipe = redis_client.pipeline()
for i in range(SHARD_COUNT):
pipe.set(f"{key}:shard:{i}", value, ex=ex)
pipe.execute()
def get_hot_key(redis_client: redis.Redis, key: str) -> str:
"""读取时随机选一个分片,流量均匀分散到不同 slot"""
shard_id = random.randint(0, SHARD_COUNT - 1)
return redis_client.get(f"{key}:shard:{shard_id}")
分片后,product:detail:10086:shard:0 到 product:detail:10086:shard:15 会被 CRC16 哈希到不同 slot,自动分散到不同节点。
第 3 步:写热 Key 优化 —— 聚合写入
秒杀库存扣减、计数器累加等写热 Key 场景,用 本地聚合 + 批量刷入 降低 Redis 写入频率:
import threading
import time
class WriteAggregator:
"""写入聚合器:本地累加,定期批量提交到 Redis"""
def __init__(self, redis_client, flush_interval=0.1):
self._client = redis_client
self._buffer = {}
self._lock = threading.Lock()
self._interval = flush_interval
self._start_flusher()
def incr(self, key, amount=1):
with self._lock:
self._buffer[key] = self._buffer.get(key, 0) + amount
def _flush(self):
with self._lock:
buffer = self._buffer.copy()
self._buffer.clear()
if buffer:
pipe = self._client.pipeline()
for key, amount in buffer.items():
pipe.incrby(key, amount)
pipe.execute()
def _start_flusher(self):
def _loop():
while True:
time.sleep(self._interval)
self._flush()
t = threading.Thread(target=_loop, daemon=True)
t.start()
# 使用:100ms 聚合一次,QPS 从 10万 降到 ~10
aggregator = WriteAggregator(redis_client, flush_interval=0.1)
aggregator.incr("page:view:homepage")
第 4 步:兜底限流保护
即使做了热 Key 拆分,仍需在 Redis 前加一层保护,防止极端流量击穿:
import time
from redis import Redis
def rate_limit_check(redis_client: Redis, key: str, max_qps: int = 10000) -> bool:
"""滑动窗口限流,超过阈值直接返回降级结果"""
window_key = f"ratelimit:{key}:{int(time.time())}"
current = redis_client.incr(window_key)
if current == 1:
redis_client.expire(window_key, 2)
return current <= max_qps
# 业务调用
def get_with_protection(key):
if not rate_limit_check(redis_client, key):
return get_fallback_value(key) # 降级:返回兜底数据
return redis_client.get(key)
避坑指南
1. --hotkeys 无输出或全是 0
原因:maxmemory-policy 不是 LFU 策略。必须设为 allkeys-lfu 或 volatile-lfu 才有 LFU 计数器。注意修改策略后需要等待一段时间让计数器积累。
2. Key 分片后 TTL 不一致导致缓存穿透
分片写入时如果部分失败,会出现某些分片过期而其他未过期。解决:写入用 Pipeline 保证原子性,且每次 SET 都带 EX 参数;读取时做 null 检测,降级到主 Key。
3. 本地缓存在多实例部署下的一致性问题
本地缓存无法被动失效。解决方案:
- 接受短暂不一致(TTL 1-5 秒适合大多数场景)
- 需要强一致时,通过 Redis Pub/Sub 或 Kafka 广播失效通知
- 使用 Caffeine(Java)或 cachetools(Python)的 TTLCache,避免自己实现
总结
| 场景 | 推荐方案 | 效果 |
|---|---|---|
| 读热 Key(QPS < 10w) | 本地缓存 L1,TTL 1-3s | 削减 90%+ Redis 请求 |
| 读热 Key(QPS > 10w) | Key 分片 + 本地缓存 | 流量均匀分散到多节点 |
| 写热 Key(计数/累加) | 本地聚合批量刷入 | 写入 QPS 降 100-1000 倍 |
| 极端流量/大促 | 限流 + 降级兜底 | 保护 Redis 不被击穿 |
核心原则:先能发现(监控),再去拆分(架构),最后兜底(限流降级)。生产环境建议客户端侧 Key 访问计数常态化运行,配合 Grafana 面板设置热 Key 告警阈值(如单 Key QPS > 5000),做到提前发现、提前处理,而非等到节点 CPU 报警才被动排查。