REDIS 7 · EXPIRE · LAZY · ACTIVE EXPIRE CYCLE · MAXMEMORY · LRU · LFU

到点的 key 不会准时消失,内存满了要挑人赶走

这是两件不同的事。过期删除管的是“设了过期时间的 key,到点后什么时候真正删掉”;内存淘汰管的是“内存用到上限了,还要写,该把谁赶出去”。前者靠查到时顺手删 + 定期巡逻,后者靠 maxmemory 和淘汰策略。

没设过期时间还没到期到期了,但还占着内存超过上限的写入,要先淘汰
过期删除到期的 key 先是“逻辑上不存在”:读它会返回空。真正从内存里删掉,要等有人来读它,或者被定期巡逻抽到。
内存淘汰每次执行写命令前检查内存。超过 maxmemory 就按策略挑 key 删掉,挑不出来就拒绝写入。
过期表expires

单独一张哈希表,记着每个设了过期的 key 什么时候到期。

key → 毫秒时间戳
巡逻员activeExpireCycle

每秒 10 次,随机抽 20 个带过期时间的 key,到期的删掉。

最多占 25% 的 CPU 时间
上限maxmemory

Redis 能用的内存上限,默认 0 表示不限。

超了才会淘汰
候选池eviction pool · 16

每次抽几个 key 放进池子,挑最该走的那个淘汰。

近似 LRU / LFU 靠它

过期时间存在哪

每个库有两张表:主表存 key 和值;过期表只存设了过期时间的 key,以及它到期的绝对时间(毫秒)。判断一个 key 过没过期,就是拿当前时间和过期表里的值比一下。

dict主表:所有 key
user:1→{name: tom}
session:9f→"…"
code:138xx→"4721"
config→"…"
expires过期表:只有设了 TTL 的
session:9f→1767225600000 还有 29 分钟
code:138xx→1767223980000 已经过期
user:1—不在这张表里,永不过期
设置和查看过期时间redis-cli
SET code:138xx 4721 EX 300       # 300 秒后过期;PX 是毫秒,EXAT / PXAT 给绝对时间
EXPIRE session:9f 1800           # 给已有的 key 设过期;7.0 起可加 NX / XX / GT / LT
TTL code:138xx                   # 还剩多少秒;-1 = 没设过期,-2 = key 不存在
PERSIST session:9f               # 去掉过期时间
SET code:138xx 9999 KEEPTTL      # 改值但保留原来的过期时间(6.0 起);不加 KEEPTTL 会把过期时间清掉

到期了,什么时候真删

有三种思路。Redis 用的是后两种的组合:读到时顺手删,再派个巡逻员定期抽查,把没人读的那些也清掉。

定时删除

Redis 不用

每个 key 设一个定时器,一到点就删。

内存最干净;但几百万个定时器,CPU 扛不住。

惰性删除

Redis 用

平时不管。有人读写这个 key 时,先看一眼过期表,过期了就删掉,当作不存在。

几乎不花 CPU;但没人访问的过期 key 会一直占着内存。

定期删除

Redis 用

后台定期随机抽查一批带过期时间的 key,删掉到期的。

兜住没人访问的那些;抽查也要花 CPU,所以限时、限量。
1每秒 10 次由 hz 10 决定,在定时任务 serverCron 里跑
2逐个库抽 20 个只从过期表里随机抽
3删掉到期的主表和过期表里都删
4到期的超过 10%?说明还有很多,再抽一轮(Redis 6 之前是 25%)
5时间到就停一轮最多用 25% 的 CPU 时间,没清完下次接着来

此外,每次事件循环进入等待前,还会跑一次“快速巡逻”,最多 1 毫秒。active-expire-effort(1~10,默认 1)调大,每轮抽得更多、容忍的过期比例更低,代价是多花 CPU。

巡逻实验台:没人读的过期 key 会不会一直赖着

400 个 key,其中 300 个设了过期时间。只有前 80 个是热门 key 会被读到,其余没人碰。打开或关掉两种删除方式,看“过期了还占着内存”的 key 有多少。

过期时间:
第 0.0 秒
没设过期还没到期过期了还占着内存已删除上一拍被抽查

前 4 行(80 个)是热门 key,每 0.1 秒随机被读 3 次。

过期碰上主从复制和持久化

为了让主从数据一致,删过期 key 这件事只由主节点做,删完再通知从节点和 AOF。

场景怎么处理
主节点删了过期 key往复制流和 AOF 里补一条 DEL(或 UNLINK),从节点和 AOF 跟着删
从节点上读到过期 key从节点自己不删,等主节点的 DEL;但 3.2 起读的时候会按过期处理,返回空
生成 RDB、AOF 重写已过期的 key 通常会被跳过;就算写进去了,主节点加载时也会再过滤一遍
主节点加载 RDB已过期的跳过;从节点加载时全部载入,等主节点来删

复制和持久化的细节见 Redis 第 03 篇 和 第 02 篇。

内存满了:八种淘汰策略

设了 maxmemory 后,每次执行写命令前,Redis 检查已用内存。超了,就按 maxmemory-policy 挑 key 删,直到降回上限以下。两个维度:从哪些 key 里挑,按什么标准挑。

最久没用 LRU用得最少 LFU随机最快过期 所有 key
allkeys-lru最常用的缓存配置
allkeys-lfu有明显热点时更好
allkeys-random访问均匀时
—没设过期就没有 TTL
只挑设了过期的
volatile-lru没设过期的 key 绝不动
volatile-lfu同上,按频率
volatile-random同上,随机
volatile-ttl剩余时间最短的先走
noeviction默认策略。不淘汰,写命令直接报错:OOM command not allowed when used memory > 'maxmemory'.读和删除照常。

volatile-* 策略找不到可以淘汰的 key 时(比如大部分 key 都没设过期),效果和 noeviction 一样,写入报错。

近似 LRU:不排队,抽样挑

真正的 LRU 要把所有 key 按访问时间串成链表,每次访问都要挪位置,几千万个 key 太费内存和 CPU。Redis 只在 redisObject 里用 24 位记一个秒级的访问时间,淘汰时随机抽几个 key(maxmemory-samples,默认 5),放进一个能装 16 个候选的池子,挑空闲最久的那个淘汰。

淘汰得准不准:1000 个老 key,再写 500 个新 key

淘汰方式:
该走的走了该走的还在不该走的被淘汰了该留的留下了新写入的 500 个

每写入一个新 key 就超出上限,淘汰一个。理想情况是最老的 500 个全走。抽样数越大越接近真正的 LRU,但每次淘汰花的 CPU 也越多。

LFU:看用得多不多,而不只是最近用没用

Redis 4.0 起有 LFU。它用同样的 24 位:高 16 位记上次衰减的时间(分钟),低 8 位是一个最大 255 的对数计数器。

24 位怎么用

新 key 从 5 开始,免得刚写进来就被淘汰。每次访问以一定概率加 1:计数越大,越难再加(lfu-log-factor,默认 10)。一段时间没访问就往下减(lfu-decay-time,默认每 1 分钟减 1)。OBJECT FREQ key 可以看计数。

lfu-log-factor100 次1000 次10 万次100 万次
0104255255255
11849255255
10(默认)1018142255
10081149143

表里是访问多少次后计数器大约到几,数据来自 redis.conf 里的注释。

为什么热点明显时 LFU 更好:一次全表扫描
之前
hot:A 访问 5 万次hot:B 访问 3 万次cold:1 … cold:5000 很少访问
扫描
凌晨对账任务把 5000 个冷 key 各读了一遍
LRU
hot:Ahot:B冷 key 都是“刚访问过”,热 key 反而最久没碰,先被淘汰
LFU
hot:A 计数 ~130hot:B 计数 ~120冷 key 计数才 5~6,先淘汰它们

怎么选、怎么配

先想清楚 Redis 里的数据丢了要不要紧,再选策略。

用法策略原因
纯缓存allkeys-lru 或 allkeys-lfu丢了能从数据库补,满了就把用得少的挤掉;热点明显用 LFU
缓存和重要数据混放volatile-lru / volatile-lfu缓存设过期时间,重要数据不设,淘汰只动缓存;更好的做法是分开两个实例
队列、计数、分布式锁noeviction宁可写不进去报错,也不能悄悄丢;配好内存告警
知道谁先过期就该先走volatile-ttl比如验证码、临时令牌
redis.conf缓存实例的常见配置
maxmemory 6gb                  # 8 GB 的机器,留出 fork 写时复制和各种缓冲区的余量
maxmemory-policy allkeys-lfu   # 默认 noeviction
maxmemory-samples 5            # 调到 10 更准,多花一点 CPU
lazyfree-lazy-eviction yes     # 淘汰大 key 时在后台线程释放
lazyfree-lazy-expire yes       # 删除过期大 key 时也放到后台
hz 10                          # 定期删除等后台任务的频率

看效果:INFO stats 里的 expired_keys、evicted_keys;INFO memory 里的 used_memory 和 maxmemory。

Redis 版本,以及 Java 这边

JDK 8 还是 17、Boot 2 还是 3 不影响这些机制。要注意的是 Java 代码怎么设过期时间。

Redis 4.0 ~ 6.2

机制成形
  • 4.0:新增 LFU 两种策略、UNLINK 和 lazyfree 系列配置
  • 6.0:定期删除改写,新增 active-expire-effort;SET … KEEPTTL
  • 6.2:新增 maxmemory-eviction-tenacity,控制一次写命令里最多花多少力气淘汰;GETEX 读的同时改过期时间

Redis 7.x

更细的控制
  • 7.0:EXPIRE 支持 NX / XX / GT / LT;新增 EXPIRETIME 直接看到期的绝对时间
  • 7.4:Hash 的单个字段也能设过期时间(HEXPIRE、HTTL)
Java 这边redisTemplate.opsForValue().set(k, v) 不带过期时间,会把原来的 TTL 清掉;要么每次都传 Duration,要么在 RedisConnection 这一层用 Expiration.keepTtl()。Spring Cache 统一用 spring.cache.redis.time-to-live 设过期时间。写入被 OOM 拒绝时,会抛出带 OOM command not allowed 的异常。
没变的:惰性删除 + 定期删除只由主节点删过期 key八种淘汰策略抽样 + 候选池

常踩的坑

内存问题多半是两种:该删的没删掉,该限的没限住。

没设 maxmemory,Redis 被系统杀掉

默认不限,一直涨到机器内存耗尽,被 OOM killer 杀掉,或者开始用交换分区变得极慢。缓存实例一定要设。

maxmemory 设成机器内存那么大

复制缓冲区、AOF 缓冲区不算在 maxmemory 里,fork 时写时复制还要额外内存。留出余量,常见是机器内存的 60%~75%。

OOM command not allowed when used memory > 'maxmemory'

默认策略是 noeviction,缓存写满后所有写入报错。纯缓存改成 allkeys-lru / allkeys-lfu。

用了 volatile-lru,照样报 OOM

大部分 key 没设过期时间,可淘汰的 key 不够。要么给缓存都设上 TTL,要么换 allkeys-*。

改了一下值,过期时间没了

普通的 SET 会清掉原来的 TTL,key 从此永不过期,越积越多。用 SET … KEEPTTL,或者每次都重新带上过期时间。

某个整点,Redis 延迟明显升高

大批 key 在同一时刻过期,定期删除每轮抽到的都是过期的,一直循环删,吃满 25% 的 CPU。过期时间加随机值(Redis 第 01 篇的雪崩也是这个原因)。

key 都过期了,内存却没降

一是没人访问、还没被抽到的过期 key 还在;二是删掉后内存碎片没还给系统,看 mem_fragmentation_ratio,可以开 activedefrag。

跑完一次批量任务,缓存命中率掉了一大截

全表扫描把大量冷 key 变成“刚访问过”,LRU 把热 key 挤掉了。热点明显的缓存用 allkeys-lfu。

淘汰或过期时偶尔卡一下

被淘汰、被过期删除的恰好是大 key,在主线程里一个个释放元素。打开 lazyfree-lazy-eviction 和 lazyfree-lazy-expire。

OBJECT FREQ 报错

只有 maxmemory-policy 是 LFU 类策略时才能用;LRU 策略下用 OBJECT IDLETIME 看空闲了多久。