什么是 Redis 大 Key 问题,如何解决?
2026年01月01日
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 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/
面试考察点
面试官提出这个问题,通常旨在考察以下几个层面:
- 基础概念理解:你是否能准确定义 Redis 中的 "大 Key"。
- 问题分析与定位能力:你是否清楚大 Key 会带来哪些具体的性能和运维危害,以及如何发现它们。
- 解决方案与工程实践:你是否有从 预防 和 治理 两个角度系统性地解决该问题的思路和实践经验,这能反映你的设计意识和实战能力。
- 技术权衡与原理理解:在提出解决方案时,你是否了解不同方案背后的原理(如数据结构、网络模型、序列化协议)及其优缺点,能否根据场景做出合理的选择。
核心答案
Redis 大 Key 通常指数据量大的 Key(如一个 String 类型的 Value 高达 5 MB)或成员数量多的复合类型 Key(如一个 Hash 的成员数超过 5000 个)。
它会引发一系列严重问题:客户端/网络阻塞、慢查询、内存不均、主从同步延迟,甚至引发集群内存溢出导致服务崩溃。
解决思路需要 "防" 与 "治" 结合:
- 预防:设计时拆分大 Key,优化序列化,设置合理的 TTL。
- 治理:通过
redis-cli --bigkeys、memory usage等命令定位,然后根据数据类型选择异步删除(UNLINK)、渐进式遍历删除、或将其拆分为多个小 Key。
深度解析
原理与危害分析
大 Key 的危害根植于 Redis 的单线程处理模型和内存管理机制。
- 阻塞请求与网络延迟:由于 Redis 核心命令处理是单线程的,操作一个大 Key(例如
hgetall一个包含十万 field 的 Hash)会长时间占用该线程,导致后续所有命令被延迟,表现为服务响应时间飙升。同时,序列化/反序列化大数据或通过网络传输也会消耗大量 CPU 和带宽。 - 内存分配与释放压力:大 Key 占用连续大块内存,可能引发内存碎片。更危险的是直接删除大 Key(如使用
DEL),因为 Redis 的DEL命令在释放内存时是同步阻塞的,可能引发秒级甚至更长的服务停顿。 - 集群数据倾斜:在 Redis Cluster 中,Key 通过哈希槽分配到不同节点。若某个大 Key 体积巨大,会导致所在节点内存使用率远高于其他节点,影响集群扩展性和稳定性,也容易触发该节点的内存驱逐(
maxmemory-policy)或溢出。
发现与定位
redis-cli --bigkeys:快速采样扫描,给出每种数据类型中最大 Key 的信息。优点是快;缺点是采样可能不准,且只报告最大的一个。MEMORY USAGE <key>命令:精确计算某个 Key 及其 Value 的实际内存占用(单位字节)。这是最准确的定位方法,常用于在怀疑某个 Key 时进行验证。scan命令编程扫描:编写脚本,使用SCAN遍历所有 Key,并结合STRLEN、HLEN、LLEN、ZCARD等命令判断大小。这种方式最灵活、全面,可以自定义阈值。- 监控与告警:通过监控平台(如 Prometheus)采集 Redis 的
slowlog(慢查询日志)和节点内存差异,设置告警规则。
解决方案与最佳实践
| 场景 | 解决方案 | 原理与操作 | 注意事项 |
|---|---|---|---|
| String 类型大 Key | 1. 拆分:将大 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 的遍历 | 避免使用 hgetall、lrange 0 -1,改用 hscan、lrange 分页或 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();
}
}
}
注意事项与最佳实践:
- 选择合适的客户端:生产环境建议使用 Lettuce 或 Jedis 的最新版本,它们对 Redis 新特性支持更好。
- 连接池配置:确保使用连接池,避免每次操作都创建新连接。
- 异常处理:在生产代码中需要添加完善的异常处理和重试机制。
- 监控与日志:在执行大 Key 删除操作时,记录详细的日志,并监控 Redis 的内存和性能指标。
- 低峰期操作:大 Key 的删除和迁移操作尽量安排在业务低峰期进行。
总结
Redis 大 Key 问题的本质是单线程模型与大数据量操作之间的矛盾,解决它需要在设计与运维阶段双管齐下:设计时通过拆分、分片预防其产生;运维时通过专业工具定位,并采用 UNLINK 或渐进式方案安全治理。Java 开发者应熟悉相关客户端的 API,编写健壮的生产级代码来处理此类问题。