缓存淘汰神技!手写 LRU 算法,秒杀 90% 程序员!

引言

Redis 的高效内存管理离不开缓存淘汰策略,其中 LRU(Least Recently Used,最近最少使用)是核心机制,通过优先淘汰最久未访问的数据,优化缓存命中率。在面试中,“如何实现 LRU 缓存?” 是高频考题,考察数据结构和算法能力。继前三篇实现基础缓存、键过期和 AOF 持久化后,本篇带你手写 LRU 淘汰策略,打造一个高效的 LruCache,让你在面试中秒杀对手!
目标

  • 实现 LRU 缓存淘汰策略,支持 O(1) 时间复杂度的 getput
  • 优化 LRU 性能,解决缓存污染问题。
  • 提供可运行代码、测试用例和图解,助你掌握 Redis 内存管理。

适合人群

  • 想深入理解 Redis 缓存淘汰机制的开发者。
  • 准备大厂面试、需手写 LRU 算法的程序员。
  • 对高效数据结构感兴趣的初学者和进阶者。

前置工作

  • 环境:Java 8+,Maven/Gradle。

  • 依赖:前三篇的 SimpleCacheExpiryCacheAofCache 类(本篇继承)。
    代码仓库: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 淘汰策略!下一篇文章,我们将实现 LFUCLOCKFIFO 淘汰策略,让缓存更智能,解锁 Redis 的多样化内存管理!标题:“淘汰策略终极对决!手写 LFU、CLOCK、FIFO,碾压面试官!” 敬请期待!
完整代码:请访问redis-handwritten 获取完整实现和测试用例。
互动:
运行代码了吗?有问题?欢迎在评论区留言!

觉得本篇炸裂吗?一键三连,下一期更精彩!
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值