引言
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 获取完整实现和测试用例。
运行代码了吗?有问题?欢迎在评论区留言!
觉得本篇炸裂吗?点个赞,关注系列,下一期更精彩!