Redis LRU缓存淘汰机制深度解析:从采样到参数配置

📅 发布时间:2026/10/12 4:07:17
Redis LRU缓存淘汰机制深度解析:从采样到参数配置
1. 从缓存雪崩说起为什么Redis需要一套淘汰机制内存是昂贵的资源这是所有做后端开发的同行都心知肚明的事。Redis把所有数据都放在内存里换来的是单机轻松扛住十万级QPS的读取能力。但内存终究是有限的一台8GB的服务器你不可能把20GB的热门数据全塞进去。当写入的新数据超过了内存容量系统就必须决定哪些老数据可以被请出去给新数据腾地方。这个请出去的决策就是缓存淘汰策略要干的事。Redis提供了一整套机制其中应用最广、被讨论最多的就是LRULeast Recently Used最近最少使用。说实话LRU不是Redis发明的它是操作系统页面置换领域的经典算法但Redis把它用在缓存场景时做了不少改造这套改造逻辑本身就很值得聊。很多刚接触Redis的开发者会有一个困惑明明业务里已经设置了过期时间为什么还要关心淘汰策略这两个是不同维度的事。过期时间是数据主动声明我什么时候失效而淘汰策略是内存满了之后Redis被动做的强制清理。就算你给每个key都设置了TTL如果写入速度远超过期速度内存照样会被打满。我用一个生活场景来类比过期时间像是冰箱里贴了保质期的牛奶到期了你就主动扔掉。而LRU淘汰是冰箱塞满了、新买的菜放不进去时你不得不把最久没动过的那瓶老酱油先拿出来。两者解决的问题完全不同生产环境往往是同时依赖这两套机制来保护Redis进程不崩溃。这套机制的价值在真实的业务故障里体现得最明显。某团队曾经把Redis当作全量业务数据的存储层把所有用户维度的配置都塞进去结果双十一流量高峰写入量暴增内存瞬间打满。如果没有配置合理的淘汰策略Redis会直接拒绝写入请求甚至触发OOM被内核杀掉整个业务链路直接雪崩。配置了LRU之后虽然部分冷数据会被淘汰但核心读写链路没有断系统只是轻微受伤而不是当场死亡。所以本文要做的就是在不依赖任何内部源码的前提下把Redis的LRU算法讲透它是怎么设计的、为什么这样设计、参数怎么调、有哪些坑。我会结合自己的实践经历来写不会给你念文档。2. Redis为什么不用纯正的LRU内存换精度的生意经教科书上的LRU算法很干净维护一个双向链表每次访问数据就把节点移动到链表头部当内存满时直接淘汰链表尾部的节点。这个逻辑的时间复杂度是O(1)看起来完美无缺。但如果你真的把这个数据结构搬到Redis里你会发现一个致命问题——每个key都需要额外维护一个前驱指针和一个后继指针这两个指针在64位系统下各占8字节意味着每个key要凭空多付出16字节的内存成本。Redis是出了名的抠内存它的设计哲学是能用bit绝不用byte。如果存储1亿个key纯LRU需要的额外指针内存就是1.6GB这个代价对于内存数据库来说几乎是不可接受的。而且Redis还支持很多编码方式比如intset、ziplist这些数据结构本身就高度压缩你让它们去维系一个双向链表语义上也很别扭。所以Redis做了一个非常符合工程气质的决定不追求精确LRU而是用近似LRU来逼近LRU的效果。它把最近访问时间这个信息用一个24位的字段塞进每个对象的头部。注意这24位不是单独分配的而是复用了Redis对象原本就有的lru字段——在开启LRU淘汰时这个字段被用来记录时钟而不是记录引用计数或者其他元数据所以几乎是零额外内存成本。但这24位存储的时间精度很低。它记录的不是毫秒级时间戳而是一个以秒为单位的分钟级估值服务器启动时设置一个基准时间用当前的Unix时间戳除以某个基数默认情况下分辨率大约是10秒然后只记录相对值。如果你把Redis的LRU时钟和真实时间对比会发现它不是完全同步的这是为了在溢出时能用更少的位来表示更长的时间跨度而做的妥协。更关键的是Redis在淘汰时并没有遍历所有key去精准找最久没被访问的那个而是用一种叫采样的策略。具体过程是这样的每次需要淘汰数据时从设置了过期时间的key集合中随机抽取若干个key这个数量由maxmemory-samples参数控制默认是5然后在这几个样本里挑出lru字段最小的那个也就是最近最久没被访问的淘汰它。你可能会说这算什么LRU随机抽5个就敢说是全局最久未用这个设计的内在逻辑叫以概率换效率。Redis的作者在早期版本中已经做过测试当采样数为10时近似LRU已经非常接近理论LRU的淘汰效果。而且在真实的缓存场景里数据的访问热度差异极大20%的热点数据占了80%的访问量冷数据本来就很多随机采样即使不精准也有很大概率命中那些真·冷数据。算法追求的不是数学上的最优而是业务上的够用。如果你把maxmemory-samples调大比如调到20淘汰的准确性会更好但每次淘汰时需要比较的元素更多CPU开销会稍微上升。反之调到1那基本就是随机淘汰没有任何LRU的语义了。生产环境我一般会建议保持在默认的5或者对热点非常敏感的业务调到10继续往上意义不大。这里稍微提一下Redis 4.0之后引入的LFULeast Frequently Used最不经常使用策略它的淘汰依据是访问频率而不是访问时间。LFU也有和LRU类似的内存开销问题同样使用了采样的方式来降低精确度成本。如果你的业务数据访问频率极度不均比如少数key占了99%的流量LFU可能在效果上优于LRU。但LFU也有自己的坑新写入的key访问频率还没有积累起来很容易被误杀所以Redis给LFU也设计了衰减机制和老化机制。本文聚焦LRU但你在选型时应该知道这两者不是互斥的替代关系而是适用场景不同的两种思路。3. 手写一个LRU并不难难的是理解Redis的取舍为了真正搞清楚Redis面临的问题我建议你自己动手实现一个简单的LRU缓存然后再对照Redis的源码设计去思考。这不是多余的练习很多面试官喜欢问手写LRU但面试题里的LRU和Redis里的LRU差的恰恰是工程上最值钱的那部分思考。最简单的LRU实现在Java里可以这样写import java.util.LinkedHashMap; import java.util.Map; public class SimpleLruCacheK, V extends LinkedHashMapK, V { private final int capacity; public SimpleLruCache(int capacity) { super(capacity, 0.75f, true); this.capacity capacity; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() capacity; } }这个实现依赖LinkedHashMap的accessOrder特性构造参数里传true表示按访问顺序维护链表每次get或者put一个已存在的key它都会被挪到链表尾部。当插入新条目时removeEldestEntry会被调用如果我们判断当前size超过了容量就把链表头部的元素移除也就是最久没被访问的那个。这段代码可以说是LRU的标准答案但它有一个问题LinkedHashMap在维护访问顺序时是线程不安全的。如果在多线程环境使用你需要额外加锁或者使用ConcurrentLinkedHashMap之类的并发容器。此外这个实现是精确LRU每个key的位置都需要在访问时实时调整代价是每次命中都要做一次链表节点移动对于高并发场景这个锁冲突成本可能比缓存本身的计算成本还高。用这个简单实现跑个性能测试就能发现问题当缓存容量在100万级别、读多写少的时候LinkedHashMap的LRU维护操作成了瓶颈因为每一次get都要修改链表结构——即使数据已经存在也要把它从当前位置摘下来插到链表尾部。这意味着你在读缓存的同时也在写一个共享的链表结构并发竞争非常严重。Redis为什么不用这个方案核心原因有三个第一内存成本不可接受。精确LRU需要额外的指针和链表节点而Redis的对象模型是高度定制的数据结构的头部信息越少越好。第二并发模型不匹配。Redis是单线程事件循环它的所有操作都是在主线程里串行执行的。如果每个key都要维护一个链表那所有涉及key的访问操作都会产生链表指针的读写即便单线程不需要加锁维护成本也集中在热点路径上拖慢主循环。第三Redis支持多种数据类型和内存编码字符串、哈希、列表、集合的底层实现各不相同强行引入统一的双向链表会让内存布局支离破碎碎片化加剧。所以Redis选了一条完全不同的路不记录全量的访问顺序而是给每个对象打一个时间戳淘汰时通过采样来近似。这个设计牺牲了一点精度换来了内存零额外消耗和O(1)的淘汰判断成本。在实际测试中Redis的近似LRU和真正的LRU在淘汰效果上的差异并不大尤其是在数据量较大、冷热分布明显的场景下。我还见过有人在业务代码里自己做二级LRU把访问频率高的key缓存在本地内存把冷数据放在Redis里Redis只开启allkeys-lru后面会讲这个配置作为兜底。这种混合架构在实际项目中效果非常好因为Redis的LRU并不适合承载每个key都要精确淘汰的需求它更适合做数据量大、容错率高的场景。回到那句话算法没有银弹LRU也不是万能的。你要理解的是Redis在特定约束下的取舍逻辑而不是死记硬背一个链表结构。4. 关键参数与配置策略maxmemory和maxmemory-policy深度解读很多运维事故的根源就是Redis配置里的几个关键参数没有吃透。我先说一个最常见的误区很多人以为只要设置了maxmemoryRedis就会自动开始淘汰数据。实际上maxmemory只是给Redis划了一条内存红线真正决定满了之后怎么办的是maxmemory-policy。如果maxmemory设置了但maxmemory-policy没有配置或者配置成了noevictionRedis在内存达到上限后不仅不会淘汰任何数据还会直接拒绝所有写操作返回OOM错误。这个noeviction策略在某些场景下是合理的它相当于内存写满即熔断用来保护数据的完整性。但如果你把Redis当作缓存使用这个策略是灾难性的——在流量高峰期新写入的缓存数据会因为内存已满而全部失败而且老数据也不会被清理整个缓存层等于瘫痪。另一个常见的坑是以为设置了maxmemory就万事大吉结果发现内存还在涨直到Redis进程被OOM Killer干掉。原因往往是没有设置maxmemory-policy或者设置成了volatile-lru但你的key都没有设置过期时间。volatile-lru表示只从设置了过期时间的key里淘汰如果所有key都没有TTL这个策略就形同虚设Redis永远不会淘汰任何数据内存照样被打满。所以配置之前你需要先想清楚业务场景里Redis扮演的角色纯缓存场景所有数据都是临时的丢了也没关系这时候用allkeys-lruRedis可以从所有key里自由选择淘汰对象。缓存持久化存储混合场景部分数据是重要配置或会话状态丢了会影响业务这时候用volatile-lru只有那些设置了TTL的key可以被淘汰重要数据不设置过期时间就不会被误杀。数据绝对不能被淘汰的场景用noeviction并做好监控告警内存接近上限时要主动扩容或清理。这里必须补充一个非常重要的细节Redis的maxmemory参数的单位是字节而且要注意它的计算口径。Redis7.0之前maxmemory只统计key本身和value占用的内存不包含缓冲区、连接、命令输出缓冲等额外开销。也就是说实际内存占用会超过maxmemory设置的值。Redis自己也知道这一点所以进程的实际内存消耗经常是你配置的1.2倍甚至更多。如果你给Redis分配了4GB内存限制但服务器物理内存只有4GB那Redis还没跑到maxmemory可能就先把机器内存吃完了。生产环境配置maxmemory时一定要留出20%到30%的余量给操作系统的页缓存、网络缓冲和Redis自身的运行开销。另一个容易被忽略的参数是maxmemory-samples也就是前面提到的采样数。这个参数直接决定了LRU淘汰的精准度。默认值是5对应Redis的默认行为。如果你的Redis里冷数据特别多或者你特别在意淘汰的准确性可以试试点调高到10。相反如果你希望每次淘汰的开销更小可以让它更低。还有一个相对冷门的参数replica-ignore-maxmemory在主从复制架构中从节点的maxmemory默认是忽略的也就是说从节点不会主动淘汰数据。这个设计的本意是保证主从数据一致性——如果从节点自己淘汰了数据主节点再来同步时会发现数据不一致。但这引出一个问题在读写分离架构里从节点承载了大量读流量如果从节点内存满了它不会淘汰任何数据而会把写请求直接拒绝导致从节点读服务不可用。所以在做主从时你需要评估好从节点的内存容量确保它能容纳主节点的全量数据或者手动调整这个参数。5. 从源码行数看LRU的真实行为采样、池化与淘汰流程不看源码光是看文档很难理解Redis的LRU跟教科书LRU到底差在哪。我建议你打开Redis源码找到evict.c这个文件里面的核心逻辑并不复杂。我用关键代码片段的方式带你过一遍它的思考过程。Redis执行命令之前会调用freeMemoryIfNeeded函数判断当前内存是否超过maxmemory如果超过就进入淘汰逻辑。int freeMemoryIfNeeded(void) { // 计算当前内存使用量 size_t mem_reported zmalloc_used_memory(); if (mem_reported server.maxmemory) return C_OK; // 根据策略分支处理 if (server.maxmemory_policy MAXMEMORY_NO_EVICTION) { return C_ERR; // 不淘汰直接拒绝写 } while (mem_freed mem_tofree) { // 选择合适的淘汰key for (i 0; i server.dbnum; i) { dict server.db[i].dict; // 根据策略选择从哪个字典采样 if (server.maxmemory_policy MAXMEMORY_FLAG_ALLKEYS) { // 从所有key中采样 } else { // 从设置了过期时间的key中采样 } // 采样并选出最佳淘汰key key dictGetSomeKey(dict, sample); // 最优key的筛选逻辑 } } }实际源码比这个多很多分支但核心思路已经很明显了。Redis会循环执行采样、比较、淘汰这个动作直到回收的内存达到目标值。每次循环它根据maxmemory_policy决定是从所有key里采样还是从有过期时间的key里采样。真正有意思的是Redis 3.0之后引入的淘汰池eviction pool机制。Redis维护一个大小为16的池子每次采样得到的key会先进入池子池子里的key按照空闲时间idle time排序空闲时间最长的排在最前面。如果池子满了新采样的key只有比池子里最空闲的key还要空闲才会被替换进去。最终淘汰的时候直接从池子头部取出那个最空闲的key。这个设计的精妙之处在于它让淘汰决策不再局限于单次采样的局部最优而是通过一个跨多轮淘汰累积的池子逼近了全局近似最优。换句话说第一次采样可能运气不好采到的5个key都挺热但后续多轮采样累积下来的信息会不断修正淘汰方向。这个池子的代价只是16个指针的内存收益却是在不增加采样数的前提下大幅提升了淘汰精度。我实测过开启池化前后淘汰效果的差异当内存压力较大、连续触发淘汰时带淘汰池的LRU命中率比不带池的版本高出不少。原因很好理解池子保留了一段时间内的候选最冷key如果某个冷key在第一次采样的样本里不够冷被淘汰池暂时记住后续如果它真的很少被访问就有机会在后续轮次被选中淘汰。从概率上看淘汰池相当于把采样过程从无记忆变成了有记忆自然更聪明。还有一个细节Redis在淘汰时会优先淘汰空闲时间最长的key这个空闲时间是怎么计算的就是当前LRU时钟减去key的lru字段值。由于Redis的LRU时钟不是严格单调递增的它有可能回绕Redis在计算空闲时间时会处理溢出。代码里是用当前时钟减去记录值如果差值是负数就说明时钟回绕了这时需要加上一个时钟周期。这一块很容易踩坑但Redis处理得比较隐蔽大多数业务场景不会察觉。继续回到源码流程。在确定好要淘汰的key之后Redis需要做几件收尾的事情删除key对应的数据、传播删除命令给从节点和AOF文件、更新内存统计。注意删除操作是同步的不会延迟。如果删除的是大key比如一个包含上百万元素的集合这个删除过程会阻塞主线程。这在淘汰逻辑里尤其危险因为内存压力大时Redis本来已经很紧张如果再阻塞几十毫秒甚至几百毫秒去删除一个大key对在线业务的影响会被放大。这也是为什么Redis社区一直推荐拆分大key、避免hash/zet过大。LRU淘汰时你无法预测哪个key会被选中但如果大key被选中了你的服务就会体验到一次明显的卡顿。我在线上就遇到过一个包含几万字段的hash被LRU淘汰Redis的响应时间瞬间从1毫秒飙到100毫秒下游服务超时报警。解决方案有几个。第一个是从源头治理把大key拆成多个小key比如把一个大hash按业务维度拆成多个hash每个hash的字段数控制在几百以内。第二个是开启lazy-free机制即Redis 4.0引入的异步删除。把配置项lazyfree-lazy-eviction设置为yesLRU淘汰时删除key的操作会放到后台线程执行主线程不阻塞。但需要注意这里说的异步删除只是释放内存的动作异步化了查找key、判断淘汰对象这些逻辑仍然是同步的。如果淘汰的对象是序列化后的大字符串异步删除的好处非常明显。我在生产环境中推荐把lazyfree-lazy-eviction开启因为它能有效减少淘汰大key时的卡顿。但不要以为开了lazy-free就万事大吉如果内存压力大到触发持续淘汰后台线程的处理速度跟不上主线程的淘汰速度内存回收还是会有延迟所以根本的解决之道仍然是避免把内存用得太满。还有一个点容易被忽略maxmemory-policy不仅影响淘汰行为还会影响Redis的写命令处理路径。当内存超过maxmemory时Redis会拒绝所有增加内存占用的命令比如SET、LPUSH、SADD等但像DEL、EXPIRE、RENAME这种不增加内存的命令不受影响。这个限制的目的是防止内存继续膨胀但在noeviction策略下写命令返回的错误信息是OOM command not allowed when used memory maxmemory很多开发者第一次看到这个报错都不知道是自己配置的问题。从运维角度你需要知道怎么观测LRU淘汰是否正常。Redis提供了INFO命令其中的evicted_keys字段记录了累计淘汰的key数量。如果这个数字在持续增长说明内存确实不够用了要么扩容要么优化key的大小和数量。另外还可以观察keyspace_hits和keyspace_misses两个指标它们分别是缓存命中和未命中的次数。如果命中率持续走低而evicted_keys在涨说明淘汰策略已经淘汰掉了很多本来用得上的数据缓存配置需要优化。结合这些观测指标我还会检查一下内存碎片率。Redis的内存碎片率如果偏高说明频繁的分配和释放导致内存碎片化实际内存占用比理论值高很多。此时即使maxmemory设置得合理Redis也可能提前触发淘汰。处理碎片的方式可以是定期执行memory purge或者直接重启在业务低峰期的Redis实例。6. 两个经典场景allkeys-lru和volatile-lru怎么选我先讲一个线上事故这个事故每天都在很多公司重复发生。某业务系统使用Redis做用户会话缓存key的格式是session:{userId}每个key都设置了30分钟过期时间。开发同学在配置Redis时为了省事直接用了默认配置——maxmemory-policy被设成了noeviction。平时数据量不大一直相安无事。直到一次活动大促用户量激增写入速度远超过期速度内存很快触顶。然后大量新会话无法写入用户登录后拿不到会话信息全站出现大面积登录失败。这是典型的该淘汰的不淘汰不该拒绝的拒绝了。如果把策略改成volatile-lru至少可以让Redis在内存满时优先淘汰最久没访问的会话数据老用户会被强制下线但新用户能够正常登录。对整个系统来说牺牲一部分冷会话换取主流程可用是标准的降级手段。再讲一个另一个方向的案例。某数据分析平台把Redis当作ETL过程中的中间存储写入的数据大多是临时计算结果没有设置过期时间。业务方希望系统能自动清理旧数据但他们的配置是volatile-lru。结果是内存满时Redis发现没有任何带TTL的key可以淘汰直接拒绝写入ETL任务全部失败。他们花了半天时间排查最后发现是自己的策略搞错了——应该用allkeys-lru因为所有数据本来就是临时性的。这两个案例其实已经把选择逻辑讲清楚了如果你的Redis里存储的是每个key都有自己的生命周期的数据普遍设置了TTL那么volatile-lru是合理的。它保证只淘汰那些已经被标记为可过期的数据对没有TTL的持久数据是安全的。如果你的Redis纯粹是缓存所有数据都是丢了也无所谓那么allkeys-lru更合适。它不关心你有没有设置TTL只看哪个key最久没被访问这种策略在缓存场景下淘汰效果更好。千万不要出现业务数据都不设置TTL同时策略选择volatile-lru的组合那等于告诉Redis你可以淘汰但没有任何候选对象。我见过一种比较讲究的做法是同时利用volatile-lru和TTL来实现分级淘汰。具体来说把重要的缓存数据设置很长的TTL比如12小时把不重要的数据设置短TTL比如10分钟。这样内存压力大时Redis会优先淘汰那些TTL较短而且如果它们已经很久没被访问的key。这种做法的本质是把业务优先级映射到了TTL上配合LRU的访问时间维度形成了一种类似分级缓存的效果。还有一类场景值得专门提一下那就是缓存穿透。有些团队为了防止缓存穿透会把空值也缓存起来设置很短TTL比如30秒。这种做法配合volatile-lru会有一个隐患空值缓存的key数量很大而且频繁更新它们很容易成为LRU的淘汰候选把真正有用的热点数据挤掉。解决思路是给空值缓存单独一个Redis实例或者用不同DB编号隔离避免和正常缓存互相干扰淘汰决策。在单实例上硬调LRU参数是没有意义的因为你无法通过参数告诉Redis哪些key重要哪些不重要。7. 经验总结调优LRU前后你应该做的事从我的实践看调优LRU不是一个改一个配置的动作而是一个先观测、再决策、再验证的闭环。第一件要做的事是收集Redis的当前内存数据和淘汰数据。用INFO memory查看used_memory、used_memory_human、maxmemory、maxmemory_policy用INFO stats查看evicted_keys、keyspace_hits、keyspace_misses。同时看下当前有多少key多少带过期时间redis-cli info memory | grep -E used_memory|maxmemory redis-cli info stats | grep -E evicted_keys|keyspace_hits|keyspace_misses redis-cli info keyspace第二件要做的事是根据业务特点确定合理的期望命中率。如果你发现命中率在90%以上而evicted_keys不高说明你的内存余量足够LRU策略工作正常不需要调整。如果命中率在80%以下且evicted_keys在快速增长你需要分析到底是内存不足还是淘汰策略不适合。举个例子假设你的Redis内存是4GB实际用了3.8GBmaxmemory是4GB。你发现evicted_keys每天增长几十万。这时候你应该想一想这些被淘汰的key是不是本来就已经过期或者即将过期如果是说明淘汰策略并没有误杀太多热点数据。如果被淘汰的key中有不少是高频访问的数据那说明你的缓存容量确实不够了即使调LRU参数也无法解决该扩容就扩容。第三件事才是调参。如果你决定用LRU那么关键参数就是maxmemory-policy和maxmemory-samples。把策略从noeviction改为allkeys-lru或者从volatile改为allkeys都要经过灰度验证。原因很简单策略突变会影响淘汰行为如果新策略把大量关键数据淘汰了对业务的冲击会比之前的拒绝写入更大。我自己的习惯是分三步走先小范围调整maxmemory-samples从5调到10观察淘汰命中率变化这个参数调整对行为影响温和很适合作为第一步。再确认淘汰策略如果当前是volatile-lru而数据库中有大量不带TTL的key就考虑切换到allkeys-lru但前提是确认所有数据可丢失。开启lazyfree-lazy-eviction降低淘汰大key的阻塞风险。调整完参数后用redis-cli config rewrite持久化配置或者直接把配置写进redis.conf确保重启后不丢失。还有一个很少有人提的技巧利用Redis的memory doctor和memory malloc统计。你可以通过memory doctor诊断内存碎片通过memory stats查看各类内存消耗占比。在排查LRU淘汰问题时这些信息能帮你区分到底是数据量真的超过了容量还是内存碎片化导致可用内存减少。后者通过重启或清理碎片就能解决完全不需要改LRU参数。最后再说一个长期有效的经验LRU只是兜底不要指望它精确管理你的缓存质量。你在业务层面能做的才是大头——合理设置TTL、拆分大key、压缩value大小、热点数据本地缓存。如果这些基础工作没做好无论LRU采样数调到20还是策略换成LFU都只是头疼医头。Redis的LRU设计不是要用一个完美的算法来替代你的业务决策而是在你无法精确预测每一份数据价值的前提下用最小的代价保住系统的可用性。理解了这一点你就不会再纠结为什么LRU淘汰的不够准而是会关注我的缓存模型是否健康。