难度:⭐⭐⭐⭐ | 适合人群:想避免Redis生产事故的开发者
💥 开场:一次"惨烈"的线上故障
时间: 周六凌晨3点
地点: 家里(被电话吵醒)
事件: Redis雪崩
运维(慌张): "Redis集群挂了!所有节点都不响应了!"
我: "什么???" 😱(瞬间清醒)
运维: "监控显示某个节点CPU突然100%,然后就挂了,接着其他节点也跟着挂..."
我: "快看日志!" 😰
查看Redis日志:
[02:58:23] Client request timeout: DEL user:session:list
[02:58:25] Slow query: DEL user:session:list (5234ms)
[02:58:27] OOM: Cannot allocate memory
[02:58:28] Server crashed
我: "删除一个key居然要5秒???" 😱
连上Redis查看:
# 找到问题key
redis> DEBUG OBJECT user:session:list
Value at:0x7f8b9c0a1234 refcount:1 encoding:linkedlist serializedlength:524288000 lru:1234567
↑
500MB!!!
redis> LLEN user:session:list
(integer) 5000000 # 500万个元素
我: "卧槽!一个List存了500万个元素,500MB!这是谁写的代码???" 😭
第二天紧急复盘:
技术总监: "这次事故是典型的大Key问题!"
哈吉米: "一个500MB的大Key删除时,阻塞了主线程,导致整个Redis卡死。"
南北绿豆: "然后请求积压,内存暴增,最后OOM崩溃。"
阿西噶阿西: "我们必须系统地解决大Key和热Key问题..."
🎯 第一问:什么是大Key?
大Key定义
大Key: 占用内存过大或元素过多的Key
判断标准:
String类型:
value大小 > 10KB
List类型:
元素数量 > 10000
Hash类型:
元素数量 > 10000
或单个field的value > 10KB
Set类型:
元素数量 > 10000
ZSet类型:
元素数量 > 10000
注意: 这是经验值,实际要根据业务场景调整
大Key的危害
哈吉米列举了6大危害:
1. 阻塞主线程
删除500MB的大Key:
↓
需要5秒(释放内存)
↓
这5秒Redis无法处理其他请求
↓
所有客户端超时!
2. 网络阻塞
GET一个100MB的大Key:
↓
网络传输耗时长
↓
带宽被占满
↓
其他请求变慢
3. 内存不均
Redis Cluster(3个节点):
Node1:10GB(有个500MB的大Key)
Node2:5GB
Node3:5GB
数据分布不均!
Node1压力大
4. 过期删除慢
大Key设置了过期时间:
↓
到期后删除
↓
删除耗时长
↓
阻塞主线程
5. 持久化影响
BGSAVE时:
fork子进程
↓
大Key修改
↓
写时复制(Copy-On-Write)
↓
复制大Key的内存
↓
内存暴增!
6. 主从同步慢
Master有大Key:
↓
同步到Slave
↓
网络传输慢
↓
主从延迟增大
🔍 第二问:如何发现大Key?
方法1:--bigkeys扫描
# 扫描大Key
redis-cli --bigkeys
# 输出:
-------- summary -------
Sampled 10000 keys in the keyspace!
Total key length in bytes is 180000 (avg len 18.00)
Biggest string found: 'user:profile:12345' has 10485760 bytes
Biggest list found: 'user:session:list' has 5000000 items
Biggest hash found: 'product:detail:99999' has 50000 fields
Biggest set found: 'user:tags:123' has 100000 members
Biggest zset found: 'rank:score' has 200000 members
# 每种类型最大的key
优点:
- ✅ 简单快速
- ✅ 官方工具
缺点:
- ❌ 只找最大的,不是所有大Key
- ❌ 扫描时会阻塞(线上慎用)
方法2:SCAN + DEBUG OBJECT
# 遍历所有key
redis> SCAN 0 COUNT 100
# 检查每个key的大小
redis> DEBUG OBJECT key1
Value at:0x... refcount:1 encoding:raw serializedlength:10485760 lru:...
↑
序列化后大小
Java实现:
@Service
public class BigKeyScanner {
/**
* 扫描大Key
*/
public List<String> scanBigKeys(long threshold) {
List<String> bigKeys = new ArrayList<>();
ScanOptions options = ScanOptions.scanOptions()
.count(100)
.build();
Cursor<byte[]> cursor = redisTemplate.executeWithStickyConnection(
connection -> connection.scan(options)
);
while (cursor.hasNext()) {
String key = new String(cursor.next());
// 检查key大小
Long size = getKeySize(key);
if (size > threshold) {
bigKeys.add(key + " (" + size + " bytes)");
System.out.println("发现大Key:" + key + ",大小:" + size);
}
}
return bigKeys;
}
/**
* 获取key大小
*/
private Long getKeySize(String key) {
DataType type = redisTemplate.type(key);
switch (type) {
case STRING:
return redisTemplate.opsForValue().size(key);
case LIST:
return redisTemplate.opsForList().size(key);
case HASH:
return redisTemplate.opsForHash().size(key);
case SET:
return redisTemplate.opsForSet().size(key);
case ZSET:
return redisTemplate.opsForZSet().size(key);
default:
return 0L;
}
}
}
方法3:Redis慢查询
# 查看慢查询
redis> SLOWLOG GET 10
1) 1) (integer) 5 # 日志ID
2) (integer) 1705900000 # 时间戳
3) (integer) 5234567 # 执行耗时(微秒)= 5.2秒
4) 1) "DEL"
2) "user:session:list" # 大Key
# 配置慢查询阈值
redis> CONFIG SET slowlog-log-slower-than 10000 # 超过10ms记录
💻 第三问:大Key解决方案
方案1:拆分大Key
阿西噶阿西: "最根本的方法:不要让Key变大!"
场景:用户Session列表
// ❌ 不推荐:一个List存所有Session
// user:session:list → 500万个元素,500MB
LPUSH user:session:list session1
LPUSH user:session:list session2
...
LPUSH user:session:list session5000000
// 问题:
// - 单个key太大
// - 删除慢
// - 查询慢
✅ 推荐:拆分成多个小Key
/**
* 拆分策略:按时间分片
*/
public void addSession(Long userId, String session) {
// 按天拆分
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String key = "user:session:" + userId + ":" + date;
// 每天一个List
redisTemplate.opsForList().leftPush(key, session);
// 设置过期时间(7天后删除)
redisTemplate.expire(key, 7, TimeUnit.DAYS);
}
/**
* 获取最近的Session
*/
public List<String> getRecentSessions(Long userId, int days) {
List<String> allSessions = new ArrayList<>();
// 查询最近N天的
for (int i = 0; i < days; i++) {
String date = LocalDate.now().minusDays(i)
.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String key = "user:session:" + userId + ":" + date;
List<String> sessions = redisTemplate.opsForList().range(key, 0, -1);
if (sessions != null) {
allSessions.addAll(sessions);
}
}
return allSessions;
}
效果:
优化前:
user:session:list → 500万元素,500MB
优化后:
user:session:123:20240120 → 7000元素,700KB
user:session:123:20240121 → 7000元素,700KB
...
每个key很小,操作快速 ✅
方案2:异步删除
南北绿豆: "删除大Key用UNLINK,不要用DEL!"
# DEL:同步删除(阻塞)
redis> DEL bigkey
(5.23s) # 阻塞5秒
# UNLINK:异步删除(不阻塞)
redis> UNLINK bigkey
(integer) 1 # 立即返回
# 后台线程慢慢删除
Java使用:
// ❌ 不推荐:同步删除
redisTemplate.delete("bigkey"); // 可能阻塞
// ✅ 推荐:异步删除
redisTemplate.unlink("bigkey"); // 不阻塞
批量异步删除:
public void deleteBigKeys(List<String> bigKeys) {
// 分批删除
int batchSize = 1000;
for (int i = 0; i < bigKeys.size(); i += batchSize) {
int end = Math.min(i + batchSize, bigKeys.size());
List<String> batch = bigKeys.subList(i, end);
// 使用UNLINK批量异步删除
redisTemplate.unlink(batch.toArray(new String[0]));
// 避免瞬间压力过大
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
方案3:渐进式删除(List/Set/ZSet)
对于集合类型,逐步删除:
/**
* 渐进式删除大List
*/
public void deleteBigList(String key) {
long total = redisTemplate.opsForList().size(key);
System.out.println("大List元素数量:" + total);
int batchSize = 100; // 每次删除100个
while (total > 0) {
// 删除最右边的100个元素
for (int i = 0; i < batchSize && total > 0; i++) {
redisTemplate.opsForList().rightPop(key);
total--;
}
System.out.println("已删除:" + batchSize + ",剩余:" + total);
// 暂停10ms,避免持续阻塞
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("大List删除完成");
}
或使用LTRIM:
/**
* 使用LTRIM渐进式删除
*/
public void deleteBigListWithTrim(String key) {
long total = redisTemplate.opsForList().size(key);
int batchSize = 100;
while (total > batchSize) {
// 保留前(total-batchSize)个,删除后batchSize个
redisTemplate.opsForList().trim(key, 0, total - batchSize - 1);
total -= batchSize;
Thread.sleep(10);
}
// 删除剩余的
redisTemplate.delete(key);
}
🔥 第四问:什么是热Key?
热Key定义
热Key: 访问频率非常高的Key
判断标准:
QPS(每秒查询次数):
普通Key:< 1000 QPS
热Key:> 10000 QPS
超级热Key:> 100000 QPS
典型场景:
1. 热点新闻
- 突发事件的新闻
- 短时间内百万人访问
2. 秒杀商品
- iPhone新品秒杀
- 瞬间10万人抢购
3. 明星热搜
- 明星结婚、离婚等
- 微博热搜第一
4. 直播间信息
- 头部主播直播间
- 几百万人同时在线
热Key的危害
南北绿豆: "热Key比大Key更危险!"
1. 单点压力
Redis Cluster(3个节点):
热Key "product:iphone15" 在Node1
↓
所有请求都打到Node1
↓
Node1: CPU 100%,QPS 10万
Node2: CPU 10%,QPS 100
Node3: CPU 10%,QPS 100
负载不均!
Node1可能崩溃!
2. 网络瓶颈
热Key访问:10万QPS
每次响应:10KB
↓
网络流量:10万 × 10KB = 1GB/s
↓
网卡带宽(1Gbps)被打满!
↓
其他请求超时
3. 缓存击穿
热Key过期:
↓
瞬间10万请求
↓
Redis没有,全部打到数据库
↓
数据库崩溃!
🛡️ 第五问:热Key解决方案
方案1:本地缓存
阿西噶阿西: "最有效的方法:在应用层加本地缓存!"
@Service
public class ProductService {
// 本地缓存(Caffeine)
private Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(1000) // 最多1000个
.expireAfterWrite(1, TimeUnit.MINUTES) // 1分钟过期
.recordStats() // 记录统计
.build();
/**
* 获取商品(本地缓存 + Redis)
*/
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 1. 查询本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) {
System.out.println("本地缓存命中");
return product;
}
// 2. 查询Redis
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
product = JSON.parseObject(json, Product.class);
// 3. 存入本地缓存
localCache.put(key, product);
return product;
}
// 4. 查询数据库
product = productDao.findById(productId);
if (product != null) {
// 存入Redis
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
1, TimeUnit.HOURS);
// 存入本地缓存
localCache.put(key, product);
}
return product;
}
}
效果:
热Key访问:10万QPS
↓
99%命中本地缓存
↓
Redis QPS:1000(降低100倍)
↓
Redis压力骤减 ✅
方案2:热Key打散
哈吉米: "把一个热Key变成多个Key!"
/**
* 热Key打散(添加随机后缀)
*/
public void setHotKey(String key, String value) {
int copies = 10; // 复制10份
// 写入10个副本
for (int i = 0; i < copies; i++) {
String copyKey = key + ":copy:" + i;
redisTemplate.opsForValue().set(copyKey, value, 1, TimeUnit.HOURS);
}
}
/**
* 读取热Key(随机选择一个副本)
*/
public String getHotKey(String key) {
int copies = 10;
int random = ThreadLocalRandom.current().nextInt(copies);
String copyKey = key + ":copy:" + random;
return redisTemplate.opsForValue().get(copyKey);
}
效果:
原来:
product:iphone15 → 10万QPS → 集中在一个节点
打散后:
product:iphone15:copy:0 → 1万QPS
product:iphone15:copy:1 → 1万QPS
...
product:iphone15:copy:9 → 1万QPS
10个key分散到不同节点
每个节点压力降低10倍 ✅
方案3:限流
@Service
public class HotKeyService {
@Autowired
private RateLimiter rateLimiter; // Guava RateLimiter
/**
* 访问热Key(限流)
*/
public Product getHotProduct(Long productId) {
// 限流:每秒最多1000次
if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
System.out.println("请求被限流");
// 返回降级数据
return getProductFromLocalCache(productId);
}
// 正常查询Redis
return getProductFromRedis(productId);
}
}
方案4:多级缓存架构
请求
↓
浏览器缓存(1分钟)
↓ 未命中
CDN缓存(5分钟)
↓ 未命中
Nginx本地缓存(1分钟)
↓ 未命中
应用本地缓存(Caffeine,1分钟)
↓ 未命中
Redis缓存(1小时)
↓ 未命中
数据库
Java实现:
@Service
public class MultiLevelCacheService {
// L1:本地缓存
private Cache<String, Product> l1Cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
@Autowired
private RedisTemplate<String, String> redisTemplate; // L2:Redis
@Autowired
private ProductDao productDao; // L3:数据库
public Product getProduct(Long productId) {
String key = "product:" + productId;
// L1:本地缓存
Product product = l1Cache.getIfPresent(key);
if (product != null) {
return product;
}
// L2:Redis
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
product = JSON.parseObject(json, Product.class);
l1Cache.put(key, product); // 回写L1
return product;
}
// L3:数据库
product = productDao.findById(productId);
if (product != null) {
// 回写Redis
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
1, TimeUnit.HOURS);
// 回写本地缓存
l1Cache.put(key, product);
}
return product;
}
}
📊 第六问:大Key vs 热Key对比
对比表格
| 维度 | 大Key | 热Key |
|---|---|---|
| 定义 | 占用内存大/元素多 | 访问频率高 |
| 判断 | 大小>10KB或元素>1万 | QPS>1万 |
| 主要危害 | 阻塞、内存不均 | 单点压力、网络瓶颈 |
| 发现方式 | --bigkeys扫描 | 监控QPS、热点分析 |
| 解决方案 | 拆分、异步删除 | 本地缓存、打散、限流 |
| 预防 | 设计时控制大小 | 多级缓存 |
可以同时存在
一个Key既是大Key又是热Key:
product:iphone15:detail
├─ 大小:50MB(大Key)
└─ QPS:10万(热Key)
双重危害:
- 每次查询传输50MB
- 10万QPS = 5TB/s流量
- 瞬间打爆网络!💥
必须:
1. 拆分大Key
2. 加本地缓存
3. 限流
💡 最佳实践
1. 设计规范
String:
✅ 单个value < 10KB
❌ 不要存大对象、大文件
List:
✅ 元素数量 < 10000
❌ 不要当作无限队列
Hash:
✅ field数量 < 10000
✅ 单个value < 1KB
❌ 不要把所有属性都放一个Hash
Set:
✅ 元素数量 < 10000
ZSet:
✅ 元素数量 < 10000
2. 监控告警
@Component
public class BigKeyMonitor {
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void scanBigKeys() {
List<String> bigKeys = bigKeyScanner.scanBigKeys(10 * 1024); // >10KB
if (!bigKeys.isEmpty()) {
System.err.println("⚠️ 发现" + bigKeys.size() + "个大Key:");
for (String bigKey : bigKeys) {
System.err.println(" " + bigKey);
}
// 发送告警通知
alertService.sendAlert("发现大Key", bigKeys.toString());
}
}
}
3. 预防措施
/**
* 写入前检查大小
*/
public void setWithSizeCheck(String key, String value) {
// 检查大小
int size = value.getBytes().length;
if (size > 10 * 1024) { // 超过10KB
log.warn("⚠️ 尝试写入大Key:{},大小:{}KB", key, size / 1024);
// 可以拒绝或分片
throw new IllegalArgumentException("Value太大,请拆分");
}
// 正常写入
redisTemplate.opsForValue().set(key, value);
}
/**
* List添加前检查数量
*/
public void lpushWithSizeCheck(String key, String value) {
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > 10000) {
log.warn("⚠️ List元素过多:{},数量:{}", key, size);
// 可以拒绝、删除旧元素、或分片
// 删除最老的元素
redisTemplate.opsForList().trim(key, 0, 9999);
}
redisTemplate.opsForList().leftPush(key, value);
}
💡 知识点总结
大Key与热Key核心要点
✅ 大Key问题
- 定义:内存大或元素多
- 危害:阻塞、网络慢、内存不均
- 发现:--bigkeys、SCAN、慢查询
- 解决:拆分、异步删除、渐进式删除
✅ 热Key问题
- 定义:访问频率高
- 危害:单点压力、网络瓶颈、击穿
- 发现:监控QPS、热点分析
- 解决:本地缓存、打散、限流、多级缓存
✅ 拆分策略
- 按时间拆分(日期、小时)
- 按范围拆分(ID段)
- 按类型拆分(分类)
✅ 删除策略
- String/Hash小数据:DELETE
- 集合大数据:UNLINK(异步)
- 超大集合:渐进式删除
✅ 预防措施
- 设计时控制大小
- 写入前检查
- 定期扫描
- 监控告警
记忆口诀
大Key占用内存多,
阻塞删除是祸根。
拆分存储是正道,
异步删除UNLINK用。
渐进删除分批搞,
避免阻塞主线程。
热Key访问频率高,
单点压力要分担。
本地缓存第一层,
Redis压力骤减少。
热Key打散多副本,
请求分散到多节点。
多级缓存架构好,
CDN加Nginx保。
🤔 常见面试题
Q1: Redis的大Key问题是什么?大Key问题的缺点?
A:
大Key定义:
- String > 10KB
- List/Hash/Set/ZSet元素 > 1万
缺点(危害):
1. 阻塞主线程
- 删除大Key耗时长
- 阻塞其他请求
2. 网络阻塞
- 传输大Key占用带宽
3. 内存不均
- Cluster中某节点内存占用高
4. 主从同步慢
- 大Key传输慢
5. 持久化影响
- 写时复制内存暴增
6. 过期删除慢
- 阻塞定期删除任务
Q2: Redis大key如何解决?
A:
解决方案:
1. 拆分大Key
- 按时间拆分
- 按范围拆分
- 不让key变大
2. 异步删除
- 使用UNLINK代替DEL
- 后台线程删除
3. 渐进式删除
- List:逐步RPOP
- Set:逐步SPOP
- 分批删除
4. 监控预防
- 定期扫描大Key
- 写入前检查大小
- 告警通知
5. 配置优化
- lazyfree-lazy-eviction yes
- lazyfree-lazy-expire yes
Q3: 什么是热key?如何解决热key问题?
A:
热Key定义:
- 访问频率极高的key
- QPS > 1万
危害:
- 单点压力(集中在一个节点)
- 网络瓶颈(带宽打满)
- 缓存击穿(过期时压垮数据库)
解决方案:
1. 本地缓存(最有效)
- Caffeine/Guava Cache
- 应用层缓存
- 降低Redis压力
2. 热Key打散
- 复制多份(添加随机后缀)
- 分散到多个节点
3. 限流
- 控制访问频率
- 防止打垮Redis
4. 多级缓存
- CDN + Nginx + 本地 + Redis
- 层层拦截
5. 永不过期
- 热点数据设置永不过期
- 异步更新
💬 写在最后
从大Key到热Key,我们深入学习了Redis的两大隐形炸弹:
- 💣 理解了大Key和热Key的危害
- 🔍 掌握了发现大Key和热Key的方法
- 🛡️ 学会了多种解决方案
- 💻 完成了实战优化案例
这篇文章,希望能帮你避免生产环境的Redis事故!
如果这篇文章对你有帮助,请:
- 👍 点赞支持
- ⭐ 收藏备用
- 🔄 转发分享
- 💬 评论交流
感谢阅读,期待下次再见! 👋