先从一个很常见的报警说起:某业务线上Redis实例的maxmemory触顶,内存曲线连续几天只涨不跌。打开INFO keyspace一看,里面带TTL的key有上千万个,其中大量key的过期时间早就到了,却始终躺在内存里不走。很多人第一反应是把maxmemory-policy改成allkeys-lru,结果设置完不到十分钟,一批没有设置过期时间的核心数据被淘汰,线上直接出现缓存穿透和数据库压力飙升。
要解决这类问题,得把两个概念彻底拆开:Redis的过期删除策略和内存淘汰策略。它们表面上都会让某些key消失,但触发条件、作用对象、对业务的影响完全不同。这篇文章从源码行为、配置参数、真实排障三个层面把两者讲透,同时整理了我自己踩过的坑和常用的排查命令,适合刚接触Redis、或者调过maxmemory但没搞懂原理的读者直接参考。
1. 两个场景帮你分清:到底是“过期”还是“被淘汰”
1.1 场景A:key过期了,为什么还占着内存
很多人设置了EXPIRE之后,都默认Redis到点就会把key清掉。实际上Redis只有在两种情况下才会真正删除过期key:第一种是访问到这个key时发现它已经过期,顺手删掉;第二种是后台定期任务扫描时恰好扫到了它。如果某个过期key一直没人访问、又没被后台扫描扫中,它就会继续占着内存,直到满足两者之一才被处理。
这不是bug,而是Redis在CPU和内存之间做的取舍。过期只代表这个key被标记成“不可用”,不代表内存立刻被释放。所以当你看到一个实例里expires字段的数值很大,同时内存水位一直很高时,说明大量过期key还没被真正清理掉,这时候问题出在过期删除策略的执行效率,而不是内存淘汰策略没生效。
1.2 场景B:内存没满,为什么个别key消失了
另一种情况更让人摸不着头脑:maxmemory没到,TTL也没到,但某个key就是读不到了。这时候优先查一下实例是不是配置了allkeys-lru或allkeys-lfu。如果某个时间段写入量突然变大,内存短期内触顶过,Redis就会按策略从全量key里选“最近最少使用”或“访问频率最低”的数据淘汰,跟这个key有没有设置TTL完全没有关系,也跟它是否过期没有关系。
也就是说,key虽然没有过期,却因为内存压力被当成了牺牲品。这种事情最容易出现在混合业务场景:同一个Redis里既放了缓存数据,又放了一些常驻配置,配置类key没有设置TTL,但长期不怎么被读取,一旦内存告急就会被清掉。等真正用到时才读不到,而那时你手头又没有备份,只能重新初始化。
1.3 关键差异对比表
| 对比维度 | 过期删除策略 | 内存淘汰策略 |
|---|---|---|
| 触发条件 | key的TTL到期 | 内存达到maxmemory上限 |
| 处理对象 | 只针对设置过TTL的过期key | 按策略选择,可能涉及所有key |
| 执行时机 | 访问时惰性触发 + 后台定期扫描 | 写入命令执行前的前置内存检查 |
| 配置方式 | Redis内置组合策略,无需显式配置 | maxmemory + maxmemory-policy 显式配置 |
| 失败后果 | 过期key残留,内存浪费逐渐严重 | 写入失败,或常驻数据被误淘汰 |
这张表已经把核心差异说完了:过期删除解决的是“数据该不该算有效”的问题,属于时间维度;内存淘汰解决的是“内存还有没有地方放新数据”的问题,属于容量维度。两者互相独立,但生产环境里经常同时出现、一起影响你的缓存命中率。
2. Redis过期删除策略:为什么是“惰性+定期”组合拳
2.1 最直观的方案:惰性删除
惰性删除的实现逻辑很直接:当客户端对某个key发起读、写、修改操作时,Redis先检查这个key是否设置了TTL、是否已经过期。只要过期了,就在这次访问路径上直接删除,再返回nil或继续执行后续操作。
好处是CPU开销极低,只有被访问到的key才会触发检查,Redis不需要额外维护一个扫描线程。坏处也很明显:如果某个过期key一直没人访问,它就会一直存留在内存里,占用空间不释放。这就像家里不穿的旧衣服,你不去翻衣柜,它就一直在角落积灰;惰性删除只有在翻到它的那一刻才会把它扔出去。一个两个还好,量大了内存占用就非常可观。
2.2 看上去完美的方案:定时删除
定时删除,也常被叫作主动删除,指的是每个设置了TTL的key在到期瞬间,由一个精确的定时器触发删除。从内存角度看这无疑是最理想的,因为过期即释放,不会留任何残留。
但Redis是单线程事件模型,所有命令都在一个线程里跑。如果同一时间有大量key到期,删除任务就得排队执行,这期间正常的读写请求全被堵住。而且为千万级别的key维护精确定时器,本身就要消耗额外CPU和内存。所以定时删除在理论层面很干净,实际在Redis里根本没法落地。这也是很多初学者在教材里看到“三种过期删除策略”,但查看Redis源码却发现只有两类行为的原因。
2.3 折中方案:定期删除
定期删除的思路是:不为每个key维护定时器,而是让后台任务周期性地扫描一小批带TTL的key,发现过期就批量删掉。Redis通过周期任务调用activeExpireCycle函数,每次执行时按时间预算从expires字典里随机采样一批key,删掉其中已经过期的部分,然后结束本轮,下一轮再继续。
由于是随机采样,它没法保证每个过期key都在确定时间内被清掉。如果实例里带TTL的key特别多,每次采样又分散在不同数据库分区上,残留是很正常的。单独靠定期删除,依然解决不了过期key堆积问题,但它能把总量控制在一个可以接受的范围内。
2.4 Redis的真实组合:惰性删除+定期删除
实际源码里这两个是同时生效的:定期删除负责控制过期key的总量,访问路径上的惰性删除负责兜底。这样设计最核心的原因就是定时删除在单线程模型下无法支撑海量key,而只做惰性删除又会让内存浪费失控。
执行频率跟配置项hz有关,默认是10,表示每秒执行10次周期任务。我踩过一个坑:为了“更积极”地清理过期key,把hz调到了100,结果一台低配置实例的CPU空转明显,吞吐量反而下降。对绝大多数场景,默认值完全够用。真遇到过期key残留严重,优先考虑主动扫描,而不是盲目调高hz。
2.5 过期key对持久化与主从的影响
RDB快照落盘时,已经过期的key不会写进快照;AOF持久化时,一次过期删除会被记录成一条DEL命令。主从复制场景里,从节点不会自己主动执行过期删除,而是等主节点删除后把DEL命令同步给从节点。所以在主从切换的瞬间,从节点上可能短暂存在“逻辑上已过期但物理上还没删”的key。
这个问题一般不影响读,因为从节点在读取时一样会按过期时间判断,过期的key会被当成不存在。但如果你有一个严格依赖“从节点内存必须干净”的监控系统,就得知道这个机制,别把从节点上残留的过期key当成异常。
3. 内存淘汰策略:八种策略怎么选?先搞懂触发机制
3.1 淘汰发生在什么时候
内存淘汰和过期删除完全不同,它不是后台任务,而是一个“前置拦截”动作。Redis在处理每个可能增加内存的写命令,比如SET、LPUSH、SADD这类操作之前,都会先检查当前内存是否已经超过maxmemory。一旦超过,就根据maxmemory-policy尝试淘汰一批key来腾空间,腾完再继续执行写命令;如果找不到可淘汰的key,写命令就直接返回内存错误。
只读命令不会触发淘汰,比如GET和SCAN,只是读取数据和遍历key,不会带来新的内存增长压力。另外需要特别注意:maxmemory设置为0表示不限制内存,所以不配置maxmemory的情况下,内存淘汰永远不会触发。很多人以为设置了maxmemory就自动启用了淘汰,其实还要看maxmemory-policy的具体值,如果策略是默认的noeviction,内存达到上限后Redis不会删除任何key,而是直接拒绝新写入。
生产里最常见的报错就是:OOM command not allowed when used memory > 'maxmemory',字面意思是这条命令要用的内存超出了上限。这时候第一反应不应该是扩容,而是先确认这台实例的淘汰策略是否和业务预期一致。
3.2 八种策略逐个拆解
可以把常见策略先按“对谁动手”分成两类:带volatile前缀的,只从设置了TTL的key里选淘汰对象;带allkeys前缀的,则从整个字典里选,不管有没有TTL。再结合LRU、LFU、random、ttl这几种“选择算法”,就形成了下面的策略组合:
| 策略 | 选择范围 | 选择依据 | 典型用途 |
|---|---|---|---|
| noeviction | 不淘汰任何key | 无 | 有严格数据保留要求,宁可写失败 |
| allkeys-lru | 所有key | 最近访问时间 | 纯缓存业务首选 |
| volatile-lru | 带TTL的key | 最近访问时间 | 缓存为主,未设TTL的数据不想被动 |
| allkeys-lfu | 所有key | 访问频率 | 存在明显冷热分布的数据 |
| volatile-lfu | 带TTL的key | 访问频率 | 冷热明显且需要保护常驻数据 |
| allkeys-random | 所有key | 随机 | 数据没有热点,淘汰谁影响不大 |
| volatile-random | 带TTL的key | 随机 | 过期key之间无差别 |
| volatile-ttl | 带TTL的key | 剩余TTL最短 | 让“越快到期”的key先走 |
noeviction是默认策略,也是让新手最困惑的:它不会删key,只会在内存满时写入失败。有些团队把它配在缓存实例上,大促一到写入直接报错,监控没覆盖到错误率,等发现时用户数据已经写入失败很久了。给纯缓存业务用的实例,不建议选noeviction。
allkeys-lru是最常用的缓存策略,因为它能清理整个实例里“最近最闲”的数据,包括没设TTL的key。正因为这样,它也是最危险的:如果业务里有些常驻数据没有设TTL,又长期不被访问,就可能被当成冷数据清掉。使用前必须确认这些常驻数据都能容忍丢失。
volatile系列相当于在淘汰前加了一道门槛,只允许清理设置了TTL的key,因此未设置TTL的key会更安全。但要注意,如果带TTL的key数量很少,清来清去也腾不出多少空间,Redis会退化成类似noeviction的行为,写命令还是会报错。使用volatile系列之前,务必先确认“可被淘汰的key”覆盖了足够的过期数据。
LFU是Redis 4.0之后引入的,适合有明显冷热分布的访问模型。LRU的问题是只关心最近一次访问时间,一个key被批量任务集中访问过一次,之后长期不访问,LRU可能还会因为它“最近被碰过”而保护它;LFU则通过统计访问频率和衰减周期,能更快识别出这种伪热点。选择LFU时,Redis会复用key里的lru字段来存频率计数,不会显著增加单key内存,但对CPU有一点额外开销。
volatile-ttl看起来很适合缓存,生产里反而用得不多。因为TTL最短的key往往是最新写入的数据,它们刚刚产生,还没来得及被消费就被清掉,下一次访问又得回源,反而放大存储压力。
3.3 近似LRU与maxmemory-samples参数
严格LRU要求在每次访问时维护一个链表结构来记录访问顺序,Redis为了节省内存和时间开销,并没有这么做,而是用采样近似LRU。每次需要淘汰时,从字典里随机抽取maxmemory-samples个key,默认是5,再从中选一个最久没访问的key淘汰。
采样数越大,近似结果越接近真实LRU,但每次淘汰时的CPU消耗也越高。实际项目中,如果发现淘汰命中率不理想,可以把这个参数从5调大到10或20,通常在内存写入频率高的场景下会有改善。但调节参数前,先要确认瓶颈确实在“选错淘汰对象”,否则只是白白增加CPU开销。Redis 4.0之后引入的LFU实现也受这个采样参数影响,淘汰时同样需要抽样再比较频率。
3.4 按业务场景给一个可直接抄的配置
给一个简单有效的选择逻辑:
- 纯缓存,任意key可丢:maxmemory-policy allkeys-lru,配合合理的maxmemory,维护成本最低。
- 缓存+常驻数据,不能动常驻key:volatile-lru,前提是所有可淘汰的缓存数据都设置了TTL。
- 数据不能丢,内存满了宁可失败:noeviction,同时做好监控,内存到阈值前提前处理或扩容。
- 冷热分明、数据量大:allkeys-lfu,并适当调整maxmemory-samples提高淘汰准确率。
运行时调整命令:
CONFIG SET maxmemory 2gb CONFIG SET maxmemory-policy allkeys-lru如果想永久生效,在redis.conf里写同样配置,再重启实例。注意CONFIG SET是运行时临时生效,重启后不保留;需要写回配置文件的话,还要执行CONFIG REWRITE。我一般先在线上用CONFIG SET快速调整,确认业务稳定后,再在低峰期把配置固化进文件。
4. 两个策略放在一起:组合行为才更贴近生产现场
4.1 过期key堆积但内存没满,不会触发淘汰
很多实例的状态是:带TTL的key数量极大,内存水位也不算低,但还没触顶。这时候内存淘汰策略根本没有机会介入,所有清理压力都在过期删除策略身上。而定期删除的随机采样覆盖不到那么多离散key,惰性删除又只清理被访问的key,结果过期key就在内存里“滞留”下来。
这种情况最常见、也最安全的处理方式是主动扫描清理。我自己写过一个简单的清理流程:用SCAN游标遍历全量key,对每个key查询TTL,发现TTL小于等于0的就执行DEL。SCAN的COUNT参数可以控制单次返回量,比KEYS命令柔和很多,不会把整个实例卡住。分批在凌晨低峰执行,每批几千个,看着CPU曲线没有明显抬升再继续。整个过程不需要改动任何淘汰策略。
4.2 内存打满且策略是noeviction,写入直接报错
当maxmemory-policy为noeviction时,内存满后Redis的行为很“硬”:新写入返回错误,旧的key一个不删。这其实是把Redis当数据库用的场景需要的语义:数据有持久化要求,不允许未设置TTL的数据被自动淘汰。
但代价是业务必须能兜住写入失败。线上见过太多实例把这个策略配错,平时没流量时毫无感觉,一到活动期写入量大涨,错误率瞬间起来,日志里全是OOM报错。正确的做法是:纯缓存实例用allkeys系列,带存储语义的实例才用noeviction,并且必须配合监控,在内存水位接近阈值时提前触发扩缩容。
4.3 volatile系列的保护罩:没设TTL的key更安全
volatile-lru、volatile-lfu这类策略只从带TTL的key里选淘汰对象,这会给未设置TTL的key提供一个保护效果:只要内存还有可清的过期key,常驻数据就不会被碰。
但它隐藏着一个风险点:如果业务里带TTL的key占比很低,比如大部分数据都设置成了永久有效,那么内存满了之后,可淘汰的key池非常有限,清不了多少空间,最终Redis还是会退化成类似noeviction的写入失败。使用volatile系列前,建议先统计一下expires数量和总量占比。可以用INFO keyspace快速看每个db的keys和expires数字,两个值相除就能估算覆盖率。覆盖率低于三成,就要慎重选择volatile策略。
4.4 被淘汰不等于过期,过期了也不一定立即被淘汰
这里是最容易踩坑的地方:一个key设置了TTL为10分钟,刚写入5分钟就不见了,很多人第一反应是“过期时间没到怎么就被删了”。其实它可能是被内存淘汰策略选中了,跟过期时间没有半毛钱关系。反过来,大量过期key在内存里堆着,只要内存没到上限,它们也不会被淘汰逻辑清走,因为淘汰逻辑只看内存水位和访问特征,不看TTL。
两个机制互相不替代:一个看时间,一个看空间。生产环境里这两个事件几乎总是交替发生,排查的时候先确认当前实例处于哪个状态,再去决定是优化TTL策略、调整淘汰策略,还是扩容内存,就不会被表面现象带偏。
5. 常见问题与排查技巧实录
5.1 内存只增不减怎么查
先看INFO keyspace里的expires字段,确认带TTL的key规模有多大;再看INFO stats里的expired_keys,这个计数器统计的是累计过期删除次数,如果它增长缓慢,而expires数值又很大,说明大量过期key没有被及时清理。
这时候优先做主动扫描清理,而不是急着调淘汰策略。同时检查是否存在大量没设TTL的key,这类key永远不进入过期删除流程,只能靠内存淘汰或人工清理才能释放。我的习惯是写一个巡检脚本,每天扫描一次实例里没有TTL的key,逐个评估是否漏设了过期时间。
5.2 key提前消失怎么查
如果遇到TTL没到期但key已经读不到的情况,按优先级排查:
- 检查maxmemory-policy是不是allkeys-lru或allkeys-lfu,它们会淘汰常驻和未过期的数据。
- 看INFO stats里evicted_keys是否在持续增长,增长就说明淘汰动作一直在发生。
- 确认实例最近有没有主从切换、重启或持久化恢复,RDB缺失或AOF丢失会导致部分key不存在。
- 再回到业务日志,排查是否有DEL、UNLINK这类删除命令被误触发。
不要在确认前就把锅扣到Redis头上,先看淘汰计数和实例事件,再用业务日志反向追踪。
5.3 用INFO命令快速定位
| 指标 | 位置 | 说明 |
|---|---|---|
| maxmemory | memory | 内存上限,0表示不限制 |
| used_memory | memory | 当前实际内存占用 |
| expired_keys | stats | 累计过期删除次数 |
| evicted_keys | stats | 累计淘汰次数 |
| keyspace_hits / keyspace_misses | stats | 命中与未命中次数 |
| expires | keyspace | 当前库带TTL的key数量 |
如果evicted_keys持续上涨,说明淘汰压力很大,优先检查容量配置和缓存key是否合理。如果命中率长期偏低,说明很多请求都在访问不存在的key,这已经超出策略能解决的范畴,要回业务侧排查缓存穿透、TTL设置过短之类的问题。这两个计数器是Redis给你发出的信号,比任何监控图表都更直接。
5.4 一个可复现的小实验
想彻底理解淘汰策略,最好的方式是自己动手试一次。本地起一个Redis实例:
redis-server --port 6380然后把内存上限调小,并开启allkeys-lru:
CONFIG SET maxmemory 64mb CONFIG SET maxmemory-policy allkeys-lru接着循环写入不同key的大value,比如一次写2MB的字符串,写几百个。你会看到写入到一定程度后,新的key依然能写入成功,而旧的、不常访问的key被持续淘汰,INFO stats里的evicted_keys不断上涨,这就是淘汰策略在正常工作。
把策略换成noeviction重复一遍,写到内存满后,再执行SET就会直接报错,之前的key一个都不会少。相同的写入压力下,两种策略的结果完全不同。这个实验强烈建议在测试环境做一遍,实际看到的行为比读十篇文章都管用。
5.5 线上配置检查清单
接手一个新Redis实例时,我会按这个清单过一遍:
- maxmemory是否有值:不设置就是0,等于不限内存,内存可能被其他业务挤爆。
- maxmemory-policy是否符合业务预期:缓存类优先allkeys系列,存储混合类优先volatile系列或noeviction。
- 可淘汰的key覆盖率:使用volatile系列时,确认大部分数据都设置了TTL,否则策略可能形同虚设。
- 监控是否覆盖:evicted_keys、expired_keys、keyspace_hits三个指标都要有曲线和告警。
- 持久化配置是否匹配:RDB/AOF的开关、过期key对恢复文件大小的影响,都要跟淘汰策略一起看。
我接手一台Redis服务器时,第一件事不是看配置,而是先看evicted_keys和expired_keys两个计数器的增长速率。expired_keys在涨,说明过期清理机制在工作;evicted_keys在涨,说明内存压力已经传导到业务数据上,这是需要立即处理的信号。很多人把精力纠结在选了哪个策略上,其实真正决定体验的是TTL设置是否合理、内存上限是否符合业务峰值。策略只是最后的兜底,不是优化工具。命中率不断下滑、淘汰量持续增长时,再好的LRU配置也掩盖不了容量规划的问题。