
Redis 里有个很有意思的现象明明 Key 设置了过期时间数据却还在等你想读它的时候它又“恰好”消失了。这套“懒散又勤快”的过期机制很多时候决定了缓存系统的稳定性。做 Redis 开发这几年我发现真正把过期策略讲清楚的文章不多大部分只是罗列命令。这篇文章想从底层结构开始把过期时间怎么存、怎么删、主从复制下怎么处理、实战中怎么避坑一次讲透。先说个实际场景。我之前维护过一个用户会话缓存项目Key 过期时间是 30 分钟。上线初期一切正常某天业务反馈“用户明明刚登录过一会儿就掉线”。排查半天发现是后台批量任务把一批 Key 的过期时间重置了导致会话提前失效。后来又碰上另一个问题大量 Key 在同一秒过期Redis 瞬间卡了一下。这两次事故让我意识到不理解过期策略的原理出了问题连排查方向都找不到。1. 过期机制设计的底层逻辑一张“隐藏表”管所有过期时间1.1 过期信息到底存在哪dict expires 双字典结构Redis 的每个数据库实例默认 16 个库中的一个在底层维护两个核心字典一个是dict存的是真正的键值对数据另一个是expires存的是 Key 与其过期时间的映射关系。你可以把这两个字典理解为一张“数据主表”和一张“过期时间附表”。想确认某个 Key 是否设置了过期时间Redis 不会遍历整个主表去比对而是直接去 expires 字典里查。如果 Key 在 expires 中存在说明它“有寿命”不存在则说明它是永久 Key。这个设计的好处非常明显判断一个 Key 是否过期的代价是 O(1)无论库里有多少个 Key查询效率都一样。注意一个关键细节expires 里存的过期时间是“绝对时间戳”不是“剩余秒数”。执行EXPIRE key 60时Redis 会拿当前时间加上 60得到一个绝对的 Unix 毫秒时间戳存进 expires 字典。这样做的好处是删除逻辑不需要做减法运算每次判断过期时直接比对“当前时间是否已经大于这个绝对时间戳”就行。所以你会看到PERSIST命令能取消过期时间本质就是把 Key 从 expires 字典里移除。同理SET一个新值如果对同一个 Key 操作旧的过期时间会被清除因为 SET 会重新初始化整个 Key 的状态。业务上如果既要更新值又要保留过期时间必须用 Lua 脚本或者先查 TTL 再重新设置这是很多人踩过的坑。1.2 为什么要把过期时间单独存而不是和 value 混在一起很多初学者会问为什么不在每个 value 里直接带一个 expire 字段这就涉及到 Redis 内存效率和数据类型通用性之间的矛盾。Redis 的 value 类型五花八门——字符串、哈希、列表、集合、有序集合每种类型的底层编码方式都不同。如果让每种编码结构都去维护一个“过期时间”字段Redis 的代码复杂度会爆炸而且对那些不需要过期能力的 Key 来说这就是纯浪费内存。所以 Redis 选择“需要过期能力的 Key 才额外占用 expires 字典里的一项”用很小的代价换取了极大的灵活性。同时将过期时间独立存储还带来一个额外好处RDB 持久化和 AOF 重写时只需要单独扫描 expires 字典就可以处理“哪些 Key 已过期”的判断不需要逐项解析 value 内容。这个设计其实是典型的“空间换时间 关注点分离”的工程思路值得在日常开发中借鉴——主表管业务数据附表管生命周期互不干扰。2. 设置过期时间的完整命令武器库命令细节与精度讲究2.1 多个设置过期时间的命令各自的使用场景Redis 提供了好几条命令设置过期时间表面功能类似但细节差异不小。我直接整理成一张对比表平时查起来方便命令时间精度适用场景注意事项EXPIRE key seconds秒最常见的设置方式只设置过期时间不改变 ValuePEXPIRE key milliseconds毫秒需要更高精度控制时与 EXPIRE 逻辑完全一致只是单位不同SETEX key seconds value秒设置字符串值的同时附带过期时间一步完成原子操作省一次网络往返PSETEX key milliseconds value毫秒SETEX 的毫秒版场景相对少SET key value EX seconds秒最推荐的字符串设置 过期方式配合 NX/XX 选项可实现“不存在才设值且带过期时间”EXPIREAT key unix时间戳秒精度指定绝对时间点过期适合某个活动截止时间点失效的场景PEXPIREAT key unix毫秒时间戳毫秒精度绝对时间点 高精度一般是内部计费或限流类业务我实际项目里最常用的组合是SET key value EX seconds NX这个命令能同时实现“加锁 设置过期时间 只允许新 Key 写入”三个功能是手写分布式锁的基础。很多人在这里容易犯一个经典错误先SETNX再加EXPIRE两步操作中间一旦进程崩溃锁就变成永不过期的死锁。Redis 官方其实很早就意识到了这点所以在较新版本里SET命令的EX和NX选项已经能组合使用一条命令搞定。还要提一下SETRANGE、GETSET这类命令。它们会改写 Key 的值但不会清除过期时间因为它们本质是“原地修改”。这跟SET整体覆盖的行为不一样。曾经有同事用APPEND去追加内容结果发现 Key 的过期时间还在一脸困惑。其实这类命令的语义就是“只改值不动生命周期”了解这个特性后反而可以利用它批量续期。2.2 查看剩余生命周期TTL 为什么给你返回 -1 和 -2用TTL key能查看一个 Key 的剩余存活秒数PTTL key则返回毫秒精度结果。返回值分为几种情况很多人只记住了“正数就是剩余时间”却忽略了负数的含义正数剩余存活秒数或毫秒取决于命令。-1Key 存在但没设置过期时间属于“永久 Key”。-2Key 不存在。注意这里包含两种情况一是 Key 从未存在二是 Key 曾经存在但已过期被删除。实际开发里TTL返回 -2 有时会引发误解。比如一个缓存需要判断“缓存是否还存在”你用EXISTS的结果和TTL的结果可能不一致因为TTL对“不存在”返回的是 -2 而不是 0。如果你直接拿TTL的结果去判断业务逻辑建议把 -2 当作“Key 不存在”处理。这个细节在写监控脚本、清理任务时格外重要不然会统计出莫名其妙的“负数 Key 数量”。还有个小技巧EXPIRE命令执行成功会返回 1返回 0 表示 Key 不存在或设置失败。很多人在使用EXPIRE后没有检查返回值结果 Key 已经过期删除了命令静默失败业务逻辑却没感知。养成检查返回值的习惯能帮你尽早发现问题。2.3 时间单位背后的精度坑秒和毫秒别混用Redis 的过期时间精度内部统一用毫秒但对外提供了秒和毫秒两套命令。这里就牵扯出一个细节EXPIRE传入的是秒但 Redis 内部换算成毫秒时其实会有精度截断问题。举例来说你执行PEXPIRE key 1500紧接着用TTL key查看大概率会看到 1因为剩余毫秒可能已不足 1000TTL 返回整数秒时向下取整。如果你是拿 TTL 去做续期或判断逻辑一定要意识到“整数秒”和“真实剩余时间”之间存在误差最好用PTTL。这个坑在分布式锁里尤其致命。经典的 Redis 分布式锁实现会先SET key value EX 30 NX然后业务处理最后用 Lua 脚本判断 value 是否是自己再DEL。如果业务处理时间稍微超过锁的过期时间锁就已经自动释放了再删就可能误删别人的锁。所以很多实现会优化成“过期时间给足余量”或者用看门狗机制续期。这些问题的根源都是对过期时间精度和生命周期切换瞬间的理解不够深刻。3. 过期键的删除策略惰性删除与定期删除如何配合3.1 为什么 Redis 不采用“定时器立刻删”的方案这是过期策略里最核心的“为什么”。很多人第一反应是既然能设置过期时间那就用定时器时间一到立刻删掉不就行了但 Redis 没有这么做原因很简单为每个 Key 维护一个定时器内存和 CPU 开销都受不了。假设 Redis 内存里有几百万个带过期时间的 Key如果每个 Key 对应一个定时器Redis 的事件循环会被海量定时事件淹没。定时器本身就是一种资源创建、维护、触发都有成本。而且绝大多数 Key 的过期时间都在秒级甚至分钟级以上一个长时间不触发的定时器占用的内存是纯浪费。即使退一步用某种“时间轮”之类的数据结构来优化定时器调度依然绕不过一个根本问题Redis 是单线程模型执行删除操作本身会占用主线程时间。如果同一时刻有大量 Key 到期定时器触发后的集中删除会导致主线程卡顿这正是高并发场景下最忌讳的。所以 Redis 的设计哲学是“能不主动删就不主动删能批量蹭着删就批量蹭着删”。3.2 惰性删除平时懒用到才动手惰性删除是 Redis 最基础的过期清理手段。每次客户端访问一个 Key 时Redis 会先检查这个 Key 是否在 expires 字典里如果在就比对当前时间是否已超过过期时间戳。超过了就先删除这个 Key然后当作“不存在”返回给客户端。这个策略的核心好处是“零成本维护”没有 Key 被访问时Redis 不会为过期清理花一分钱 CPU。但它有一个明显的副作用过期的 Key 如果不被访问就会一直残留在内存里。想象一下你往 Redis 写了几十万个只存活 5 分钟的验证码 Key之后再也没有客户端访问它们那它们会一直占着内存直到下一次定期删除或内存淘汰策略介入。所以惰性删除必须和定期删除配合否则内存迟早会被“僵尸 Key”塞满。这就像家里的大扫除平时每件东西用到才擦一擦但总得定期全屋清洁否则灰尘就堆起来了。3.3 定期删除后台兜底的“巡检员”定期删除解决了惰性删除“懒”的问题。Redis 会以一定的频率默认每秒 10 次由配置项hz决定触发一次后台任务对 expires 字典做随机抽样检查。具体逻辑是每次从 expires 字典里随机取一批 Key默认 20 个检查其中已经过期的比例。如果过期的比例超过 25%说明“过期密集区”存在就继续循环抽取删除如果比例较低说明过期 Key 已经不多了就提前结束本轮扫描。这个机制有点像“基于采样率的自适应清扫”既能及时清理过期 Key又不会在一个时间点消耗大量主线程时间。这里有几个关键参数需要重点关注hz默认 10表示每秒执行多少次定期任务。提高 hz 会提升过期清理的及时性但也会增加 CPU 消耗。对延迟极度敏感的业务可以适当降低 hz 到 5但要接受过期 Key 残留更长。active-expire-effort这是 Redis 7.0 引入的配置取值范围 1-10默认 1。调高它会让定期删除更“努力”地扫描极端情况下可能增加主线程负担。如果你的业务里有大量集中过期的 Key可以适当往上调但不要盲目调高。定期删除之所以是“随机采样”而不是“全量扫描”是因为全量扫描几百万个 Key 要遍历整个 expires 字典这个操作太昂贵。用采样加比例反馈的方式基本能在“清理速度”和“CPU 开销”之间找到一个相对平衡的点。3.4 删除策略和“内存淘汰”是一回事吗很多人搞混这里必须把两个概念严格区分开因为它们在面试和实战中高频出现过期删除是“针对设置了过期时间且已到期的 Key 的主动清理”内存淘汰是“Redis 内存到达 maxmemory 上限时为了腾出空间而驱逐 Key”。打个比方过期删除是“食品保质期到了必须下架”内存淘汰是“冰箱装不下了得扔掉一些东西腾位置”。前者是时间驱动后者是容量驱动。所以即使一个 Key 没有设置过期时间在内存淘汰策略下也可能被驱逐比如allkeys-lru策略会优先驱逐最近最少使用的 Key完全不看它有没有过期时间。内存淘汰策略有noeviction、allkeys-lru、volatile-lru、allkeys-lfu、volatile-ttl等。其中volatile-*系列策略只驱逐设置了过期时间的 Keyallkeys-*系列则不分青红皂白。这又引出一个常见误区设置了过期时间不代表“到期一定被立刻删除”而是在“某个时间点之后它会被标记为不可用并等待惰性删除或定期删除来物理清除”到了内存紧张的时候Redis 则会根据淘汰策略优先挑选出合适的 Key 进行物理删除。4. 键过期在主从复制和持久化场景下怎么处理4.1 主从复制过期删除是怎么“传话”给从库的Redis 主从模式下过期键处理显得更微妙。主库负责执行过期删除并在删除时向所有从库广播一条DEL命令让从库也把对应 Key 删掉。这里的关键点是从库的过期 Key 在未收到主库的DEL之前逻辑上是“可见”的。为什么不是从库自己判断时间到了就删因为主从复制环境下从库可能有一定延迟如果从库按自己的时钟提前删了主库却还没删客户端读写分离时就会从从库读到“缺失的数据”造成数据不一致。所以从库放弃自主判断一切以主库的指令为准。这是 Redis 保证数据一致性的重要设计。这就带来一个实际问题主库网络抖动或复制积压缓冲区溢出时从库可能长时间收不到 DEL 命令导致已经“过期”的 Key 还在从库上可读。对读多写少的业务如果对数据强一致有要求不能只依赖从库读过期数据必要时得在业务层做兜底校验。4.2 AOF 和 RDB 持久化里过期键是怎么呈现的持久化层面对过期键的处理也很讲究值得单独记忆。RDB 持久化生成 RDB 快照时Redis 会把“已过期的 Key”直接过滤掉不写入文件加载 RDB 时对写入的过期 Key 会重新检查过期时间如果已过期就丢弃。这里有个容易忽略的坑用SAVE或BGSAVE生成快照时如果某 Key 在快照生成过程中过期它是否会被写入 RDB取决于 Redis 在扫描时的判断节点。官方文档的说法是“RDB 文件中不会包含已过期的 Key”但因为快照生成需要时间快照生成时刻尚未过期、但加载时刻已经过期的 Key加载时会再被检查一遍然后丢弃。所以机制是双保险。AOF 持久化如果 Key 过期了但还没被删除AOF 里仍然会保留它的原始写入记录。只有当过期键被惰性删除、定期删除或淘汰策略实际删除时Redis 才会在 AOF 文件末尾追加一条DEL记录。这意味着 AOF 文件里一个 Key 可能同时存在“写入记录”和“删除记录”载入重放时以删除记录为准。AOF 重写时Redis 会扫描数据库把已过期的 Key 直接从重写后的持久化内容中剔除。所以每次 AOF 重写后文件会明显“瘦身”一部分功劳就在这里。4.3 从库过期键读取为什么会出现“过期了但还能读到”承接 4.1 的内容这里展开讲一下实际表现。在主从延迟比较大、或者从库长时间断连重连的场景下你从从库读取一个 Key可能发现它已经“过期”但还能返回数据。这个现象的根源是从库在收到主库的DEL指令前不会主动清理过期键而TTL命令在从库上也会返回真实剩余时间。于是会出现“TTL 显示 -2但 GET 能读到值”的诡异情况吗其实严格来说不会因为TTL在从库上是触发了过期检查的。更准确的描述是从库在响应GET请求时如果发现 Key 在 expires 字典中且已过期会返回空值但 Key 的物理内存并未释放因为从库等待主库的DEL指令来执行真正的删除。如果复制链路正常这个时间差很短肉眼无感如果主从断连从库堆积了大量过期但未删除的 Key内存占用会异常升高。我遇到过一次从库内存暴涨的案例最后定位就是主从断连期间过期 Key 无法同步清理导致的。5. 实战中踩过的坑与排查实录5.1 缓存雪崩、穿透、击穿与过期时间设计过期策略的经典应用场景就是应对缓存的三大灾难。缓存雪崩指大量 Key 在同一时间集中过期导致大量请求直接打到数据库。解决办法很朴素把过期时间加上一个随机增量比如基础 10 分钟再随机加 1-60 秒。这样 Key 的过期时间被打散不会形成波峰。缓存穿透指查询一个不存在的数据缓存中没有数据库也没有于是每次请求都穿透到 DB。过期策略能做的不是直接解决穿透而是给“空结果”也设置一个短暂的过期时间比如 5 分钟避免反复打库。缓存击穿指某个热点 Key 恰好在过期瞬间大量并发请求同时打到数据库。常规方案是互斥锁重建缓存或者把热点 Key 的过期时间设置得非常长通过异步任务在后台更新数据。这里我要强调一点过期时间不是越大越好。之前有个项目把用户信息缓存设置为 24 小时过期结果用户改头像后缓存没更新一直显示旧头像。后来改成“写后即失效”的策略数据变更时主动删除缓存让下一次读取重新加载。过期时间只作为兜底避免异常情况下缓存永远不更新。5.2 大 Key 过期引发的阻塞风险这是很多 Redis 实战玩家容易忽略的坑。如果一个 Key 的 value 特别大比如几 MB 的字符串或者一个包含几十万元素的哈希当它触发惰性删除时主线程必须执行“释放内存”的操作。释放超大内存块本身可能耗时几十毫秒甚至更久在极端高并发下这次卡顿会被大量积压请求放大造成雪崩式的延迟飙升。更麻烦的是定期删除如果抽到这个大 Key同样会引发主线程卡顿。Redis 4.0 开始引入了UNLINK命令它把删除操作拆成“逻辑删除 后台异步释放内存”两步先在主线程把 Key 从键空间中解除引用很快真正的大内存释放交给后台线程慢慢做。所以清理大 Key 时优先用UNLINK而不是DEL。过期键的内部删除老版本不支持异步释放Redis 6.0 之后针对“过期 Key 的异步释放”做了一些优化lazyfree-lazy-expire配置项默认开启核心思路就是把过期键的内存释放也丢给后台线程。遇到大 Key 时这两个配置一定要确认开启状态否则线上迟早会卡给你看。5.3 过期时间随机打散的一个小模板分享一个我常用的工具方法避免缓存雪崩时的集中过期。以 Java 为例核心逻辑就是给基础过期时间加一个均匀分布的随机值public static int expiresRandom(int baseSeconds, int randomRangeSeconds) { // baseSeconds 是业务设定的基础过期时间 // randomRangeSeconds 是随机波动范围建议至少 baseSeconds 的 1/10 int randomDelta ThreadLocalRandom.current() .nextInt(0, randomRangeSeconds); return baseSeconds randomDelta; } // 使用时 int expire expiresRandom(600, 120); redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);这个模板看起来简单但有两点实践经验要分享一是“随机范围”不要太窄太窄起不到打散效果二是“基础时间”要考虑数据变化的频率不能为了防雪崩而盲目拉长过期时间。两者要平衡。如果你使用的是 Go 的go-redis、Python 的redis-py道理完全一样只是语言层面的随机函数不同。关键是“加随机”这个思想而不是具体代码。5.4 排查工举与常见问题速查表最后整理一份速查表遇到问题可以对照定位现象可能原因排查方式Key 设置了过期时间但内存一直不降过期 Key 长期未访问定期删除未覆盖到INFO memory看expires总量用SCAN抽样检查TTL 返回 -1Key 是永久 Key未设置过期时间用OBJECT IDLETIME判断活跃度考虑是否有必要设过期TTL 返回 -2Key 不存在或已过期删除不要疑惑按“不存在”处理从库读到“已过期”的数据主从复制延迟或断连检查INFO replication的master_link_status大量 Key 集中过期导致卡顿过期扫描遇到“过期密集区”在业务层随机打散过期时间必要时调大hz删除大 Key 导致延迟尖刺大内存块释放阻塞主线程用UNLINK替代DEL检查lazyfree-lazy-expire是否开启AOF 文件很大过期 Key 的删除记录没有及时重写触发BGREWRITEAOF观察重写后大小排查工具方面redis-cli --bigkeys可以找出大 KeyINFO keyspace能看各个库的 Key 总数和过期 Key 总数DEBUG OBJECT key可以看到 Key 内部结构信息和空闲时间。线上禁用危险命令时要注意KEYS *会阻塞主线程换成SCAN游标遍历更安全。关于过期策略我自己的一些想法说实话Redis 的过期策略设计并不复杂但它把“单线程模型下的性能与一致性平衡”做得非常优雅。惰性删除负责“用的时候保证不再返回过期数据”定期删除负责“即使不被访问也尽量清掉残留内存”主从复制下又通过“以主库 DEL 为准”保证了副本一致性。这套机制没有任何花哨的技巧但每一层的取舍都经得起推敲。我个人的建议是不要停留在“会用 EXPIRE”这个层面。面试里问到的“过期键会立刻删除吗”“主从下过期键怎么处理”“内存淘汰和过期删除的区别”本质上都是在考察你是否理解这套机制背后“不做什么”的设计哲学。生产环境里遇到的缓存雪崩、大 Key 阻塞、主从不一致源自你对这些细节的掌控程度。动手把每个实验跑一遍把 TTL 的返回值、主从模式下过期键的表现、大 Key 删除时的延迟对比都记录下来这些经验比背再多的文档都值钱。