
很多项目上线前根本没有关注过 Redis 键过期策略直到缓存同一时刻失效数据库被压垮才慌慌张张去查日志。我经历过一次这样的事故之后才把键过期策略从“会用 EXPIRE 命令”这个层面彻底拉通到原理层。这篇文章不绕弯子直接讲清楚 Redis 键过期策略是什么、底层怎么存储、惰性删除和定期删除各自干了什么、主从复制和持久化对过期键怎么处理、线上业务到底怎么设计过期时间以及面试官最爱问的那几个坑。无论你是刚接触 Redis 的开发者还是正在准备 Redis 面试题或者已经被线上缓存问题折磨过这篇都值得花十分钟完整看一下。1. 键过期策略先搞明白它到底解决了什么问题1.1 被大缓存追着跑的三个场景先说一个我印象特别深的例子。某个凌晨系统里一大批用户维度的缓存全部设置了同一个过期时间时间点一到Redis 里面的 key 像约好了一样集体消失几万请求瞬间打到 MySQL 上数据库连接数直接被打满接口大面积超时。这就是典型的缓存雪崩大量 key 在同一时刻过期请求绕过了缓存层直扑底层存储。键过期策略解决的第一个问题就是防止缓存无限膨胀。如果给 Redis 里的数据设置过期时间那么长期没有被访问的数据就能在后台被清理掉内存不会像滚雪球一样越滚越大。尤其是在缓存治理工作中“给每个 key 设置合理的 TTL”几乎是所有团队的基本规范没有 TTL 的 key 一旦大量堆积再高的内存也撑不住。第二个问题是数据一致性。缓存不是数据库它保存的是数据的副本副本应该有一个“保质期”。比如商品价格、库存数量如果改了数据库但缓存永远不失效用户看到的就一直是很久以前的数据。通过设置过期时间可以让缓存数据在一段时间后自动失效下次读取时再回源到数据库拉取新值这是最朴素的缓存一致性方案。第三个场景是很多业务功能本身就是依赖过期时间实现的典型的有验证码、登录 Token、限流计数、分布式锁。这些场景都是把“在一段时间内有效”直接变成需求没有键过期策略这类逻辑就只能靠人工删 key 或者定时任务扫表效率和准确性都会差很多。1.2 过期时间和 TTLRedis 真正意义上的“保质期”明白了为什么要过期再看 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 300PEXPIRE key milliseconds毫秒设置 key 在 N 毫秒后过期PEXPIRE user:001 300000EXPIREAT key timestamp秒级时间戳设置 key 在某个绝对时间点过期EXPIREAT user:001 1780000000PEXPIREAT key timestamp毫秒级时间戳精确设置绝对过期时间PEXPIREAT user:001 1780000000123SETEX key seconds value秒设置字符串值并同时设置过期时间SETEX sms:code:137 60 123456SET key value EX seconds秒SET 命令的扩展原子性设置值与 TTLSET lock:order:1 1 EX 10TTL key秒查看剩余过期秒数TTL user:001PTTL key毫秒查看剩余过期毫秒数PTTL user:001PERSIST 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 根本没有我给它登记过期时间没有意义”所以直接忽略。写代码时不要忽略这个返回值最好用这个返回值做一层防御。第四过期时间设置成负数或者 0Redis 会立刻把 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 __keyevent0__: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 键过期策略看起来就是几条命令的事真正落到线上每一个细节都值得反复打磨。