当前位置: 首页 > news >正文

Redis缓存淘汰算法:LRU与LFU的实现原理与调优实战

1. 项目概述:为什么我们需要淘汰算法?

缓存,几乎是现代高并发系统设计的标配。无论是电商秒杀、社交信息流,还是实时推荐,背后都离不开缓存的身影。而 Redis,作为内存数据存储的王者,其高性能的核心秘密之一,就在于它巧妙地将数据存放在内存中。但内存是昂贵且有限的,当内存被数据填满时,新的写入请求该如何处理?Redis 不会像传统数据库那样直接报错,而是启动一套精密的“淘汰机制”,像一位经验丰富的仓库管理员,决定哪些“旧货物”需要被移出仓库,为新到的“紧俏商品”腾出空间。这个决策过程所依赖的核心算法,就是 LRU(最近最少使用)和 LFU(最不经常使用)。

这个项目标题“Redis的LRU与LFU算法实现”,看似在探讨两个经典的缓存淘汰算法,但其背后直指一个核心的工程问题:在有限的内存资源下,如何最大化缓存的命中率,从而降低后端数据库的压力,提升整体系统性能?这不仅仅是理论上的算法比较,更是每一个使用 Redis 的开发者、架构师必须深入理解的实战课题。理解它们的实现,能帮助你在面对“缓存穿透”、“缓存雪崩”等复杂场景时,做出更合理的配置选择,甚至能启发你设计更适合自身业务场景的自定义淘汰策略。本文将深入 Redis 源码的简化模型,拆解 LRU 与 LFU 的实现细节、适用场景以及那些官方文档里不会写的调优心得。

2. 核心思路:近似算法与权衡的艺术

在理想的理论模型中,LRU 和 LFU 都需要维护一个全局的、精确的访问顺序链表或频率统计。对于 LRU,每次访问都需要将节点移动到链表头部,这是一个 O(1) 操作,但需要为每个对象维护前后指针,内存开销大。对于 LFU,需要为每个对象维护一个访问计数器,并在淘汰时找到计数最小的对象,这通常需要堆结构,实现更复杂。

然而,Redis 作为一个追求极致性能和简洁性的单线程内存数据库,它无法承受为海量(可能数百万)的 key 维护全局精确数据结构带来的内存和 CPU 开销。因此,Redis 采取了一种“近似算法”的哲学。它不追求 100% 的精确淘汰,而是用一个相对较小的、固定的内存开销,去实现一个在绝大多数生产场景下都“足够好”的淘汰效果。这是一种典型的工程权衡:用微小的精度损失,换取巨大的性能和资源收益。

具体到实现上,Redis 的淘汰算法并非独立存在,而是与它的核心数据结构——redisObject紧密绑定。每个 Redis 对象(无论是 String、Hash 还是 List)的元数据头里,都藏着一个 24 位的字段lru(在 LRU 模式下)或lfu(在 LFU 模式下)。这个字段,就是整个近似算法的基石。所有的魔法,都围绕着如何高效地更新和解读这 24 位信息而展开。

3. LRU 算法实现:时间戳的采样与淘汰

3.1 核心数据结构:精简的 LRU 时钟

Redis 实现 LRU 的关键,在于它如何记录每个 key 的“最近访问时间”。它没有保存一个精确的 Unix 时间戳,那样需要 32 位或 64 位。相反,它维护了一个全局的lruclock,这个时钟通常每秒钟更新多次(在 Redis 源码中,由serverCron函数更新,默认每秒更新 10 次,即精度为 100 毫秒)。

这个lruclock本身也只有 24 位。24 位无符号整数最大值为 2^24 - 1 = 16,777,215。当lruclock递增到这个最大值后,会回绕到 0 重新开始。这意味着这个时钟的周期大约是 194 天(16,777,215 / (24360010) 天,这里按每秒10次更新估算)。对于缓存场景来说,一个 key 如果近 200 天未被访问,那它几乎肯定可以被淘汰了,这个周期是足够的。

当一个 key 被访问(读或写)时,Redis 就会将当前的全局lruclock值,写入这个 key 对应的redisObject.lru字段。所以,redisObject.lru记录的不是一个绝对时间,而是该 key 最后一次被访问时,所处的那个“时钟滴答”时刻。

3.2 淘汰过程:随机采样与比较

当内存不足需要淘汰 key 时,Redis 并不会遍历所有 key 来找出那个“最久远”的。遍历全部 key 是 O(N) 操作,在 key 数量巨大时是不可接受的。Redis 采用的是随机采样(Sampling)淘汰

它会从所有待淘汰的键空间中(例如所有的volatile-lrukey),随机选取一定数量的 key(默认是 5 个,可通过maxmemory-samples配置),放入一个“候选池”。然后,它比较这 5 个 key 的redisObject.lru字段值。这个值越小,说明它上次被访问的时间点离当前的lruclock可能越“远”(需要考虑时钟回绕问题)。Redis 会淘汰掉这个候选池中lru值最小的那个 key。

注意:由于是随机采样,你淘汰掉的未必是全局最旧的 key,但很大概率是一个比较旧的 key。增加maxmemory-samples的值可以提高淘汰的精确度,但也会增加淘汰时的 CPU 开销。这又是一个需要根据实际情况权衡的配置点。通常,设置为 10 就能达到很好的效果,再增加收益递减。

3.3 处理时钟回绕(Wrap-around)

由于lruclock只有 24 位,会发生回绕。如何比较两个lru值的先后?Redis 使用了一个巧妙的技巧。它假设在两个 key 的访问时间差之内,时钟回绕最多发生一次。比较函数大致逻辑如下:

// 伪代码,演示比较逻辑 unsigned long estimateObjectIdleTime(redisObject *o) { unsigned long lruclock = getLRUClock(); // 获取当前全局lruclock // 当前时钟减去对象的lru时间,如果结果没有溢出(即当前时钟 >= 对象时钟),直接返回差值 if (lruclock >= o->lru) { return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION; // 转换为毫秒或秒 } else { // 否则发生了回绕,需要加上一个最大周期值 return (lruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION; } }

通过这个函数,可以将一个 key 的“空闲时间”估算出来,从而在候选池中进行比较。这个估算的时间是近似的,但对于淘汰决策来说已经足够。

实操心得:在生产环境中,如果你发现volatile-lru策略下,缓存命中率不理想,感觉该留的 key 被淘汰了,不该留的却还在。除了检查业务访问模式是否真的是“最近最少使用”,另一个排查方向就是maxmemory-samples。对于 key 数量特别大(百万级以上)的场景,适当调大这个值(比如到 10 或 20),让采样更充分,往往能提升淘汰的准确性。

4. LFU 算法实现:频率计数与衰减机制

LFU 的核心思想是淘汰访问频率最低的 key。但直接记录绝对访问次数会遇到两个问题:1) 内存开销,每个 key 需要一个计数器;2) 历史累积效应,一个过去很热但现在冷了的 key,会因为历史高计数而长期无法被淘汰。

Redis 的 LFU 实现同样基于那 24 位的lfu字段,并精巧地解决了上述问题。

4.1 数据结构:分频段计数与访问时间

Redis 将 24 位的lfu字段拆分成两部分:

  • 高 16 位:用于记录最近一次访问的时间(分钟精度),用于衰减。
  • 低 8 位:用于记录访问频率计数器(counter),这是一个基于概率递增的计数器,最大值是 255。

8 位计数器意味着最大频率记录为 255。这足够吗?由于它是概率递增且会衰减,这个范围对于区分“非常热”、“热”、“温”、“冷”的 key 是完全足够的。它不是一个精确的访问次数,而是一个经过平滑处理的“热度分数”。

4.2 计数器增长:基于概率的对数递增

当一个 key 被访问时,如何增加它的计数器?如果每次访问都counter++,那么一个短时间内被疯狂访问的 key 会迅速达到 255,之后就失去了区分度。Redis 采用了一种概率递增策略,其增长难度随着当前counter值的增大而指数级增加。

增长逻辑的伪代码如下:

uint8_t LFULogIncr(uint8_t counter) { if (counter == 255) return 255; // 已达最大值 // 生成一个0到1之间的随机数 double r = (double)rand()/RAND_MAX; // 计算增长概率的倒数,base是配置参数lfu_log_factor,默认是10 double baseval = counter - LFU_INIT_VAL; // LFU_INIT_VAL是初始值5 if (baseval < 0) baseval = 0; double p = 1.0 / (baseval * server.lfu_log_factor + 1); // 只有当随机数r小于概率p时,计数器才加1 if (r < p) counter++; return counter; }

这个公式意味着:

  • counter值越小,增长概率p越大(接近 1),很容易增长。
  • counter值越大,增长概率p越小。例如,当counter=10lfu_log_factor=10时,p ≈ 1/(5*10+1) ≈ 0.0196,大约需要 50 次访问才能有一次增长。
  • lfu_log_factor可配置,用于调整计数器增长的难度。因子越大,counter 越难增长,区分度越细。

这种设计使得counter值能够反映 key 的“长期热度和近期热度”的加权,并且天然地将热度映射到一个较小的、可控的范围内。

4.3 频率衰减:模拟时间窗口

为了解决“历史热 key 霸占缓存”的问题,LFU 必须支持衰减。Redis 利用lfu字段的高 16 位记录上次访问时间(单位是分钟,精度足够)。

Redis 有一个后台定时任务,会定期(默认每1分钟)随机检查一批 key,进行衰减计算。衰减算法如下:

// 伪代码:衰减一个key的counter值 unsigned long LFUDecrAndReturn(redisObject *o) { // 取出高16位记录的上次访问时间(分钟) unsigned long ldt = o->lfu >> 8; // 取出低8位的counter值 unsigned long counter = o->lfu & 255; // 计算该key已经空闲了多少分钟(当前时间 - ldt) unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0; if (num_periods) { // 如果空闲周期数大于counter,则减到0,否则减去相应周期数 if (counter > num_periods) { counter -= num_periods; } else { counter = 0; } // 将衰减后的counter和新的访问时间写回lfu字段 o->lfu = (LFUGetTimeInMinutes() << 8) | counter; } return counter; }

这里的关键配置是lfu_decay_time(默认是1)。它的含义是:经过多少分钟,counter 值应该减 1。例如,lfu_decay_time=10,意味着一个 key 如果 10 分钟没被访问,它的热度值就衰减 1。这个值越大,衰减越慢,历史热度影响越持久;值越小,衰减越快,算法对近期访问越敏感。

4.4 LFU 淘汰过程

LFU 的淘汰过程与 LRU 类似,也是采用随机采样。从候选池中,它比较的是每个 key 当前的counter值(经过可能衰减后的值)。counter值最小的 key 被淘汰。如果counter值相同,则比较它们的空闲时间(高16位),空闲时间更长的被淘汰。

避坑指南:从 Redis 4.0 开始支持 LFU。如果你从旧版本升级,或者之前一直使用 LRU,切换到 LFU 时需要特别注意lfu_log_factorlfu_decay_time这两个参数的调整。默认值(10 和 1)是一个通用保守值。对于访问模式变化非常快的业务(如热点新闻),可能需要调小lfu_decay_time(如 0.5,表示30秒衰减1),让算法更快“忘记”过去。对于访问模式稳定、希望长期留住热 key 的场景(如用户基础信息缓存),可以适当调大lfu_log_factor(如 20)和lfu_decay_time(如 10)。

5. 配置选择与实战场景分析

了解了实现原理,我们就能更理性地选择淘汰策略和配置参数。Redis 的maxmemory-policy提供了多种选择:

策略含义适用场景
noeviction不淘汰,写操作报错。数据绝对不允许丢失,且有其他监控保障内存不超限。
allkeys-lru从所有 key 中近似 LRU 淘汰。最常用。业务期望缓存整体访问服从“二八原则”,即部分热点数据支撑大部分请求。
volatile-lru从设置了过期时间(TTL)的 key 中近似 LRU 淘汰。需要区分“永久数据”和“缓存数据”。只希望对缓存部分进行淘汰。
allkeys-lfu从所有 key 中近似 LFU 淘汰。访问频率分布相对稳定,希望长期保留最常访问的热点,比如“商品品类列表”、“城市信息”等。
volatile-lfu从设置了 TTL 的 key 中近似 LFU 淘汰。同 volatile-lru,但希望基于频率淘汰。
allkeys-random/volatile-random随机淘汰。所有 key 被访问的概率几乎相等,或者你对淘汰没有特殊要求,只追求速度。
volatile-ttl淘汰剩余存活时间(TTL)最短的 key。你明确知道并希望优先淘汰即将过期的数据,常用于缓存层叠场景。

5.1 如何选择 LRU 还是 LFU?

这是一个没有标准答案的问题,取决于你的数据访问模式。

  • 选择 LRU 的场景

    • 访问模式具有明显的时间局部性:最近访问过的数据,在短期内再次被访问的概率很高。例如,社交媒体的时间线、新闻客户端的最新列表。
    • 存在“扫描式”或“循环式”访问:如果业务存在全量遍历 key 的操作(虽然不推荐),LRU 会因为最近被遍历过而保护了这些 key,可能导致真正的热点被误伤。这时需要评估影响。
    • 实现简单,理解直观:LRU 的概念更容易被团队成员理解和接受。
  • 选择 LFU 的场景

    • 访问频率是价值的核心指标:某些数据被访问的次数直接决定了其重要性。例如,热门商品详情、常用 API 响应缓存、基础资料库(如行政区划)。
    • 希望抵御“一次性批量操作”的干扰:例如,一个爬虫脚本一次性读取了大量冷数据。在 LRU 下,这些冷数据会“污染”LRU 链表,挤走真正的热点。而 LFU 因为其概率递增和衰减机制,这些一次性访问的数据很难积累起高counter值,很快会被淘汰。
    • 存在“老而弥热”的数据:有些经典内容(如经典文章、常青商品)长期有稳定但不高频的访问。LRU 可能因为其一段时间没被访问而淘汰它,但 LFU 能凭借其积累的频率计数将其保留。

一个简单的判断方法:观察你的缓存命中率监控。如果使用 LRU 时,命中率波动大,或在特定事件(如爬虫、定时任务)后显著下降,可以尝试切换到 LFU 观察效果。

5.2 关键参数调优实战

  1. maxmemory-samples(LRU/LFU 采样数)

    • 默认值:5
    • 调优建议:在内存充足、CPU 压力不大的情况下,可以适当增加到 10。通过INFO stats命令观察evicted_keys趋势,如果淘汰量很大但命中率不高,可以尝试提高采样数以获得更精确的淘汰。增加到 10 以上通常收益很小。
  2. lfu-log-factor(LFU 对数因子)

    • 默认值:10
    • 调优建议:这个值决定了 counter 增长的难度。值越大,counter 越难增长,不同访问量级 key 的 counter 值区分度越细。如果你希望更精细地区分“超级热”和“普通热”,可以调大(如 20)。如果业务访问量级普遍很大,希望快速区分热度,可以调小(如 5)。调整后,需要观察一段时间内不同热度 key 的 counter 值分布
  3. lfu-decay-time(LFU 衰减时间,单位:分钟)

    • 默认值:1
    • 调优建议:这个值决定了历史访问记录的“遗忘速度”。对于热点变化非常快的场景(如秒杀、实时热搜),可以调小(如 0.2,即12秒衰减1),让算法更关注最近几分钟的热度。对于热点稳定、希望长期保留的场景(如基础库),可以调大(如 60,即1小时衰减1)。

配置方法:可以在redis.conf中配置,也可以在运行时通过CONFIG SET命令动态调整,方便进行 A/B 测试。

CONFIG SET maxmemory-policy allkeys-lfu CONFIG SET lfu-log-factor 15 CONFIG SET lfu-decay-time 5

6. 常见问题与排查技巧

6.1 内存明明没满,为什么触发了淘汰?

这可能是因为 Redis 的maxmemory机制。当已用内存达到maxmemory阈值时,Redis 就会开始执行淘汰策略,而不是等到 100% 才行动。此外,Redis 的内存统计可能因为内存碎片而略高于实际数据大小。可以通过INFO memory命令查看used_memory(总分配内存)、used_memory_rss(进程实际占用的物理内存)以及mem_fragmentation_ratio(碎片率)。如果碎片率很高(比如大于 1.5),可能需要进行清理或重启。

6.2 设置了volatile-lru,但带 TTL 的 key 没被淘汰,反而noeviction报错了?

检查你的 key 是否真的设置了 TTL。使用TTL key命令查看,返回-1表示没有设置过期时间。volatile-*策略只会在设置了过期时间的 key 集合中进行淘汰。如果这类 key 的内存占用总和未达到maxmemory,但所有 key 的总和达到了,而volatile-*策略又找不到可淘汰的带 TTL 的 key,那么对于volatile-lru等策略,Redis 的行为会退化成类似noeviction,导致写操作报错。这是一个常见的配置陷阱。

6.3 LFU 的 counter 值为什么一直很低,上不去?

首先,确认 key 是否被频繁访问。其次,理解 LFU 的 counter 是基于概率递增的,且上限是 255。对于一个新 key,初始 counter 是 5(LFU_INIT_VAL)。如果lfu-log-factor设置得较大(比如默认的10),那么从 5 增长到 10 可能需要数十次甚至上百次访问。你可以通过OBJECT FREQ key命令查看一个 key 当前的 LFU counter 值。如果业务确实访问很频繁但 counter 增长慢,可以考虑适当调小lfu-log-factor

6.4 如何监控淘汰效果?

  1. INFO stats命令:关注keyspace_hitskeyspace_misses计算命中率,关注evicted_keys观察淘汰数量。一个健康的缓存,命中率应该较高且稳定,淘汰数量平稳缓慢增长。如果evicted_keys暴增,说明内存压力极大或淘汰策略可能不合适。
  2. INFO memory命令:关注used_memorymaxmemory以及mem_fragmentation_ratio
  3. 慢日志:如果淘汰过程(特别是采样过程)影响了性能,可能会在慢日志中体现。
  4. 自定义监控:可以通过定期采样OBJECT FREQOBJECT IDLETIME(对于 LRU)来观察 key 的热度分布变化。

6.5 除了 LRU/LFU,还有其他优化思路吗?

绝对有。Redis 的淘汰策略是最后一道防线。更优的实践是“主动管理”:

  • 合理设置 TTL:为缓存数据设置一个合理的、分散的过期时间,让数据自然过期,避免集中淘汰。
  • 缓存维度化与分级:区分热点数据和全量数据。极热数据可以设置更长的 TTL 甚至永不过期,使用独立的 Redis 实例或数据库。
  • 使用 Redis 4.0+ 的MEMORY命令MEMORY USAGE key可以精确计算一个 key 的内存占用,用于更精细的内存分析。
  • 考虑使用 Redis Module:如 RedisBloom 的 Cuckoo Filter 可以用于解决缓存穿透,间接减轻内存压力。

理解 Redis LRU 和 LFU 的实现,最终目的是为了让你在“资源有限”这个永恒的技术约束下,做出更明智的决策。它不是一个“设置完就忘”的配置,而是需要结合业务监控、不断观察和调优的活组件。下次当你面对缓存命中率下降的告警时,希望你能想起这两个 24 位的字段,以及它们背后精巧的权衡艺术。

http://www.cnnetsun.cn/news/2486972.html

相关文章:

  • 动手搭建一个‘能源局域网’:基于开源硬件的微型能源路由器原型构想
  • RT-Thread实战:基于STM32F103的线程创建与LED控制
  • 3分钟完成Windows包管理器Winget安装:PowerShell自动化部署方案
  • 微博相册批量下载神器:三步搞定海量图片收藏
  • 别再为RK3588 NPU环境头疼了!手把手教你用Conda搞定rknn-toolkit2安装(附国内源加速)
  • 深入理解STM32的FSMC:如何像访问内存一样轻松驱动TFTLCD屏
  • 开漏输出上拉电阻计算:从原理到I2C/GPIO实战选型
  • Android BroadcastReceiver 深度解析:原理、实践与面试指南
  • SpringBoot+Vue3实战:从零搭建一个咖啡店后台管理系统(附完整源码和数据库设计)
  • WPF TabControl美化实战:从默认丑到高级感,自定义样式与交互动画全攻略
  • 基于HPM6750 RISC-V的PX4飞控硬件设计与移植实战
  • 别再死记硬背了!用‘虚拟时间’这个比喻,5分钟彻底搞懂Linux CFS调度器
  • 你的STM32 RTC时间总跑飞?可能是LSE晶振和电池备份没配对
  • 别再为画图发愁了!手把手教你用开源神器draw.io搞定流程图和数学公式
  • 毕业设计救星:用STC89C52单片机+AD采集,手把手教你做一个400Hz中频电源(附完整电路图)
  • 逆向分析新思路:当Flutter遇上Frida,如何Hook加密函数并自吐算法参数?
  • Linux网络编程实战:从Socket基础到高并发服务器设计
  • 从‘黑窗口’到彩色世界:用GLUT快速实现你的第一个OpenGL图形程序(含完整代码解析)
  • UnityPackage Extractor终极指南:快速免费提取Unity资源包
  • ADS1110与51单片机I2C通信详解:手把手教你驱动并读取三路电压(附常见问题排查)
  • 用Python串口控制机械臂:从RS232协议解析到完整指令序列编程实战
  • 从一次安全扫描告警说起:聊聊SSH Banner那点事与自定义的‘安全艺术’
  • 华科计组实验通关秘籍:用Logisim搞定数据表示九大关卡(附避坑指南与源码)
  • 告别C盘爆满!保姆级教程:在D盘用Qt在线安装器搞定6.2.4开发环境(附组件选择避坑指南)
  • OmniSharp-vim与fzf、vim-clap深度集成:提升C开发效率的7个关键点
  • 拆解ESP32-C3最小系统:除了MCU,你的开发板还需要哪些外围电路?(附BOM清单)
  • 如何快速掌握Rufus:从USB格式化到启动盘制作的终极指南
  • 用GEE和Landsat 8数据,5步搞定城市生态健康“体检报告”(附完整代码)
  • CANN/cann-recipes-train:一站式平台快速启动RL训练示例
  • 终极指南:如何在OneNote 2016中实现专业级代码高亮