饮墨

子安饮墨馀三斗,留与卿儿作赋来

Redis Cluster 热 Key 问题排查与优化实战:从发现到根治

痛点

线上 Redis Cluster 突然出现单节点 CPU 飙升到 90%+,其他节点负载却很低。业务端大量超时报错,监控告警不断。排查发现:某个 Key 的 QPS 占该节点总请求的 60% 以上 —— 典型的热 Key(Hot Key)问题。

热 Key 是 Redis Cluster 架构下最常见的性能瓶颈之一。由于 Cluster 按 slot 分片,同一个 Key 只会落在一个节点上,无论集群规模多大,单 Key 的流量天花板就是单节点的处理能力。秒杀活动的库存 Key、热门商品详情缓存、全局计数器 —— 这些场景都是热 Key 的重灾区。

方案概述

解决热 Key 问题的核心思路是 发现 → 拆分 → 兜底

  1. 快速定位热 Key:利用 Redis 内置命令 + 客户端监控双管齐下
  2. 读热 Key 拆分:本地缓存 + Key 分片打散
  3. 写热 Key 优化:聚合写入 + Lua 原子操作
  4. 兜底保护:限流熔断,防止单点击穿

实操步骤

第 1 步:快速定位热 Key

方法一:redis-cli --hotkeys(Redis 4.0+,需开启 LFU)

确保 maxmemory-policy 设为 LFU 策略(如 allkeys-lfuvolatile-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:0product: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-lfuvolatile-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 报警才被动排查。

您还没有登录,请登录后发表评论。