找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3045

积分

0

好友

413

主题
发表于 3 天前 | 查看: 17| 回复: 0

在做后端架构设计时,缓存无疑是优化系统性能的首选策略。无论是在高并发的互联网大厂,还是在传统企业的数字化转型项目中,缓存的设计都至关重要。今天,我们就来深入探讨一个在面试中必问,在实际工作中又极容易被忽视的核心话题——缓存淘汰策略

很多同学在面试时,提到这个问题,张嘴就是“LRU 是最近最少使用,LFU 是最近最不常使用”,背得滚瓜烂熟。但这样的回答充其量只能算是个“及格分”。如果你想在高级职位的面试中脱颖而出,或者在实际架构设计中解决真正的痛点,你需要展示的是如何根据千变万化的业务场景,设计出最合适的解决方案。

如果你能在面试中,不仅讲清楚基础算法,还能抛出一套基于业务优先级的组合淘汰方案,那绝对能给面试官留下极其深刻的专业印象。

1. 为什么我们需要淘汰?

在深入研究各种花哨的算法之前,我们先回到原点,思考一个本质问题:为什么我们的缓存系统需要淘汰机制?

在日常开发中引入缓存,初衷都是为了提升读写性能。但是,资源永远是有限的,特别是对于应用进程内的本地缓存,内存更是寸土寸金。我曾接手过一个遗留系统,上线初期运行正常,但过一段时间后,运维团队频频报警,说应用服务器的内存占用率飙升,甚至偶尔会出现 OOM(Out Of Memory)导致服务重启。排查下来发现,开发同学在代码里写了一个简单的 Map 做缓存,只顾着往里塞数据,压根没考虑过内存也会有被吃光的一天。

所以,作为资深开发,我们在设计缓存系统时,必须设定一条红线:控制内存的使用上限。这就引出了一个非常现实的问题:当内存满了,达到了我们设定的上限,业务侧又源源不断地有新数据要写入,这时候该怎么办?

最简单粗暴的做法,就是直接报错,告诉业务方:“满了,写不进去了”。但这显然太“硬”了,不符合高可用系统的设计原则。除非你的业务能接受新数据无法缓存,只能等待老数据自然过期腾出空间。

缓存淘汰场景对比示意图

但在绝大多数互联网业务场景下,我们是不能接受这种方案的。因为我们通常认为,虽然缓存里的老数据还没过期,但它们现在的价值可能已经不如那条正要写入的新数据高了。所以,更合理的做法是:淘汰老数据,腾空位置。

淘汰的本质,就是一种在资源有限的情况下的价值权衡:剔除价值较低的旧数据,接纳价值较高的新数据,从而维持缓存整体的高命中率。

2. 经典缓存淘汰算法

在缓存淘汰的算法界,有两位主流算法统治了很多年,它们就是 LRULFU。此外还有 FIFO(先进先出)和 OPT(最佳置换)等,但实战中应用最广的还得是前两位。

2.1 LRU

LRU 的全称是 Least Recently Used,即最近最少使用。它的核心逻辑非常符合直觉:如果一个数据在最近一段时间内从未被访问过,那么它在未来被访问的概率通常也很低。反之,刚刚被访问过的数据,大概率还会再被用到。 这种基于时间局部性原理的假设,在大多数业务场景下都是成立的。

从实现的角度来看,LRU 其实并不复杂。我们可以想象一个链表,每当有数据被读取或写入时,我们就把它摘下来,移到链表的头部(或者尾部,取决于定义的顺序)。这样一来,链表的顺序就代表了访问的时间顺序。当空间不足需要淘汰时,我们只需要把链表尾部的那个数据踢掉就可以了。

在 Java 的生态里,LinkedHashMap 其实就是天生的 LRU 实现胚子,它内部维护的双向链表机制,稍微配置一下就能实现这个逻辑。

LRU数据结构操作示意图

这里需要注意一个架构细节:所谓的“访问”,通常既包含读操作,也包含写操作。但在一些特殊的写多读少,或者对写入数据热度要求更高的场景下,我们也可以做一个变种:只在“写”操作时才更新数据位置,以此来保护那些高频写入的热点数据。

2.2 LFU

如果说 LRU 关注的是时间,那么 LFU(Least Frequently Used)关注的就是频率。LFU 的核心思想是:过去被访问次数最少的数据,将来被访问的可能性也最小。 它是根据数据的热度来排座次的。

实现 LFU 时,我们需要给每个数据对象挂一个计数器。每次读写操作,计数器就加 1。当需要淘汰时,我们就遍历所有数据,把那个计数器数值最小的踢出去。

LFU数据结构变化示意图

不过,LFU 在实际落地时,也有它的局限性。它最大的问题在于过去的热度不能代表现在。举个例子,比如某个热点新闻,刚出来的时候访问量巨大,计数器瞬间飙升。但过了两天,新闻过气了,没人看了。可是因为它历史累计的访问次数太高了,导致它一直霸占着缓存空间。而新来的数据可能刚开始计数器只有 1,直接被淘汰了。

为了解决这个问题,进阶版的 LFU 通常会引入“时间衰减”机制。比如只统计最近 1 小时内的频率,或者定期让所有数据的计数器减半,让历史的高热度随时间慢慢冷却。

2.3 策略选择

在绝大多数通用场景下,作为后端架构师,我会推荐你优先考虑 LRU。因为它简单、高效,且完美契合了大部分业务的访问规律。

但是,我们必须清楚它的软肋。LRU 最怕的就是大规模的冷数据遍历。这在实际生产环境中太常见了。想象一下,你的电商系统平时运行得好好的,热点商品的缓存命中率很高。突然有一天,运营部门说要导出一份半年前的历史订单数据做分析,或者后台有一个定时任务要全量扫描一遍所有商品数据。

这些历史数据可能很久才用一次,但在遍历的那一刻,它们被疯狂地读取。在 LRU 算法眼里,这些数据就是“刚被访问的热点”,于是它们被一股脑地塞进缓存头部。后果是什么?原本真正高频使用的热点数据,因为位置被挤占,全部被淘汰出了缓存。等你遍历完了,留下一堆没用的冷数据,而真正的用户流量进来时,缓存全空,请求直接打透到数据库。这就是典型的缓存污染。

缓存污染与遍历顺序示意图

如图所示,当遍历发生时,key5key4 这些新来的键值会把 key1key2 这些原本的键值无情淘汰。

3. Redis 中的缓存淘汰策略

聊完了理论,我们来看看工业界的标准组件——Redis 是怎么落地这些策略的。

Redis 提供了 maxmemory 参数来限制最大内存,并通过 maxmemory_policy 来指定具体的淘汰策略。它的工具箱里不仅有 LRU 和 LFU,还有更多花样:

  • volatile-lru:在那些设置了过期时间(TTL)的键值对中,使用 LRU 算法进行淘汰。
  • volatile-lfu:同样是在设置了过期时间的数据中,使用 LFU 算法。
  • volatile-random:在设置了过期时间的数据中,随机选一个淘汰。
  • volatile-ttl:优先淘汰那些 TTL(剩余存活时间)最短的数据。
  • allkeys-lru:在所有数据(不管有没有设置过期时间)中,使用 LRU 算法。
  • allkeys-lfu:在所有数据中,使用 LFU 算法。
  • allkeys-random:在所有数据中,随机淘汰。
  • noeviction:不淘汰任何数据。内存满了直接报错,拒绝写入。

3.1 共享环境下的缓存淘汰问题

在深入使用 Redis 时,架构师需要关注一个更宏观的问题:全局视角的局限性

Redis 的淘汰策略是全局生效的。它无法精细控制只淘汰 A 业务的数据,保留 B 业务的数据。在很多公司,Redis 都是作为一个公共的基础设施,往往是一个大的集群供多个业务线共用。这就带来了一个经典的架构难题:缓存共享淘汰。

假设你的核心交易业务和隔壁组的一个日志分析业务共用一个 Redis 实例。隔壁组的开发同学写代码不讲究,疯狂往 Redis 里塞体积巨大的 Key,瞬间把 8G 的内存配额吃掉了 7.9G。这时候,你的交易业务哪怕只存几个极小的 Token,也会因为内存不足,触发 Redis 的全局淘汰策略。Redis 可不管这个 Key 是谁的,只要符合策略就杀。

Redis共享环境内存问题示意图

为了解决这个问题,在无法做到物理隔离的情况下,我们在应用层架构设计时,必须采用“逻辑隔离”的思路。

比如,我们可以通过代码逻辑,严格控制当前业务在 Redis 中的键值对总数,以及单个 Key 的大小。实现上,我们可以引入一个额外的计数器来追踪本业务使用的 Key 数量,并利用 Redis 的 KeySpace Notification(键空间通知) 功能来监听 Key 的删除事件,以修正计数器。

用户数据操作与计数器流程图

-- 伪代码逻辑示例
-- 1. 业务逻辑写入商品库存缓存
redisClient.set("inventory:sku:8888", "100");
-- 2. 同时原子性地增加该业务线的 Key 计数器
redisClient.incr("inventory:total:count");
-- 3. 当主动删除或淘汰时(需要配合监听机制)
redisClient.decr("inventory:total:count");
redisClient.del("inventory:sku:8888");

这种机制虽然增加了实现的复杂度,但在多业务混部的环境下,它是保护核心业务的一道重要防线。

4. 面试实战指南

在面试之前,在聊到缓存淘汰时,建议你提前准备好以下几个问题的答案:

  1. 你们公司的 Redis 用的是什么淘汰策略?(如果是 volatile-lru,为什么选它?)
  2. 有没有遇到过本地缓存导致的 OOM?(这是一个展示排查能力的好机会)
  3. 有没有遇到过因为缓存被误淘汰导致的数据库雪崩?
  4. 如果你用过 Guava Cache 或 Caffeine,它们默认的淘汰策略是什么?(Caffeine 的 W-TinyLFU 是个加分项)

4.1 面试回答套路

在面试中,千万不要只背定义。最好的回答思路,是“把优化缓存淘汰策略作为系统性能优化闭环的一部分”。你可以这样切入:

“为了保证系统的高性能,我曾经对缓存策略做过深度优化。早期我们有个核心服务用的是本地 LRU 缓存,起初跑得挺好。但后来,我们的 VIP 大客户 经常反馈后台报表加载时快时慢。一听到时快时慢,我就直觉判断是缓存出了问题。”

场景重现与痛点分析: “经过排查,我发现那个业务场景下,VIP 大商户的数据报表计算非常复杂,数据库查询极其耗时。而普通小商户的数据很少,计算很快。原本的 LRU 策略是一视同仁的。当流量高峰来临时,由于内存有限,LRU 机械地执行淘汰,可能会把这些计算昂贵的 VIP 大商户数据给淘汰掉,去存一些计算廉价的小商户数据。结果就是,VIP 客户一旦缓存失效,请求直接打到数据库,造成明显的卡顿。”

架构优化方案: “后来我重构了淘汰策略,不仅仅看‘最近是否使用’,还引入了‘计算代价(Cost)’这个维度。在触发淘汰时,我们优先保留那些‘计算成本高’的数据,优先淘汰那些‘计算成本低’的数据。上线后,VIP 客户的平均响应时间显著下降,系统的整体吞吐量和稳定性也提升了一个台阶。”

这个案例的优势在于:你不是在套用死板的算法,而是在用算法服务业务。

4.2 亮点方案:基于优先级的智能淘汰

顺着上面的思路,如果你想彻底征服面试官,可以进一步提出一套基于优先级的智能淘汰策略

在实际业务中,数据的重要性往往是不平等的。所以,核心思路是:给每一个缓存 Key 绑定一个优先级属性,淘汰时,先斩优先级低的。

4.2.1 在 Redis 中如何落地?

Redis 的 ZSET(有序集合)简直就是为这种需求量身定制的数据结构。我们可以利用 ZSET 来存储所有缓存 Key 的索引,其中 Member 存储业务数据的 Key,Score 存储计算好的优先级(Score 越小,优先级越低)。

Redis ZSET存储优先级索引示意图

我们来看一个具体的落地流程:

  1. 定义优先级的计算逻辑

    • 策略 A:大对象优先保留。Score = 对象的大小(字节数)。
    • 策略 B:小对象优先保留。Score = 1 / 对象的大小。
    • 策略 C:计算代价优先。Score = 数据生成所需的时间(毫秒)。
    • 策略 D:业务等级优先。Score = 用户等级权重。
  2. 原子化的执行流程

为了保证并发安全,所有的逻辑必须封装在一个 Lua 脚本 里执行。

基于优先级的缓存淘汰流程图

下面是一个完整的 Lua 脚本示例:

-- 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

这段脚本实现了一个“定容、基于优先级淘汰”的自定义缓存容器,是架构师功力的直接体现。

5. 小结

我们从缓存淘汰的必要性出发,聊到了 LRU/LFU 的原理,再到 Redis 的实战配置,最后深入到基于业务优先级的自定义策略设计。这里想传达的核心观点是:架构设计没有银弹,缓存淘汰也是如此。

  • 基础层面:你要理解内存限制的必要性。
  • 进阶层面:你要掌握 LRU、LFU 的原理,熟练配置 Redis 的各种策略。
  • 高阶层面:你要学会跳出算法看业务。当通用算法无法满足特定的业务价值导向时,利用 Redis 的 ZSET 和 Lua 脚本,构建符合你业务价值观的自定义淘汰策略

希望这篇深入浅出的解析,能帮助你在下一次技术讨论或面试中,更有底气地聊透缓存淘汰这个话题。如果你想与更多同行交流架构设计心得,欢迎到技术社区云栈社区参与讨论。




上一篇:系统QPS问题详解:从概念、获取到面试实战指南
下一篇:Redis定期删除如何控制开销?深度解析内存清理机制与实战策略
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 20:59 , Processed in 0.575744 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表