在系统性能优化中,缓存无疑是后端架构师手中的王牌策略。无论是在高并发的互联网大厂,还是在传统企业的数字化转型项目中,合理的缓存设计都至关重要。今天,我们将深度探讨一个在面试中高频出现,却又在实际工作中极易被忽视的核心话题——缓存淘汰策略。
很多同学在面试时,提到这个问题,张嘴就是“LRU 是最近最少使用,LFU 是最近最不常使用”,背得滚瓜烂熟。但这样的回答充其量只能算及格。若想在高级职位面试中脱颖而出,或者在实际架构中解决真正的痛点,你需要展示的是如何根据千变万化的业务场景,设计出最合适的解决方案。
如果你能在面试中,不仅讲清基础算法,还能抛出一套基于业务优先级的组合淘汰方案,定能给面试官留下极其深刻的专业印象。现在,我们就从架构师的视角,彻底拆解这个话题。
为什么我们需要淘汰机制?
在深入研究各种算法之前,我们先回到原点,思考一个本质问题:为什么缓存系统需要淘汰机制?
引入缓存的初衷是为了提升读写性能。然而,资源永远是有限的,特别是对于应用进程内的本地缓存(Local Cache),内存更是寸土寸金。我曾接手一个遗留系统,上线初期运行良好,但一段时间后,运维团队频频报警,说应用服务器内存占用率飙升,甚至偶尔出现 OOM(Out Of Memory)导致服务重启。排查后发现,开发同学在代码里写了一个简单的 Map 做缓存,只顾着写入数据,却未考虑内存容量。
对于Java这类带GC的语言,缓存对象过多还会导致频繁的 Full GC,让系统出现间歇性“卡顿”,性能不升反降。因此,设计缓存系统时,必须设定一条红线:控制内存使用上限。这就引出一个现实问题:当内存达到上限,业务侧又源源不断有新数据要写入,该怎么办?
最简单粗暴的做法是直接报错,告诉业务方“满了,写不进去”。但这显然太“硬”,不符合高可用系统设计原则,除非业务能接受新数据无法缓存,只能等待老数据自然过期腾出空间。

在绝大多数互联网业务场景下,我们无法接受这种方案。因为我们通常认为,缓存里的老数据虽然没过期,但其当前价值可能已不如正要写入的新数据。更合理的做法是:淘汰老数据,腾出空间。即使旧数据没过期,也得忍痛割爱,清理出去,为价值更高的新数据让位。
淘汰的本质,是在资源有限的情况下进行价值权衡:剔除价值较低的旧数据,接纳价值较高的新数据,从而维持缓存整体的高命中率。
经典缓存淘汰算法
在缓存淘汰算法领域,LRU和LFU是两位主流“霸主”。此外还有 FIFO(先进先出)和 OPT(最佳置换)等,但实战中应用最广的当属前两者。
LRU:最近最少使用
LRU 的全称是 Least Recently Used,即最近最少使用。其核心逻辑非常符合直觉:如果一个数据在最近一段时间内从未被访问过,那么它在未来被访问的概率通常也很低。反之,刚刚被访问过的数据,大概率还会被用到。这种基于时间局部性原理的假设,在多数业务场景下都成立。
实现角度看,LRU 并不复杂。可以想象一个链表,每当数据被读取或写入时,就将其移到链表头部(或尾部)。链表顺序代表了访问的时间顺序:一头是刚被访问的热数据,另一头则是久未访问的冷数据。当空间不足需淘汰时,只需移除链表尾部的数据即可。
在 Java 生态中,LinkedHashMap 是天生的 LRU 实现胚子,其内部维护的双向链表机制稍加配置即可实现此逻辑。

需要注意一个架构细节:所谓“访问”,通常包含读和写操作。但在一些特殊场景(如写多读少,或对写入数据热度要求更高),也可变种为只在“写”操作时更新数据位置,以保护高频写入的热点数据。
LFU:最近最不经常使用
如果说 LRU 关注时间,那么 LFU(Least Frequently Used)关注的就是频率。其核心思想是:过去被访问次数最少的数据,将来被访问的可能性也最小。它是根据数据的热度来排序。
实现 LFU 时,需给每个数据对象挂一个计数器。每次读写,计数器加1。淘汰时,遍历所有数据,踢掉计数器数值最小的那个。实战中可能遇到并列情况:若两个数据访问次数一样少,通常看谁先来踢谁,或随机踢一个,甚至可结合 LRU 规则,踢掉更久未被访问的。

然而,LFU 在实际落地时有其局限性。最大问题在于过去的热度不能代表现在。例如,某热点新闻刚出时访问量巨大,计数器瞬间飙至百万。但两天后新闻过气,无人问津。因其历史累计访问次数过高,会一直霸占缓存空间。而新数据计数器可能只有1,直接被淘汰。
为解决此问题,进阶版 LFU 通常引入“时间衰减”机制,如只统计最近1小时频率,或定期让所有数据计数器减半,让历史高热度随时间冷却,给新数据机会。
策略选择
在绝大多数通用场景下,我推荐优先考虑 LRU。因其简单、高效,且契合大部分业务访问规律。
但必须清楚其软肋。LRU 最怕大规模的冷数据遍历。这在生产环境中很常见。想象一下,电商系统平时运行良好,热点商品缓存命中率高。突然某天,运营要导出半年前历史订单做分析,或后台定时任务全量扫描所有商品数据。
这些历史数据可能许久才用一次,但在遍历瞬间被疯狂读取。在 LRU 算法眼中,它们就是“刚被访问的热点”,于是一股脑塞进缓存头部。
后果是什么?原本真正高频使用的热点数据(如当天秒杀商品),因位置被挤占,全被淘汰出缓存。遍历结束后,留下一堆无用的冷数据占着位置,而真实用户流量进来时,缓存全空,请求直接打透到数据库,导致数据库 CPU 瞬间飙升。这就是典型的缓存污染。

如图所示,当遍历发生时,key5、key4等新键值会把key1、key2等原键值无情淘汰。当再次需要key1时,必须重新回源加载。
Redis 中的缓存淘汰策略
聊完理论,再看工业界标准组件——Redis 如何落地这些策略。
Redis 提供 maxmemory 参数限制最大内存,并通过 maxmemory_policy 指定具体淘汰策略。其工具箱不仅有 LRU 和 LFU,还有更多选项。面试时,你可以如数家珍:
- volatile-lru:在设置了过期时间(TTL)的键值对中,使用 LRU 算法淘汰。这是最常用策略之一,因它只清理本就会过期的数据。
- volatile-lfu:在设置了过期时间的数据中,使用 LFU 算法。
- volatile-random:在设置了过期时间的数据中,随机淘汰。听起来随意,但在某些场景效率很高。
- volatile-ttl:优先淘汰 TTL(剩余存活时间)最短的数据,即“快要死”的先走。
- allkeys-lru:在所有数据(无论是否设置过期时间)中使用 LRU 算法。此策略激进,若 Redis 既做缓存又做持久化存储,需慎用,否则可能误删持久化数据。
- allkeys-lfu:在所有数据中使用 LFU 算法。
- allkeys-random:在所有数据中随机淘汰。
- noeviction:不淘汰任何数据。内存满时直接报错拒绝写入。保证数据不丢失,但牺牲了可用性。
共享环境下的缓存淘汰问题
深入使用 Redis 时,架构师需关注一个更宏观的问题:全局视角的局限性。
Redis 的淘汰策略是全局生效的。它无法精细控制只淘汰 A 业务数据而保留 B 业务数据。在许多公司,Redis 作为公共基础设施,常以一个大的 Cluster 集群供多个业务线共用。这带来了经典架构难题:缓存共享淘汰。
假设核心交易业务(业务 A)与日志分析业务(业务 B)共用一个 Redis 实例。B 业务开发同学写代码不讲究,疯狂写入巨大 Key,瞬间吃掉 8G 内存配额的 7.9G。
此时,A 业务哪怕只存几个极小 Token,也会因内存不足触发 Redis 全局淘汰策略。Redis 不管 Key 属于谁,符合策略就删。结果,核心业务数据被误伤淘汰,甚至因 noeviction 策略导致写入失败,引发线上故障。

为解决此问题,在无法物理隔离(如各业务独立部署 Redis)的情况下,应用层架构设计必须采用“逻辑隔离”思路。
例如,通过代码逻辑严格控制当前业务在 Redis 中的键值对总数及单个 Key 大小。假设当前业务为“商品详情页缓存”,限制其只能存 10000 个商品详情,每个 JSON 不超过 2KB。那么,该业务内存消耗可锁定在约 20MB 内,无论其他业务如何折腾,自己的资源是可控的。
实现上,可引入额外计数器:
- 每新增一个商品 Key,计数器加一。
- 每删除一个商品 Key,计数器减一。
- 最麻烦的是 Key 自然过期,应用程序不知情。此时需利用 Redis 的 KeySpace Notification(键空间通知) 功能,监听删除事件以修正计数器。

以下伪代码演示此逻辑,以“商品库存缓存”控制场景为例:
// 1. 业务逻辑写入商品库存缓存
redisClient.set("inventory:sku:8888", "100");
// 2. 同时原子性地增加该业务线的 Key 计数器
redisClient.incr("inventory:total:count");
// 3. 此时 Redis 中记录的计数
// inventory:total:count = 150
// 4. 当主动删除或淘汰时(需配合监听机制)
redisClient.decr("inventory:total:count");
redisClient.del("inventory:sku:8888");
注意:需区分新增与更新。若只是更新已有 Key,计数器无需加1。此机制虽增加了实现复杂度,甚至需编写 Lua 脚本,但在多业务混部环境下,是保护核心业务的重要防线。
面试实战指南
面试前,你需对自己的简历及公司现状了如指掌。聊到缓存淘汰时,建议提前准备以下问题答案:
- 贵公司 Redis 使用什么淘汰策略?(若是
volatile-lru,为什么选它?)
- 是否遇到过本地缓存导致的 OOM?(这是展示排查能力的好机会)
- 是否遇到过因缓存被误淘汰导致的数据库雪崩?
- 若用过 Guava Cache 或 Caffeine,其默认淘汰策略是什么?(Caffeine 的 W-TinyLFU 是加分项)
面试回答套路
面试中,切勿只背定义。最佳回答思路是“将优化缓存淘汰策略作为系统性能优化闭环的一部分”。你可以这样切入:
“为保证系统高性能,我曾对缓存策略深度优化。早期核心服务使用本地 LRU 缓存,起初运行良好。但后来,VIP 大客户(如几个顶级大商户)常反馈后台报表加载时快时慢。一听‘时快时慢’,我直觉判断是缓存问题。”
场景重现与痛点分析: “经排查日志和监控,我发现该业务场景下,VIP 大商户数据报表计算复杂,数据库查询极耗时(需3-5秒)。而普通小商户数据少,计算快(几十毫秒)。原 LRU 策略一视同仁。当流量高峰来临,因内存有限,LRU 机械执行淘汰,可能将计算昂贵的 VIP 数据淘汰,去存计算廉价的小商户数据。结果,VIP 客户一旦缓存失效,请求直打数据库,造成明显卡顿,体验极差。小商户数据即使缓存失效,重建成本低,对系统影响不大。”
架构优化方案: “后来我重构淘汰策略,不仅看‘最近是否使用’,还引入‘计算代价(Cost)’维度。触发淘汰时,优先保留‘计算成本高’(如 VIP 客户)数据,淘汰‘计算成本低’(如小商户)数据。上线后对比测试,VIP 客户平均响应时间下降约40%,系统整体吞吐量和稳定性显著提升。”
此案例优势在于:你不是套用死板算法,而是用算法服务业务。 你展示了发现问题、分析问题、解决问题的完整架构思维。
此外,面试官可能问:“除了这两种,还有什么策略?” 此时你可顺势抛出:“其实解决缓存淘汰的最佳思路是给缓存足够内存,不触发淘汰。虽听起来像废话,但实际运维中,优先扩容往往是成本最低方案。当然,若不得不淘汰,我会考虑结合业务特性的方案……”
亮点方案:基于优先级的智能淘汰
顺此思路,若想彻底征服面试官,可进一步提出一套基于优先级的智能淘汰策略。
实际业务中,数据重要性常不平等:
- VIP 用户 vs 普通用户:显然 VIP 更重要。
- 大对象 vs 小对象:大对象反序列化耗 CPU,传输占带宽,或许更该保留?或为省内存,先踢占地大的?这取决于瓶颈是 CPU 还是内存。
- 高热度 vs 低热度:大 V 微博肯定比僵尸粉微博更需要缓存。
核心思路是:给每个缓存 Key 绑定优先级属性,淘汰时,先斩优先级低的。
在 Redis 中如何落地?
Redis 的 ZSET(有序集合)简直是为此需求量身定制的数据结构。可利用 ZSET 存储所有缓存 Key 索引:
- Member(成员):存储业务数据的 Key。
- Score(分值):存储计算好的优先级。Score 越小,优先级越低,越易被淘汰。

来看具体落地流程,每一步都需严谨设计:
1. 定义优先级计算逻辑
- 策略 A:大对象优先保留。 Score = 对象大小(字节数)。适合网络带宽紧张场景,保留大对象可减少网络传输。
- 策略 B:小对象优先保留。 Score = 1 / 对象大小。适合内存极度紧张场景,踢掉一个大对象能为几十个小对象腾地,追求缓存 Key 数量最大化。
- 策略 C:计算代价优先。 Score = 数据生成所需时间(毫秒)。如前例,谁算得慢谁更金贵,Score 越高。
- 策略 D:业务等级优先。 Score = 用户等级权重(VIP=100,普通=1)。Key 重要性需根据业务灵活定义。
2. 原子化执行流程
为保证并发安全,避免判断容量与写入数据间出现竞态条件,所有逻辑必须封装在 Lua 脚本 中执行。整个 Lua 脚本逻辑如下:
- 检查容量:先看当前
ZSET 元素个数是否达到业务上限(如10000个)。
- 如果未超限:直接执行
SET 写入业务数据,同时执行 ZADD 将 Key 及其优先级 Score 写入 ZSET 索引。
- 如果已超限:执行
ZRANGE ... 0 0,从 ZSET 中由低到高取出分数最低的 Key(优先级最低者)。拿到此 Key 后,先执行 DEL 在 Redis 主空间删除实际业务数据,再执行 ZREM 从 ZSET 索引中移除此 Key。腾出位置后,再执行 SET 和 ZADD 写入新数据。

以下为标准 Lua 脚本参考,可直接在项目中运行,展示了如何在Java等后端服务中与Redis深度交互:
-- KEYS[1]: 业务数据的 Key
-- ARGV[1]: 业务数据的内容 Value
-- ARGV[2]: 该数据的优先级 Score
-- ARGV[3]: 允许的最大缓存数量 Limit
-- 定义存储优先级的 ZSET 的 Key
local priority_key = "biz:priority_index"
-- 获取当前已缓存的 Key 数量
local current_count = redis.call("ZCARD", priority_key)
local limit = tonumber(ARGV[3])
if current_count < limit then
-- 1. 容量未满,直接写入
redis.call("SET", KEYS[1], ARGV[1])
-- 2. 记录索引和优先级
redis.call("ZADD", priority_key, ARGV[2], KEYS[1])
else
-- 1. 容量已满,找出优先级最低的(Score最小的第一个)
local keys_to_kill = redis.call("ZRANGE", priority_key, 0, 0)
if #keys_to_kill > 0 then
local kill_key = keys_to_kill[1]
-- 2. 淘汰旧数据(先删数据,再删索引)
redis.call("DEL", kill_key)
redis.call("ZREM", priority_key, kill_key)
-- 3. 写入新数据
redis.call("SET", KEYS[1], ARGV[1])
redis.call("ZADD", priority_key, ARGV[2], KEYS[1])
end
end
此脚本虽简单,但完美实现了“定容、基于优先级淘汰”的自定义缓存容器。这正是架构师功力的体现。
小结
我们从缓存淘汰必要性出发,探讨了 LRU/LFU 原理、Redis 实战配置,最后深入基于业务优先级的自定义策略设计。这里想传达的核心观点是:架构设计没有银弹,缓存淘汰亦是如此。
- 基础层面:理解内存限制必要性,避免缓存无限膨胀撑爆系统。
- 进阶层面:掌握 LRU、LFU 原理,熟练配置 Redis 各种
volatile-* 和 allkeys-* 策略,知悉适用场景。
- 高阶层面:学会跳出算法看业务。当通用算法无法满足特定业务价值导向时,利用 Redis 的
ZSET 和 Lua 脚本,构建符合业务价值观(计算成本、数据热度、用户等级)的自定义淘汰策略。