Redis 过期机制大揭秘!一招让键值自动消失,面试稳了!

引言

Redis 的键过期机制expire是其核心功能之一,让键值对在指定时间后自动消失,广泛应用于缓存失效、会话管理等场景。在面试中,“Redis 如何实现键过期?” 是高频考题,考察你对数据结构、算法和系统设计的理解。继第一篇实现固定大小缓存后,本篇将带你手写 Redis 的过期机制,打造一个支持 TTL(Time To Live)的 ExpiryCache
目标:

  • 实现键的过期功能,支持 expire 设置和自动删除。
  • 引入随机键淘汰策略,优化内存管理。
  • 提供可运行代码、测试用例和图解,助你掌握 Redis 底层原理。

适合人群:

  • 想深入理解 Redis 过期机制的开发者。
  • 准备大厂面试、需要手写缓存系统的程序员。
  • 对键值数据库感兴趣的初学者和进阶者。

准备工作:

  • 环境:Java 8+,Maven/Gradle(推荐用于测试)。

  • 依赖:第一篇的 SimpleCache 类(本篇将继承扩展)。

  • 代码仓库:完整代码已上传至 GitHub: redis-handwritten

建议:运行第一篇代码后,跟着本篇敲代码,体验键值自动消失的魔法!

在这里插入图片描述

为什么需要键过期机制?

Redis 的键过期机制允许为键设置生存时间,过期后自动删除,带来以下优势

  • 内存优化:自动清理无用数据,防止内存溢出。

  • 业务支持:如临时缓存(验证码)、会话管理(登录状态)、排行榜(定时刷新)。

  • 高性能:通过惰性删除和定期删除,高效管理过期键。

在面试中,面试官可能问:

  • “Redis 如何实现键过期?惰性删除和定期删除的区别?”

  • “如何设计一个支持 TTL 的缓存系统?”

  • “过期键清理会影响性能吗?”
    本篇将实现一个支持 TTL 的缓存,结合 惰性删除(访问时检查过期)和 定期删除(定时清理),并引入 随机键淘汰 策略,为后续功能(如持久化、LRU)铺路。

实现步骤

我们将基于第一篇的 SimpleCache(支持固定大小的键值存储),实现 ExpiryCache 类,新增以下功能:

  • 设置 TTL:通过 put(key, value, ttlMillis) 为键设置生存时间(毫秒)。
  • 惰性删除:在 get 时检查键是否过期,过期则删除。
  • 定期删除:通过定时任务定期清理过期键。
  • 随机键淘汰:当缓存满时,随机移除部分过期键。

步骤 1:扩展缓存类结构

继承 SimpleCache,添加 Map 存储键的过期时间。

定义 put 方法重载,支持传入 TTL 参数。

步骤 2:实现惰性删除

get 方法中检查键的过期时间,若过期则删除并返回 null。

步骤 3:实现定期删除

使用 ScheduledExecutorService 创建定时任务,每隔固定时间清理过期键。

步骤 4:实现随机键淘汰

put 时,若缓存满,优先移除随机选取的过期键。

步骤 5:添加测试用例

使用 JUnit 测试 TTL 设置、惰性删除、定期删除和随机淘汰功能。
在这里插入图片描述

核心代码实现

以下是 ExpiryCache 的完整实现,存放在 ExpiryCache.java 中,继承自第一篇的 SimpleCache

前置代码:SimpleCache(复用第一篇)
import java.util.HashMap;
import java.util.Map;

public class SimpleCache {
    private Map<String, String> cache;
    private int capacity;

    public SimpleCache(int capacity) {
        if (capacity <= 0) {
            throw new IllegalArgumentException("Capacity must be positive");
        }
        this.cache = new HashMap<>();
        this.capacity = capacity;
    }

    public String get(String key) {
        if (key == null) {
            throw new IllegalArgumentException("Key cannot be null");
        }
        return cache.get(key);
    }

    public void put(String key, String value) {
        if (key == null || value == null) {
            throw new IllegalArgumentException("Key or value cannot be null");
        }
        if (cache.size() >= capacity) {
            String firstKey = cache.keySet().iterator().next();
            cache.remove(firstKey);
            System.out.println("Cache full, removed key: " + firstKey);
        }
        cache.put(key, value);
    }

    public int size() {
        return cache.size();
    }

    // 供子类访问的受保护方法
    protected Map<String, String> getCache() {
        return cache;
    }

    protected void remove(String key) {
        cache.remove(key);
    }
}
主代码:ExpiryCache
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ExpiryCache extends SimpleCache {
    private Map<String, Long> expiryTimes; // 存储键的过期时间戳
    private ScheduledExecutorService scheduler; // 定时清理任务

    public ExpiryCache(int capacity) {
        super(capacity);
        this.expiryTimes = new HashMap<>();
        this.scheduler = Executors.newScheduledThreadPool(1);
        // 每10秒清理一次过期键
        scheduler.scheduleAtFixedRate(this::removeExpiredKeys, 10, 10, TimeUnit.SECONDS);
    }

    // 重载 put 方法,支持 TTL
    public void put(String key, String value, long ttlMillis) {
        if (key == null || value == null) {
            throw new IllegalArgumentException("Key or value cannot be null");
        }
        if (ttlMillis <= 0) {
            throw new IllegalArgumentException("TTL must be positive");
        }

        // 检查缓存是否已满,尝试移除过期键
        if (getCache().size() >= super.size()) {
            removeRandomExpiredKey();
        }

        super.put(key, value);
        expiryTimes.put(key, System.currentTimeMillis() + ttlMillis);
    }

    // 重写 get 方法,实现惰性删除
    @Override
    public String get(String key) {
        if (key == null) {
            throw new IllegalArgumentException("Key cannot be null");
        }
        Long expiry = expiryTimes.get(key);
        if (expiry != null && System.currentTimeMillis() > expiry) {
            super.remove(key);
            expiryTimes.remove(key);
            return null;
        }
        return super.get(key);
    }

    // 定期清理过期键
    public void removeExpiredKeys() {
        expiryTimes.entrySet().removeIf(entry -> {
            if (System.currentTimeMillis() > entry.getValue()) {
                super.remove(entry.getKey());
                return true;
            }
            return false;
        });
    }

    // 随机移除一个过期键
    private void removeRandomExpiredKey() {
        for (String key : expiryTimes.keySet()) {
            Long expiry = expiryTimes.get(key);
            if (expiry != null && System.currentTimeMillis() > expiry) {
                super.remove(key);
                expiryTimes.remove(key);
                return;
            }
        }
        // 若无过期键,调用父类的移除逻辑
        if (getCache().size() >= super.size()) {
            String firstKey = getCache().keySet().iterator().next();
            super.remove(firstKey);
            expiryTimes.remove(firstKey);
            System.out.println("No expired keys, removed key: " + firstKey);
        }
    }

    // 关闭定时任务(清理资源)
    public void shutdown() {
        scheduler.shutdown();
    }
}
测试代码

以下是 ExpiryCacheTest.java,验证过期功能惰性删除定期删除随机淘汰

import org.junit.Test;
import static org.junit.Assert.*;

public class ExpiryCacheTest {
    @Test
    public void testExpiryCache() throws InterruptedException {
        ExpiryCache cache = new ExpiryCache(3);

        // 测试 TTL 过期
        cache.put("key1", "value1", 1000); // 1秒后过期
        cache.put("key2", "value2", 5000); // 5秒后过期
        assertEquals("value1", cache.get("key1"));
        Thread.sleep(1500); // 等待 1.5秒
        assertNull(cache.get("key1")); // key1 已过期
        assertEquals("value2", cache.get("key2"));

        // 测试容量限制和随机淘汰
        cache.put("key3", "value3", 5000);
        cache.put("key4", "value4", 1000); // 触发淘汰
        assertTrue(cache.size() <= 3);

        // 测试定期删除
        cache.put("key5", "value5", 1000);
        Thread.sleep(1500); // 等待定期任务触发
        cache.removeExpiredKeys(); // 手动触发清理
        assertNull(cache.get("key5"));

        // 测试无效输入
        try {
            cache.put("key6", "value6", 0);
            fail("Should throw IllegalArgumentException");
        } catch (IllegalArgumentException e) {
            // 预期异常
        }

        // 清理资源
        cache.shutdown();
    }
}
运行方式:
  • 确保 Maven 项目包含 JUnit 依赖(同第一篇)。

  • 将 SimpleCache.java、ExpiryCache.java 和 ExpiryCacheTest.java 放入项目。

  • 运行测试:mvn test 或通过 IDE 执行。

测试输出(示例):

Cache full, removed key: key1

面试要点

以下是基于本篇的常见面试问题及答案:
问:Redis 如何实现键过期

  • 答:通过为键关联过期时间戳,结合惰性删除(访问时检查)和定期删除(定时扫描)清理过期键。本实现使用 Map 存储时间戳,惰性删除在 get 时触发,定期删除由定时任务执行。

问:惰性删除和定期删除的优缺点

  • 答:惰性删除节省 CPU 但可能导致内存泄漏(未访问的过期键未删除);定期删除清理彻底但扫描耗 CPU。本实现结合两者,平衡性能和内存。

问:随机键淘汰的适用场景?

  • 答:随机淘汰实现简单,适合键分布均匀的场景,但可能误删高价值键,后续可优化为 LRU 或 LFU(第4、5篇)。

问:如何优化定期删除的性能?

  • 答:可采用采样删除(随机检查部分键),或维护过期键的优先队列,降低全量扫描的开销。

在这里插入图片描述

下一步预告

恭喜你掌握 Redis 的键过期机制!下一篇文章,我们将实现 AOF 持久化,让数据永不丢失,解锁 Redis 的持久化魔法! 敬请期待!
完整代码:请访问 GitHub: redis-handwritten 获取完整实现和测试用例。

运行代码了吗?有问题?欢迎在评论区留言!
觉得本篇炸裂吗?点个赞,关注系列,下一期更精彩!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wáng bēn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值