Skip to content
清晨的一缕阳光
返回

Redis 大 Key 与热 Key 问题分析与解决

Redis 大 Key 与热 Key 问题分析与解决

大 Key 和热 Key 是 Redis 生产环境中最常见的性能问题。大 Key 导致网络阻塞、内存不均,热 Key 导致单点过载。本文将深入分析这两类问题的识别和解决方案。

一、大 Key 问题

1.1 什么是大 Key

大 Key 定义

大 Key 的危害

大 Key 影响:
┌─────────────────────────────────────┐
│ 网络阻塞                            │
│ - 读取大 Key 占用大量网络带宽        │
│ - 阻塞其他命令执行                   │
│ - 典型:读取 10MB key 需 100ms+     │
├─────────────────────────────────────┤
│ 内存不均                            │
│ - Cluster 模式下导致数据倾斜         │
│ - 单节点内存占用过高                 │
│ - 影响扩容和缩容                     │
├─────────────────────────────────────┤
│ 删除阻塞                            │
│ - 删除大 Key 阻塞主线程             │
│ - UNLINK 异步删除(推荐)           │
├─────────────────────────────────────┤
│ 序列化开销                          │
│ - RDB/AOF 序列化耗时                │
│ - 网络传输慢                         │
└─────────────────────────────────────┘

1.2 大 Key 识别

方法 1:redis-cli 分析

# 分析整个数据库
redis-cli --bigkeys

# 输出示例
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type.  You can use -X  option to quit.

# Biggest key found: user:profile:12345 (15.2MB) [hash]

# Summary of big keys found:
# memsize  type      key
# 15.2MB   hash      user:profile:12345
# 8.5MB    string    article:content:98765
# 5.3MB    list      queue:task:waiting
# 3.2MB    zset      leaderboard:global
# 2.1MB    set       tag:products:all

# Average key size by type:
# string: 256 bytes
# list: 1.2KB
# hash: 45KB
# set: 2.3KB
# zset: 1.8KB

方法 2:使用 MEMORY 命令

# 查看单个 key 的内存占用
redis-cli MEMORY USAGE user:profile:12345
# 输出:15938352 (bytes) = 15.2MB

# 详细内存分析
redis-cli MEMORY STATS

# 输出示例
#  1) "peak.allocated"
#  2) (integer) 1073741824
#  3) "total.allocated"
#  4) (integer) 536870912
#  5) "startup.allocated"
#  6) (integer) 1048576
#  ...
# 27) "db.0"
# 28) 1) "overhead"
#     2) (integer) 1048576
#     3) "tables"
#     4) (integer) 2
#     5) "keys"
#     6) (integer) 10000
#     7) "keys-per-slot"
#     8) (integer) 61

方法 3:使用 Redis Rump

# redis-rump 是专门分析大 key 的工具
# https://github.com/sripathikrishnan/redis-rump

# 安装
git clone https://github.com/sripathikrishnan/redis-rump.git
cd redis-rump

# 运行
python redis-rump.py --host 127.0.0.1 --port 6379 --output rump_output.txt

# 分析输出
cat rump_output.txt | sort -k2 -nr | head -20

方法 4:Java 程序扫描

import redis.clients.jedis.Jedis;
import redis.clients.jedis.ScanParams;
import redis.clients.jedis.ScanResult;
import java.util.*;

public class BigKeyScanner {
    private Jedis jedis;
    private long sizeThreshold = 10 * 1024; // 10KB
    private long countThreshold = 5000;
    
    public BigKeyScanner(Jedis jedis) {
        this.jedis = jedis;
    }
    
    /**
     * 扫描大 Key
     */
    public List<BigKeyInfo> scanBigKeys() {
        List<BigKeyInfo> bigKeys = new ArrayList<>();
        String cursor = ScanParams.SCAN_POINTER_START;
        ScanParams scanParams = new ScanParams();
        scanParams.count(1000);
        
        while (true) {
            ScanResult<String> scanResult = jedis.scan(cursor, scanParams);
            cursor = scanResult.getCursor();
            List<String> keys = scanResult.getResult();
            
            for (String key : keys) {
                BigKeyInfo info = checkBigKey(key);
                if (info != null) {
                    bigKeys.add(info);
                    System.out.println("发现大 Key: " + info);
                }
            }
            
            if (ScanParams.SCAN_POINTER_START.equals(cursor)) {
                break;
            }
        }
        
        return bigKeys;
    }
    
    /**
     * 检查单个 key
     */
    private BigKeyInfo checkBigKey(String key) {
        String type = jedis.type(key);
        
        switch (type) {
            case "string":
                String value = jedis.get(key);
                if (value != null && value.getBytes().length > sizeThreshold) {
                    return new BigKeyInfo(key, type, value.getBytes().length, 0);
                }
                break;
                
            case "list":
                long listSize = jedis.llen(key);
                if (listSize > countThreshold) {
                    Long memory = jedis.memoryUsage(key);
                    return new BigKeyInfo(key, type, memory != null ? memory : 0, listSize);
                }
                break;
                
            case "set":
                long setSize = jedis.scard(key);
                if (setSize > countThreshold) {
                    Long memory = jedis.memoryUsage(key);
                    return new BigKeyInfo(key, type, memory != null ? memory : 0, setSize);
                }
                break;
                
            case "zset":
                long zsetSize = jedis.zcard(key);
                if (zsetSize > countThreshold) {
                    Long memory = jedis.memoryUsage(key);
                    return new BigKeyInfo(key, type, memory != null ? memory : 0, zsetSize);
                }
                break;
                
            case "hash":
                long hashSize = jedis.hlen(key);
                if (hashSize > countThreshold) {
                    Long memory = jedis.memoryUsage(key);
                    return new BigKeyInfo(key, type, memory != null ? memory : 0, hashSize);
                }
                break;
        }
        
        return null;
    }
    
    /**
     * 大 Key 信息
     */
    static class BigKeyInfo {
        String key;
        String type;
        long memory;
        long count;
        
        BigKeyInfo(String key, String type, long memory, long count) {
            this.key = key;
            this.type = type;
            this.memory = memory;
            this.count = count;
        }
        
        @Override
        public String toString() {
            return String.format("key=%s, type=%s, memory=%d bytes, count=%d", 
                               key, type, memory, count);
        }
    }
}

1.3 大 Key 解决方案

方案 1:拆分大 Key

import redis.clients.jedis.Jedis;
import java.util.List;
import java.util.ArrayList;

public class BigKeySplitter {
    private Jedis jedis;
    
    public BigKeySplitter(Jedis jedis) {
        this.jedis = jedis;
    }
    
    /**
     * 拆分大 List
     * 原始:queue:tasks (10000 个元素)
     * 拆分后:queue:tasks:0, queue:tasks:1, ... (每个 1000 个元素)
     */
    public void splitLargeList(String key, int batchSize) {
        long totalSize = jedis.llen(key);
        List<String> values = jedis.lrange(key, 0, -1);
        
        int batchCount = 0;
        List<String> batch = new ArrayList<>();
        
        for (String value : values) {
            batch.add(value);
            
            if (batch.size() >= batchSize) {
                String newKey = key + ":" + batchCount;
                jedis.rpushAll(newKey, batch.toArray(new String[0]));
                batch.clear();
                batchCount++;
            }
        }
        
        // 处理剩余
        if (!batch.isEmpty()) {
            String newKey = key + ":" + batchCount;
            jedis.rpushAll(newKey, batch.toArray(new String[0]));
        }
        
        // 删除原 key(使用 UNLINK 异步删除)
        jedis.unlink(key);
        
        System.out.println("拆分完成:共 " + batchCount + " 个新 key");
    }
    
    /**
     * 拆分大 Hash
     * 原始:user:profile:12345 (50000 个 field)
     * 拆分后:user:profile:12345:0, user:profile:12345:1, ... (每个 5000 个 field)
     */
    public void splitLargeHash(String key, int batchSize) {
        Map<String, String> allFields = jedis.hgetAll(key);
        
        int batchCount = 0;
        Map<String, String> batch = new ArrayList<>();
        int currentBatch = 0;
        
        for (Map.Entry<String, String> entry : allFields.entrySet()) {
            batch.put(entry.getKey(), entry.getValue());
            
            if (batch.size() >= batchSize) {
                String newKey = key + ":" + batchCount;
                jedis.hmset(newKey, batch);
                batch.clear();
                batchCount++;
            }
        }
        
        // 处理剩余
        if (!batch.isEmpty()) {
            String newKey = key + ":" + batchCount;
            jedis.hmset(newKey, batch);
        }
        
        // 删除原 key
        jedis.unlink(key);
        
        System.out.println("拆分完成:共 " + batchCount + " 个新 key");
    }
    
    /**
     * 拆分大 String
     * 原始:article:content:123 (10MB)
     * 拆分后:article:content:123:0, article:content:123:1, ... (每个 1MB)
     */
    public void splitLargeString(String key, int chunkSize) {
        String value = jedis.get(key);
        byte[] bytes = value.getBytes();
        
        int chunkCount = (bytes.length + chunkSize - 1) / chunkSize;
        
        for (int i = 0; i < chunkCount; i++) {
            int start = i * chunkSize;
            int end = Math.min(start + chunkSize, bytes.length);
            byte[] chunk = Arrays.copyOfRange(bytes, start, end);
            
            String newKey = key + ":" + i;
            jedis.set(newKey, new String(chunk));
        }
        
        // 删除原 key
        jedis.unlink(key);
        
        System.out.println("拆分完成:共 " + chunkCount + " 个新 key");
    }
}

方案 2:异步删除

# 错误方式:使用 DEL 删除大 Key(阻塞主线程)
redis-cli DEL user:profile:12345
# 阻塞时间:可能数百毫秒

# 正确方式:使用 UNLINK 异步删除(非阻塞)
redis-cli UNLINK user:profile:12345
# 立即返回,后台异步删除

# Java 实现
public void deleteBigKey(String key) {
    // 异步删除(推荐)
    jedis.unlink(key);
}

// 分批删除(适用于超大型 key)
public void deleteBigKeyInBatches(String key) {
    String type = jedis.type(key);
    
    switch (type) {
        case "list":
            long listSize = jedis.llen(key);
            while (listSize > 0) {
                // 每次删除 1000 个元素
                for (int i = 0; i < 1000 && listSize > 0; i++) {
                    jedis.lpop(key);
                    listSize--;
                }
                Thread.sleep(10); // 避免阻塞
            }
            break;
            
        case "hash":
            // 类似处理
            break;
            
        case "set":
            // 类似处理
            break;
            
        case "zset":
            // 类似处理
            break;
    }
    
    // 最后删除空 key
    jedis.del(key);
}

方案 3:压缩存储

import java.io.*;
import java.util.zip.*;
import java.util.Base64;

public class CompressedStorage {
    private Jedis jedis;
    
    public CompressedStorage(Jedis jedis) {
        this.jedis = jedis;
    }
    
    /**
     * 压缩存储
     */
    public void setCompressed(String key, String value) {
        try {
            // 压缩
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            GZIPOutputStream gzos = new GZIPOutputStream(baos);
            gzos.write(value.getBytes("UTF-8"));
            gzos.close();
            
            // Base64 编码
            String compressed = Base64.getEncoder()
                                     .encodeToString(baos.toByteArray());
            
            // 存储
            jedis.set(key, compressed);
            
            System.out.println("原始大小:" + value.getBytes().length + " bytes");
            System.out.println("压缩后:" + compressed.getBytes().length + " bytes");
            System.out.println("压缩率:" + 
                (1.0 - (double) compressed.getBytes().length / value.getBytes().length) * 100 + "%");
                
        } catch (IOException e) {
            throw new RuntimeException("压缩失败", e);
        }
    }
    
    /**
     * 解压读取
     */
    public String getCompressed(String key) {
        try {
            String compressed = jedis.get(key);
            if (compressed == null) {
                return null;
            }
            
            // Base64 解码
            byte[] bytes = Base64.getDecoder().decode(compressed);
            
            // 解压
            ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
            GZIPInputStream gzis = new GZIPInputStream(bais);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            
            byte[] buffer = new byte[1024];
            int len;
            while ((len = gzis.read(buffer)) != -1) {
                baos.write(buffer, 0, len);
            }
            
            return baos.toString("UTF-8");
            
        } catch (IOException e) {
            throw new RuntimeException("解压失败", e);
        }
    }
}

二、热 Key 问题

2.1 什么是热 Key

热 Key 定义

热 Key 的危害

热 Key 影响:
┌─────────────────────────────────────┐
│ 单点过载                            │
│ - 大量请求打到单个节点              │
│ - CPU/网络资源耗尽                  │
│ - 影响其他 key 访问                 │
├─────────────────────────────────────┤
│ 集群倾斜                            │
│ - Cluster 模式下热点问题            │
│ - 单节点成为瓶颈                    │
│ - 无法通过扩容解决                  │
├─────────────────────────────────────┤
│ 缓存失效风险                        │
│ - 热 Key 失效导致雪崩               │
│ - 数据库压力激增                    │
└─────────────────────────────────────┘

2.2 热 Key 识别

方法 1:redis-cli —hotkeys

# Redis 4.0.3+ 支持
redis-cli --hotkeys

# 输出示例
# # Hot keys found:
# 98234 hits:1234567 bytes:1024 string:product:stock:12345
# 56789 hits:987654 bytes:512 string:user:session:67890
# 34567 hits:543210 bytes:2048 zset:leaderboard:daily

方法 2:MONITOR 命令分析

# 实时监控命令
redis-cli MONITOR | grep -o '"[^"]*"' | sort | uniq -c | sort -rn | head -20

# 输出示例
# 12345 "GET" "product:stock:12345"
# 9876  "GET" "user:session:67890"
# 5432  "ZCARD" "leaderboard:daily"

方法 3:使用 redis-faina

# redis-faina 是 Facebook 开发的分析工具
# https://github.com/facebookarchive/redis-faina

# 保存慢查询日志
redis-cli config set slowlog-log-slower-than 0
redis-cli config set slowlog-max-len 10000

# 导出慢查询
redis-cli slowlog get > slow.log

# 分析
python faina.py --input=slow.log --output=analysis.txt

方法 4:Java 程序监控

import redis.clients.jedis.Jedis;
import java.util.*;
import java.util.concurrent.*;

public class HotKeyMonitor {
    private Jedis jedis;
    private Map<String, LongAdder> keyAccessCount = new ConcurrentHashMap<>();
    private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
    
    public HotKeyMonitor(Jedis jedis) {
        this.jedis = jedis;
        startMonitoring();
    }
    
    /**
     * 开始监控
     */
    private void startMonitoring() {
        // 每秒统计一次
        scheduler.scheduleAtFixedRate(() -> {
            Map<String, Long> hotKeys = getHotKeys(1000);
            if (!hotKeys.isEmpty()) {
                System.out.println("热 Key 发现:");
                hotKeys.forEach((key, count) -> 
                    System.out.println("  " + key + ": " + count + " 次/秒")
                );
            }
            keyAccessCount.clear();
        }, 1, 1, TimeUnit.SECONDS);
    }
    
    /**
     * 记录 key 访问
     */
    public void recordAccess(String key) {
        keyAccessCount.computeIfAbsent(key, k -> new LongAdder()).increment();
    }
    
    /**
     * 获取热 Key
     */
    public Map<String, Long> getHotKeys(long threshold) {
        Map<String, Long> hotKeys = new HashMap<>();
        
        keyAccessCount.forEach((key, adder) -> {
            long count = adder.sum();
            if (count >= threshold) {
                hotKeys.put(key, count);
            }
        });
        
        // 按访问次数排序
        return hotKeys.entrySet().stream()
            .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                Map.Entry::getValue,
                (e1, e2) -> e1,
                LinkedHashMap::new
            ));
    }
    
    /**
     * 停止监控
     */
    public void stop() {
        scheduler.shutdown();
    }
}

2.3 热 Key 解决方案

方案 1:本地缓存

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

public class HotKeyWithLocalCache {
    private Jedis jedis;
    private Cache<String, String> localCache;
    
    public HotKeyWithLocalCache(Jedis jedis) {
        this.jedis = jedis;
        // 本地缓存:最大 10000 个 key,过期时间 5 秒
        this.localCache = Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(5, TimeUnit.SECONDS)
            .build();
    }
    
    /**
     * 读取带本地缓存
     */
    public String get(String key) {
        // 先查本地缓存
        String value = localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
        
        // 查 Redis
        value = jedis.get(key);
        if (value != null) {
            localCache.put(key, value);
        }
        
        return value;
    }
    
    /**
     * 写入并清除本地缓存
     */
    public void set(String key, String value) {
        jedis.set(key, value);
        localCache.invalidate(key);
    }
}

方案 2:热 Key 备份(多副本)

import redis.clients.jedis.Jedis;
import java.util.*;

public class HotKeyReplication {
    private Jedis jedis;
    private Random random = new Random();
    
    public HotKeyReplication(Jedis jedis) {
        this.jedis = jedis;
    }
    
    /**
     * 热 Key 多副本存储
     * 原始:product:stock:12345
     * 副本:product:stock:12345:replica:0, product:stock:12345:replica:1, ...
     */
    public void createHotKeyReplicas(String key, int replicaCount) {
        String value = jedis.get(key);
        if (value == null) {
            return;
        }
        
        // 创建多个副本
        for (int i = 0; i < replicaCount; i++) {
            String replicaKey = key + ":replica:" + i;
            jedis.set(replicaKey, value);
        }
        
        System.out.println("创建 " + replicaCount + " 个副本");
    }
    
    /**
     * 随机读取副本(负载均衡)
     */
    public String getWithLoadBalance(String key, int replicaCount) {
        // 随机选择一个副本
        int replicaIndex = random.nextInt(replicaCount);
        String replicaKey = key + ":replica:" + replicaIndex;
        
        String value = jedis.get(replicaKey);
        if (value != null) {
            return value;
        }
        
        // 副本不存在,读取原 key
        return jedis.get(key);
    }
    
    /**
     * 更新所有副本
     */
    public void updateWithReplicas(String key, String value, int replicaCount) {
        // 更新原 key
        jedis.set(key, value);
        
        // 更新所有副本
        for (int i = 0; i < replicaCount; i++) {
            String replicaKey = key + ":replica:" + i;
            jedis.set(replicaKey, value);
        }
    }
}

方案 3:读写分离

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.List;
import java.util.Random;

public class ReadWriteSeparation {
    private JedisPool writePool;
    private List<JedisPool> readPools;
    private Random random = new Random();
    
    public ReadWriteSeparation(JedisPool writePool, List<JedisPool> readPools) {
        this.writePool = writePool;
        this.readPools = readPools;
    }
    
    /**
     * 写操作(主节点)
     */
    public void set(String key, String value) {
        try (Jedis jedis = writePool.getResource()) {
            jedis.set(key, value);
        }
    }
    
    /**
     * 读操作(随机从节点)
     */
    public String get(String key) {
        if (readPools.isEmpty()) {
            try (Jedis jedis = writePool.getResource()) {
                return jedis.get(key);
            }
        }
        
        // 随机选择一个从节点
        int index = random.nextInt(readPools.size());
        try (Jedis jedis = readPools.get(index).getResource()) {
            return jedis.get(key);
        }
    }
}

方案 4:分布式缓存

import redis.clients.jedis.Jedis;
import java.util.*;

public class DistributedHotKeyCache {
    private List<Jedis> jedisList;
    private Random random = new Random();
    
    public DistributedHotKeyCache(List<Jedis> jedisList) {
        this.jedisList = jedisList;
    }
    
    /**
     * 一致性哈希存储热 Key
     */
    public void set(String key, String value) {
        // 选择节点
        Jedis jedis = selectNode(key);
        jedis.set(key, value);
    }
    
    /**
     * 读取
     */
    public String get(String key) {
        Jedis jedis = selectNode(key);
        return jedis.get(key);
    }
    
    /**
     * 选择节点(简单哈希)
     */
    private Jedis selectNode(String key) {
        int hash = Math.abs(key.hashCode());
        int index = hash % jedisList.size();
        return jedisList.get(index);
    }
    
    /**
     * 广播更新(强一致性场景)
     */
    public void broadcastSet(String key, String value) {
        for (Jedis jedis : jedisList) {
            jedis.set(key, value);
        }
    }
}

三、综合优化方案

3.1 架构设计

热 Key 优化架构:
┌─────────────────────────────────────────┐
│  应用层                                  │
│  ┌─────────┐  ┌─────────┐              │
│  │本地缓存 │  │本地缓存 │               │
│  └────┬────┘  └────┬────┘              │
│       │            │                     │
│       └──────┬─────┘                     │
│              │ 读写分离                   │
├──────────────┼───────────────────────────┤
│  缓存层                                  │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐ │
│  │ Redis 1 │  │ Redis 2 │  │ Redis 3 │ │
│  │ (Master)│  │(Slave)  │  │(Slave)  │ │
│  └─────────┘  └─────────┘  └─────────┘ │
└─────────────────────────────────────────┘

3.2 监控告警

import redis.clients.jedis.Jedis;
import java.util.*;

public class BigHotKeyAlert {
    private Jedis jedis;
    private long bigKeySizeThreshold = 10 * 1024; // 10KB
    private long hotKeyCountThreshold = 1000; // 1000 次/秒
    
    public BigHotKeyAlert(Jedis jedis) {
        this.jedis = jedis;
    }
    
    /**
     * 定期检查
     */
    public void periodicCheck() {
        // 检查大 Key
        List<String> bigKeys = findBigKeys();
        if (!bigKeys.isEmpty()) {
            sendAlert("发现大 Key: " + bigKeys);
        }
        
        // 检查热 Key
        List<String> hotKeys = findHotKeys();
        if (!hotKeys.isEmpty()) {
            sendAlert("发现热 Key: " + hotKeys);
        }
    }
    
    /**
     * 发送告警
     */
    private void sendAlert(String message) {
        System.out.println("⚠️  告警:" + message);
        // 实际项目中发送邮件/短信/钉钉
    }
    
    private List<String> findBigKeys() {
        // 实现大 Key 扫描逻辑
        return new ArrayList<>();
    }
    
    private List<String> findHotKeys() {
        // 实现热 Key 扫描逻辑
        return new ArrayList<>();
    }
}

四、总结

4.1 大 Key 处理要点

  1. 预防为主

    • 设计时避免存储大对象
    • 设置合理的 key 大小限制
  2. 及时发现

    • 定期扫描大 Key
    • 监控内存使用
  3. 合理拆分

    • List/Set 分片存储
    • String 压缩存储
    • Hash 按 field 拆分
  4. 安全删除

    • 使用 UNLINK 异步删除
    • 分批删除超大型 key

4.2 热 Key 处理要点

  1. 识别热 Key

    • 使用 —hotkeys 参数
    • 监控访问频率
  2. 多级缓存

    • 本地缓存(Caffeine/Guava)
    • Redis 缓存
    • 数据库
  3. 负载均衡

    • 读写分离
    • 多副本备份
    • 分布式缓存
  4. 预防雪崩

    • 设置不同过期时间
    • 永不过期的热 Key
    • 降级熔断机制

参考资料


分享这篇文章到:

上一篇文章
Redis 高可用架构对比:主从 vs 哨兵 vs Cluster
下一篇文章
Kafka 容量规划与性能优化实战