
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越小。例如当counter10lfu_log_factor10时p ≈ 1/(5*101) ≈ 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_time10意味着一个 key 如果 10 分钟没被访问它的热度值就衰减 1。这个值越大衰减越慢历史热度影响越持久值越小衰减越快算法对近期访问越敏感。4.4 LFU 淘汰过程LFU 的淘汰过程与 LRU 类似也是采用随机采样。从候选池中它比较的是每个 key 当前的counter值经过可能衰减后的值。counter值最小的 key 被淘汰。如果counter值相同则比较它们的空闲时间高16位空闲时间更长的被淘汰。避坑指南从 Redis 4.0 开始支持 LFU。如果你从旧版本升级或者之前一直使用 LRU切换到 LFU 时需要特别注意lfu_log_factor和lfu_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 关键参数调优实战maxmemory-samples(LRU/LFU 采样数)默认值5调优建议在内存充足、CPU 压力不大的情况下可以适当增加到 10。通过INFO stats命令观察evicted_keys趋势如果淘汰量很大但命中率不高可以尝试提高采样数以获得更精确的淘汰。增加到 10 以上通常收益很小。lfu-log-factor(LFU 对数因子)默认值10调优建议这个值决定了 counter 增长的难度。值越大counter 越难增长不同访问量级 key 的 counter 值区分度越细。如果你希望更精细地区分“超级热”和“普通热”可以调大如 20。如果业务访问量级普遍很大希望快速区分热度可以调小如 5。调整后需要观察一段时间内不同热度 key 的 counter 值分布。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 56. 常见问题与排查技巧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 是 5LFU_INIT_VAL。如果lfu-log-factor设置得较大比如默认的10那么从 5 增长到 10 可能需要数十次甚至上百次访问。你可以通过OBJECT FREQ key命令查看一个 key 当前的 LFU counter 值。如果业务确实访问很频繁但 counter 增长慢可以考虑适当调小lfu-log-factor。6.4 如何监控淘汰效果INFO stats命令关注keyspace_hits、keyspace_misses计算命中率关注evicted_keys观察淘汰数量。一个健康的缓存命中率应该较高且稳定淘汰数量平稳缓慢增长。如果evicted_keys暴增说明内存压力极大或淘汰策略可能不合适。INFO memory命令关注used_memory、maxmemory以及mem_fragmentation_ratio。慢日志如果淘汰过程特别是采样过程影响了性能可能会在慢日志中体现。自定义监控可以通过定期采样OBJECT FREQ或OBJECT 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 位的字段以及它们背后精巧的权衡艺术。