Redis的零食盒满了怎么办?详解缓存淘汰策略
Redis 缓存过期淘汰策略
第一站:为什么要“淘汰”?(概念铺垫)
首先,我们得明白为什么要费劲心思地设计这些“淘汰策略”?
通俗类比:
想象你的 Redis 就像一个 容量有限的“零食盒”。
- 过期时间(TTL): 你买了一堆零食,有些零食(数据)是有保质期(TTL,Time To Live,存活时间)的。时间一到,即使盒子没满,这个零食也得扔掉,因为它“过期”了。
- 内存限制(Maxmemory): 你的零食盒就这么大(
maxmemory
配置),当盒子满了,你又想放新的零食进去,怎么办?你必须先扔掉一些旧的零食腾出空间。
过期策略 解决的是 “时间一到就扔” 的问题。
淘汰策略 解决的是 “盒子满了该扔谁” 的问题。
我们本次主要关注的是第二个问题,即 maxmemory
限制下的 淘汰策略。
第二站:Redis 的两大基石:过期和淘汰
在介绍具体的淘汰策略前,我们先快速了解一下 Redis 如何处理“过期”这件事,因为它和“淘汰”策略是并存的。
数据的“过期”机制(Expiration)
Redis 处理过期数据的机制,被称为 惰性删除(Lazy Deletion) 和 定期删除(Active Deletion)。
A. 惰性删除(Lazy Deletion):随用随清
- 学术解释: 当客户端尝试访问(GET/MGET 等操作)某个带有 TTL 的键时,Redis 会先检查这个键的过期时间,如果发现它已经过期,则在返回结果前将其删除。
- 通俗比方: 就像你打开零食盒,拿起一块饼干时,先闻一闻、看一看日期,发现过期了,马上扔掉,而不是放回盒子里。
- 优点: 节省 CPU 资源,只有被访问时才检查,避免了不必要的扫描。
- 缺点: 如果大量数据过期后一直没被访问,它们会一直占用内存,直到被淘汰策略处理。
B. 定期删除(Active Deletion):抽样巡检
- 学术解释: Redis 会周期性地(默认每秒执行 10 次)随机抽取一些设置了 TTL 的键进行检查,并删除其中已过期的键。
- 这个过程是有限制的,例如每次执行时长不超过 25 毫秒。
- 通俗比方: 零食盒旁边有个“巡检员”,每隔一段时间(100 毫秒)就随便拿出几包零食看看有没有过期,发现过期就扔掉。
- 优点: 弥补了惰性删除的不足,可以清理一些不常访问但已过期的数据。
第三站:缓存淘汰策略(Eviction Policies)
当 Redis 内存达到 maxmemory
限制,并且有新的数据需要写入时,淘汰策略 就会启动,来决定“牺牲”哪些数据。
Redis 提供了 8 种主要的淘汰策略(自 Redis 4.0 以后):
A. 不淘汰策略(No Eviction)
- 策略名:
noeviction
- 作用: 当内存不足,且有新的写入命令时,直接返回错误,不会删除任何键。
- 底层哲学: “宁可报错,不删数据”。适用于那些对数据完整性要求极高,或依赖外部机制保证内存管理的应用。
- 比方: 零食盒满了,你想放新零食进来,但守卫说:“没地方了,你得等别人吃了或扔了腾出位置再说,我现在不会替你扔任何东西。”
B. 针对“设置了过期时间”的键进行淘汰(Volatile Family)
这组策略只关注那些设置了 TTL 的键。
volatile-lru
(Least Recently Used)- 作用: 从所有设置了过期时间的键中,淘汰 最近最少使用 的键。
- 核心思想: 那些长时间没人碰的,以后用到的概率也小,优先淘汰它们。
volatile-lfu
(Least Frequently Used)- 作用: 从所有设置了过期时间的键中,淘汰 最不经常使用 的键。
- 核心思想: 相比 LRU 关注“最后一次使用时间”,LFU 更关注“使用次数”。使用次数少的,优先淘汰。
volatile-ttl
(Time To Live)- 作用: 从所有设置了过期时间的键中,淘汰 剩余 TTL 值最小 的键(即最快要过期的键)。
- 核心思想: 反正快过期了,不如先扔掉,让过期机制的工作更简单。
volatile-random
- 作用: 从所有设置了过期时间的键中,随机 淘汰键。
- 核心思想: 简单粗暴,不求性能最优,只求执行速度快。
C. 针对“所有键”进行淘汰(Allkeys Family)
这组策略会考虑 Redis 数据库中的 所有键,无论是否设置了 TTL。
allkeys-lru
(Least Recently Used)- 作用: 从 所有键 中,淘汰 最近最少使用 的键。
- 核心思想: 相比
volatile-lru
,它连永久键(没有设置 TTL 的键)也敢删。
allkeys-lfu
(Least Frequently Used)- 作用: 从 所有键 中,淘汰 最不经常使用 的键。
allkeys-random
- 作用: 从 所有键 中,随机 淘汰键。
第四站:底层解剖:LRU 和 LFU 的“近似”实现
LRU 和 LFU 是最常用的淘汰策略,但 Redis 的实现并非是 完全精准 的,而是 近似(Approximation) 的。
1. 为什么是“近似”?
- 学术解释: 完全精准 的 LRU/LFU 需要维护一个全局有序链表(LRU)或复杂的频率计数结构(LFU)。对于拥有数百万键的 Redis 来说,每次访问或插入/删除都需要移动或更新这些结构,这将带来巨大的 性能开销,完全无法达到 Redis 所追求的高并发、低延迟目标。
- 通俗比方: 想象你有一千万本图书,要找出“最近最少被借阅”的那一本。如果每借出一本,你都要移动或排序一千万本图书的记录,那图书馆就瘫痪了。
2. Redis 的“近似 LRU” (allkeys-lru
/ volatile-lru
)
Redis 采用的是 随机采样法 来近似实现 LRU。
- 实现原理:
- Redis 维护了一个 24-bit 的字段(
lru
字段)记录每个键的 最后一次被访问的时间戳(相对时间)。 - 当需要淘汰时,随机选择 少量键(例如,默认配置下选择
maxmemory-samples
个键,默认为 5 个)。 - 从这 5 个随机选出的键 中,淘汰 掉那个
lru
字段最小(即最后一次访问时间最久)的键。
- Redis 维护了一个 24-bit 的字段(
- 比方: 守卫不是挨个检查所有零食,而是 随机抓出 5 个,比较它们的生产日期(最后访问时间),扔掉 这 5 个里面最久远的那个。
- 总结: 随机采样、局部最优,效率极高,效果接近真正的 LRU。
3. Redis 的“近似 LFU” (allkeys-lfu
/ volatile-lfu
)
LFU 比 LRU 复杂,它关注的是使用 频率。
- 实现原理:
- Redis 使用了一个 24-bit 的字段(
lfu
字段)记录每个键的 访问频率。这个字段被分为两部分:- 高 8 位记录 访问频率计数器(
counter
)。 - 低 16 位记录 访问时间戳(
ltime
),用于对频率进行 衰减。
- 高 8 位记录 访问频率计数器(
- 计数器增长: 每次键被访问,
counter
都会递增,但不是简单地 +1,而是使用 概率对数计数 的方式,保证高频率的键不会无限增长,而是缓慢趋近一个上限。 - 频率衰减: 如果一个键长时间(例如,几分钟)没有被访问,
counter
会根据ltime
的信息自动 衰减,防止长时间不访问的“历史高频键”霸占内存。
- Redis 使用了一个 24-bit 的字段(
- 比方: 守卫给每包零食贴一个“热度标签”,每次有人拿它,热度就增加一点。但如果长时间没人碰,热度会随着时间自动冷却。淘汰时,就找那些 热度最低 的扔掉。
- 总结: LFU 通过频率衰减机制,更好地适应了“热点漂移”的情况,即一个键曾经很热,但现在不再使用了,它最终会被淘汰。
第五站:Java 后端技术栈的选择建议
作为 Java 后端开发者,理解这些策略后,如何选择呢?
策略 | 适用场景 | 优点 | 缺点 | 推荐指数 |
---|---|---|---|---|
allkeys-lru | 最常用和推荐的。当你不知道选择什么时,选它。适用于大多数业务场景,如 Session 缓存、热门商品列表等。 | 效果好,命中率高,性能高(近似实现)。 | 可能淘汰掉永不过期但很久没用的关键配置数据。 | ⭐⭐⭐⭐⭐ |
volatile-lru | 当你需要将 重要配置或字典数据 设置为永不(或极长 TTL)过期,不希望被淘汰,但又需要缓存大量临时数据时。 | 兼顾了重要数据的保护和缓存淘汰的需求。 | 只能淘汰设置了 TTL 的键。 | ⭐⭐⭐⭐ |
allkeys-lfu | 适用于 热点数据分布非常不均匀,且希望长期不被访问的键能比 LRU 更长时间保留 的场景。 | 对高频键的保护比 LRU 更好。 | 略微复杂,计算开销略高于 LRU。 | ⭐⭐⭐ |
noeviction | 仅用于 非缓存应用(如作为分布式锁服务)或你完全掌控内存容量,绝不允许数据丢失的场景。 | 保证数据完整性。 | 内存一满立即无法写入。 | ⭐⭐ |
总结:
在绝大多数 Java 缓存场景中,allkeys-lru
是首选。它简单、高效,并且能很好地模拟出热点数据的特性。如果你的 Redis 既做缓存又做配置存储,可以考虑 volatile-lru
,将配置数据不设置 TTL 来保护起来。