为什么Redis内存满了会自动淘汰数据?
为什么浏览器缓存能快速找到最近访问的网页?
为什么操作系统能智能管理物理内存?
背后都是LRU缓存在发力
📚 完整教程: github.com/Lee985-cmd/…
⭐ Star支持 | 💬 提Issue | 🔄 Fork分享
🎯 从一个真实场景说起
假设你在做一个电商网站:
// 场景:用户频繁查询热门商品
const products = {
'product_1001': { name: 'iPhone 15', price: 5999 },
'product_1002': { name: 'MacBook Pro', price: 12999 },
'product_1003': { name: 'AirPods', price: 1299 },
// ... 假设有100万个商品
};
// 问题:数据库查询太慢(100ms)
function getProductFromDB(productId) {
// 模拟数据库查询
return products[productId];
}
// 优化:加个缓存
const cache = {};
function getProduct(productId) {
// 先查缓存
if (cache[productId]) {
console.log('✅ 缓存命中');
return cache[productId];
}
// 缓存没有,查数据库
console.log('❌ 缓存未命中,查数据库');
const product = getProductFromDB(productId);
// 存入缓存
cache[productId] = product;
return product;
}
getProduct('product_1001'); // ❌ 缓存未命中
getProduct('product_1001'); // ✅ 缓存命中
问题来了:
如果缓存可以无限增长:
- 100万用户查询不同商品
- 缓存占用 8GB 内存
- 服务器内存爆了!💥
解决方案:限制缓存大小,满了淘汰旧数据。
但淘汰哪些数据?这就是 LRU(最近最少使用)策略。
🔍 LRU的核心思想
一句话解释
缓存满了,淘汰最久没被访问的数据。
为什么叫"最近最少使用"?
假设缓存容量是 3,当前缓存了:[A, B, C]
时间线:
T1: 访问A → A是最近使用的
T2: 访问B → B是最近使用的
T3: 访问C → C是最近使用的
T4: 访问A → A变成最近使用的,C变成最久没用的
T5: 要插入D → 淘汰C(最久没用的)
核心规则:
- 被访问的数据,标记为"最近使用"
- 缓存满了,淘汰"最久没使用"的数据
🛠️ 为什么哈希表 + 双向链表是最佳组合?
方案对比
| 方案 | 查询 | 插入 | 删除 | 维护顺序 |
|---|---|---|---|---|
| 数组 | O(n) | O(n) | O(n) | ✅ |
| 普通对象 | O(1) | O(1) | O(1) | ❌ |
| 哈希表 + 数组 | O(1) | O(1) | O(n) | ✅ |
| 哈希表 + 双向链表 | O(1) | O(1) | O(1) | ✅ |
双向链表的优势
普通链表(单链表):
head → A → B → C → tail
查找C的prev需要遍历:O(n)
双向链表:
head ↔ A ↔ B ↔ C ↔ tail
查找C的prev:O(1)
删除中间节点需要知道prev和next,双向链表O(1)搞定!
💻 完整代码实现
核心结构
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map(); // 哈希表:O(1)查询
// 双向链表的虚拟头尾节点
this.head = { key: null, value: null };
this.tail = { key: null, value: null };
this.head.next = this.tail;
this.tail.prev = this.head;
this.size = 0;
}
}
get操作(获取缓存)
get(key) {
if (!this.cache.has(key)) {
return -1; // 不存在
}
// 获取节点
const node = this.cache.get(key);
// 移动到头部(标记为最近使用)
this._moveToHead(node);
return node.value;
}
为什么访问后要移动到头部?
原始:head ↔ C ↔ B ↔ A ↔ tail
↑
访问B
移动后:head ↔ B ↔ C ↔ A ↔ tail
↑
B变成最近使用的
put操作(设置缓存)
put(key, value) {
if (this.cache.has(key)) {
// 键已存在,更新值并移动到头部
const node = this.cache.get(key);
node.value = value;
this._moveToHead(node);
} else {
// 键不存在,创建新节点
const newNode = { key, value };
this.cache.set(key, newNode);
this._addToHead(newNode);
this.size++;
// 如果超出容量,删除尾部节点
if (this.size > this.capacity) {
const tailNode = this._removeTail();
this.cache.delete(tailNode.key);
this.size--;
}
}
}
核心辅助方法
// 添加到头部
_addToHead(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
}
// 从链表中删除节点
_removeNode(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 移动到头部(先删除再添加)
_moveToHead(node) {
this._removeNode(node);
this._addToHead(node);
}
// 删除尾部节点(淘汰)
_removeTail() {
const tailNode = this.tail.prev;
this._removeNode(tailNode);
return tailNode;
}
🚀 真实场景应用
应用1:模拟Redis缓存淘汰
class RedisCache {
constructor(maxMemory) {
// 假设每个键值对占用1MB
this.lru = new LRUCache(maxMemory);
this.stats = {
hits: 0,
misses: 0
};
}
set(key, value) {
this.lru.put(key, value);
console.log(`✅ SET ${key}`);
}
get(key) {
const value = this.lru.get(key);
if (value !== -1) {
this.stats.hits++;
console.log(`✅ GET ${key}(缓存命中)`);
return value;
} else {
this.stats.misses++;
console.log(`❌ GET ${key}(缓存未命中)`);
return null;
}
}
getStats() {
const total = this.stats.hits + this.stats.misses;
const hitRate = total > 0 ? (this.stats.hits / total * 100).toFixed(1) : 0;
console.log(`缓存统计:`);
console.log(` 命中: ${this.stats.hits}`);
console.log(` 未命中: ${this.stats.misses}`);
console.log(` 命中率: ${hitRate}%`);
}
}
// 使用示例
const redis = new RedisCache(3);
redis.set('user:1', 'Alice');
redis.set('user:2', 'Bob');
redis.set('user:3', 'Charlie');
redis.get('user:1'); // 命中
redis.get('user:2'); // 命中
redis.set('user:4', 'David'); // 淘汰user:3
redis.get('user:3'); // 未命中(已被淘汰)
redis.get('user:1'); // 命中
redis.getStats();
// 缓存统计:
// 命中: 3
// 未命中: 1
// 命中率: 75.0%
应用2:浏览器HTTP缓存模拟
class BrowserCache {
constructor() {
this.cache = new LRUCache(100); // 最多缓存100个页面
this.networkRequests = 0;
}
// 访问网页
visitPage(url) {
const cachedPage = this.cache.get(url);
if (cachedPage !== -1) {
console.log(`⚡ 从缓存加载: ${url}`);
return cachedPage;
}
// 从网络加载
console.log(`🌐 从网络加载: ${url}`);
this.networkRequests++;
const page = this._fetchFromNetwork(url);
this.cache.put(url, page);
return page;
}
_fetchFromNetwork(url) {
// 模拟网络请求
return { url, content: `HTML content of ${url}`, timestamp: Date.now() };
}
getCacheHitRate() {
// 实际场景需要记录总访问次数
return '需要根据访问次数计算';
}
}
const browser = new BrowserCache();
browser.visitPage('https://example.com/home');
browser.visitPage('https://example.com/about');
browser.visitPage('https://example.com/home'); // 缓存命中
browser.visitPage('https://example.com/products');
browser.visitPage('https://example.com/home'); // 缓存命中
应用3:操作系统页面置换
class OSPageCache {
constructor(physicalPages) {
// 物理内存只能存放fixed数量的页面
this.pageTable = new LRUCache(physicalPages);
this.diskIO = 0; // 磁盘I/O次数
}
// 访问内存页
accessPage(pageId) {
const page = this.pageTable.get(pageId);
if (page === -1) {
// 页面不在内存(Page Fault)
console.log(`💾 Page Fault: ${pageId}`);
this.diskIO++;
// 从磁盘加载到内存
const loadedPage = this._loadFromDisk(pageId);
this.pageTable.put(pageId, loadedPage);
return loadedPage;
}
console.log(`✅ 内存命中: ${pageId}`);
return page;
}
_loadFromDisk(pageId) {
// 模拟磁盘I/O
return { pageId, data: `Page ${pageId} content` };
}
getStats() {
console.log(`页面置换统计:`);
console.log(` 磁盘I/O次数: ${this.diskIO}`);
}
}
// 模拟程序访问模式
const os = new OSPageCache(3);
os.accessPage(1); // Page Fault
os.accessPage(2); // Page Fault
os.accessPage(3); // Page Fault
os.accessPage(1); // 内存命中
os.accessPage(4); // Page Fault(淘汰页面2)
os.accessPage(2); // Page Fault(淘汰页面3)
os.getStats();
// 页面置换统计:
// 磁盘I/O次数: 5
⚙️ 性能优化指南
优化1:使用虚拟头尾节点
问题: 处理空链表需要特殊判断
// ❌ 不好的写法
addToHead(node) {
if (this.head === null) {
this.head = this.tail = node;
} else {
// 复杂逻辑...
}
}
// ✅ 使用虚拟节点
addToHead(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
}
虚拟头尾节点的好处:
- 不需要判断链表是否为空
- 插入/删除逻辑统一
- 代码更简洁
优化2:Map替代普通对象
// ❌ 普通对象
this.cache = {};
// ✅ Map
this.cache = new Map();
Map的优势:
- 保持插入顺序(虽然LRU自己维护顺序)
- 更好的性能
- 可以键是任意类型
优化3:容量预分配
// 如果知道大致访问模式,可以预分配容量
class OptimizedLRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map();
// 预分配空间(V8引擎优化)
this.cache._capacity = capacity;
}
}
🐛 常见坑与解决方案
坑1:忘记更新哈希表
// ❌ 错误:只删除了链表节点,没删除哈希表
_removeTail() {
const tailNode = this.tail.prev;
this._removeNode(tailNode);
// 忘记:this.cache.delete(tailNode.key);
return tailNode;
}
// ✅ 正确
_removeTail() {
const tailNode = this.tail.prev;
this._removeNode(tailNode);
this.cache.delete(tailNode.key); // 必须删除
return tailNode;
}
坑2:双向链表指针错误
// ❌ 错误顺序
_addToHead(node) {
this.head.next.prev = node; // 这行依赖this.head.next
node.next = this.head.next;
// 顺序错了!
}
// ✅ 正确顺序
_addToHead(node) {
node.prev = this.head; // 1. 设置node的prev
node.next = this.head.next; // 2. 设置node的next
this.head.next.prev = node; // 3. 更新后继节点的prev
this.head.next = node; // 4. 更新head的next
}
坑3:容量为1的边界情况
const cache = new LRUCache(1);
cache.put(1, 10);
cache.put(2, 20); // 应该淘汰1
console.log(cache.get(1)); // 应该是-1
console.log(cache.get(2)); // 应该是20
确保你的实现能处理容量为1的情况!
📊 性能基准测试
// 测试:LRU缓存性能
console.log('===== LRU缓存性能测试 =====\n');
const cache = new LRUCache(1000);
// 插入10万次
const startTime = Date.now();
for (let i = 0; i < 100000; i++) {
cache.put(i, i * 10);
}
const insertTime = Date.now() - startTime;
console.log(`插入10万次: ${insertTime}ms`);
console.log(`平均每次: ${(insertTime / 100000).toFixed(3)}ms`);
// 查询10万次
const queryStart = Date.now();
for (let i = 0; i < 100000; i++) {
cache.get(i);
}
const queryTime = Date.now() - queryStart;
console.log(`查询10万次: ${queryTime}ms`);
console.log(`平均每次: ${(queryTime / 100000).toFixed(3)}ms`);
// 预期输出:
// 插入10万次: < 100ms
// 查询10万次: < 100ms
// 平均每次: < 0.001ms
🎯 LeetCode相关题目
1. LRU缓存(LeetCode 146)⭐⭐⭐⭐⭐
题目: 设计和实现LRU缓存机制。
我的实现就是这道题的标准答案!
2. LFU缓存(LeetCode 460)⭐⭐⭐⭐⭐
进阶: 淘汰使用频率最低的数据(不是时间最近)。
思路:
- 需要记录每个key的使用次数
- 多个哈希表维护频率
- 比LRU复杂很多
💡 面试高频问题
Q1:为什么用Map不用普通对象?
A:
- Map保持插入顺序
- 性能更好
- 键可以是任意类型
Q2:为什么需要双向链表?
A:
- 删除中间节点需要prev指针
- 单链表删除需要O(n)查找prev
- 双向链表O(1)删除
Q3:LRU和LFU有什么区别?
A:
| 特性 | LRU | LFU |
|---|---|---|
| 淘汰策略 | 最近最少使用 | 使用频率最低 |
| 数据结构 | 哈希表 + 双向链表 | 多个哈希表 + 双向链表 |
| 时间复杂度 | O(1) | O(1) |
| 适用场景 | 时间局部性 | 频率局部性 |
| 实现难度 | 简单 | 复杂 |
Q4:如果缓存很大,内存不够怎么办?
A:
- 分级缓存(L1/L2/L3)
- 使用外部存储(Redis集群)
- 压缩缓存数据
- 使用布隆过滤器预过滤
📈 扩展:LRU的变体
1. LRU-K
- 记录最近K次访问
- 更准确的局部性判断
2. Two Queue (2Q)
- 两个队列:新数据队列 + 频繁访问队列
- 平衡性能和准确性
3. ARC (Adaptive Replacement Cache)
- 自适应调整LRU和LFU的权重
- PostgreSQL使用
4. Clock-Pro
- 类似Clock算法
- 扫描抵抗能力强
🎓 总结
LRU缓存的核心价值
- O(1)操作:get和put都是常数时间
- 自动淘汰:不需要手动清理
- 广泛应用:Redis、浏览器、OS都在用
- 面试常客:LeetCode 146是高频题
什么时候用?
✅ 适合:
- 需要缓存的场景
- 访问有明显的时间局部性
- 内存有限
- 要求O(1)操作
❌ 不适合:
- 访问模式完全随机
- 需要按频率淘汰(用LFU)
- 数据量太小(直接全量缓存)
- 需要持久化(用数据库)
下一篇文章会讲什么?
留言告诉我你最想看的算法主题!
📚 完整教程和代码: github.com/Lee985-cmd/…
⭐ 如果这篇文章帮到你,请Star支持一下!
💬 有问题欢迎在评论区讨论!
📢 欢迎关注我的公众号:Lee 的成长日记
💡 福利:关注公众号回复“算法”,获取本教程的 PDF 完整版及 LeetCode 刷题清单。