目录
引言
Redis 的缓存淘汰策略是其高效内存管理的核心,LFU(Least Frequently Used,最少使用频次)、CLOCK 和 FIFO(First-In-First-Out)提供了多样化的淘汰方式,适用于不同场景。在面试中,“如何实现 LFU 或 CLOCK 算法?” 是高频考题,考察你对数据结构和算法的深度理解。继前四篇实现基础缓存、键过期、AOF 持久化和 LRU 后,本篇带你手写 LFU、CLOCK 和 FIFO 淘汰策略,打造MultiEvictionCache
,让你在面试中一招制胜!
目标:
-
实现 LFU、CLOCK 和 FIFO 缓存淘汰策略,支持灵活切换。
-
优化性能,保持 O(1) 或近似 O(1) 复杂度。
-
兼容 TTL 和 AOF 持久化,提供可运行代码和测试用例。
-
提供详细步骤、图表和面试要点,助你深入学习。
适合人群:
-
想深入理解 Redis 淘汰策略的开发者。
-
准备大厂面试、需手写复杂算法的程序员。
-
对缓存优化感兴趣的初学者和进阶者。
前置工作:
-
环境:Java 8+,Maven/Gradle。
-
依赖:前四篇的 SimpleCache、ExpiryCache、AofCache、LruCache 类(本篇继承)。
-
代码仓库:https://github.com/yourname/redis-handwritten.git。
-
建议:运行前四篇代码后,跟着本篇敲代码,体验多种淘汰策略的魅力!
Redis 淘汰策略场景
为什么需要多种淘汰策略?
Redis 的内存有限,缓存满时需要通过淘汰策略移除数据。不同策略适用于不同场景:
-
LFU
(最少使用频次):基于访问频率淘汰,优先移除访问次数最少的键,适合长期热点数据场景。 -
CLOCK
:近似 LRU,使用参考位和循环指针,效率高且内存开销低,适合高并发。 -
FIFO
(先进先出):按插入顺序淘汰,简单但命中率较低,适合临时数据。
本篇将实现 MultiEvictionCache,支持 LFU
、CLOCK
和 FIFO
策略,兼容 TTL 和 AOF,为后续哈希表优化(第6篇)铺路。
实现步骤(详细展开)
以下是实现 MultiEvictionCache 的五个详细步骤,
确保用户清晰理解每个环节,易于学习和实践。
步骤 1:设计统一数据结构
目标:
- 为 LFU、CLOCK 和 FIFO 设计统一的数据结构,支持多种淘汰策略,兼容 TTL 和 AOF。
详细说明:
-
Node 类
:扩展前篇的 Node,增加字段支持多种策略:key, value, expiry(TTL,继承自 ExpiryCache)。
-
accessCount
(LFU 的频率计数)。 -
referenceBit
(CLOCK 的引用位,0 或 1)。 -
insertionOrder
(FIFO 的插入序号)。 -
prev, next
(双向链表指针,复用 LRU 结构)。 -
HashMap
:存储键到节点的映射,O(1) 查找。 -
双向链表
:维护节点顺序(LFU 用频率排序,CLOCK/FIFO 用插入顺序)。 -
策略标志
:添加 evictionStrategy 枚举(LFU, CLOCK, FIFO),运行时切换策略。 -
哨兵节点
:头尾哨兵简化链表操作。 -
兼容性
:保留 TTL(过期检查)和 AOF(持久化)功能。
要求:
-
定义 enum EvictionStrategy { LFU, CLOCK, FIFO }。
-
Node 包含所有字段,灵活支持三种策略。
-
初始化时指定策略,默认 LFU。
为何如此设计:
-
统一数据结构减少代码冗余,支持动态切换策略。
-
字段复用(如 accessCount)兼顾 LFU 和缓存污染优化。
-
哨兵节点和 HashMap 确保 O(1) 或近似 O(1) 操作。
MultiEvictionCache 数据结构
步骤 2:实现 LFU 淘汰策略
目标:
实现 LFU 算法,基于访问频率淘汰最少使用的键。
详细说明:
LFU 逻辑:
-
每次 get 或 put 增加节点 accessCount。
-
缓存满时,移除 accessCount 最低的节点(若频率相同,移除最久未访问的)。
数据结构:
-
使用 HashMap 快速定位节点。
-
使用双向链表维护频率排序(低频节点靠近尾部)。
-
为优化查找最低频率,维护一个 TreeMap<Integer, List>(频率到节点列表的映射)。
操作流程:
-
get:查找节点,增加 accessCount,更新频率映射,移到高频位置。
-
put:插入或更新节点,更新频率,缓存满时移除最低频率节点。
TTL 兼容
: 检查过期时间,过期键优先移除。
要求:
-
get 和 put 操作尽量接近 O(1)(TreeMap 可能引入 O(log n))。
-
维护频率排序,确保最低频率节点易于查找。
-
异常处理空输入、无效 TTL。
为何如此设计:
- LFU 适合热点数据场景,频率计数提高命中率。
- TreeMap 优化最低频率查找,平衡复杂度和效率。
- 兼容 TTL 确保与前篇无缝衔接。
LFU 操作流程
步骤 3:实现 CLOCK 淘汰策略
目标:
实现 CLOCK 算法,近似 LRU,降低内存开销。
详细说明:
CLOCK 逻辑:
- 每个节点有 referenceBit(0 或 1),表示最近是否被访问。
- 使用循环指针遍历链表,缓存满时:
- 若节点
referenceBit = 1
,置为 0,指针后移。
若节点referenceBit = 0
,移除该节点。 - get 或 put 将节点 referenceBit 置为 1。
数据结构:
-
复用双向链表,循环指针(clockHand)指向当前检查节点。
-
HashMap 存储键到节点映射。
操作流程:
- get:查找节点,置 referenceBit = 1,返回值。
- put:插入或更新节点,置 referenceBit = 1,缓存满时运行 CLOCK 扫描。
- TTL 兼容:过期键优先移除。
要求:
-
CLOCK 扫描接近 O(1) 均摊复杂度。
-
维护 clockHand 指针,确保循环遍历。
-
兼容 AOF 和 TTL 逻辑。
为何如此设计:
- CLOCK 是 Redis 近似 LRU 的实现,内存开销低(仅需 1 位)。
- 循环指针简化实现,适合高并发场景。
- 复用链表结构减少代码改动。
时钟驱逐机制图解
步骤 4:实现 FIFO 淘汰策略
目标:
- 实现 FIFO 算法,按插入顺序淘汰最早的键。
详细说明:
FIFO 逻辑:
-
按插入顺序维护节点,缓存满时移除最早插入的键(链表尾部)。
-
每个节点记录 insertionOrder(全局递增计数)。
数据结构:
-
复用双向链表,按插入顺序排列(最早插入的在尾部)。
-
HashMap 存储键到节点映射。
操作流程:
-
get:查找节点,返回值(不影响顺序)。
-
put:插入新节点到头部,缓存满时移除尾部节点。
-
TTL 兼容:过期键优先移除。
要求:
-
FIFO 操作保持 O(1) 复杂度。
-
维护插入顺序,尾部节点即最早插入。
-
兼容 AOF 和 TTL。
为何如此设计:
-
FIFO 实现简单,适合临时数据场景。
-
复用链表结构,代码改动最小。
-
插入顺序记录确保淘汰准确。
FIFO 操作流程
步骤 5:添加测试用例
目标:
通过 JUnit 测试验证 LFU、CLOCK 和 FIFO 功能,覆盖淘汰、TTL 和 AOF。
详细说明:
测试场景:
LFU
:验证低频键淘汰,高频键保留。CLOCK
:验证参考位更新和循环扫描。FIFO
:验证按插入顺序淘汰。TTL
兼容:过期键自动移除。AOF恢复
:模拟重启,验证数据和策略状态。异常处理
:测试空输入、无效 TTL。
实现细节:
-
使用 @Before 和 @After 清理 AOF 文件和资源。
-
测试每种策略的独立实例(strategy=LFU/CLOCK/FIFO)。
-
通过 Thread.sleep 模拟 TTL 过期。
为何如此设计:
- 全面测试确保代码可靠性,增强用户信心。
- 模拟真实场景(如过期、重启)帮助理解策略差异。
- 详细注释便于学习和调试。
核心代码实现
以下是 MultiEvictionCache
的完整实现,存放在 MultiEvictionCache.java,继承自 AofCache。
前置代码
:SimpleCache、ExpiryCache、AofCache、LruCache
为节省篇幅,假设前四篇的类已存在:
SimpleCache
:固定大小缓存。ExpiryCache
:TTL、惰性删除、定期删除。AofCache
:AOF 持久化。LruCache
:LRU 淘汰。
主代码:MultiEvictionCache
import java.io.*;
import java.util.*;
import java.util.concurrent.*;
public class MultiEvictionCache extends AofCache {
public enum EvictionStrategy { LFU, CLOCK, FIFO }
private class Node {
String key, value;
long expiry;
int accessCount; // LFU
int referenceBit; // CLOCK
long insertionOrder; // FIFO
Node prev, next;
Node(String key, String value, long expiry) {
this.key = key;
this.value = value;
this.expiry = expiry;
this.accessCount = 1;
this.referenceBit = 1;
this.insertionOrder = insertionCounter++;
}
}
private Map<String, Node> cache;
private Node head, tail;
private int capacity;
private EvictionStrategy strategy;
private TreeMap<Integer, List<Node>> freqMap; // LFU 频率映射
private Node clockHand; // CLOCK 指针
private long insertionCounter; // FIFO 插入序号
public MultiEvictionCache(int capacity, EvictionStrategy strategy) {
super(capacity);
this.capacity = capacity;
this.strategy = strategy;
this.cache = new HashMap<>();
this.freqMap = new TreeMap<>();
this.head = new Node(null, null, 0);
this.tail = new Node(null, null, 0);
head.next = tail;
tail.prev = head;
this.clockHand = head;
this.insertionCounter = 0;
loadAof();
}
@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);
if (strategy == EvictionStrategy.LFU) updateFreqMap(node, -node.accessCount);
}
return null;
}
updateNodeAccess(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;
updateNodeAccess(node);
} else {
if (cache.size() >= capacity) {
removeEvictedNode();
}
node = new Node(key, value, System.currentTimeMillis() + ttlMillis);
cache.put(key, node);
addToHead(node);
if (strategy == EvictionStrategy.LFU) {
freqMap.computeIfAbsent(1, k -> new ArrayList<>()).add(node);
}
}
appendToAof("PUT", key, value, ttlMillis);
}
private void updateNodeAccess(Node node) {
if (strategy == EvictionStrategy.LFU) {
updateFreqMap(node, -node.accessCount);
node.accessCount++;
updateFreqMap(node, node.accessCount);
moveToHead(node);
} else if (strategy == EvictionStrategy.CLOCK) {
node.referenceBit = 1;
} else if (strategy == EvictionStrategy.FIFO) {
// FIFO 不更新顺序
}
}
private void removeEvictedNode() {
if (strategy == EvictionStrategy.LFU) {
Map.Entry<Integer, List<Node>> entry = freqMap.firstEntry();
if (entry != null) {
List<Node> nodes = entry.getValue();
Node node = nodes.remove(nodes.size() - 1);
if (nodes.isEmpty()) freqMap.remove(entry.getKey());
removeNode(node);
cache.remove(node.key);
}
} else if (strategy == EvictionStrategy.CLOCK) {
while (true) {
clockHand = clockHand.next == tail ? head.next : clockHand.next;
if (clockHand == head) continue;
if (System.currentTimeMillis() > clockHand.expiry) {
Node toRemove = clockHand;
clockHand = clockHand.prev;
removeNode(toRemove);
cache.remove(toRemove.key);
return;
}
if (clockHand.referenceBit == 0) {
removeNode(clockHand);
cache.remove(clockHand.key);
return;
}
clockHand.referenceBit = 0;
}
} else if (strategy == EvictionStrategy.FIFO) {
Node toRemove = tail.prev;
if (toRemove != head) {
removeNode(toRemove);
cache.remove(toRemove.key);
}
}
}
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 updateFreqMap(Node node, int count) {
if (strategy != EvictionStrategy.LFU) return;
freqMap.computeIfAbsent(count, k -> new ArrayList<>()).remove(node);
if (freqMap.get(count) != null && freqMap.get(count).isEmpty()) {
freqMap.remove(count);
}
}
}
测试代码
以下是 MultiEvictionCacheTest.java,验证 LFU
、CLOCK
和 FIFO
功能。
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
import java.io.File;
public class MultiEvictionCacheTest {
private MultiEvictionCache cache;
private File aofFile = new File("appendonly.aof");
@Before
public void setUp() {
if (aofFile.exists()) aofFile.delete();
}
@After
public void tearDown() {
if (cache != null) cache.shutdown();
if (aofFile.exists()) aofFile.delete();
}
@Test
public void testLfuEviction() throws InterruptedException {
cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.LFU);
cache.put("key1", "value1", 5000);
cache.put("key2", "value2", 5000);
cache.put("key3", "value3", 5000);
for (int i = 0; i < 5; i++) cache.get("key1"); // key1 高频
cache.put("key4", "value4", 5000); // 移除低频 key2
assertNull(cache.get("key2"));
assertEquals("value1", cache.get("key1"));
}
@Test
public void testClockEviction() {
cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.CLOCK);
cache.put("key1", "value1", 5000);
cache.put("key2", "value2", 5000);
cache.put("key3", "value3", 5000);
cache.get("key1"); // referenceBit = 1
cache.put("key4", "value4", 5000); // 移除 referenceBit = 0 的键
assertTrue(cache.size() <= 3);
}
@Test
public void testFifoEviction() {
cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.FIFO);
cache.put("key1", "value1", 5000);
cache.put("key2", "value2", 5000);
cache.put("key3", "value3", 5000);
cache.put("key4", "value4", 5000); // 移除 key1(最早插入)
assertNull(cache.get("key1"));
assertEquals("value4", cache.get("key4"));
}
@Test
public void testTtlAndAof() throws InterruptedException {
cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.LFU);
cache.put("key1", "value1", 1000);
Thread.sleep(1500);
assertNull(cache.get("key1")); // 过期
cache.put("key2", "value2", 5000);
cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.LFU);
assertEquals("value2", cache.get("key2")); // AOF 恢复
}
}
运行方式:
-
确保 Maven 项目包含 JUnit 依赖(同前四篇)。
-
将 SimpleCache.java, ExpiryCache.java, AofCache.java, LruCache.java, MultiEvictionCache.java 和 MultiEvictionCacheTest.java 放入项目。
-
运行测试:mvn test 或通过 IDE 执行。
测试输出(示例):
Cache full, removed key: key2
面试要点
问:如何实现一个高效的 HashMap?
- 答:使用数组 + 链表,哈希函数映射键到桶,链表处理冲突。动态扩容保持负载因子。本实现用位运算优化哈希,容量为 2 的幂。
问:HashMap 的冲突处理如何优化?
- 答:链表适合低冲突场景,高冲突可转为红黑树(如 Java 8)。本实现用链表,简单且高效。
问:动态扩容的性能影响?
- 答:一次性 rehash 可能导致延迟,可通过渐进式 rehash 分摊开销(第7篇实现)。
问:Redis 哈希表与 Java HashMap 的区别?
- 答:Redis 使用自定义哈希表,支持渐进式 rehash和内存优化,适合高并发。本实现是简化版,接近 Java HashMap。
下一步预告
恭喜你掌握 Redis 的哈希表核心!下一篇文章,我们将实现 渐进式 rehash
,让哈希表扩容更高效!敬请期待!
完整代码:https://github.com/codeInbpm/redis-handwritten
**运行代码了吗?有问题?欢迎在评论区留言!**
觉得本篇炸裂吗?一键三连,下一期更精彩!