目录
引言
Redis 的高效内存管理离不开缓存淘汰策略,其中 LRU(Least Recently Used,最近最少使用)
是核心机制,通过优先淘汰最久未访问的数据,优化缓存命中率。在面试中,“如何实现 LRU 缓存?
” 是高频考题,考察数据结构和算法能力。继前三篇实现基础缓存、键过期和 AOF 持久化后,本篇带你手写 LRU 淘汰策略,打造一个高效的 LruCache
,让你在面试中秒杀对手!
目标:
- 实现 LRU 缓存淘汰策略,支持 O(1) 时间复杂度的
get
和put
。 - 优化 LRU 性能,解决缓存污染问题。
- 提供可运行代码、测试用例和图解,助你掌握 Redis 内存管理。
适合人群:
- 想深入理解 Redis 缓存淘汰机制的开发者。
- 准备大厂面试、需手写 LRU 算法的程序员。
- 对高效数据结构感兴趣的初学者和进阶者。
前置工作:
-
环境:Java 8+,Maven/Gradle。
-
依赖:前三篇的 SimpleCache、ExpiryCache、AofCache 类(本篇继承)。
代码仓库:https://github.com/codeInbpm/redis-handwritten -
建议:运行前三篇代码后,跟着本篇敲代码,体验 LRU 的高效淘汰!
LRU 缓存淘汰场景
# 为什么需要 LRU 缓存淘汰?
Redis 的内存有限,缓存满时需通过淘汰策略移除数据。LRU
是一种高效策略,优先淘汰最久未访问的键,适合热点数据场景。核心优势:
-
高命中率:保留最近访问的数据,提高缓存效率。
-
O(1) 复杂度:结合双向链表和 HashMap,实现高效操作。
-
灵活性:可优化以应对缓存污染(如一次性扫描访问)。
面试常见问题:
-
“如何实现一个 O(1) 的 LRU 缓存?”
-
“LRU 如何避免缓存污染?”
-
“Redis 的 LRU 和精确 LRU 有什么区别?”
本篇将实现一个完整的 LRU 缓存
,基于双向链表和 HashMap,支持 O(1) 操作,并优化缓存污染问题,为后续功能(如 LFU、CLOCK)铺路。
实现步骤
我们将基于前三篇的 AofCache(支持固定大小、过期、AOF 持久化),实现 LruCache
类,新增以下功能:
-
LRU 淘汰:缓存满时移除最近最少使用的键。
-
O(1) 操作:使用双向链表 + HashMap 实现高效 get 和 put。
-
缓存污染优化:通过访问频率判断,减少一次性访问的影响。
兼容前篇功能:保留 TTL 和 AOF 持久化。
以下是实现 LruCache 的五个详细步骤,确保用户清晰理解每个环节,易于学习和实践。
步骤 1:设计数据结构
目标:
为 LRU 缓存设计高效的数据结构,支持 O(1) 的 get 和 put 操作,同时兼容 TTL 和 AOF。
详细说明:
-
双向链表:维护键的访问顺序,头部存储最近访问的键,尾部是最久未访问的键(待淘汰)。双向链表支持 O(1) 的插入、删除和移动操作。
-
HashMap:存储键到节点的映射,快速定位键对应的链表节点,实现 O(1) 查找。
-
Node 类:每个节点存储键、值、过期时间(TTL)、访问频率(防缓存污染)以及前后指针。
-
哨兵节点:使用头尾哨兵节点简化链表操作,避免边界判断(如空链表)。
-
兼容性:保留前篇的 TTL(过期时间)和 AOF(持久化)功能。
要求:
-
HashMap 键为字符串,值为 Node 对象。
-
Node 包含 key、value、expiry(时间戳)、accessCount(访问频率)、prev 和 next 指针。
-
初始化头尾哨兵节点,维护链表结构。
为何如此设计:
-
双向链表 + HashMap 是 LRU 缓存的经典组合,HashMap 提供快速定位,链表维护顺序,共同实现 O(1) 操作。
-
访问频率用于后续优化缓存污染,优先淘汰低频键。
-
哨兵节点简化代码逻辑,避免特殊情况处理。
步骤 2:实现 LRU 逻辑
目标:
- 实现 get 和 put 方法,支持 O(1) 的 LRU 淘汰,更新访问顺序。
详细说明:
get 方法:
-
输入键,检查是否在 HashMap 中存在且未过期。
-
若存在,增加访问频率(accessCount++),将节点移到链表头部(最近使用)。
-
若不存在或过期,删除节点并返回 null。
put 方法:
-
若键已存在,更新值、TTL 和访问频率,移到头部。
-
若键不存在,创建新节点,加入 HashMap 和链表头部。
-
若缓存满(达到 capacity),移除尾部节点(最少使用)。
链表操作:
-
addToHead:将节点插入链表头部。
-
removeNode:从链表中移除节点。
-
moveToHead:移除节点并插入头部。
要求:
-
所有操作(查找、插入、删除、移动)需保持 O(1) 时间复杂度。
-
检查 TTL,过期节点自动删除(继承 ExpiryCache 的惰性删除)。
-
更新访问频率,为后续缓存污染优化做准备。
为何如此设计:
-
get 和 put 是缓存核心操作,O(1) 复杂度确保高性能。
-
链表头部表示最近访问,尾部表示最久未访问,符合 LRU 逻辑。
-
集成 TTL 确保与前篇功能无缝衔接。
LRU 操作流程
步骤 3:优化缓存污染
目标:
- 通过访问频率优化 LRU,减少一次性高频访问对热点数据的干扰。
详细说明:
-
缓存污染问题:一次性批量访问(如爬虫扫描)可能将低价值键移到链表头部,挤占热点数据,导致命中率下降。
-
优化方案:
为每个节点添加 accessCount 字段,记录访问频率。 在缓存满时,优先移除访问频率低的尾部节点(而非严格按 LRU 顺序)。 若尾部节点频率较高,尝试移除其他低频节点。
实现细节:
-
在 get 和 put 中递增 accessCount。
-
在 removeLruNode 中,检查尾部节点的 accessCount,若高于阈值(例如 5),扫描链表寻找低频节点。
-
平衡 TTL:过期键优先移除,减少无效数据占用。
要求:
- 增加 accessCount 字段,初始化为 1,每次访问 +1。
- 定义阈值(如 accessCount < 5)判断低频节点。
- 保持 O(1) 或接近 O(1) 的淘汰效率。
为何如此设计:
-
缓存污染是 LRU 的常见问题,访问频率优化可提升命中率。
-
简单阈值机制易于实现,后续可扩展为 LFU(第5篇)。
-
结合 TTL 确保内存高效利用。
缓存污染优化
图片说明:标准LRU
与优化LRU
的比较图。
左:标准LRU,节点按访问时间排序,在头部显示一次性访问节点。
右:优化了带有accessCount的LRU,低频节点移动到尾部。
步骤 4:支持 AOF 持久化
目标:
- 确保 LRU 缓存兼容 AOF 持久化,记录 put 操作并支持数据恢复。
详细说明:
日志记录:
-
重写 put 方法
,调用父类 AofCache 的 appendToAof 方法,记录 PUT key value ttl。确保每次写操作都追加到 appendonly.aof 文件。
-
数据恢复
:构造函数调用父类的 loadAof 方法,解析 AOF 文件。 恢复时按插入顺序重建链表(近似 LRU,最新插入的键靠前)。
-
一致性
: -
确保 put 和 remove 操作同步更新 HashMap、链表和 AOF 文件。
-
过期键在恢复时过滤(继承 AofCache 逻辑)。
要求:
-
复用 AofCache 的 AOF 写入和读取逻辑。
-
恢复时维护 LRU 顺序(按插入时间近似)。
-
处理文件操作异常,确保数据一致性。
为何如此设计:
-
AOF 持久化是 Redis 的核心功能,兼容前篇逻辑确保功能完整。
-
按插入顺序恢复链表是简化的 LRU 实现,适合初始版本。
-
异常处理保证生产级健壮性。
AOF 与 LRU 集成
说明:
显示AOF与LRU集成的图。
- 左:put操作更新HashMap和链表,然后追加到AOF文件中。
- 右:启动程序读取AOF文件,重建HashMap和链表。
步骤 5:添加测试用例
目标
通过 JUnit 测试验证 LRU 淘汰、访问顺序、TTL 兼容和 AOF 恢复。
详细说明:
测试场景:
-
基本 LRU 功能:验证 get 和 put 更新访问顺序,缓存满时移除尾部键。
-
访问频率优化:测试高频键保留,低频键优先淘汰。
-
TTL 兼容:验证过期键自动移除。
-
AOF 恢复:模拟重启,确认数据和访问顺序正确恢复。
-
异常处理:测试空输入、无效 TTL 等边界情况。
实现细节:
-
使用 JUnit 的 @Before 和 @After 清理 AOF 文件和资源。
-
通过 Thread.sleep 模拟 TTL 过期。
-
验证链表顺序(通过检查移除的键)。
用户学习点:
-
提供详细测试代码,读者可直接运行。
-
附带测试输出解释,帮助理解 LRU 行为。
要求:
-
测试覆盖所有功能:LRU 淘汰、访问频率、TTL、AOF。
-
模拟重启场景,确保数据一致性。
-
提供清晰的断言和错误信息。
为何如此设计:
-
全面测试确保代码可靠性,增强用户信心。
-
模拟真实场景(如重启、过期)帮助用户理解 LRU 在 Redis 中的实际应用。
-
详细注释和输出便于学习和调试。
核心代码实现
以下是 LruCache
的完整实现,存放在 LruCache.java 中,继承自 AofCache。
前置代码:
SimpleCache、ExpiryCache、AofCache(复用前三篇)
为节省篇幅,假设前三篇的类已存在:
SimpleCache
:固定大小缓存,支持 get、put。
ExpiryCache
:TTL、惰性删除、定期删除、随机淘汰。
AofCache
:AOF 日志记录和数据恢复。
主代码:LruCache
import java.io.*;
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 LruCache extends AofCache {
private class Node {
String key, value;
long expiry; // 过期时间戳
int accessCount; // 访问频率
Node prev, next;
Node(String key, String value, long expiry) {
this.key = key;
this.value = value;
this.expiry = expiry;
this.accessCount = 1;
}
}
private Map<String, Node> cache;
private Node head, tail;
private int capacity;
public LruCache(int capacity) {
super(capacity);
this.capacity = capacity;
this.cache = new HashMap<>();
this.head = new Node(null, null, 0); // 哨兵节点
this.tail = new Node(null, null, 0);
head.next = tail;
tail.prev = head;
loadAof(); // 恢复 AOF 数据
}
@Override
public String get(String key) {
if (key == null) {
throw new IllegalArgumentException("Key cannot be null");
}
Node node = cache.get(key);
if (node == null || System.currentTimeMillis() > node.expiry) {
if (node != null) {
removeNode(node);
cache.remove(key);
}
return null;
}
node.accessCount++; // 增加访问频率
moveToHead(node); // 移到链表头部
return node.value;
}
@Override
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");
}
Node node = cache.get(key);
if (node != null) {
node.value = value;
node.expiry = System.currentTimeMillis() + ttlMillis;
node.accessCount++;
moveToHead(node);
} else {
if (cache.size() >= capacity) {
removeLruNode(); // 移除最近最少使用的节点
}
node = new Node(key, value, System.currentTimeMillis() + ttlMillis);
cache.put(key, node);
addToHead(node);
}
appendToAof("PUT", key, value, ttlMillis); // 记录到 AOF
}
private void addToHead(Node node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
private void removeLruNode() {
// 优先移除过期键
for (Node node = tail.prev; node != head; node = node.prev) {
if (System.currentTimeMillis() > node.expiry) {
removeNode(node);
cache.remove(node.key);
return;
}
}
// 若无过期键,检查访问频率
Node lru = tail.prev;
if (lru.accessCount > 5) { // 阈值示例
// 寻找低频节点
for (Node node = tail.prev; node != head; node = node.prev) {
if (node.accessCount <= 5) {
removeNode(node);
cache.remove(node.key);
return;
}
}
}
// 默认移除尾部节点
removeNode(lru);
cache.remove(lru.key);
}
@Override
protected void loadAof() {
super.loadAof(); // 调用父类加载 AOF
// 重建链表顺序(按插入顺序近似 LRU)
for (String key : cache.keySet()) {
Node node = cache.get(key);
addToHead(node);
}
}
}
测试代码
以下是 LruCacheTest.java,验证 LRU 淘汰、访问频率、TTL 和 AOF 恢复。
package com;
import com.redis.cache.LruCache;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
import java.io.File;
public class LruCacheTest {
private LruCache cache;
private File aofFile = new File("appendonly.aof");
@Before
public void setUp() {
if (aofFile.exists()) {
aofFile.delete();
}
cache = new LruCache(3);
}
@After
public void tearDown() {
cache.shutdown();
if (aofFile.exists()) {
aofFile.delete();
}
}
@Test
public void testLruCache() throws InterruptedException {
// 测试 LRU 淘汰
cache.put("key1", "value1", 5000);
cache.put("key2", "value2", 5000);
cache.put("key3", "value3", 5000);
cache.get("key1"); // key1 移到头部
cache.put("key4", "value4", 5000); // 移除 key2(最久未用)
assertNull(cache.get("key2"));
assertEquals("value1", cache.get("key1"));
assertEquals("value4", cache.get("key4"));
// 测试访问频率优化
cache.put("key5", "value5", 5000);
for (int i = 0; i < 10; i++) {
cache.get("key1"); // key1 高频访问
}
cache.put("key6", "value6", 5000); // 移除低频键
assertNotNull(cache.get("key1")); // key1 保留
// 测试 TTL 兼容
cache.put("key7", "value7", 1000);
Thread.sleep(1500);
assertNull(cache.get("key7"));
// 测试 AOF 恢复
cache = new LruCache(3);
assertEquals("value1", cache.get("key1"));
}
}
运行方式:
-
确保 Maven 项目包含 JUnit 依赖(同前三篇)。
-
将 SimpleCache.java、ExpiryCache.java、AofCache.java、LruCache.java 和 LruCacheTest.java 放入项目。
-
运行测试:mvn test 或通过 IDE 执行。
测试输出(示例):
Cache full, removed key: key2
面试要点
以下是基于本篇的常见面试问题及答案:
问:如何实现一个 O(1) 的 LRU 缓存?
- 答:使用双向链表维护访问顺序,HashMap 存储键到节点的映射。get 和 put 操作通过 HashMap 定位节点,链表更新顺序,均为 O(1)。本实现使用哨兵节点简化边界处理。
问:LRU 如何处理缓存污染?
- 答:通过访问频率计数,优先淘汰低频键。本实现为节点添加 accessCount,缓存满时移除低频节点,保留热点数据。
问:Redis 的近似 LRU 和精确 LRU 有什么区别?
- 答:Redis 使用近似 LRU,通过采样(随机检查部分键)降低开销,牺牲少量精度。本实现是精确 LRU,适合小规模场景。
问:如何优化 LRU 的性能?
- 答:可通过访问频率、采样删除或结合 LFU 优化命中率。本实现添加频率计数,后续可扩展为更复杂的策略。
下一步预告
恭喜你掌握 Redis 的 LRU 淘汰策略
!下一篇文章,我们将实现 LFU
、CLOCK
和 FIFO
淘汰策略,让缓存更智能,解锁 Redis 的多样化内存管理!标题:“淘汰策略终极对决!手写 LFU、CLOCK、FIFO,碾压面试官!” 敬请期待!
完整代码:请访问redis-handwritten 获取完整实现和测试用例。
互动:
运行代码了吗?有问题?欢迎在评论区留言!
觉得本篇炸裂吗?一键三连
,下一期更精彩!