Redis过期删除策略深度解析:为什么不立即删除过期key?

难度:⭐⭐⭐⭐ | 适合人群:想深入理解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. 随机抽取20key2. 删除其中过期的
    ↓
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 - 定期删除还没抽到这个key10: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
  • 平滑删除,不阻塞

三个删除时机

  1. 访问时(惰性)
  2. 定期扫描(定期)
  3. 内存满时(淘汰)

设计思想

  • 时间-空间权衡
  • 性能优先
  • 稳定性优先
  • 牺牲一点内存换取高性能

最佳实践

  • 设置合理过期时间
  • 分散过期时间
  • 不依赖过期事件
  • 主动检查超时

记忆口诀

过期不会立即删,
定时器消耗太大了。
惰性删除访问时,
定期删除随机抽。
每次抽取二十个,
过期超过四分一。
继续抽取再检查,
单次不超二十五。
三道防线来保障,
惰性定期加淘汰。
设计思想重性能,
牺牲内存换稳定。

🤔 常见面试题

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的设计哲学!

如果这篇文章对你有帮助,请:

  • 👍 点赞支持
  • ⭐ 收藏备用
  • 🔄 转发分享
  • 💬 评论交流

感谢阅读,期待下次再见! 👋