什么是 Redis 大 Key 问题,如何解决?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官提出这个问题,通常旨在考察以下几个层面:

  1. 基础概念理解:你是否能准确定义 Redis 中的 "大 Key"。
  2. 问题分析与定位能力:你是否清楚大 Key 会带来哪些具体的性能和运维危害,以及如何发现它们。
  3. 解决方案与工程实践:你是否有从 预防治理 两个角度系统性地解决该问题的思路和实践经验,这能反映你的设计意识和实战能力。
  4. 技术权衡与原理理解:在提出解决方案时,你是否了解不同方案背后的原理(如数据结构、网络模型、序列化协议)及其优缺点,能否根据场景做出合理的选择。

核心答案

Redis 大 Key 通常指数据量大的 Key(如一个 String 类型的 Value 高达 5 MB)或成员数量多的复合类型 Key(如一个 Hash 的成员数超过 5000 个)。

它会引发一系列严重问题:客户端/网络阻塞、慢查询、内存不均、主从同步延迟,甚至引发集群内存溢出导致服务崩溃。

解决思路需要 "防" 与 "治" 结合

  1. 预防:设计时拆分大 Key,优化序列化,设置合理的 TTL。
  2. 治理:通过 redis-cli --bigkeysmemory usage 等命令定位,然后根据数据类型选择异步删除(UNLINK)、渐进式遍历删除、或将其拆分为多个小 Key。

深度解析

原理与危害分析

大 Key 的危害根植于 Redis 的单线程处理模型内存管理机制

  • 阻塞请求与网络延迟:由于 Redis 核心命令处理是单线程的,操作一个大 Key(例如 hgetall 一个包含十万 field 的 Hash)会长时间占用该线程,导致后续所有命令被延迟,表现为服务响应时间飙升。同时,序列化/反序列化大数据或通过网络传输也会消耗大量 CPU 和带宽。
  • 内存分配与释放压力:大 Key 占用连续大块内存,可能引发内存碎片。更危险的是直接删除大 Key(如使用 DEL),因为 Redis 的 DEL 命令在释放内存时是同步阻塞的,可能引发秒级甚至更长的服务停顿。
  • 集群数据倾斜:在 Redis Cluster 中,Key 通过哈希槽分配到不同节点。若某个大 Key 体积巨大,会导致所在节点内存使用率远高于其他节点,影响集群扩展性和稳定性,也容易触发该节点的内存驱逐(maxmemory-policy)或溢出。

发现与定位

  1. redis-cli --bigkeys:快速采样扫描,给出每种数据类型中最大 Key 的信息。优点是快;缺点是采样可能不准,且只报告最大的一个。
  2. MEMORY USAGE <key> 命令:精确计算某个 Key 及其 Value 的实际内存占用(单位字节)。这是最准确的定位方法,常用于在怀疑某个 Key 时进行验证。
  3. scan 命令编程扫描:编写脚本,使用 SCAN 遍历所有 Key,并结合 STRLENHLENLLENZCARD 等命令判断大小。这种方式最灵活、全面,可以自定义阈值。
  4. 监控与告警:通过监控平台(如 Prometheus)采集 Redis 的 slowlog(慢查询日志)和节点内存差异,设置告警规则。

解决方案与最佳实践

场景解决方案原理与操作注意事项
String 类型大 Key1. 拆分:将大 Value 拆分成多个小 Key,如 big:object -> big:object:part1, part2
2. 使用更高效序列化:例如用 Protobuf、Kryo 替代 JSON,减少体积。
从数据源头上避免产生大 Key。拆分后需要应用层做聚合,增加了复杂度。
Hash/List/Set/Zset 大 Key分片存储:在原 Key 名中加入分片标识。例如用户购物车 cart:{userId},可改为 cart:{userId}:{shardId},其中 shardId = userId % 100将数据分散到多个小 Key 中,操作时通过哈希定位到具体分片。需要改造业务代码,维护分片逻辑。
删除已存在的大 Key异步非阻塞删除:优先使用 UNLINK 命令(Redis 4.0+),它仅在键空间移除 Key,实际内存释放放在后台线程进行。避免主线程因释放大量内存而阻塞。对于 4.0 以下版本,对于 Hash/List/Set/Zset,需自己编写 scan/hscan/sscan/zscan 脚本渐进式删除。
复杂集合 Key 的遍历避免使用 hgetalllrange 0 -1,改用 hscanlrange 分页或 hmget 指定 field。将单次大操作拆分成多次小操作,减少单次响应时间和网络传输压力。需要评估并改造所有涉及大 Key 的查询代码。

Java 代码示例

以下是用 Java 实现的 渐进式删除 Hash 大 Key 的示例,使用 Jedis 客户端:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.ScanParams;
import redis.clients.jedis.ScanResult;

public class RedisBigKeyCleaner {
    private Jedis jedis;
    
    public RedisBigKeyCleaner(String host, int port) {
        this.jedis = new Jedis(host, port);
    }
    
    /**
     * 渐进式删除 Hash 大 Key
     * @param key Hash 的 key
     * @param batchSize 每次删除的字段数量
     * @param delayMs 每次删除后的延迟(毫秒)
     */
    public void deleteLargeHashKey(String key, int batchSize, long delayMs) {
        String cursor = "0";
        
        do {
            // 使用 HSCAN 分批获取字段
            ScanParams scanParams = new ScanParams().count(batchSize);
            ScanResult<Map.Entry<String, String>> scanResult = 
                jedis.hscan(key, cursor, scanParams);
            
            // 获取当前游标和结果
            cursor = scanResult.getCursor();
            List<Map.Entry<String, String>> entries = scanResult.getResult();
            
            if (!entries.isEmpty()) {
                // 提取字段名
                String[] fields = entries.stream()
                    .map(Map.Entry::getKey)
                    .toArray(String[]::new);
                
                // 批量删除字段
                jedis.hdel(key, fields);
                System.out.println("已删除 " + fields.length + " 个字段");
                
                // 添加延迟,避免对 Redis 造成过大压力
                if (delayMs > 0) {
                    try {
                        Thread.sleep(delayMs);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        break;
                    }
                }
            }
            
        } while (!"0".equals(cursor)); // 游标为 "0" 表示遍历完成
        
        // 最后删除空的 Hash Key
        jedis.del(key);
        System.out.println("大 Key " + key + " 删除完成");
    }
    
    /**
     * 使用 UNLINK 异步删除(推荐,Redis 4.0+)
     */
    public void safeDeleteKey(String key) {
        jedis.unlink(key); // 异步非阻塞删除
        // 注意:如果 Redis 版本 < 4.0,这个方法不存在
    }
    
    /**
     * 示例:如何避免使用 hgetall,改用 hscan 分页获取
     */
    public Map<String, String> getLargeHashInPages(String key, int pageSize) {
        Map<String, String> result = new HashMap<>();
        String cursor = "0";
        
        do {
            ScanParams scanParams = new ScanParams().count(pageSize);
            ScanResult<Map.Entry<String, String>> scanResult = 
                jedis.hscan(key, cursor, scanParams);
            
            cursor = scanResult.getCursor();
            List<Map.Entry<String, String>> entries = scanResult.getResult();
            
            // 处理当前批次的字段
            for (Map.Entry<String, String> entry : entries) {
                result.put(entry.getKey(), entry.getValue());
            }
            
        } while (!"0".equals(cursor));
        
        return result;
    }
    
    public void close() {
        if (jedis != null) {
            jedis.close();
        }
    }
}

// 使用示例
public class Main {
    public static void main(String[] args) {
        RedisBigKeyCleaner cleaner = new RedisBigKeyCleaner("localhost", 6379);
        
        try {
            // 方法1:渐进式删除 Hash 大 Key
            cleaner.deleteLargeHashKey("big:hash:key", 100, 50);
            
            // 方法2:安全异步删除
            cleaner.safeDeleteKey("big:string:key");
            
            // 方法3:分页获取大 Hash,避免 hgetall
            Map<String, String> largeData = cleaner.getLargeHashInPages("large:hash", 500);
            
        } finally {
            cleaner.close();
        }
    }
}

注意事项与最佳实践:

  1. 选择合适的客户端:生产环境建议使用 Lettuce 或 Jedis 的最新版本,它们对 Redis 新特性支持更好。
  2. 连接池配置:确保使用连接池,避免每次操作都创建新连接。
  3. 异常处理:在生产代码中需要添加完善的异常处理和重试机制。
  4. 监控与日志:在执行大 Key 删除操作时,记录详细的日志,并监控 Redis 的内存和性能指标。
  5. 低峰期操作:大 Key 的删除和迁移操作尽量安排在业务低峰期进行。

总结

Redis 大 Key 问题的本质是单线程模型与大数据量操作之间的矛盾,解决它需要在设计与运维阶段双管齐下:设计时通过拆分、分片预防其产生;运维时通过专业工具定位,并采用 UNLINK 或渐进式方案安全治理。Java 开发者应熟悉相关客户端的 API,编写健壮的生产级代码来处理此类问题。