很多项目上线前根本没有关注过 Redis 键过期策略,直到缓存同一时刻失效,数据库被压垮,才慌慌张张去查日志。我经历过一次这样的事故之后,才把键过期策略从“会用 EXPIRE 命令”这个层面彻底拉通到原理层。这篇文章不绕弯子,直接讲清楚 Redis 键过期策略是什么、底层怎么存储、惰性删除和定期删除各自干了什么、主从复制和持久化对过期键怎么处理、线上业务到底怎么设计过期时间,以及面试官最爱问的那几个坑。无论你是刚接触 Redis 的开发者,还是正在准备 Redis 面试题,或者已经被线上缓存问题折磨过,这篇都值得花十分钟完整看一下。
1. 键过期策略:先搞明白它到底解决了什么问题
1.1 被大缓存追着跑的三个场景
先说一个我印象特别深的例子。某个凌晨,系统里一大批用户维度的缓存全部设置了同一个过期时间,时间点一到,Redis 里面的 key 像约好了一样集体消失,几万请求瞬间打到 MySQL 上,数据库连接数直接被打满,接口大面积超时。这就是典型的缓存雪崩:大量 key 在同一时刻过期,请求绕过了缓存层,直扑底层存储。
键过期策略解决的第一个问题,就是防止缓存无限膨胀。如果给 Redis 里的数据设置过期时间,那么长期没有被访问的数据就能在后台被清理掉,内存不会像滚雪球一样越滚越大。尤其是在缓存治理工作中,“给每个 key 设置合理的 TTL”几乎是所有团队的基本规范,没有 TTL 的 key 一旦大量堆积,再高的内存也撑不住。
第二个问题是数据一致性。缓存不是数据库,它保存的是数据的副本,副本应该有一个“保质期”。比如商品价格、库存数量,如果改了数据库但缓存永远不失效,用户看到的就一直是很久以前的数据。通过设置过期时间,可以让缓存数据在一段时间后自动失效,下次读取时再回源到数据库拉取新值,这是最朴素的缓存一致性方案。
第三个场景是很多业务功能本身就是依赖过期时间实现的,典型的有验证码、登录 Token、限流计数、分布式锁。这些场景都是把“在一段时间内有效”直接变成需求,没有键过期策略,这类逻辑就只能靠人工删 key 或者定时任务扫表,效率和准确性都会差很多。
1.2 过期时间和 TTL:Redis 真正意义上的“保质期”
明白了为什么要过期,再看 Redis 是怎么记录一个 key 是否过期的。在 Redis 的数据结构中,每个数据库实例除了保存所有键值对的字典,还有一个专门的字典叫 expires,你可以把它理解为一个“闹钟登记表”,里面只存放那些设置了过期时间的 key,以及它们对应的过期时间戳。
举个例子,当我执行命令EXPIRE user:name 60时,Redis 并不会立刻修改user:name这个字符串本身,而是在 expires 字典里登记一条记录:这个 key 的过期时刻是“当前时间 + 60 秒”。以后你执行TTL user:name,它查的是 expires 字典里的剩余时间,如果返回 -1,说明这个 key 没有设置过期时间;返回 -2,说明 key 不存在。
这里有个很关键的理解:过期时间是一个绝对的时间点,而不是倒计时秒表。Redis 内部存的是“到哪个时刻过期”,所以TTL每次查询时用过期时间戳减去当前时间,算出一个剩余秒数。这个设计保证了即使 Redis 重启、主从切换,只要时间戳被持久化下来,过期判断依然成立。
2. 设置过期时间:命令全家桶与关键细节
2.1 一张表摸清 Set 过期时间的完整命令
很多初学者只知道EXPIRE,但 Redis 实际提供的过期设置命令远比想象中多。如果用错单位或者选错命令,线上很容易出 bug。我把常用命令整理成了表格,建议先收藏再细看:
| 命令 | 单位 | 含义 | 示例 |
|---|---|---|---|
| EXPIRE key seconds | 秒 | 设置 key 在 N 秒后过期 | EXPIRE user:001 300 |
| PEXPIRE key milliseconds | 毫秒 | 设置 key 在 N 毫秒后过期 | PEXPIRE user:001 300000 |
| EXPIREAT key timestamp | 秒级时间戳 | 设置 key 在某个绝对时间点过期 | EXPIREAT user:001 1780000000 |
| PEXPIREAT key timestamp | 毫秒级时间戳 | 精确设置绝对过期时间 | PEXPIREAT user:001 1780000000123 |
| SETEX key seconds value | 秒 | 设置字符串值并同时设置过期时间 | SETEX sms:code:137 60 123456 |
| SET key value EX seconds | 秒 | SET 命令的扩展,原子性设置值与 TTL | SET lock:order:1 1 EX 10 |
| TTL key | 秒 | 查看剩余过期秒数 | TTL user:001 |
| PTTL key | 毫秒 | 查看剩余过期毫秒数 | PTTL user:001 |
| PERSIST key | - | 清除 key 的过期时间 | PERSIST user:001 |
实际业务里,我最常用的是SET key value EX seconds,因为它能一次性完成“写入值 + 设置过期时间”两个操作,天然是原子的,省去了先 SET 再 EXPIRE 的两次网络往返,也能避免两次命令之间 key 被其他客户端覆盖的风险。
EXPIREAT在需要统一失效时刻的场景很有用,比如给每天凌晨激活的优惠券设置一个当天 24 点的过期时间,可以用代码算出当天结束的时间戳,再传给EXPIREAT。这样就不用每秒都重新计算 TTL 了。
2.2 五个容易翻车的设置细节
第一,对一个已经存在的 key 再次执行EXPIRE,不会报错,而是直接覆盖原来的过期时间。如果代码里有一个定时任务每隔几分钟就给某个 key 续期,那每次续期都会把之前的过期时间抹掉重来。这不是 bug,但如果你以为“两次设置会取最小剩余时间”,那就完全想反了。
第二,SET key value如果不带 EX 或 PX,就会清除这个 key 原来设置的过期时间。我见过很多同事在更新缓存时,先SET key newValue,然后发现这个 key 怎么永远不过期了,原因就在这里。所以更新已设置过期的 key 时,要么继续用完整写法SET key value EX seconds,要么更新后用EXPIRE重新补一次过期时间。
第三,EXPIRE作用于一个不存在的 key 时会返回 0,但命令本身不报错。很多新手看到返回 0 以为设置失败,实际上 Redis 的判断逻辑是“这个 key 根本没有,我给它登记过期时间没有意义”,所以直接忽略。写代码时不要忽略这个返回值,最好用这个返回值做一层防御。
第四,过期时间设置成负数或者 0,Redis 会立刻把 key 当成过期来处理,效果等同于直接删除。这在某些场景反而是个技巧,比如你想“立刻让一个 key 失效”,除了DEL,还可以EXPIRE key 0。
第五,PERSIST可以移除过期时间,但要注意它只清除过期登记,不会删除 key 本身。如果某个 key 原本设置过 10 分钟过期,你中途想让它永久保留,执行PERSIST后 TTL 会返回 -1,表示永久存活。这个命令在防止误删缓存数据时很实用。
3. 删除策略三大机制:惰性、定期与内存淘汰
3.1 惰性删除:被访问时才动手
先回答一个很多面试者都会懵的问题:到了过期时间,Redis 是不是马上就删除这个 key?答案是“不一定”。因为如果设置一个后台线程,到点就立刻扫描删除所有过期 key,那在 Redis 单线程模型下会严重阻塞正常的命令请求。Redis 真正采用的第一个策略叫惰性删除。
惰性删除的核心逻辑是:每次执行读取或者写入命令之前,Redis 都会先调一次expireIfNeeded,检查这个 key 是否在 expires 字典里,并且已经过了过期时间。如果过期了,就先把它删除,然后返回给客户端一个类似 key 不存在的响应。如果没过期,正常处理。
这个策略的好处是 CPU 开销非常小,因为 Redis 只处理“正在被访问”的 key,而不是无差别扫描所有 key。缺点也很明显:如果一个 key 过期以后再也没有人访问它,它就永远不会被惰性删除触发,会一直躺在内存里,占着空间不干活。说白了,惰性删除就像一个只在有人踩到它的时候才扔掉的垃圾袋,没人路过,垃圾就一直堆在那里。
3.2 定期删除:Redis 背后的“定时扫地”
为了弥补惰性删除的缺漏,Redis 还有一个后台机制叫定期删除。它不是等到访问才处理,而是由 Redis 的 serverCron 周期性任务定期触发activeExpireCycle,相当于给 Redis 安排了一个定时扫地机器人。
这个扫地机器人怎么工作?它并不会把所有数据库的 expires 字典都完整扫一遍,那样在大 key 数量的 Redis 实例上代价太高。实际做法是:在每次周期任务里,随机抽取一批设置了过期时间的 key,检查它们是否已经过期,如果过期就删除。如果本轮抽到的 key 过期比例很高,说明系统中确实积累了较多过期 key,那下一次会继续抽样并清理;如果过期比例很低,就会提前停止,避免占用太多 CPU。
这里有一个很实际的思考:惰性删除负责把“马上要被访问的过期 key”清理掉,保证用户不会读到脏数据;定期删除负责把“长期无人问津的过期 key”慢慢清理掉,保证内存不会被废数据完全占满。两者配合,才是完整的 Redis 过期清理机制。这也是很多 Redis 面试题里“为什么不用定时器删除所有 key”的标准答案。
3.3 内存淘汰策略:当内存达到上限,过期键也不是绝对安全
先强调一个容易混淆的点:“删除过期 key”和“内存淘汰”是两回事。前者针对已经过期的 key,是理所当然要清理的;后者是在 Redis 内存已经达到 maxmemory 上限时,无论 key 是否过期,都要按策略挑一些 key 移除,属于一种“应急手段”。
通过maxmemory-policy配置,Redis 提供了一系列策略,常见的有:
| 策略 | 含义 | 适用场景 |
|---|---|---|
| noeviction | 内存满后不淘汰任何 key,写命令直接报错 | 只读缓存或不允许丢数据的场景 |
| allkeys-lru | 在全部 key 中按最近最少使用算法淘汰 | 通用缓存,整体数据可丢弃 |
| allkeys-lfu | 在全部 key 中按访问频率淘汰 | 对热点集中、低频冷数据多的场景 |
| allkeys-random | 在全部 key 中随机淘汰 | 数据访问分布非常均匀 |
| volatile-lru | 只在设置了过期时间的 key 中按 LRU 淘汰 | 希望优先淘汰带 TTL 的缓存数据 |
| volatile-ttl | 在设置了过期时间的 key 中优先淘汰剩余 TTL 最短的 | 希望“快要过期”的 key 先被淘汰 |
| volatile-random | 在设置了过期时间的 key 中随机淘汰 | 带 TTL 缓存随机淘汰即可 |
注意一个关键细节:volatile-lru这类策略只会在设置了过期时间的 key 里挑淘汰对象。如果整个实例的所有 key 都没设置 TTL,那 volatile 系列策略就会退化成 noeviction,内存满后照样写不进去。我之前就踩过这个坑,给 Redis 配了 volatile-lru,结果业务方图省事,所有缓存都没加 TTL,最后 Redis 写满了直接报错。所以选择淘汰策略之前,先确认业务里的 key 有没有统一的过期管理规范。
4. 进阶机制:主从、持久化和过期事件
4.1 主从复制和 AOF/RDB 中的过期键
生产和面试都会追问:设置了过期的 key,在主从复制环境里是怎么保持一致性的?我先说结论:主库删除一个过期 key 时,会向从库发送一条 DEL 命令,从库收到后才会把本地的这个 key 删掉。从库自身不会因为时间到了就主动删除过期 key,因为主从机器的时钟可能不一致,如果从库按本地时间删除,很容易造成主从不一致。
那从库在收到 DEL 命令之前,如果客户端正好查到那个已经过期的 key,会读到脏数据吗?在新版 Redis 中不会。Redis 3.2 之后,从库读取时会做逻辑过期检查,发现自己本地的某个 key 已超过过期时间,即使内存中的 key 还没被物理删除,也会直接返回 nil,不会把过期数据返回给客户端。也就是说,逻辑上它已经“过期不可见”,但物理上要等主库的 DEL 指令来做最终清理。
持久化方面,RDB 和 AOF 也都考虑到了过期 key。生成 RDB 文件时,Redis 会过滤掉已经过期的 key,所以重启后不会出现一批“僵尸 key”突然复活;加载 RDB 时同样会做一次过期检查。AOF 重写和追加时,当一个过期 key 被删除,Redis 会往 AOF 里追加一条 DEL 命令,保证重启后重放 AOF 也不会让这个 key 再次出现。理解这一块,可以帮你回答“Redis 重启后过期 key 会不会复活”这种坑人问题。
4.2 键空间通知:把过期当成消息推送
Redis 从 2.8 版本开始提供键空间通知功能,可以让客户端订阅某个事件,比如 key 过期、key 删除、key 修改等。配置项是notify-keyspace-events,默认是关闭的。想接收过期事件,需要设置成包含Ex,其中 E 表示 keyevent 事件,x 表示 expired 事件。
开启后,你可以用PSUBSCRIBE __keyevent@0__:expired订阅过期消息。这个功能很实用,比如订单超时未支付自动关闭,或者优惠券到期提醒,都可以不依赖定时任务轮询数据库,而是等 Redis 发过期事件通知后端服务处理。
但这里有一个必须提前建立的认知:Redis 的过期事件并不是“到了过期时间那一刻”立刻触发的。因为过期 key 的实际删除依赖惰性删除和定期删除,如果这个 key 一直没有被访问,定期删除又要等抽样轮到它,那事件可能延迟很久。所以键空间通知适合对实时性要求不高的场景,不适合做精确到秒的强实时业务。真要精确,还是得自己维护延迟队列。
5. 线上实战:我从雪崩和穿透里抄回来的经验
5.1 防雪崩:给过期时间加随机偏移
回到文章开头那个事故。当时我排查到最后,发现所有缓存 key 的 TTL 都是固定 1800 秒,写入顺序又一致,导致过期时间点完全重叠,一到时间集体失效。后来我做的第一件事,就是在代码中给所有 TTL 加一个随机偏移量。
最简单的做法是:定义基础过期时间,再加一个随机数区间。例如 Java 里可以写int ttl = 1800 + ThreadLocalRandom.current().nextInt(600);,这样每个 key 虽然基础 TTL 相同,但实际过期时间会分布在 1800 到 2400 秒之间,原本“同一时刻集体过期”的问题就被打散了。
如果是使用 Spring Cache,可以自定义CacheManager的配置,或者在写入缓存时统一走一个 RedisUtil 工具方法,在里面统一做 TTL 随机化。我的经验是,不要把随机化逻辑散落在各个业务代码里,否则早晚会漏掉几个 key。等到线上又出现雪崩的苗头,再也没办法全局治理。
5.2 防穿透:空值缓存和布隆过滤器
缓存穿透和过期策略不一定直接相关,但解决它时离不开 TTL。当大量请求查询一个根本不存在的 key,比如恶意攻击者不断请求不存在的用户 ID,缓存里没有数据,所有请求都会打到数据库。这种情况下,如果把查询结果也缓存进来,哪怕是个空值,也能挡住后续请求。
我常用的方案是:当数据库查不到数据时,也在 Redis 里写入一个空值,同时设置一个比较短的 TTL,比如 60 秒。这样同样的“不存在 key”下一次会直接命中缓存,但因为是空值,不能缓存太久,否则会影响数据一致性。也可以在缓存层之前加布隆过滤器,先判断 key 是否存在,不存在直接拦截,存在才去查缓存和数据库;布隆过滤器有误判,所以空值缓存依然要留着做兜底。
这里要提醒一句:空值缓存如果 TTL 太短,攻击者高频变换 key 还是会穿透;如果 TTL 太长,潜在的大量空缓存会占内存。我一般建议结合业务请求量来定,比如 30 到 60 秒是一个比较常用的区间。
5.3 分布式锁里的过期时间:不能拍脑袋
Redis 做分布式锁是热词里的常客,而分布式锁一旦和过期时间扯上关系,就很容易出事故。标准的加锁命令是SET lock:order:123 uuid NX EX 30,意思是只有这个 key 不存在时才设置成功,同时设置 30 秒过期。如果线程持有锁期间宕机,30 秒后锁自动释放,其他线程能进来,不会造成死锁。
但锁的过期时间设成多少,是一个需要压测的业务参数。设短了,业务还没执行完锁就过期了,其他线程也能拿到锁,本来想互斥的逻辑就失效了;设长了,如果持有锁的线程真的挂了,其他线程要等待很久。最佳实践是用 Redisson 这类客户端提供的看门狗机制,默认锁 30 秒,每 10 秒自动续期,只要持有锁的线程还活着,锁就会一直续期,防止业务没执行完锁就过期。释放锁的时候也必须用 Lua 脚本比较锁的持有者标记再删除,否则可能出现一个线程把另一个线程的锁删掉的问题。
5.4 监控和排查:用 INFO stats 盯住 expired_keys
线上排查过期相关问题时,最容易忽略的就是监控。其实 Redis 的INFO stats里就包含expired_keys和evicted_keys两个累计计数器,分别表示累计删除了多少过期 key、累计淘汰了多少 key。如果事故时间点附近expired_keys增量飙升,多数就是大量 key 同时过期引发的雪崩。
除了看计数,我还会用redis-cli --scan配合TTL命令统计没有设置过期时间的 key 数量。比如扫描某个业务前缀下的所有 key,输出 TTL 等于 -1 的 key,如果数量很大,说明这个业务存在大量“永久缓存”,内存膨胀的风险很高。定位到之后,可以利用业务低峰期分批给这些 key 补上 TTL,或者直接清理无用数据。
另外,SLOWLOG get也可以辅助定位,因为删除一个大 key 会阻塞 Redis 主线程,如果慢日志里出现 DEL 相关的耗时记录,就要注意是不是过期 key 太大,删除时导致命令阻塞。这种情况需要对大 key 做拆分,或者使用UNLINK命令异步删除。
6. 面试问 Redis 过期策略,90% 的人都答不全
6.1 为什么 Redis 不采用“到点就删”?
这个问题我在面试别人时几乎必问。很多人第一反应就是“到点删不是更干净吗?”但如果把所有过期时间当成触发条件,每隔很短周期就全库扫描,单线程的 Redis 根本扛不住,CPU 会被扫描任务占满,正常的读写命令只能排队等待,这是不可接受的。
退一步讲,即使能全库扫描,一次性把大量到期 key 都删除,也会引发类似缓存雪崩的连锁反应。键过期策略采用“惰性删除 + 定期删除”的组合,本质是用一部分 CPU 开销换取内存空间,并且在 CPU 消耗上做了严格的时间预算控制。这个答案不仅是原理题,更是一道设计权衡题。
6.2 惰性删除会不会导致内存泄漏?
严格说不会,但存在“短期内存占用残留”。如果一个 key 过期后长期不被访问,惰性删除永远碰不到它,内存就一直被它占用着。好在定期删除会不断抽样清理,最终把这类 key 清掉,只是清理速度有随机性,并不能保证“立刻回收”。
在极端场景下,如果过期 key 非常多,定期删除还没来得及清完,内存压力已经很大,就需要配合 maxmemory-policy 做淘汰。所以生产环境必须明确设置 maxmemory 和淘汰策略,不要只依赖默认配置,这个习惯能省掉很多线上麻烦。
6.3 key 明明删了,为什么 used_memory 还在涨?
很多人遇到过这种现象:删了大量 key,甚至flushdb了,Redis 的内存占用却不降反升。这通常不是过期策略出问题,而是内存碎片在作怪。Redis 向系统申请的内存在释放后不一定完整还给操作系统,尤其是删除的 key 大小不一,内存块之间产生大量碎片,导致 RSS 指标居高不下。
遇到这种情况,不要急着继续删 key,先执行INFO memory查看mem_fragmentation_ratio。如果碎片率高,考虑在业务低峰期执行CONFIG SET activedefrag yes打开自动碎片整理。如果是大 key 导致的删除耗时长、内存难以释放,就要从源头上拆分大 key,避免一个 key 占用几 GB 内存。
6.4 主从切换后读到过期 key 怎么办?
这个问题考察的是对复制机制的熟悉程度。新版 Redis 从库在本地逻辑上会判断 key 是否过期,即使主库的 DEL 还没同步过来,客户端查询也会返回 nil,所以不会读到脏数据。但物理内存中的 key 可能还存在,需要等主库同步 DEL 后才能清掉。
有一种常见误解是“主从切换后缓存会出现大量过期残留,最后 OOM”。实际上残留的过期 key 并不会无限增长,主库删除动作最终会以 DEL 命令同步到新主库。只是在极端情况下,主库本身也堆积了大量未被清理的过期 key,切换后新主库内存压力才会变大。所以平时养成为缓存设置合理 TTL、定期扫描过期 key 的习惯,比出事后再补救重要得多。
最后再分享一个我个人的小习惯:所有写入缓存的代码都统一收口到一个 RedisUtil 方法里,方法内部强制要求传入 TTL,并在内部做随机化处理;同时每个季度会在低峰期扫一遍所有TTL = -1的 key,和业务方确认哪些该清理、哪些该补过期时间。这套流程不复杂,但确实帮我躲过了好几次内存增长和缓存雪崩的事故。Redis 键过期策略看起来就是几条命令的事,真正落到线上,每一个细节都值得反复打磨。