Redis 过期策略与 LRU 算法深度解析:定期删除 + 惰性删除与内存淘汰机制实战(doocs/advanced-java 高并发缓存篇)
【免费下载链接】advanced-java😮 Core Interview Questions & Answers For Experienced Java(Backend) Developers | 互联网 Java 工程师进阶知识完全扫盲:涵盖高并发、分布式、高可用、微服务、海量数据处理等领域知识项目地址: https://gitcode.com/doocs/advanced-java
Redis 是互联网高并发架构中最核心的缓存组件,而「数据写进去为什么没了」「过期数据为什么还占着内存」正是线上使用 Redis 最常踩的两个坑。本文基于 doocs/advanced-java 高并发知识体系中的 《Redis 的过期策略和 LRU 算法》 展开,系统讲解 Redis 的定期删除 + 惰性删除双策略、maxmemory下的六种内存淘汰机制,并给出可现场手写的 Java 版 LRU 缓存实现,帮助你既能在面试中对答如流,也能在真实项目中正确配置与使用 Redis 缓存。
为什么 Redis 会"丢数据":先把缓存和存储分清楚
很多同学在生产环境遇到过这样的现象:数据写进 Redis,过一会儿再读就没了。这通常不是 Bug,而是把缓存当存储用导致的误判。
Redis 的定位是缓存,核心价值在于"用内存换性能"。与磁盘相比,内存是宝贵而有限的资源:一台机器可能只有几十 G 内存,却可以有几个 T 的硬盘空间。Redis 主要基于内存完成高性能、高并发的读写操作,内存空间天然是稀缺的。
- 假设 Redis 只能使用 10G 内存,你却写入了 20G 数据,会发生什么?—— 必然要干掉 10G 的数据,只保留 10G;
- 那么干掉哪些、保留哪些?—— 干掉不常用的数据,保留常用的数据,这就是内存淘汰要回答的问题;
- 另一类现象是:key 明明设置了过期时间,到了时间却依然占用着内存?—— 这由 Redis 的过期策略来决定。
关于缓存的本质(高性能、高并发两大用途)以及缓存使用不当引发的双写不一致、缓存雪崩/穿透/击穿等问题,可以进一步阅读本仓库的《缓存的使用方式》。
Redis 过期策略:定期删除 + 惰性删除
Redis 的过期策略可以概括为一句话:定期删除 + 惰性删除,两者配合使用。
定期删除(active expiration)
所谓定期删除,指的是 Redis 默认每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。
这里有两个关键点需要特别注意:
- 不是遍历所有 key。假设 Redis 里放了 10 万个设置了过期时间的 key,如果每隔几百毫秒就全量检查一遍,Redis 的 CPU 负载会飙升,绝大部分开销都会消耗在"检查过期 key"这件事上,基本等于让 Redis "死掉"。因此 Redis 的做法是每隔 100ms 随机抽取一部分 key 来检查和删除,把检查成本控制在可接受范围;
- 这是概率性删除。既然是随机抽样,就必然存在"漏网之鱼"——某些过期 key 到了时间却没被抽查到,也就没被删除。这一部分工作就要交给惰性删除来兜底。
惰性删除(lazy expiration)
惰性删除发生在读取路径上:当你获取某个 key 的时候,Redis 会检查一下这个 key 是否设置了过期时间、是否已经过期;如果已经过期,此时就会将其删除,并且不会返回任何内容给你。
获取 key 的时候,如果此时 key 已经过期,就删除,不会返回任何东西。
惰性删除的核心价值在于:保证客户端读到的数据一定是"活着"的,同时把删除动作的代价摊到访问路径上,避免后台做大规模扫描。但它同样有短板——如果一个过期 key 既没被定期删除抽查到,又一直没有被访问,它就永远不会被惰性删除。
过期时间从哪来
在 Redis 中,给 key 设置过期时间通常使用以下命令:
# 设置 key 在 60 秒后过期 EXPIRE key 60 # 以毫秒为单位设置过期时间 PEXPIRE key 60000 # 写入时直接带上过期时间(最常用) SET key value EX 60 SETEX key 60 value两个策略配合仍不够时怎么办
回到开头的场景:如果定期删除漏掉了很多过期 key,而你又没有及时去查询这些 key(没走惰性删除),大量过期 key 就会堆积在内存中,最终导致Redis 内存耗尽。此时怎么办?
答案是:走内存淘汰机制(maxmemory 淘汰策略)。过期策略负责"及时清理",内存淘汰机制负责"兜底保命",二者共同构成了 Redis 内存管理的完整闭环。
内存淘汰机制:六种 maxmemory 策略
当 Redis 内存不足以容纳新写入的数据时,会触发内存淘汰机制。Redis 提供了以下六种策略:
| 策略 | 作用范围 | 行为 | 适用性评价 |
|---|---|---|---|
noeviction | 全部键空间 | 新写入操作直接报错,不淘汰任何 key | 一般没人用,直接拒绝写入过于"粗暴" |
allkeys-lru | 键空间(全部 key) | 移除最近最少使用的 key | 最常用,覆盖所有 key 做 LRU 淘汰 |
allkeys-random | 键空间(全部 key) | 随机移除某个 key | 一般没人用,既然是淘汰,理应淘汰最少使用的 |
volatile-lru | 设置了过期时间的键空间 | 移除最近最少使用的 key | 一般不太合适,会漏掉大量未设置过期时间的 key |
volatile-random | 设置了过期时间的键空间 | 随机移除某个 key | 很少使用 |
volatile-ttl | 设置了过期时间的键空间 | 更早过期时间的 key 优先移除 | 适合希望优先清理"即将过期"数据的场景 |
逐一拆解如下:
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。这在生产环境基本不会采用,因为缓存场景下我们宁可淘汰旧数据,也不能让写入全部失败;
- allkeys-lru:当内存不足以容纳新写入数据时,在整个键空间中,移除最近最少使用的 key。它不区分 key 是否设置了过期时间,覆盖范围最全,能够最大化利用有限内存服务热点数据,是最常用的策略;
- allkeys-random:在整个键空间中随机移除某个 key。淘汰行为不可控、与业务热度无关,一般没人用——既然要淘汰,肯定是把最近最少使用的 key 干掉;
- volatile-lru:仅在设置了过期时间的键空间中移除最近最少使用的 key。如果你的 key 大部分都没有设置过期时间,这个策略会"无从下手",所以一般不太合适;
- volatile-random:在设置了过期时间的键空间中随机移除某个 key,淘汰行为同样不可控;
- volatile-ttl:在设置了过期时间的键空间中,更早过期时间的 key 优先移除,即优先淘汰"快要过期"的数据,让内存淘汰方向与业务生命周期对齐。
补充说明:
volatile-*系列策略只作用于设置过过期时间的 key。如果键空间中不存在任何设置了过期时间的 key,那么volatile-lru、volatile-random、volatile-ttl的行为会退化为noeviction(即写入报错),这一点在使用时需要注意。此外,Redis 4.0 之后官方还引入了 LFU(Least Frequently Used,最不经常使用)系列策略(如allkeys-lfu、volatile-lfu),在 LRU 的基础上进一步考虑访问频率,适合"一次访问后就长期不用的冷数据"居多的场景,本文以经典的 LRU 体系为主线展开。
如何配置与动态调整
在redis.conf配置文件中,通过以下两个参数控制内存淘汰行为:
# 限制 Redis 最大可用内存,例如 10g maxmemory 10gb # 指定内存淘汰策略,例如最常用的 allkeys-lru maxmemory-policy allkeys-lru生产环境也常常动态调整而不重启进程,使用CONFIG命令即可:
# 查询当前内存上限与淘汰策略 CONFIG GET maxmemory CONFIG GET maxmemory-policy # 动态修改(立即生效,无需重启) CONFIG SET maxmemory 10gb CONFIG SET maxmemory-policy allkeys-lru关于内存上限的量级,可参考本仓库《生产环境中的 Redis 是怎么部署的》中的实践:线上单实例 Redis 内存一般不要超过 10g,超出后可能带来 fork、持久化、淘汰扫描等多方面的稳定性问题;高并发下缓存的使用也依赖 Redis 单线程模型的高效性,详见《Redis 的线程模型》。
为什么是 LRU:让内存服务热点数据
LRU 是Least Recently Used的缩写,翻译过来就是"最近最少使用"。LRU 算法的核心思想是:当缓存满需要腾空间时,移除"最近最少使用"的数据,把空间让给"最新使用"的数据。
这条朴素策略之所以有效,是因为互联网业务的访问分布天然符合局部性原理:往往最常被读取的数据,也就是被访问次数最多的数据(热点数据)。利用好 LRU 算法,我们能够显著提升对热点数据的缓存命中率,进而提高缓存服务的内存使用率。
LRU 的直观逻辑可以用下面这张图来理解:缓存按"最近使用"到"最少使用"排列,当某个项被get()访问时,它会被移动到缓存的最前面(最近使用位置),而最少使用的项会逐渐沉到列表尾部,成为下一次淘汰的候选。
在 Redis 内部,LRU 淘汰正是依托于这种"访问即更新位置"的机制:每次 key 被访问,其 LRU 时钟都会更新,内存不足时优先淘汰 LRU 时钟最久未被更新的 key。
手写一个 LRU 算法:基于 LinkedHashMap 的 Java 实现
面试中"手写 LRU"通常考察两件事:是否理解 LRU 的核心思想,以及是否熟悉 JDK 现成数据结构。从零手工实现双向链表 + 哈希表版的 LRU 代码量太大,现场手写不现实;更务实的做法是:利用 JDK 已有的LinkedHashMap实现一个 Java 版的 LRU 缓存。
LinkedHashMap内部由哈希表 + 双向链表组成:哈希表保证 O(1) 的存取定位,双向链表维护元素的访问顺序。其构造器支持accessOrder参数——当accessOrder = true时,链表顺序即"访问顺序":每次get/put命中的元素都会被移动到链表尾部(最新位置),链表头部自然沉淀为"最久未使用"的元素,这正好就是 LRU 需要的语义。
public class LRUCache<K, V> extends LinkedHashMap<K, V> { private int capacity; /** * 传递进来最多能缓存多少数据 * * @param capacity 缓存大小 */ public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity = capacity; } /** * 如果map中的数据量大于设定的最大容量,返回true,再新加入对象时删除最老的数据 * * @param eldest 最老的数据项 * @return true则移除最老的数据 */ @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { // 当 map中的数据量大于指定的缓存个数的时候,自动移除最老的数据 return size() > capacity; } }代码要点拆解
super(capacity, 0.75f, true)三个参数的含义:- 第一个参数
initialCapacity:初始容量,这里直接用缓存容量capacity; - 第二个参数
loadFactor:负载因子,0.75f是HashMap的默认值,即当元素个数超过容量 × 0.75 时触发扩容; - 第三个参数
accessOrder:置为true是关键——开启"访问顺序"模式后,LinkedHashMap内部的双向链表会按访问(get/put)顺序维护节点,最近访问的节点移到链表尾部,最久未访问的节点沉淀在链表头部;
- 第一个参数
removeEldestEntry(Map.Entry<K, V> eldest)钩子方法:LinkedHashMap在每次put插入新元素后都会回调此方法,参数eldest就是当前链表头部(最老)的节点。我们重写它,在size() > capacity时返回true,LinkedHashMap就会自动把最老的数据移除——这正是 LRU 淘汰动作的落点;- 用法:对外表现与普通
Map完全一致,put、get、remove均可直接使用,超过容量自动淘汰最久未使用的条目。
removeEldestEntry底层依赖的正是哈希表 + 双向链表的结构:哈希表以 O(1) 完成 key 定位,双向链表以 O(1) 完成节点的移动与头尾摘除,两者结合保证了 LRU 缓存在读写和淘汰上都是高效操作。
不依赖 LinkedHashMap 的经典实现思路
如果你被要求"不能直接用LinkedHashMap",也需要掌握经典的哈希表 + 双向链表手写思路,其骨架可以概括为:
- 哈希表(
HashMap):key → 链表节点,保证 O(1) 定位; - 双向链表:头节点为最近使用,尾节点为最少使用,每个节点持有 key 和 value;
get(key):在哈希表中找到节点,将其从链表当前位置摘除并移动到头部;put(key, value):若 key 已存在,更新值并移动到头部;若不存在,插入到头部,若此时容量超限,则删除尾节点(最久未使用),并同步从哈希表移除。
上述思路的每一步都对应一个确定的数据结构操作,理解之后无论用哪种语言都能快速落地。
面试答题框架与要点回顾
围绕"Redis 过期策略 + LRU"这道高频面试题,可以按以下主线组织回答:
- 先定性:Redis 是缓存不是存储,内存有限,必然存在淘汰与过期清理;
- 过期策略:定期删除(默认每隔 100ms 随机抽取部分设置过期时间的 key 检查删除)+ 惰性删除(访问 key 时发现过期立即删除),两者互补,前者防堆积、后者防漏删,但不保证所有过期 key 及时被清;
- 兜底机制:当过期 key 堆积导致内存耗尽时,由
maxmemory-policy指定的内存淘汰机制接管; - 六种策略:
noeviction、allkeys-lru、allkeys-random、volatile-lru、volatile-random、volatile-ttl,并说明allkeys-lru最常用、volatile-*只作用于设置过过期时间的 key; - 手写 LRU:现场给出基于
LinkedHashMap(accessOrder = true+ 重写removeEldestEntry)的实现,并补充哈希表 + 双向链表的原理级思路。
延伸阅读
本文属于 doocs/advanced-java 高并发架构「缓存」知识链中的一环,建议结合以下同仓库文档串成完整知识体系:
- 为什么要用缓存:缓存的高性能与高并发价值
- Redis 的数据类型与使用场景
- Redis 单线程模型与高并发原理
- Redis 持久化机制:RDB 与 AOF
- Redis 集群模式与分布式寻址算法
- 缓存雪崩、缓存穿透与缓存击穿
- 缓存与数据库双写一致性
- 生产环境中的 Redis 部署方案
【免费下载链接】advanced-java😮 Core Interview Questions & Answers For Experienced Java(Backend) Developers | 互联网 Java 工程师进阶知识完全扫盲:涵盖高并发、分布式、高可用、微服务、海量数据处理等领域知识项目地址: https://gitcode.com/doocs/advanced-java
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考