TinyLFU 缓存原理
1. TinyLFU 原理简述
TinyLFU(Tiny Least Frequently Used)是一种高效的缓存淘汰策略,它结合了 LFU(最不常用)和 LRU(最近最少使用)的优点,主要用于统计访问频率并结合窗口区(LRU)进行缓存管理。
核心思想:
- 使用近似计数器(如 Count-Min Sketch)高效统计 key 的访问频率(但不保存所有 key 的精确频率)。
- 缓存分为窗口区(LRU)和主区(LFU),新数据先进入窗口区,频率高的数据晋升到主区。
- 淘汰时,比较新数据和主区中频率最低的数据,决定是否替换。
TinyLFU 适合大规模缓存场景,命中率高,且内存开销低。
2. TinyLFU Demo(简化版)
由于 TinyLFU 需要高效计数(Count-Min Sketch),这里用简单的 HashMap 近似模拟计数器。
实际生产实现(如 Caffeine)会用更复杂的数据结构。
java
import java.util.*;
class TinyLFUCache<K, V> {
private final int windowSize;
private final int mainSize;
private final LinkedHashMap<K, V> windowCache; // LRU窗口
private final LinkedHashMap<K, V> mainCache; // LFU主区
private final Map<K, Integer> freqMap; // 访问频率计数器
public TinyLFUCache(int capacity) {
this.windowSize = Math.max(1, capacity / 10); // 10%窗口
this.mainSize = capacity - windowSize;
this.windowCache = new LinkedHashMap<>(windowSize, 0.75f, true);
this.mainCache = new LinkedHashMap<>(mainSize, 0.75f, true);
this.freqMap = new HashMap<>();
}
public V get(K key) {
if (windowCache.containsKey(key)) {
freqMap.put(key, freqMap.getOrDefault(key, 0) + 1);
return windowCache.get(key);
}
if (mainCache.containsKey(key)) {
freqMap.put(key, freqMap.getOrDefault(key, 0) + 1);
return mainCache.get(key);
}
return null;
}
public void put(K key, V value) {
// 增加频率
freqMap.put(key, freqMap.getOrDefault(key, 0) + 1);
if (windowCache.containsKey(key) || mainCache.containsKey(key)) {
// 更新已存在的
if (windowCache.containsKey(key)) windowCache.put(key, value);
else mainCache.put(key, value);
return;
}
// 新数据先放窗口区
if (windowCache.size() < windowSize) {
windowCache.put(key, value);
} else {
// 淘汰窗口区最旧的
Iterator<K> it = windowCache.keySet().iterator();
K evictKey = it.next();
V evictValue = windowCache.get(evictKey);
it.remove();
// 晋升淘汰的数据到主区
int evictFreq = freqMap.getOrDefault(evictKey, 0);
if (mainCache.size() < mainSize) {
mainCache.put(evictKey, evictValue);
} else {
// 主区满了,找最低频率项
K minFreqKey = null;
int minFreq = Integer.MAX_VALUE;
for (K k : mainCache.keySet()) {
int freq = freqMap.getOrDefault(k, 0);
if (freq < minFreq) {
minFreq = freq;
minFreqKey = k;
}
}
// 淘汰主区最低频率项
if (evictFreq > minFreq) {
mainCache.remove(minFreqKey);
mainCache.put(evictKey, evictValue);
}
// 如果淘汰的数据频率不高于主区最低频率,则直接丢弃
}
// 最后把新 key 放进窗口区
windowCache.put(key, value);
}
}
}
3. 使用示例
java
public class Main {
public static void main(String[] args) {
TinyLFUCache<Integer, String> cache = new TinyLFUCache<>(5);
cache.put(1, "A");
cache.put(2, "B");
cache.put(3, "C");
cache.get(1); // 增加1的频率
cache.put(4, "D");
cache.put(5, "E");
cache.get(2); // 增加2的频率
cache.put(6, "F"); // 触发淘汰
System.out.println(cache.get(1)); // 可能输出A
System.out.println(cache.get(2)); // 可能输出B
System.out.println(cache.get(3)); // 可能null
System.out.println(cache.get(4)); // 可能输出D
System.out.println(cache.get(5)); // 可能输出E
System.out.println(cache.get(6)); // 可能输出F
}
}
4. 原理总结
- 新数据进入窗口区(LRU),窗口满时晋升到主区(LFU)。
- 主区淘汰时,比较新数据与主区最低频率的数据,决定替换与否。
- 频率计数用近似计数器,实际生产建议用 Count-Min Sketch。
- TinyLFU 兼顾访问频率和最近访问时间,命中率高,抗热点污染能力强。
TinyLFU 为什么能兼顾短期热点和长期高频数据
1. 短期热点(窗口区 LRU)
- **窗口区(Window Cache)**采用 LRU(最近最少使用)策略。
- 新数据总是先进入窗口区。
- 如果某个 key 在短时间内被频繁访问(比如突然成为热点),它会一直被窗口区保留,不会被快速淘汰。
- 这样可以应对"突发热点":比如某个新闻突然很火,相关 key 频繁被访问,能优先缓存这些数据。
2. 长期高频数据(主区 LFU)
- **主区(Main Cache)**采用 LFU(最不常用)策略。
- 当窗口区满了,最旧的数据会被淘汰,但淘汰时会晋升到主区(如果它的访问频率高于主区最低频率的数据)。
- 主区只保留访问频率高的数据。那些长期被频繁访问的数据,其频率计数很高,即使它们暂时"冷却"也不会被淘汰。
- 这样可以应对"长期热点":比如用户每天都访问的首页、常用功能等,能长期缓存这些数据。
3. 两者结合的优势
- 短期热点:窗口区及时响应最近访问的数据,避免热点数据被频率统计"稀释"而被淘汰。
- 长期高频:主区通过访问频率统计,避免短期访问高的数据冲击导致长期高频数据被淘汰。
- 提升命中率:这种分区和分策略,能让缓存既有"灵活性"(应对热点),又有"稳定性"(保留高频)。
4. 示意图
5. 实际例子说明
- 某个 key(比如热搜词)突然被大量访问,能迅速进入窗口区并被缓存,用户体验好。
- 某个 key(比如用户主页)虽然最近访问不多,但长期累计访问很多,频率高,会被主区保留,不容易被淘汰。
6. 结论
TinyLFU 通过窗口区(LRU)和主区(LFU)分区管理 ,实现了对短期热点和长期高频数据的兼顾,有效提升了缓存命中率和抗冲击能力。这也是它比单纯 LRU 或 LFU 更受欢迎的原因。