难度:⭐⭐⭐⭐ | 适合人群:想深入理解Redis设计思想的开发者
💥 开场:一次"困惑"的面试
时间: 上周
地点: 某大厂面试
事件: 技术二面
面试官: "Redis的key过期了会立即删除吗?"
我: "会啊,设置了过期时间,到期就删除。" 😊
面试官: "真的会立即删除?"
我: "呃...应该会吧?" 🤔(开始不确定)
面试官: "如果有1000万个key同时过期,Redis会立即删除这1000万个key吗?"
我: "这..." 😰(语塞)
面试官: "如果立即删除,会有什么问题?"
我: "会...会阻塞?" 😓
面试官: "对,那Redis是怎么处理的呢?为什么不设计成立即删除?"
我: "...不知道。" 😭(凉凉)
回到家,我赶紧研究:
我: "为什么不立即删除呢?明明可以设置定时器啊..." 🤔
查资料后: "原来这背后有深刻的设计思想!" 💡
🎯 第一问:为什么不立即删除?
立即删除的问题
假设Redis采用"立即删除"策略:
每个key设置一个定时器
↓
到期时间一到,立即删除
看起来很完美?实际问题巨大!
问题1:CPU压力巨大
场景模拟:
系统中有1000万个key,都设置了过期时间
立即删除策略:
需要1000万个定时器
↓
每个定时器都要:
- 占用内存
- 定期检查
- 到期时触发
↓
CPU要维护1000万个定时器!💥
哈吉米计算了一下:
假设每个定时器占用100字节:
1000万 × 100B = 1GB内存(只是定时器!)
每秒检查定时器:
1000万次检查/秒
↓
CPU使用率爆炸!
问题2:删除操作阻塞
南北绿豆: "同时删除大量key会阻塞主线程!"
sequenceDiagram
participant Client
participant Redis主线程
Note over Redis主线程: 10:00:00<br/>100万个key同时到期
Redis主线程->>Redis主线程: 删除key1
Redis主线程->>Redis主线程: 删除key2
Note over Redis主线程: 删除100万个key<br/>耗时:5秒
Client->>Redis主线程: GET user:1
Note over Client: 等待...5秒无响应
Note over Redis主线程: 删除完成
Redis主线程-->>Client: 超时!
后果:
- 客户端请求超时
- Redis假死
- 雪崩
问题3:集中过期问题
阿西噶阿西: "批量导入数据时,容易造成集中过期。"
// 批量导入商品(都设置1小时过期)
for (Product product : products) {
redisTemplate.opsForValue().set(
"product:" + product.getId(),
JSON.toJSONString(product),
3600, // 都是3600秒
TimeUnit.SECONDS
);
}
// 1小时后:
// 100万个key同时过期
// 如果立即删除 → Redis卡死!
🔧 第二问:Redis的实际删除策略
组合策略:惰性 + 定期
哈吉米: "Redis采用了更聪明的方式!"
不立即删除
↓
而是采用:
惰性删除(被动)+ 定期删除(主动)
↓
既节省CPU,又及时清理
惰性删除详解
访问时才检查:
sequenceDiagram
participant Client
participant Redis
Note over Redis: key "user:1" 过期时间:10:00:00<br/>当前时间:10:05:00<br/>已过期,但还在内存中
Client->>Redis: GET user:1
Redis->>Redis: 检查过期时间
Note over Redis: 发现已过期(5分钟前)
Redis->>Redis: 删除key
Redis-->>Client: nil
Note over Redis: 内存已释放
源码逻辑(伪代码):
// 每次访问key都会调用这个函数
int expireIfNeeded(redisDb *db, robj *key) {
// 1. 获取过期时间
long long when = getExpire(db, key);
// 2. 没有过期时间,返回
if (when < 0) return 0;
// 3. 检查是否过期
if (mstime() <= when) return 0; // 未过期
// 4. 已过期,删除
deleteKey(db, key);
// 5. 发送删除通知(主从同步、AOF持久化)
notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired", key, db->id);
return 1;
}
定期删除详解
后台定时清理:
// 默认每100ms执行一次
void activeExpireCycle(int type) {
// 控制单次执行时间
long long timelimit = 1000000 * 25 / 100; // 25ms
long long start = ustime();
// 遍历所有数据库
for (j = 0; j < dbs_per_call && timelimit > 0; j++) {
int expired = 0;
do {
// 1. 随机抽取20个有过期时间的key
for (i = 0; i < 20; i++) {
dictEntry *de = dictGetRandomKey(db->expires);
// 2. 检查是否过期
long long ttl = dictGetSignedIntegerVal(de) - now;
if (ttl < 0) {
// 3. 过期,删除
deleteKey(db, key);
expired++;
}
}
// 4. 如果过期key超过25%(5个),继续抽取
// 否则跳出循环
} while (expired > 20 * 0.25 &&
ustime() - start < timelimit); // 控制时间不超过25ms
}
}
执行规则:
每100ms执行一次:
↓
1. 随机抽取20个key
↓
2. 删除其中过期的
↓
3. 如果过期key > 25%(5个)
↓
继续抽取20个
↓
4. 重复,但单次不超过25ms
为什么是20个?为什么是25%?
南北绿豆: "这是经过精心设计的!"
抽取20个:
- 太少:清理不及时
- 太多:CPU消耗大
- 20个:平衡点
过期25%继续:
- 说明过期key很多
- 需要继续清理
单次不超过25ms:
- 避免阻塞主线程太久
- 保证响应速度
⚠️ 第三问:过期删除的"坑"
坑1:大量key同时过期
场景:
// 凌晨2点,批量设置key,都是1小时过期
for (int i = 0; i < 1000000; i++) {
redisTemplate.opsForValue().set("key:" + i, "value", 1, TimeUnit.HOURS);
}
// 凌晨3点:
// 100万个key同时过期
// 定期删除开始疯狂工作
// Redis出现卡顿!
解决方案:
// 添加随机过期时间
Random random = new Random();
for (int i = 0; i < 1000000; i++) {
// 1小时 + 0-5分钟随机
int expire = 3600 + random.nextInt(300);
redisTemplate.opsForValue().set("key:" + i, "value",
expire, TimeUnit.SECONDS);
}
// key分散在1小时-1小时5分钟之间过期
// 定期删除压力分散 ✅
坑2:过期key占用内存
阿西噶阿西: "过期了不一定立即删除,会占用内存!"
10:00:00 - key "session:abc" 过期
↓
10:05:00 - 定期删除还没抽到这个key
↓
10:10:00 - 还没抽到
↓
10:15:00 - 还没抽到
↓
这个key一直占用内存!
直到:
- 被定期删除抽中(运气)
- 或被访问时惰性删除
- 或被内存淘汰策略删除
监控指标:
redis> INFO stats
expired_keys:12450 # 已删除的过期key数量
# 如果这个数字长时间不变,说明:
# - 没有key过期
# - 或过期key一直没被删除(问题!)
坑3:过期事件可能延迟
场景: 监听过期事件
@Component
public class RedisKeyExpireListener extends KeyExpirationEventMessageListener {
public RedisKeyExpireListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
System.out.println("Key过期:" + expiredKey + ",时间:" + new Date());
// 执行业务逻辑(如订单超时取消)
}
}
问题:
订单过期时间:10:00:00
实际收到过期事件:10:00:15(延迟15秒!)
原因:
- 定期删除是随机抽取
- 可能抽不到这个key
- 导致过期事件延迟
解决方案:
// 不能完全依赖过期事件,要主动检查
@Scheduled(fixedRate = 10000) // 每10秒检查一次
public void checkExpiredOrders() {
// 查询未支付订单
List<Order> orders = orderDao.findUnpaidOrders();
long now = System.currentTimeMillis();
for (Order order : orders) {
// 主动检查是否超时
if (now - order.getCreateTime() > 30 * 60 * 1000) { // 超过30分钟
// 取消订单
cancelOrder(order.getId());
}
}
}
💡 第四问:设计思想深度剖析
为什么这样设计?
哈吉米: "Redis的设计哲学:性能优先!"
对比:立即删除 vs 延迟删除
| 维度 | 立即删除 | 延迟删除(Redis方案) |
|---|---|---|
| CPU消耗 | 高(维护定时器) | 低(随机抽样) |
| 内存占用 | 低(立即释放) | 稍高(可能延迟) |
| 响应速度 | 慢(可能阻塞) | 快(不阻塞) |
| 实现复杂度 | 高 | 中 |
| 稳定性 | 差(集中过期卡顿) | 好(平滑删除) |
Redis选择:牺牲一点内存,换取高性能和稳定性!
设计权衡
南北绿豆: "这是经典的时间-空间权衡。"
立即删除:
✅ 内存利用率100%
❌ CPU消耗大
❌ 可能阻塞
延迟删除:
❌ 内存利用率95%(有5%的过期key还没删)
✅ CPU消耗小
✅ 不阻塞
✅ 平滑删除
Redis选择:
性能 > 内存
稳定 > 完美
🔍 第五问:实际删除时机
三个时机
阿西噶阿西: "过期key实际会在3个时机被删除。"
时机1:访问时(惰性删除)
时间线:
10:00:00 - key "user:1" 过期
10:01:00 - 无人访问(还在内存中)
10:02:00 - 无人访问(还在内存中)
10:03:00 - 客户端访问 GET user:1
↓
检测到过期,删除
↓
返回nil
代码模拟:
// 模拟惰性删除
public String get(String key) {
// 1. 检查是否过期
Long expireTime = expires.get(key);
if (expireTime != null && System.currentTimeMillis() > expireTime) {
// 2. 过期了,删除
data.remove(key);
expires.remove(key);
System.out.println("【惰性删除】" + key + " 已过期,删除");
return null;
}
// 3. 未过期,返回值
return data.get(key);
}
时机2:定期扫描(定期删除)
每100ms执行一次:
10:00:00.0 - 扫描20个key,删除3个过期的
10:00:00.1 - 扫描20个key,删除1个过期的
10:00:00.2 - 扫描20个key,删除8个过期的(>25%)
10:00:00.2 - 继续扫描20个,删除6个(>25%)
10:00:00.2 - 继续扫描20个,删除2个(<25%)
10:00:00.2 - 停止(或超过25ms)
10:00:00.3 - 扫描20个key...
代码模拟:
// 模拟定期删除(简化版)
@Scheduled(fixedRate = 100) // 每100ms执行
public void periodicDelete() {
long start = System.currentTimeMillis();
int expiredCount;
do {
expiredCount = 0;
// 1. 随机抽取20个key
List<String> randomKeys = getRandomKeys(20);
// 2. 检查并删除过期的
for (String key : randomKeys) {
Long expireTime = expires.get(key);
if (expireTime != null && System.currentTimeMillis() > expireTime) {
data.remove(key);
expires.remove(key);
expiredCount++;
System.out.println("【定期删除】" + key);
}
}
// 3. 过期超过25%且时间<25ms,继续
} while (expiredCount > 5 && System.currentTimeMillis() - start < 25);
}
时机3:内存满时(内存淘汰)
定期删除 + 惰性删除
↓
还是可能内存满
↓
触发内存淘汰策略
↓
删除部分key(包括过期和未过期的)
三道防线:
第一道:定期删除(主动清理)
↓ 漏掉的
第二道:惰性删除(访问时清理)
↓ 还不够
第三道:内存淘汰(强制清理)
📊 第六问:实战案例分析
案例1:订单超时取消
需求: 订单30分钟未支付自动取消
❌ 错误方案:依赖过期事件
// 创建订单时,设置30分钟过期
redisTemplate.opsForValue().set("order:" + orderId, "unpaid", 30, TimeUnit.MINUTES);
// 监听过期事件
@Component
public class OrderExpireListener extends KeyExpirationEventMessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String key = message.toString();
if (key.startsWith("order:")) {
String orderId = key.substring(6);
// 取消订单
orderService.cancelOrder(Long.parseLong(orderId));
}
}
}
// 问题:
// 1. 过期事件可能延迟(定期删除随机性)
// 2. 过期事件可能丢失(持久化配置)
// 3. 不可靠!
✅ 正确方案:主动检查 + 延迟队列
// 方案1:定时任务主动检查
@Scheduled(fixedRate = 10000) // 每10秒
public void checkExpiredOrders() {
// 查询未支付订单
List<Order> unpaidOrders = orderDao.findUnpaidOrders();
long now = System.currentTimeMillis();
for (Order order : unpaidOrders) {
// 检查是否超时
if (now - order.getCreateTime() > 30 * 60 * 1000) {
cancelOrder(order.getId());
}
}
}
// 方案2:使用ZSet延迟队列
public void createOrder(Order order) {
// 保存订单
orderDao.save(order);
// 添加到延迟队列(30分钟后执行)
long executeTime = System.currentTimeMillis() + 30 * 60 * 1000;
redisTemplate.opsForZSet().add("delay:order:cancel",
order.getId().toString(), executeTime);
}
// 消费延迟队列
@Scheduled(fixedRate = 1000)
public void consumeDelayQueue() {
long now = System.currentTimeMillis();
// 获取到期的任务
Set<String> tasks = redisTemplate.opsForZSet().rangeByScore(
"delay:order:cancel", 0, now, 0, 100);
for (String orderId : tasks) {
// 删除任务
redisTemplate.opsForZSet().remove("delay:order:cancel", orderId);
// 取消订单
cancelOrder(Long.parseLong(orderId));
}
}
案例2:Session过期
需求: 用户30分钟无操作,Session过期
实现:
@Service
public class SessionService {
/**
* 创建Session
*/
public String createSession(Long userId) {
String sessionId = UUID.randomUUID().toString();
String key = "session:" + sessionId;
// 设置30分钟过期
redisTemplate.opsForHash().put(key, "userId", userId.toString());
redisTemplate.opsForHash().put(key, "createTime",
String.valueOf(System.currentTimeMillis()));
redisTemplate.expire(key, 30, TimeUnit.MINUTES);
return sessionId;
}
/**
* 验证Session(自动续期)
*/
public Long validateSession(String sessionId) {
String key = "session:" + sessionId;
// 获取userId(惰性删除会在这里触发)
Object userIdObj = redisTemplate.opsForHash().get(key, "userId");
if (userIdObj == null) {
return null; // Session不存在或已过期
}
// Session有效,续期30分钟
redisTemplate.expire(key, 30, TimeUnit.MINUTES);
return Long.parseLong(userIdObj.toString());
}
}
流程:
用户登录 → 创建Session(30分钟过期)
↓
用户操作 → 验证Session → 续期30分钟
↓
30分钟无操作 → Session过期
↓
访问时惰性删除 or 定期删除清理
💡 最佳实践
1. 过期时间设置
// ✅ 推荐:根据业务设置合理的过期时间
@Service
public class CacheService {
// 热点数据:1小时
public void cacheHotData(String key, Object data) {
redisTemplate.opsForValue().set(key, data, 1, TimeUnit.HOURS);
}
// 普通数据:10分钟
public void cacheNormalData(String key, Object data) {
redisTemplate.opsForValue().set(key, data, 10, TimeUnit.MINUTES);
}
// Session:30分钟
public void cacheSession(String key, Object data) {
redisTemplate.opsForValue().set(key, data, 30, TimeUnit.MINUTES);
}
// 验证码:5分钟
public void cacheCode(String key, String code) {
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);
}
}
2. 避免集中过期
/**
* 批量设置缓存(分散过期时间)
*/
public void batchCache(List<Data> dataList, int baseExpire) {
Random random = new Random();
for (Data data : dataList) {
String key = "data:" + data.getId();
// 基础过期时间 + 随机时间
int randomExpire = random.nextInt(baseExpire / 10); // 基础时间的10%
int expire = baseExpire + randomExpire;
redisTemplate.opsForValue().set(key, JSON.toJSONString(data),
expire, TimeUnit.SECONDS);
}
}
// 使用
batchCache(products, 3600); // 1小时 + 0-360秒随机
3. 监控过期key
@Component
public class RedisMonitor {
@Scheduled(fixedRate = 60000) // 每分钟
public void monitorExpiredKeys() {
Properties info = redisTemplate.execute((RedisCallback<Properties>) connection ->
connection.info("stats"));
String expiredKeys = info.getProperty("expired_keys");
String evictedKeys = info.getProperty("evicted_keys");
System.out.println("过期删除:" + expiredKeys);
System.out.println("内存淘汰:" + evictedKeys);
// 如果内存淘汰数量增长快,说明内存不够
if (Long.parseLong(evictedKeys) > 10000) {
System.err.println("⚠️ 内存淘汰频繁,考虑扩容或优化");
}
}
}
4. 不要依赖过期事件
// ❌ 不推荐:完全依赖过期事件
@Component
public class OrderExpireListener extends KeyExpirationEventMessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
// 收到过期事件才处理
// 问题:事件可能延迟或丢失
}
}
// ✅ 推荐:主动检查 + 过期事件辅助
@Scheduled(fixedRate = 10000)
public void checkOrders() {
// 主动扫描超时订单
}
@Component
public class OrderExpireListener extends KeyExpirationEventMessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
// 过期事件作为辅助触发
}
}
💡 知识点总结
过期删除核心要点
✅ 为什么不立即删除?
- CPU消耗太大(维护定时器)
- 可能阻塞主线程
- 集中过期造成卡顿
- Redis选择:性能 > 内存
✅ 实际删除策略
- 惰性删除:访问时检查
- 定期删除:每100ms随机抽样
- 内存淘汰:内存满时强制删除
✅ 定期删除规则
- 抽取20个key
- 过期超过25%继续抽
- 单次不超过25ms
- 平滑删除,不阻塞
✅ 三个删除时机
- 访问时(惰性)
- 定期扫描(定期)
- 内存满时(淘汰)
✅ 设计思想
- 时间-空间权衡
- 性能优先
- 稳定性优先
- 牺牲一点内存换取高性能
✅ 最佳实践
- 设置合理过期时间
- 分散过期时间
- 不依赖过期事件
- 主动检查超时
记忆口诀
过期不会立即删,
定时器消耗太大了。
惰性删除访问时,
定期删除随机抽。
每次抽取二十个,
过期超过四分一。
继续抽取再检查,
单次不超二十五。
三道防线来保障,
惰性定期加淘汰。
设计思想重性能,
牺牲内存换稳定。
🤔 常见面试题
Q1: Redis的缓存失效会不会立即删除?
A:
不会立即删除!
原因:
1. 立即删除需要维护大量定时器
- CPU消耗大
- 内存消耗大
2. 大量key同时过期会阻塞
- 影响性能
- 造成卡顿
实际策略:
- 惰性删除:访问时检查并删除
- 定期删除:每100ms随机抽样删除
- 内存淘汰:内存满时强制删除
设计思想:
- 用空间换时间
- 用延迟换性能
- 保证稳定性
Q2: 那为什么不设计成过期即删除?
A:
过期即删除的问题:
1. 定时器开销
- 1000万个key = 1000万个定时器
- 内存占用大
- CPU消耗高
2. 删除阻塞
- 大量key同时过期
- 同时删除阻塞主线程
- 影响正常请求
3. 不够灵活
- 无法应对集中过期
- 性能不可控
Redis方案更优:
- 延迟删除,平滑处理
- 控制删除速度(25ms限制)
- 性能可控
Q3: 过期删除策略和内存淘汰策略的区别?
A:
过期删除策略:
- 处理:已过期的key
- 目的:删除过期数据
- 时机:访问时、定期扫描
- 策略:惰性 + 定期
内存淘汰策略:
- 处理:所有key(或有过期时间的key)
- 目的:腾出内存空间
- 时机:内存达到maxmemory
- 策略:LRU、LFU、随机等8种
关系:
- 过期删除是常规清理
- 内存淘汰是兜底方案
- 两者配合保证内存不爆
💬 写在最后
从"为什么不立即删除"到Redis的设计思想,我们深入理解了过期删除策略:
- ⏰ 理解了立即删除的问题
- 🔧 掌握了惰性+定期组合策略
- 📊 学会了实际删除时机
- 💻 完成了实战案例
这篇文章,希望能让你理解Redis的设计哲学!
如果这篇文章对你有帮助,请:
- 👍 点赞支持
- ⭐ 收藏备用
- 🔄 转发分享
- 💬 评论交流
感谢阅读,期待下次再见! 👋