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

426

积分

0

好友

62

主题
发表于 昨天 14:24 | 查看: 5| 回复: 0

在系统性能优化中,缓存无疑是后端架构师手中的王牌策略。无论是在高并发的互联网大厂,还是在传统企业的数字化转型项目中,合理的缓存设计都至关重要。今天,我们将深度探讨一个在面试中高频出现,却又在实际工作中极易被忽视的核心话题——缓存淘汰策略

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

如果你能在面试中,不仅讲清基础算法,还能抛出一套基于业务优先级的组合淘汰方案,定能给面试官留下极其深刻的专业印象。现在,我们就从架构师的视角,彻底拆解这个话题。

为什么我们需要淘汰机制?

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

引入缓存的初衷是为了提升读写性能。然而,资源永远是有限的,特别是对于应用进程内的本地缓存(Local Cache),内存更是寸土寸金。我曾接手一个遗留系统,上线初期运行良好,但一段时间后,运维团队频频报警,说应用服务器内存占用率飙升,甚至偶尔出现 OOM(Out Of Memory)导致服务重启。排查后发现,开发同学在代码里写了一个简单的 Map 做缓存,只顾着写入数据,却未考虑内存容量。

对于Java这类带GC的语言,缓存对象过多还会导致频繁的 Full GC,让系统出现间歇性“卡顿”,性能不升反降。因此,设计缓存系统时,必须设定一条红线:控制内存使用上限。这就引出一个现实问题:当内存达到上限,业务侧又源源不断有新数据要写入,该怎么办?

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

1

在绝大多数互联网业务场景下,我们无法接受这种方案。因为我们通常认为,缓存里的老数据虽然没过期,但其当前价值可能已不如正要写入的新数据。更合理的做法是:淘汰老数据,腾出空间。即使旧数据没过期,也得忍痛割爱,清理出去,为价值更高的新数据让位。

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

经典缓存淘汰算法

在缓存淘汰算法领域,LRULFU是两位主流“霸主”。此外还有 FIFO(先进先出)和 OPT(最佳置换)等,但实战中应用最广的当属前两者。

LRU:最近最少使用

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

实现角度看,LRU 并不复杂。可以想象一个链表,每当数据被读取或写入时,就将其移到链表头部(或尾部)。链表顺序代表了访问的时间顺序:一头是刚被访问的热数据,另一头则是久未访问的冷数据。当空间不足需淘汰时,只需移除链表尾部的数据即可。

在 Java 生态中,LinkedHashMap 是天生的 LRU 实现胚子,其内部维护的双向链表机制稍加配置即可实现此逻辑。

2

需要注意一个架构细节:所谓“访问”,通常包含读和写操作。但在一些特殊场景(如写多读少,或对写入数据热度要求更高),也可变种为只在“写”操作时更新数据位置,以保护高频写入的热点数据。

LFU:最近最不经常使用

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

实现 LFU 时,需给每个数据对象挂一个计数器。每次读写,计数器加1。淘汰时,遍历所有数据,踢掉计数器数值最小的那个。实战中可能遇到并列情况:若两个数据访问次数一样少,通常看谁先来踢谁,或随机踢一个,甚至可结合 LRU 规则,踢掉更久未被访问的。

3

然而,LFU 在实际落地时有其局限性。最大问题在于过去的热度不能代表现在。例如,某热点新闻刚出时访问量巨大,计数器瞬间飙至百万。但两天后新闻过气,无人问津。因其历史累计访问次数过高,会一直霸占缓存空间。而新数据计数器可能只有1,直接被淘汰。

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

策略选择

在绝大多数通用场景下,我推荐优先考虑 LRU。因其简单、高效,且契合大部分业务访问规律。

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

这些历史数据可能许久才用一次,但在遍历瞬间被疯狂读取。在 LRU 算法眼中,它们就是“刚被访问的热点”,于是一股脑塞进缓存头部。

后果是什么?原本真正高频使用的热点数据(如当天秒杀商品),因位置被挤占,全被淘汰出缓存。遍历结束后,留下一堆无用的冷数据占着位置,而真实用户流量进来时,缓存全空,请求直接打透到数据库,导致数据库 CPU 瞬间飙升。这就是典型的缓存污染。

4

如图所示,当遍历发生时,key5key4等新键值会把key1key2等原键值无情淘汰。当再次需要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 策略导致写入失败,引发线上故障。

5

为解决此问题,在无法物理隔离(如各业务独立部署 Redis)的情况下,应用层架构设计必须采用“逻辑隔离”思路。

例如,通过代码逻辑严格控制当前业务在 Redis 中的键值对总数单个 Key 大小。假设当前业务为“商品详情页缓存”,限制其只能存 10000 个商品详情,每个 JSON 不超过 2KB。那么,该业务内存消耗可锁定在约 20MB 内,无论其他业务如何折腾,自己的资源是可控的。

实现上,可引入额外计数器:

  1. 每新增一个商品 Key,计数器加一。
  2. 每删除一个商品 Key,计数器减一。
  3. 最麻烦的是 Key 自然过期,应用程序不知情。此时需利用 Redis 的 KeySpace Notification(键空间通知) 功能,监听删除事件以修正计数器。

6

以下伪代码演示此逻辑,以“商品库存缓存”控制场景为例:

// 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 脚本,但在多业务混部环境下,是保护核心业务的重要防线。

面试实战指南

面试前,你需对自己的简历及公司现状了如指掌。聊到缓存淘汰时,建议提前准备以下问题答案:

  1. 贵公司 Redis 使用什么淘汰策略?(若是 volatile-lru,为什么选它?)
  2. 是否遇到过本地缓存导致的 OOM?(这是展示排查能力的好机会)
  3. 是否遇到过因缓存被误淘汰导致的数据库雪崩?
  4. 若用过 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 主空间删除实际业务数据,再执行 ZREMZSET 索引中移除此 Key。腾出位置后,再执行 SETZADD 写入新数据。

8

以下为标准 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 脚本,构建符合业务价值观(计算成本、数据热度、用户等级)的自定义淘汰策略。



上一篇:DDD实战指南:从CRUD走向业务架构师,以电商订单系统为例
下一篇:嵌入式优先级消息队列设计与实现:基于FreeRTOS的实战指南
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2025-12-6 23:55 , Processed in 0.109763 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 CloudStack.

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