1. 从一次线上告警说起:为什么你的Redis突然“失忆”了?
那天下午,我正在工位上摸鱼,突然钉钉群里一阵急促的告警声响起。点开一看,是核心业务线的缓存服务告警:“Redis实例内存使用率超过95%”。心里咯噔一下,这可不是小事。登录监控一看,一个用作热门商品信息缓存的Redis集群,内存像坐了火箭一样往上蹿,眼看就要触顶。按照常规操作,我第一反应是检查是否有大Key或者大量无过期时间的缓存堆积。但一番排查下来,Key的数量和大小都还算正常,过期时间也设置得明明白白。
问题出在哪?直到我查看了Redis的INFO命令输出,在# Memory部分看到了关键信息:maxmemory_policy: noeviction。瞬间明白了——这个实例配置的内存淘汰策略是noeviction,也就是“不淘汰”。当内存用满后,新的写入操作会直接返回错误,而我们的业务代码没有很好地处理这个错误,导致部分请求失败,用户体验到的就是“数据加载不出来”或者“操作失败”。
这次事故的根因,表面上是内存触顶,深层原因是对Redis内存淘汰机制的理解和配置不到位。Redis作为一个基于内存的数据库,其高性能的前提是数据都在内存中。但内存是有限的、昂贵的资源,不可能无限扩容。当实际数据量超过物理内存时,Redis必须有一套机制来决定“牺牲”哪些数据,以腾出空间给新的或更重要的数据。这套机制,就是内存淘汰策略。它绝不是可以随意忽略的配置项,而是保障Redis服务稳定、数据“有选择地”存活的生死线。
今天,我们就抛开那些枯燥的概念,结合实战中的坑,把Redis最核心的两种淘汰策略——LRU和LFU,掰开了、揉碎了讲清楚。你会明白,为什么你的缓存“热”数据可能被误杀,为什么有些Key看似重要却留不下来,以及面对不同的业务场景,你究竟该选哪一个。
2. 淘汰策略全景图:你的Redis在内存告急时会怎么做?
在深入LRU和LFU之前,我们必须先对Redis的内存淘汰策略有一个全局的认识。这就像打仗,你得先知道手里有哪些牌,每张牌怎么打。
Redis通过maxmemory配置项来设置内存使用上限。一旦使用的内存达到这个限制,后续的写入命令就会触发内存淘汰流程。而具体怎么淘汰,则由maxmemory-policy这个配置项决定。它主要有以下几类策略:
第一类:不淘汰,直接报错
noeviction:这是Redis的默认策略。当内存不足时,所有会申请更多内存的命令(如SET,LPUSH,SADD等)都会返回错误(OOM command not allowed when used memory > 'maxmemory'),而只读命令(如GET)则不受影响。这个策略适用于你把Redis当作纯内存数据库,数据绝对不能丢的场景,但要求你的应用必须有完善的错误处理机制。像我们开头遇到的线上问题,就是反面教材。
第二类:从所有Key中淘汰这类策略会从整个键空间(所有数据库,无论是否设置过期时间)中挑选受害者。
allkeys-lru: 使用LRU算法淘汰最近最少使用的键。allkeys-lfu: 使用LFU算法淘汰最不经常使用的键。allkeys-random: 随机淘汰任意键。allkeys-lfu是Redis 4.0引入的,如果你的版本低于4.0,则没有这个选项。
第三类:从设置了过期时间的Key中淘汰这类策略只从那些设置了过期时间(TTL)的键中挑选。这通常更符合缓存的使用场景:缓存数据本来就有生命周期,淘汰它们对业务的影响相对可控。
volatile-lru: 使用LRU算法淘汰设置了过期时间的键中,最近最少使用的。volatile-lfu: 使用LFU算法淘汰设置了过期时间的键中,最不经常使用的。volatile-random: 随机淘汰任意一个设置了过期时间的键。volatile-ttl: 淘汰过期时间最近(TTL最小)的键。这个策略的目标是尽快释放即将过期的内存。
这里有一个非常重要的选择逻辑:volatile-xxx策略只在有键设置了过期时间时才会生效。如果内存满了,但没有任何键有过期时间,那么这些策略的行为会退化成noeviction,同样会报错。所以,如果你选择了volatile-lru,请务必为你的大部分缓存键合理设置过期时间。
那么,面对这么多选项,我们该如何选择?这里有一个简单的决策流:
- 数据能否丢?如果你的Redis实例承担了持久化存储的角色(例如会话存储、排行榜),数据绝对不能丢,那么
noeviction是唯一选择,但你必须确保应用能优雅处理OOM错误,并有监控告警。 - 是否是纯缓存?如果是,那么优先在
volatile-xxx策略中选择。这能保证你的永久性数据(如果存在)绝对安全。 - 缓存访问模式是否明确?如果你的缓存有明显的“热点”数据(如电商首页商品、新闻头条),且希望尽可能保留这些热点,那么
LRU或LFU是更好的选择。random和ttl更简单粗暴,适用于访问模式非常随机或你更关心内存回收速度的场景。 - 是否所有数据都可淘汰?如果你把Redis当作缓存,并且所有数据都可以被淘汰,那么
allkeys-xxx策略也是可以的,这样能最大化内存的利用率。
注意:在生产环境中,
allkeys-random和volatile-random很少被使用,因为随机淘汰可能导致重要的热点数据被意外清除,引发缓存穿透,打垮后端数据库。volatile-ttl在某些特定场景下有用,比如你希望尽快腾出空间,并且过期时间设置得比较准确。
3. LRU算法深度拆解:它真的是你以为的“最近最少使用”吗?
LRU,全称Least Recently Used,最近最少使用。它的核心思想非常直观:如果一个数据最近被访问过,那么它将来被访问的可能性也更高。因此,当需要淘汰时,应该淘汰最久没有被访问的数据。
想象一下你书桌的桌面。你最近正在看的书、正在写的笔记本,肯定会放在手边(内存里)。而一本几个月前翻过一下,之后再也没碰过的书,就会被你塞回书架(淘汰掉)。LRU就是基于这种“时间局部性”原理。
3.1 教科书里的LRU与Redis的“近似LRU”
在数据结构教科书里,实现一个完美的LRU通常需要“哈希表+双向链表”。链表维护了数据的访问顺序,最近访问的移到头部,最久未访问的在尾部;哈希表提供O(1)的查找能力。当需要淘汰时,直接移除链表尾部的节点即可。
但是,Redis没有采用这种经典实现。为什么?因为内存开销和性能。每个Redis对象本身已经是一个复杂结构(redisObject),如果再给每个对象加上前驱和后继指针来组成一个全局链表,内存消耗会显著增加。更重要的是,每次访问一个Key(GET,SET等),都需要修改这个全局链表(移动到头部),这是一个写操作,在Redis单线程模型下,会对性能产生不小的影响。
因此,Redis采用了一种近似LRU算法。它通过在每个Redis对象的redisObject结构体中,额外增加一个unsigned lru:22的字段(22位,在LRU模式下用于存储时间戳)。这个字段只有24字节(在64位系统中)的额外开销,非常小。
它的工作流程是这样的:
- 采样:当需要淘汰数据时,Redis并不会遍历所有Key,而是从键空间中随机抽取一定数量的Key(默认是5个,可通过
maxmemory-samples配置),放入一个“候选池”。 - 比较:比较池中所有Key的
lru字段值(即上次访问的时间戳)。 - 淘汰:淘汰掉池中
lru值最小的那个Key,也就是候选样本里“最久远”未被访问的。
3.2 近似LRU的优劣与配置调优
这种近似算法带来了明显的优势:
- 内存开销极小:每个Key只多了24字节。
- 性能影响小:访问Key时只需要更新自己对象的
lru字段,是纯内存操作,速度快。淘汰时的随机采样,时间复杂度是O(N),其中N是采样数量,而不是键总数,速度也很快。
但它的代价就是“近似”可能不准确。你可能会淘汰掉一个并不是全局最久未使用的Key,而是“那一批样本里”最久未使用的。这会导致淘汰精度下降。
这里就引出了关键的配置项:maxmemory-samples。它的默认值是5。增大这个值会让淘汰精度提高,因为考察的样本更多了,更有可能找到真正“最近最少使用”的Key,但相应的,每次淘汰时的CPU消耗也会增加。这是一个典型的“时间换空间”或“精度换性能”的权衡。
那么,该设置为多少呢?Redis官方提供了一个非常直观的测试结果。他们使用一个符合幂律分布的访问请求流来测试,发现:
samples=5时,已经能够非常好地近似真实LRU。- 将
sample提高到10,精度提升非常有限,但CPU消耗几乎翻倍。
所以,对于绝大多数生产环境,保持默认的maxmemory-samples 5是完全足够的,不建议盲目调大。除非你有非常确切的证据(通过监控发现淘汰精度严重影响缓存命中率),并且愿意承担更高的CPU成本。
3.3 LRU的经典陷阱与适用场景
LRU算法虽然直观,但在某些场景下会表现出明显的缺陷:
“冷数据污染”问题:想象一个场景,你的Redis内存很大,平时只访问其中20%的热点数据。某天,有一个一次性的大数据扫描任务(比如全量数据导出),它依次读取了Redis里所有80%的冷数据。在LRU算法下,这些冷数据的lru时间戳全部被更新成了“最新”。当内存不足需要淘汰时,之前那20%真正的热点数据,因为“最近”没被扫描任务访问,反而可能因为lru时间戳更旧而被淘汰掉!这就是一次全量扫描“污染”了LRU队列,导致热点数据被误伤。
周期性访问问题:如果一个数据每隔固定的长周期(比如一天)才被访问一次,而其他数据访问更频繁。在LRU看来,这个长周期数据每次被访问后都是“最新的”,它很难被淘汰,即使它的整体访问频率很低。它占着茅坑不拉屎,挤占了那些更活跃数据的内存空间。
因此,LRU更适用于访问模式相对稳定,且没有上述“批量扫描”或“超长周期访问”干扰的场景。例如:
- 用户会话(Session)存储。用户最近活跃的会话需要保留。
- 新闻客户端的热点新闻列表,最新的新闻总是最热的。
- 一些临时性的、访问时间局部性很强的计算中间结果。
实操心得:使用
volatile-lru时,一定要给Key设置一个合理的、不同梯度的过期时间。例如,核心热点数据TTL设长些(如30分钟),普通数据设短些(如5分钟)。这样即使发生误淘汰,也能通过过期时间这个安全网来兜底,并且结合volatile-ttl的思想,让内存回收更平滑。
4. LFU算法深度解析:如何量化数据的“热度”?
LFU,全称Least Frequently Used,最不经常使用。它的核心思想是:如果一个数据在过去被访问的次数越多,那么它将来被访问的可能性也越高。因此,当需要淘汰时,应该淘汰访问频率最低的数据。
还是用书桌的比喻。LRU关心的是“你最近一次碰这本书是什么时候”,而LFU关心的是“过去一年里,你翻看这本书的总次数”。显然,一本你经常查阅的工具书(高频),即使今天没看,也不应该被扔掉;而一本去年旅游买回来只翻过一次的画册(低频),就算昨天刚拿出来掸了掸灰,也该考虑清理了。
LFU算法能更好地应对LRU中提到的“冷数据污染”和“周期性访问”问题。一次性的全量扫描,只会让每个冷数据的计数器增加1,无法撼动那些计数器成百上千的真正热点数据。长周期访问的数据,因为总访问次数低,也更容易被淘汰。
4.1 Redis中LFU的精妙实现:不仅仅是计数器
如果只是简单地对每个Key维护一个访问计数器,会带来两个大问题:
- 空间问题:计数器可能会无限增长(比如一个全局计数器),需要很多位来存储。
- 时间衰减问题:一个在过去非常热门,但近期已经“过气”的数据,会因为历史的高计数而长期霸占内存,无法被淘汰。我们需要算法能反映“近期热度”,而不是“历史总热度”。
Redis的LFU实现(自4.0版本起)非常巧妙地解决了这两个问题。它复用了redisObject里那个24位的lru字段,但这次把它拆分成两部分来用:
- 高16位:存储一个“分钟级的时间戳”(对
UNIX时间戳取模,精度到分钟)。这个时间戳并非最后访问时间,而是用于衰减的“上一次衰减时间”。 - 低8位:存储一个“访问频率计数器”,简称
counter。这个counter的最大值就是255。
8位计数器,最大值255,这够用吗?Redis通过一个概率递增算法,让这个小小的计数器能够有效区分不同热度的Key。counter值越大,下一次访问时counter再增加的概率就越低。它的递增逻辑近似于对数增长,这意味着从0到1很容易,但从100到101就很难。这正好符合我们的直觉:区分冷数据和温数据很重要,但区分“非常热”和“极其热”就没那么重要了。
4.2 热度衰减机制:让“过气明星”优雅退场
这是Redis LFU实现中最精彩的部分。如果只有计数器,一个曾经火爆的Key会永远赖在内存里。Redis引入了“衰减”机制。简单来说,如果一个Key在一段时间内没有被访问,它的counter值会随着时间的推移而减少。
衰减的速度由一个配置参数lfu-decay-time控制(单位是分钟)。它的默认值是1。算法大致逻辑是:根据当前时间与Key的“上一次衰减时间”(存储在lru字段的高16位)的差值,来计算这个Key的counter应该衰减多少。
例如,lfu-decay-time=1意味着,如果某个Key在过去1分钟内没有被访问,它的counter值就可能被减少。这个衰减不是定时任务全局扫描,而是在Key被访问时惰性计算的。当Redis尝试访问一个Key并准备更新其LFU信息时,会先根据时间差计算出它应该衰减多少,然后再进行可能的递增。
你可以通过lfu-decay-time来控制热度的“半衰期”。设置得越大,热度衰减越慢,历史访问记录影响越久;设置得越小,衰减越快,算法越关注近期热度。通常建议保持默认值1,除非你有非常特殊的业务周期特征。
4.3 LFU的配置与实战观察
与LFU相关的配置主要有两个:
lfu-log-factor:这个参数控制counter递增的难度。默认值是10。因子越大,counter增长到相同值所需的访问次数就越多。通常不需要调整。lfu-decay-time:上面提到的衰减时间,默认1(分钟)。
如何观察一个Key的LFU信息?使用OBJECT FREQ <key>命令。它会返回该Key当前的counter值。这是监控缓存热点、调试LFU策略的利器。
LFU的适用场景非常广泛,尤其是当你的业务有明显的“热点”数据,并且希望缓存能智能地长期保留这些热点时:
- 社交网络热点内容:明星八卦、爆款文章,短期内访问频率极高。
- 电商平台热门商品:iPhone新品、秒杀商品,在活动期间访问集中。
- 视频网站热门榜单:排行榜前列的视频,会被反复访问。
- 任何访问模式符合“二八定律”的场景:20%的数据承载了80%的流量。
踩坑记录:我们有一个内容推荐系统,使用Redis缓存文章特征向量。最初使用
volatile-lru,发现每当有运营人员进行后台批量数据分析(全量扫描)时,线上推荐接口的缓存命中率就会骤降,响应时间飙升。切换为volatile-lfu后,这个问题基本消失。因为批量扫描只会给每个文章增加1点热度,无法撼动那些被千万用户点击过的热门文章的高热度值。这个切换带来了显著的性能稳定性提升。
5. LRU vs LFU:面对业务场景,你该如何抉择?
现在,我们对LRU和LFU都有了深入的理解。是时候把它们放在一起,做一次全面的对比,并给出最终的选择指南了。
我们可以从以下几个维度进行对比:
| 特性维度 | LRU (最近最少使用) | LFU (最不经常使用) |
|---|---|---|
| 核心思想 | 淘汰最久未访问的数据 | 淘汰访问频率最低的数据 |
| 关注点 | 访问的时间远近(何时访问) | 访问的频率高低(访问多少次) |
| 数据结构 | 在redisObject中存储最后访问时间戳 | 在redisObject中存储频率计数器+衰减时间戳 |
| 内存开销 | 极低 (24位时间戳) | 极低 (复用24位字段) |
| 计算开销 | 低 (更新时间戳) | 略高 (需要概率计算与衰减计算) |
| 抗扫描干扰 | 弱。批量扫描会污染缓存,误伤热点。 | 强。单次扫描对计数器影响微乎其微。 |
| 处理周期性访问 | 差。长周期访问的数据难以被淘汰。 | 好。低频访问的数据容易被淘汰。 |
| 数据热度稳定性 | 热度变化快。一段时间不访问就会变“冷”。 | 热度变化慢。历史访问次数影响大,热度更持久。 |
| 配置复杂度 | 简单,主要调maxmemory-samples。 | 较复杂,涉及lfu-log-factor和lfu-decay-time。 |
| Redis版本要求 | 所有版本支持。 | Redis 4.0 及以上版本。 |
选择决策流程图:
面对一个具体的业务场景,你可以遵循以下思路来选择:
- 你的数据访问模式是否有明显的、持久的热点?比如爆款商品、热搜话题。如果是,LFU通常是更好的选择,它能牢牢抓住这些热点。
- 你的业务是否存在后台批量任务(如数据分析、全量导出)?如果存在,并且这些任务会扫描大量缓存数据,那么必须选择LFU来避免LRU的缓存污染问题。
- 你的业务数据是否具有强烈的“最新性”?比如新闻Feed、实时排行榜,最新的数据就是最热的,旧的数据价值迅速衰减。这种情况下,LRU的表现可能更符合直觉,因为它天然地淘汰旧数据。
- 你的访问模式是否是均匀的、随机的,或者没有明显规律?如果找不到规律,
volatile-ttl(依赖过期时间)或allkeys-random(随机淘汰)可能是更简单直接的选择,但需要警惕缓存穿透。 - 你的Redis版本是否低于4.0?如果是,那么LFU不可用,只能在LRU和其他策略中做选择。
一个综合案例:一个典型的电商平台,其Redis缓存可能分为多种用途:
- 商品详情缓存:有明显的热点商品(iPhone、茅台)。访问模式是少数商品被高频访问。推荐使用
volatile-lfu,并为商品设置合理的过期时间(如30分钟)。 - 用户会话缓存:用户最近活跃的会话需要保留。会话通常有固定有效期,且更关注“最近是否活跃”。推荐使用
volatile-lru,并设置会话过期时间(如30分钟)。 - 秒杀库存缓存:这是一个特殊场景,数据极度热点,但生命周期极短(秒杀结束后即失效)。对于这种场景,
volatile-ttl可能是最合适的,因为你可以设置一个很短的、精确的TTL(如5分钟),让Redis在内存不足时优先淘汰那些即将过期的库存数据,既保护了热点,又快速回收了内存。
6. 实战配置、监控与问题排查指南
理论再好,不落地也是空谈。这一部分,我们直接上命令和配置,看看怎么用,怎么监控,出了问题怎么查。
6.1 如何配置与查看淘汰策略
配置方法(二选一):
- 配置文件
redis.conf:找到maxmemory-policy配置项,修改为你需要的策略,例如maxmemory-policy volatile-lfu。然后重启Redis或通过CONFIG REWRITE命令重写配置(如果允许)。 - 运行时动态修改(推荐):通过
CONFIG SET命令在线修改,无需重启。这对于生产环境调整非常友好。# 设置最大内存为1GB CONFIG SET maxmemory 1gb # 设置淘汰策略为 volatile-lfu CONFIG SET maxmemory-policy volatile-lfu # 使配置持久化到 redis.conf (如果希望重启后生效) CONFIG REWRITE
查看当前策略:
# 查看所有配置信息中的淘汰策略 CONFIG GET maxmemory-policy # 或者通过 INFO 命令查看 INFO memory # 在输出的 Memory 部分找到 maxmemory_policy 字段6.2 关键监控指标
光配置完还不够,必须建立监控,了解策略的运行效果。
- 内存使用率与淘汰触发:通过
INFO memory关注used_memory,used_memory_peak,maxmemory以及evicted_keys(累计淘汰的Key数量)。如果evicted_keys在持续增长,说明淘汰正在频繁发生,可能需要扩容内存或优化缓存使用。 - 缓存命中率:这是衡量缓存有效性的黄金指标。可以通过监控系统计算:
keyspace_hits / (keyspace_hits + keyspace_misses)。命中率下降,可能意味着淘汰策略不合理,误伤了热点数据。 OBJECT命令探查:这是一个强大的诊断工具。# 查看某个Key的空闲时间(LRU视角下的“冷”程度),单位秒。数值越大,越久没被访问。 OBJECT IDLETIME mykey # 查看某个Key的访问频率(LFU视角下的“热”程度),仅当策略为LFU时有效。 OBJECT FREQ mykey
6.3 常见问题排查思路
问题一:缓存命中率突然暴跌。
- 排查步骤:
- 检查
evicted_keys是否在同期有陡增。如果是,说明发生了大量淘汰。 - 确认是否近期有代码发布,引入了新的、未设置过期时间的大Key,或者改变了数据访问模式。
- 如果使用的是LRU策略,回忆是否有后台批量任务(如报表生成、数据迁移)运行,可能导致“缓存污染”。
- 使用
OBJECT IDLETIME或OBJECT FREQ抽样检查一些核心热点Key,看它们的“热度”是否异常降低。
- 检查
- 解决方向:如果是LRU污染,考虑切换到LFU。如果是内存不足,考虑扩容或优化缓存数据大小(如压缩、拆分大Key)。
问题二:使用了volatile-lru但内存满后依然报OOM错误。
- 排查步骤:
- 使用
INFO keyspace命令,查看expires的数量。如果这个数量为0或极少,说明几乎没有Key设置过期时间。 - 使用
SCAN命令抽样检查一些Key,用TTL命令查看其剩余生存时间。
- 使用
- 根因:
volatile-xxx策略只淘汰有过期时间的Key。如果内存中大部分Key都没有设置TTL,当内存满时,这些策略无Key可淘汰,行为就退化成了noeviction。 - 解决方案:为缓存Key合理设置过期时间。这是一个必须养成的好习惯。
问题三:从LRU切换到LFU后,预期中的热点数据留存更久了,但感觉响应速度有点微小的下降。
- 排查步骤:这是正常现象。LFU的计数器更新和衰减计算比LRU单纯更新时间戳要稍微复杂一点,会带来极微小的CPU开销。通过
INFO stats命令查看used_cpu_sys和used_cpu_user的变化,通常这个开销在1%以内,对于绝大多数应用是可接受的。 - 权衡:用微不足道的CPU开销,换取缓存命中率的显著提升和服务的稳定性,这笔交易几乎总是划算的。
内存淘汰策略不是“设置完就忘”的配置。它需要你根据业务形态进行初选,通过监控指标进行验证,在业务变化时进行调整。理解LRU和LFU背后的原理,能让你在出现问题时,不再盲目,而是能够有理有据地分析和解决。记住,没有最好的策略,只有最适合你当前业务场景的策略。