← 返回最佳实践列表

*Redis 大 Key 与热 Key 治理最佳实践:从诊断到根治的完整方案

本文深入解析 Redis 大 Key 与热 Key 的产生原理、诊断方法和治理方案,提供生产环境可直接落地的最佳实践,帮助你避免系统雪崩与性能退化。


*目录


*一、为什么大 Key 与热 Key 是生产环境的"隐形炸弹"

在 Redis 的日常运维中,大 Key 与热 Key 是最容易被忽视、却最能引发系统性故障的两个问题。它们不像网络中断或主从切换那样直接报警,而是像温水煮青蛙一样,逐渐拖垮整个集群。

大 Key(Big Key) 指的是单个 Key 对应的 Value 体积过大。Redis 的单线程模型意味着所有命令都是串行执行的,一个 HGETALLLRANGE 操作可能阻塞其他请求数十毫秒甚至秒级。当大 Key 被频繁访问或删除时,会导致:

  • 主线程阻塞,响应延迟飙升
  • 主从同步中断或超时
  • AOF 重写与 RDB 快照耗时剧增
  • 内存碎片率飙升,内存无法及时释放

热 Key(Hot Key) 指的是单个 Key 被极高频率访问。在电商秒杀、社交媒体热点、排行榜等场景中,一个 Key 可能承载数万甚至数十万 QPS。热 Key 会导致:

  • 单个 Redis 节点 CPU 使用率 100%
  • 集群负载严重不均衡(某些节点过热)
  • 缓存击穿后瞬间流量打到数据库,引发雪崩
  • 主从复制延迟,从节点数据不一致

生产环境警示:某头部电商曾在双 11 期间因一个存储了 10 万条商品库存的 Hash Key(大小约 50MB)被频繁读取,导致 Redis 集群主节点阻塞,最终级联故障影响到支付链路,造成分钟级不可用。


*二、核心原理:从 Redis 单线程模型说起

*2.1 Redis 为什么是单线程?

Redis 的核心处理逻辑(命令执行、数据结构操作)基于单线程事件循环(Event Loop)。这并不意味着 Redis 只用一个 CPU 核心——它使用了多线程处理网络 I/O(Redis 6.0+)和后台任务(持久化、过期清理),但命令执行本身仍然是单线程的

单线程的优势在于:

  • 无锁编程,避免了复杂的并发控制开销
  • 内存访问顺序执行,CPU 缓存友好
  • 原子性天然保证,无需事务锁

但代价也很明显:一个慢命令会阻塞后面所有命令

*2.2 大 Key 阻塞原理

┌─────────────────────────────────────────────────────────┐
│  Redis 主线程事件循环                                     │
├─────────────────────────────────────────────────────────┤
│  Client A: HGETALL big_hash_key  ←  阻塞 200ms 开始    │
│  Client B: GET normal_key          ←  等待            │
│  Client C: SET another_key           ←  等待            │
│  Client D: LPUSH list_key            ←  等待            │
│  ...                                  ←  全部等待        │
│  Client A: HGETALL big_hash_key  ←  200ms 后完成,后续恢复 │
└─────────────────────────────────────────────────────────┘

当 Value 过大时,以下操作会触发阻塞:

数据类型 危险操作 阻塞原因
String GET / SET 超大值 内存拷贝耗时
Hash HGETALL / HVALS / HDEL (多字段) 遍历整个 Hash 结构
List LRANGE 0 -1 / LREM / DEL 遍历整个列表
Set SMEMBERS / SUNION / DEL 遍历整个集合
ZSet ZRANGE 0 -1 / ZREMRANGEBYSCORE / DEL 遍历有序集合
Stream XREAD 大量消息 / XRANGE 遍历 Stream 条目

DEL 大 Key 的风险:Redis 4.0 之前,DEL 一个包含百万级元素的集合是阻塞操作。Redis 4.0+ 引入了 UNLINK(异步删除),但 DEL 本身在默认配置下仍然可能阻塞(取决于 lazyfree-lazy-user-del 配置)。

*2.3 热 Key 压垮原理

Redis 的网络 I/O 虽然多线程化(Redis 6.0+),但命令执行仍然在主线程。单个 Key 被高频访问时:

  1. CPU 饱和:主线程 100% 使用率,无法处理其他请求
  2. 网络带宽集中:单节点带宽被少数 Key 占满
  3. 集群倾斜:Redis Cluster 的 Slot 分布是静态的,热 Key 所在节点成为瓶颈,其他节点却空闲
  4. 缓存击穿风险:如果热 Key 过期或被删除,瞬间流量打到后端存储
┌────────────────────────────────────────────────────────────┐
│                    Redis Cluster                          │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐     │
│  │ Node 1  │  │ Node 2  │  │ Node 3  │  │ Node 4  │     │
│  │  hot    │  │  normal │  │  normal │  │  normal │     │
│  │  95% CPU│  │  30% CPU│  │  25% CPU│  │  28% CPU│     │
│  └─────────┘  └─────────┘  └─────────┘  └─────────┘     │
│  ↑ 流量倾斜                                                │
└────────────────────────────────────────────────────────────┘

*三、大 Key 诊断:如何精准发现"体积怪兽"

*3.1 诊断命令与工具

*1. redis-cli --bigkeys(最常用)

# 扫描整个实例,找出每种类型的最大 Key
redis-cli -h 127.0.0.1 -p 6379 --bigkeys

# 输出示例
# [00.00%] Biggest string found so far 'user:1000000' with 1048576 bytes
# [00.00%] Biggest hash found so far 'config:all' with 50000 fields
# [00.00%] Biggest list found so far 'log:queue' with 1000000 items
# [00.00%] Biggest set found so far 'ip:blacklist' with 200000 members
# [00.00%] Biggest zset found so far 'rank:score' with 500000 members

注意--bigkeys 使用 SCAN 迭代,不会阻塞主线程,但在大集群上可能耗时较长(千万级 Key 实例可能需要 10-30 分钟)。

*2. MEMORY USAGE 命令(精确测量)

# 精确测量单个 Key 的内存占用(字节)
redis-cli MEMORY USAGE user:1000000
# (integer) 1048576

# 包含抽样值内存(更精确,适用于大集合)
redis-cli MEMORY USAGE user:1000000 SAMPLES 100
# (integer) 1054321

*3. DEBUG OBJECT(开发调试)

# 查看 Key 的底层编码和序列长度
redis-cli DEBUG OBJECT user:1000000
# Value at:0x7f8c4c0b8000 refcount:1 encoding:raw serializedlength:1048576 lru:12345678 lru_seconds_idle:3600

⚠️ 生产环境慎用DEBUG OBJECT 会阻塞主线程,建议在从节点或低峰期使用。

*4. 分析 RDB 文件(离线诊断)

# 使用 rdb-tools 分析 RDB 文件
pip install rdbtools python-lzf
rdb -c memory /var/redis/dump.rdb > memory.csv

# 查看内存占用最大的 Key
sort -t, -k4 -nr memory.csv | head -n 20
# database,type,key,size_in_bytes,encoding,num_elements,len_largest_element
# 0,hash,config:all,52428800,hashtable,50000,1024

优点:完全离线,零影响线上。适合定期审计。

*3.2 自动化巡检脚本(Shell)

#!/bin/bash
# bigkey_scanner.sh - 大 Key 定时巡检脚本

HOST="127.0.0.1"
PORT="6379"
THRESHOLD=1048576  # 1MB 阈值
OUTPUT="/var/log/redis/bigkeys-$(date +%Y%m%d).log"

# 扫描大 Key
redis-cli -h $HOST -p $PORT --bigkeys > $OUTPUT

# 提取超过阈值的 Key 并告警
while IFS= read -r line; do
    if [[ $line =~ "with" ]]; then
        size=$(echo $line | grep -oP 'with \K[0-9]+')
        if [ "$size" -gt "$THRESHOLD" ]; then
            key=$(echo $line | grep -oP "found so far '\K[^']+")
            echo "[ALERT] $(date) Big Key found: $key = ${size} bytes" >> $OUTPUT
            # 接入企业告警通道(钉钉/飞书/邮件)
            # curl -X POST ...
        fi
    fi
done < $OUTPUT

echo "Scan complete. Report: $OUTPUT"

*3.3 诊断结果分析示例

Key 名称 类型 大小 元素数 风险等级 说明
user:1000000 String 1MB 1 单个 Value 过大,可能是 JSON 序列化
config:all Hash 50MB 50,000 配置项全量存储,需拆分
log:queue List 200MB 1,000,000 极高 日志堆积,消费端可能已挂
ip:blacklist Set 30MB 200,000 集合过大,需考虑 Bloom Filter 替代
rank:score ZSet 80MB 500,000 排行榜全量,需按时间/范围分片

*四、热 Key 诊断:如何定位"流量黑洞"

*4.1 基于 Redis 监控的热 Key 发现

*1. MONITOR 命令(实时抓取)

# 实时输出所有执行的命令(高负载下慎用,会损耗 50%+ 性能)
redis-cli MONITOR | head -n 10000 > monitor.log

# 分析日志,找出高频访问的 Key
grep -oP '(?<=GET |HGET |LPUSH |ZADD |SADD )[^ ]+' monitor.log | \
  sort | uniq -c | sort -nr | head -n 20

# 输出示例
#  54321 "stock:item:8888"
#  32100 "user:session:1000000"
#  21000 "config:rate_limit"

⚠️ 生产环境极度谨慎MONITOR 会输出所有命令,CPU 开销巨大。仅应在故障排查时短时间(<30秒)使用。

*2. redis-cli --hotkeys(Redis 4.0+)

# 需要先在配置中开启热点 Key 统计
redis-cli -h 127.0.0.1 -p 6379 --hotkeys

# 输出示例
# [00.00%] Hot key 'stock:item:8888' found so far with 54321 hits
# [00.00%] Hot key 'user:session:1000000' found so far with 32100 hits

开启配置

# redis.conf
# 开启热点 Key 统计(内存消耗约 1-2MB/百万 Key)
maxmemory-policy allkeys-lru  # 或 volatile-lru
# 使用 --hotkeys 时需要 Redis 4.0+ 且配置 maxmemory-policy 为 lru 相关策略

*3. INFO COMMANDSTATS(命令级统计)

redis-cli INFO COMMANDSTATS
# cmdstat_get:calls=1000000,usec=500000,usec_per_call=0.50
# cmdstat_hget:calls=500000,usec=300000,usec_per_call=0.60

结合应用层日志,可以推算出哪些 Key 被高频访问。

*4.2 基于客户端/代理的诊断

*1. 应用层日志分析(Python 示例)

from collections import Counter
import redis
import logging

# 配置 Redis 访问日志
r = redis.Redis(host='127.0.0.1', port=6379)

class HotKeyTracker:
    def __init__(self, sample_rate=0.01):
        self.sample_rate = sample_rate  # 1% 采样率
        self.counter = Counter()
        self.total_calls = 0

    def track(self, key):
        self.total_calls += 1
        if self.total_calls % int(1 / self.sample_rate) == 0:
            self.counter[key] += 1

    def report(self, top_n=20):
        print(f"Total sampled calls: {sum(self.counter.values())}")
        for key, count in self.counter.most_common(top_n):
            print(f"  {key}: {count} hits")

# 使用示例
tracker = HotKeyTracker(sample_rate=0.01)

# 在每次 Redis 访问时调用
def get_data(key):
    tracker.track(key)
    return r.get(key)

# 运行一段时间后输出报告
# tracker.report()

*2. Twemproxy / Redis Cluster Proxy 层统计

如果使用代理层,可以在代理层统计 Key 访问频率:

# 伪代码:代理层统计
class ProxyStats:
    def __init__(self):
        self.key_hits = {}

    def record_hit(self, key):
        self.key_hits[key] = self.key_hits.get(key, 0) + 1

    def get_hot_keys(self, threshold=1000):
        return {k: v for k, v in self.key_hits.items() if v > threshold}

*4.3 基于 eBPF 的系统级诊断(高级)

# 使用 bcc-tools 跟踪 Redis 网络流量
# 需要安装 bcc-tools(Linux 4.1+)
/usr/share/bcc/tools/tcptop -C 2>/dev/null | grep :6379

# 或者使用 perf 分析 Redis 进程
perf record -g -p $(pgrep redis-server) -- sleep 30
perf report --sort=symbol

*五、大 Key 治理:拆分、压缩、迁移三步走

*5.1 治理策略总览

┌─────────────────────────────────────────────────────────┐
│  大 Key 治理策略                                          │
├─────────────────────────────────────────────────────────┤
│  1. 拆分(Split)                                        │
│     ├─ 按业务维度拆分(Hash 按字段拆)                       │
│     ├─ 按时间维度拆分(List 按时间片)                      │
│     └─ 按哈希维度拆分(Hash Tag 分散)                      │
│  2. 压缩(Compress)                                     │
│     ├─ 使用 Snappy/LZ4 压缩 Value                         │
│     └─ 使用 Protobuf/MsgPack 替代 JSON                    │
│  3. 迁移(Migrate)                                     │
│     ├─ 异步删除(UNLINK)                                 │
│     └─ 数据归档(冷存储迁移)                                │
└─────────────────────────────────────────────────────────┘

*5.2 拆分:Hash 按字段拆分

场景config:all 存储了 50,000 个配置项,总大小 50MB。

方案:按业务模块拆分为多个 Hash。

# 改造前:一个超大 Hash
HGETALL config:all  # 50MB,阻塞主线程

# 改造后:按模块拆分
HGETALL config:database   # 50 个字段
HGETALL config:cache      # 30 个字段
HGETALL config:security   # 20 个字段
HGETALL config:api        # 40 个字段

Python 迁移脚本

import redis

r = redis.Redis(host='127.0.0.1', port=6379)

# 1. 读取旧的大 Key
old_key = "config:all"
old_data = r.hgetall(old_key)  # 注意:这一步仍然会阻塞,请在从节点执行

# 2. 按前缀分组
groups = {}
for field, value in old_data.items():
    field = field.decode()
    value = value.decode()
    prefix = field.split(':')[0]  # 按冒号前缀分组
    if prefix not in groups:
        groups[prefix] = {}
    groups[prefix][field] = value

# 3. 写入新的拆分 Key
for prefix, fields in groups.items():
    new_key = f"config:{prefix}"
    r.hset(new_key, mapping=fields)
    print(f"Created {new_key} with {len(fields)} fields")

# 4. 验证后删除旧 Key(使用 UNLINK 异步删除)
r.unlink(old_key)
print(f"Removed old key: {old_key}")

*5.3 拆分:List 按时间片拆分

场景log:queue 存储了 100 万条日志,总大小 200MB。

方案:按时间窗口拆分为多个 List,使用 List 的消费者模式。

# 改造前:一个超大 List
LLEN log:queue        # 1000000
LRANGE log:queue 0 100  # 仅取前 100 条,但底层结构仍大

# 改造后:按小时拆分
LLEN log:queue:20250101-08  # 3600 条
LLEN log:queue:20250101-09  # 3600 条
LPUSH log:queue:20250101-10 "new log entry"

Java 生产者代码

import redis.clients.jedis.Jedis;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class LogProducer {
    private Jedis jedis;
    private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd-HH");

    public LogProducer() {
        this.jedis = new Jedis("127.0.0.1", 6379);
    }

    public void pushLog(String logEntry) {
        // 按小时生成 Key
        String hourKey = "log:queue:" + LocalDateTime.now().format(formatter);
        jedis.lpush(hourKey, logEntry);

        // 设置 7 天过期(自动清理旧数据)
        jedis.expire(hourKey, 7 * 24 * 3600);
    }

    public void close() {
        jedis.close();
    }

    public static void main(String[] args) {
        LogProducer producer = new LogProducer();
        producer.pushLog("[INFO] User 12345 logged in");
        producer.close();
    }
}

*5.4 压缩:使用 Snappy 压缩大 Value

场景:String 类型存储了 1MB 的 JSON 数据。

方案:客户端压缩后存储。

Python 压缩示例

import redis
import json
import snappy

r = redis.Redis(host='127.0.0.1', port=6379)

def set_compressed(key, data):
    """压缩存储 JSON 数据"""
    json_str = json.dumps(data)
    compressed = snappy.compress(json_str.encode())
    r.set(key, compressed)
    print(f"Original: {len(json_str)} bytes, Compressed: {len(compressed)} bytes")

def get_compressed(key):
    """解压读取数据"""
    compressed = r.get(key)
    if compressed:
        json_str = snappy.decompress(compressed).decode()
        return json.loads(json_str)
    return None

# 使用示例
data = {"users": [{"id": i, "name": f"user_{i}"} for i in range(10000)]}
set_compressed("user:batch:10000", data)
result = get_compressed("user:batch:10000")

Java 压缩示例(Snappy)

import redis.clients.jedis.Jedis;
import org.xerial.snappy.Snappy;
import com.fasterxml.jackson.databind.ObjectMapper;

public class CompressedRedis {
    private Jedis jedis;
    private ObjectMapper mapper = new ObjectMapper();

    public CompressedRedis() {
        this.jedis = new Jedis("127.0.0.1", 6379);
    }

    public void setCompressed(String key, Object value) throws Exception {
        String json = mapper.writeValueAsString(value);
        byte[] compressed = Snappy.compress(json.getBytes());
        jedis.set(key.getBytes(), compressed);
    }

    public <T> T getCompressed(String key, Class<T> clazz) throws Exception {
        byte[] compressed = jedis.get(key.getBytes());
        if (compressed == null) return null;
        byte[] json = Snappy.uncompress(compressed);
        return mapper.readValue(new String(json), clazz);
    }
}

*5.5 安全删除:使用 UNLINK 替代 DEL

# 危险:阻塞删除(Redis 4.0 之前只能这样)
DEL big_key  # 阻塞 100ms+,风险极高

# 安全:异步删除(Redis 4.0+)
UNLINK big_key  # 立即返回,后台线程释放内存

# 检查 lazyfree 配置
redis-cli CONFIG GET lazyfree*
# 1) "lazyfree-lazy-eviction"
# 2) "no"
# 3) "lazyfree-lazy-expire"
# 4) "no"
# 5) "lazyfree-lazy-server-del"
# 6) "no"
# 7) "lazyfree-lazy-user-del"
# 8) "no"

# 建议开启(redis.conf)
lazyfree-lazy-user-del yes

*六、热 Key 治理:本地缓存、读写分离、哈希打散

*6.1 治理策略总览

┌─────────────────────────────────────────────────────────┐
│  热 Key 治理策略                                          │
├─────────────────────────────────────────────────────────┤
│  1. 本地缓存(Local Cache)                               │
│     ├─ 应用层 Caffeine/Guava Cache                        │
│     └─ 短 TTL 减少 Redis 压力                             │
│  2. 读写分离(Read Replica)                              │
│     ├─ 从节点分担读流量                                   │
│     └─ 主节点只处理写                                     │
│  3. 哈希打散(Hash Tag)                                  │
│     ├─ 热 Key 拆分为多个子 Key                            │
│     └─ 使用 Hash Tag 保证同一 Slot                         │
│  4. Key 拆分(Key Sharding)                              │
│     └─ 一个逻辑 Key 映射到多个物理 Key                     │
└─────────────────────────────────────────────────────────┘

*6.2 本地缓存:应用层 Caffeine Cache

场景stock:item:8888 被每秒 5 万次访问,但实际库存变更频率很低(秒级)。

方案:应用层加本地缓存,Redis 作为兜底。

Java + Caffeine 示例

import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import redis.clients.jedis.Jedis;
import java.util.concurrent.TimeUnit;

public class StockService {
    private Jedis jedis;

    // 本地缓存:1000 容量,10 秒过期
    private LoadingCache<String, String> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(10, TimeUnit.SECONDS)
        .build(this::fetchFromRedis);

    private String fetchFromRedis(String key) {
        return jedis.get(key);
    }

    public String getStock(String itemId) {
        String key = "stock:item:" + itemId;
        // 先查本地缓存
        return localCache.get(key);
    }

    public void updateStock(String itemId, String value) {
        String key = "stock:item:" + itemId;
        // 更新 Redis
        jedis.set(key, value);
        // 失效本地缓存(或等待 TTL 过期)
        localCache.invalidate(key);
    }

    public static void main(String[] args) {
        StockService service = new StockService();
        // 第一次从 Redis 加载,后续 10 秒内从本地缓存读取
        String stock = service.getStock("8888");
        System.out.println("Stock: " + stock);
    }
}

Python 本地缓存示例

import redis
import time
from functools import lru_cache

r = redis.Redis(host='127.0.0.1', port=6379)

# 使用 TTL 缓存
cache = {}
cache_ttl = {}
TTL = 10  # 10 秒

def get_with_local_cache(key):
    now = time.time()
    # 检查本地缓存
    if key in cache and cache_ttl.get(key, 0) > now:
        return cache[key]

    # 回源到 Redis
    value = r.get(key)
    if value:
        cache[key] = value
        cache_ttl[key] = now + TTL
    return value

# 使用示例
stock = get_with_local_cache("stock:item:8888")
print(f"Stock: {stock}")

*6.3 读写分离:从节点分担读流量

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisSentinelPool;
import java.util.HashSet;
import java.util.Set;

public class ReadWriteSplit {
    private JedisSentinelPool masterPool;
    private JedisSentinelPool slavePool;

    public ReadWriteSplit() {
        Set<String> sentinels = new HashSet<>();
        sentinels.add("127.0.0.1:26379");

        // 主节点连接池(写操作)
        masterPool = new JedisSentinelPool("mymaster", sentinels);

        // 从节点连接池(读操作)
        // 实际项目中可以通过 Sentinel 获取从节点列表
        slavePool = new JedisSentinelPool("mymaster", sentinels);
    }

    public String read(String key) {
        try (Jedis slave = slavePool.getResource()) {
            return slave.get(key);
        }
    }

    public void write(String key, String value) {
        try (Jedis master = masterPool.getResource()) {
            master.set(key, value);
        }
    }
}

*6.4 哈希打散:将热 Key 拆分为多个子 Key

场景vote:article:12345 在投票活动中被极高频率访问。

方案:将投票数拆分为多个分片,读取时汇总,写入时轮询。

Redis CLI 示例

# 改造前:单个热 Key
GET vote:article:12345  # 10万 QPS

# 改造后:拆分为 10 个分片
GET vote:article:12345:0
GET vote:article:12345:1
...
GET vote:article:12345:9

# 读取时汇总(Pipeline 减少 RTT)
redis-cli --pipe <<EOF
GET vote:article:12345:0
GET vote:article:12345:1
GET vote:article:12345:2
GET vote:article:12345:3
GET vote:article:12345:4
GET vote:article:12345:5
GET vote:article:12345:6
GET vote:article:12345:7
GET vote:article:12345:8
GET vote:article:12345:9
EOF

Python 分片逻辑

import redis
import time

r = redis.Redis(host='127.0.0.1', port=6379)
SHARD_COUNT = 10

def get_sharded_key(base_key, shard_id):
    return f"{base_key}:{shard_id}"

def get_vote_count(article_id):
    """读取分片投票数并汇总"""
    base_key = f"vote:article:{article_id}"
    keys = [get_sharded_key(base_key, i) for i in range(SHARD_COUNT)]

    # 使用 Pipeline 减少网络往返
    pipe = r.pipeline()
    for key in keys:
        pipe.get(key)

    values = pipe.execute()
    total = sum(int(v) if v else 0 for v in values)
    return total

def increment_vote(article_id):
    """投票:轮询写入分片"""
    base_key = f"vote:article:{article_id}"
    # 使用当前时间戳取模,轮询选择分片
    shard_id = int(time.time()) % SHARD_COUNT
    key = get_sharded_key(base_key, shard_id)
    r.incr(key)

# 使用示例
print(f"Total votes: {get_vote_count('12345')}")
increment_vote('12345')

*6.5 Redis Cluster 场景:Hash Tag 保证同一 Slot

在 Redis Cluster 中,如果分片 Key 需要参与事务或 Lua 脚本,需要使用 Hash Tag 保证它们在同一 Slot:

# 使用 Hash Tag {article:12345} 保证同一 Slot
GET vote:{article:12345}:0
GET vote:{article:12345}:1
...
GET vote:{article:12345}:9

# Lua 脚本内可以安全操作这些 Key(同一 Slot)
EVAL "local total=0; for i=0,9 do total=total+tonumber(redis.call('GET', KEYS[i+1]) or 0) end; return total" 10 vote:{article:12345}:0 vote:{article:12345}:1 vote:{article:12345}:2 vote:{article:12345}:3 vote:{article:12345}:4 vote:{article:12345}:5 vote:{article:12345}:6 vote:{article:12345}:7 vote:{article:12345}:8 vote:{article:12345}:9

*七、实战案例:千万级 DAU 应用的热 Key 根治记

*7.1 背景

某资讯类 App,日活 3000 万,Redis Cluster 共 32 个主节点,总 QPS 约 50 万。双 11 活动当天,某热点新闻突发,导致 Redis 集群出现严重倾斜:

  • 现象

    • 3 个 Redis 节点 CPU 使用率 95%+
    • 应用层超时率从 0.1% 飙升到 15%
    • 数据库连接池被打满,MySQL 出现大量慢查询
  • 根因

    • 热点新闻 ID 为 news:888888 的 Key 被每分钟超过 30 万次读取
    • 该 Key 存储了新闻的完整内容(HTML + JSON 元数据,约 80KB),加上应用层的 JSON 反序列化,单次读取耗时约 2ms
    • 由于新闻突发,本地缓存几乎失效,所有流量直接打到 Redis
    • 该 Key 位于 Slot 1024,对应 Node 3,该节点成为瓶颈

*7.2 紧急止血(5 分钟内)

步骤 1:启动应用层本地缓存兜底(Caffeine,5 秒 TTL),立即拦截 80% 以上流量。

步骤 2:将 news:888888 的内容拆分为 news:888888:titlenews:888888:summarynews:888888:content 三个 Key,列表页只读取 title 和 summary(总大小 < 5KB)。

步骤 3:将读流量切换到从节点,主节点只处理写操作(该新闻内容基本不修改,实际写入极少)。

效果:Node 3 CPU 从 95% 下降到 45%,应用层超时率恢复到 0.5%。

*7.3 根治方案(1 周内)

架构改造

  1. 内容拆分:新闻内容按字段拆分,列表场景只读取元数据 Key(< 5KB),详情页再读取完整内容 Key。
  2. 多级缓存:CDN 边缘缓存(1 分钟)→ 应用层本地缓存(5 秒)→ Redis → 数据库。
  3. 热 Key 预测:基于访问趋势,提前将可能被热点的新闻内容推送到多个 Redis 节点的副本(通过写多份实现,牺牲一致性换取可用性)。
  4. 监控告警:接入 redis-cli --hotkeys 自动巡检,每分钟采样,发现热 Key 立即告警。

改造后数据

指标 改造前 改造后
热点节点 CPU 95% 35%
应用层超时率 15% 0.1%
单次读取耗时 2ms 0.3ms
数据库压力 连接池打满 正常
集群负载均衡度 严重倾斜 标准差 < 10%

*八、最佳实践清单:生产环境 10 条铁律

  1. 禁止存储超过 1MB 的单个 Value:String 类型 Value 控制在 10KB 以内,Hash / List / Set / ZSet 元素数控制在 5000 以内。超过阈值必须拆分。
  2. 生产环境使用 UNLINK 替代 DEL:Redis 4.0+ 必须开启 lazyfree-lazy-user-del yes,避免删除大 Key 阻塞主线程。
  3. 热 Key 必须做本地缓存兜底:应用层使用 Caffeine / Guava Cache,TTL 根据业务容忍度设定(通常 5-30 秒)。
  4. Hash 禁止全量读取HGETALL 是生产环境高危命令,必须改为 HGET 指定字段或 HMGET 批量读取必要字段。
  5. List 禁止全量遍历LRANGE 0 -1 等同于读取整个列表,必须改为分页读取(LRANGE start stop,每页 100 条)。
  6. 定期执行 redis-cli --bigkeys 巡检:建议每周一次,配合自动化脚本告警,发现大 Key 立即治理。
  7. Redis Cluster 必须监控节点负载均衡度:使用 redis-cli --cluster infoINFO STATS 检查各节点 QPS 和内存使用率,标准差超过 20% 说明存在倾斜。
  8. 写操作走主节点,读操作走从节点:通过 Redis Sentinel 或 Proxy 实现读写分离,降低主节点压力。
  9. 热 Key 必须做哈希打散:预计单 Key QPS 超过 1 万,必须拆分为多个分片 Key,使用 Hash Tag 保证同一 Slot。
  10. 大 Key 迁移必须走从节点:读取旧大 Key 数据时,在从节点执行,避免阻塞主节点;写入新 Key 后,使用 UNLINK 删除旧 Key。

*九、故障排查:常见错误与诊断流程

*9.1 常见错误与解决方案

错误现象 可能原因 诊断步骤 解决方案
RedisTimeoutException 大 Key 阻塞 / 热 Key 压垮 redis-cli --bigkeys + MONITOR 拆分大 Key / 本地缓存热 Key
主从同步中断 大 Key 导致 RDB 传输超时 INFO REPLICATION 优化 repl-timeout,拆分大 Key
内存碎片率 > 2.0 大 Key 删除后内存未释放 INFO MEMORY 重启实例或启用 activedefrag
单个节点 CPU 100% 热 Key 集中 redis-cli --hotkeys 本地缓存 + 哈希打散
AOF 重写耗时剧增 大 Key 导致 AOF 文件膨胀 INFO PERSISTENCE 开启 lazyfree-lazy-user-del
数据库被打满 缓存击穿(热 Key 过期) 检查 Key TTL + 访问日志 热点 Key 永不过期 + 本地缓存

*9.2 诊断流程图

发现 Redis 延迟异常
    ↓
检查节点 CPU / 内存(INFO STATS / INFO MEMORY)
    ↓
├── CPU 高 → 怀疑热 Key
│       ↓
│   redis-cli --hotkeys 或 MONITOR
│       ↓
│   定位热 Key → 本地缓存 / 读写分离 / 哈希打散
│
└── 内存高 / 延迟高 → 怀疑大 Key
        ↓
    redis-cli --bigkeys
        ↓
    定位大 Key → 拆分 / 压缩 / UNLINK 删除

*9.3 紧急止血命令速查

# 1. 查看当前最大延迟
redis-cli --latency-history -i 1

# 2. 查看慢查询日志(需先开启 slowlog)
redis-cli SLOWLOG GET 10

# 3. 查看当前连接数与阻塞客户端
redis-cli CLIENT LIST | grep -c "cmd=hgetall"
redis-cli CLIENT LIST | grep -c "cmd=lrange"

# 4. 查看内存碎片率
redis-cli INFO MEMORY | grep mem_fragmentation_ratio

# 5. 查看主从复制延迟
redis-cli INFO REPLICATION | grep master_repl_offset
redis-cli INFO REPLICATION | grep slave_repl_offset

# 6. 查看 AOF 重写状态
redis-cli INFO PERSISTENCE | grep aof_

*十、FAQ:高频疑问与进阶问题

Q1:Redis 大 Key 的阈值是多少?

业界通常以 1MB 作为大 Key 的警戒线,但具体阈值应根据业务延迟要求调整。对于延迟敏感型业务(如金融支付),建议将阈值降低到 100KB

Q2:HSCAN 可以替代 HGETALL 吗?

可以。HSCAN 是迭代命令,每次返回少量字段,不会一次性阻塞主线程。但 HSCAN 不是原子操作,迭代过程中 Hash 被修改可能导致重复或遗漏。对于需要精确全量的场景,建议在从节点执行 HGETALL 或应用层多次 HSCAN 后合并。

Q3:热 Key 本地缓存的数据一致性如何保证?

本地缓存本质上是允许短暂不一致的。一致性策略:

  • 写时失效:更新 Redis 后,立即失效本地缓存(或发送广播通知所有节点失效)。
  • TTL 兜底:设置合理的 TTL(如 5-10 秒),即使失效通知丢失,数据也会在 TTL 后自动刷新。
  • 版本号机制:Value 中嵌入版本号,读取时对比版本号,不一致则回源刷新。

Q4:Redis 6.0 的多线程 I/O 能解决热 Key 问题吗?

不能。Redis 6.0 的多线程仅用于网络 I/O 读写和协议解析,命令执行仍然是单线程。热 Key 问题本质上是命令执行层面的瓶颈,多线程 I/O 只能缓解网络层的压力,无法解决 CPU 饱和问题。

Q5:lazyfree 开启后,UNLINK 是立即释放内存吗?

不是。UNLINK 会立即从键空间中移除 Key(客户端不可见),但内存回收由后台线程异步执行。对于极大的 Key(如 1GB),后台线程可能需要数秒才能完全释放内存。在此期间,实例内存占用不会立即下降。

Q6:Bloom Filter 可以替代大 Set 吗?

在只需要判断"存在 / 不存在"的场景下,可以。Bloom Filter 的空间复杂度远低于 Set,但存在误判率(可通过增加哈希函数和位数组大小降低)。不适合需要精确列举所有元素的场景(如 SMEMBERS)。

Q7:如何评估拆分后的性能收益?

拆分后的收益 = 原大 Key 操作耗时 × 操作频率 - 拆分后操作总耗时 × 操作频率。例如:原 HGETALL 50MB Hash 耗时 200ms,每小时 10 次;拆分后 HGET 指定字段耗时 0.5ms,每小时 1000 次。收益 = 200 × 10 - 0.5 × 1000 = 1500ms/小时。

Q8:Redis Cluster 中,Hash Tag 有什么副作用?

Hash Tag 会导致多个 Key 强制落在同一 Slot,破坏了数据的均匀分布。如果大量使用 Hash Tag 且 Tag 值热点集中,可能导致 Slot 倾斜。建议仅在需要事务或 Lua 脚本的原子操作时少量使用。


*十一、总结

Redis 大 Key 与热 Key 是生产环境中最隐蔽却最具破坏力的两类问题。它们的共同根源在于 Redis 的单线程命令执行模型——任何慢操作或高频操作都会直接转化为全局阻塞或节点瓶颈。

治理的核心思路:

  • 大 Key:通过拆分、压缩、异步删除,将"体积怪兽"拆解为可控的小单元。
  • 热 Key:通过本地缓存、读写分离、哈希打散,将"流量黑洞"分散到多个节点或缓存层级。

预防措施远比事后治理更重要。建立定期的 bigkeys / hotkeys 巡检机制、在应用层植入缓存兜底、在架构设计阶段就避免单点 Key 的过度集中,是避免故障的根本之道。

适用版本:Redis 4.0+(UNLINKlazyfree--hotkeys 需要 4.0+;多线程 I/O 需要 6.0+) 测试环境:Redis 7.0.12 Cluster 模式,3 主 3 从,8GB 内存 相关阅读