ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis过期时间全解:从TTL原理到Spring Boot批量操作与避坑

Redis过期时间全解:从TTL原理到Spring Boot批量操作与避坑 写Redis的人大多遇到过这样一个尴尬场景明明给key设置了过期时间第二天一查数据全没了又或者反过来明明设置的是1小时过期结果重启服务后key还活着过了好久才消失。还有个大半夜被叫起来排查的经典问题——缓存穿透一堆不存在的key直接在Redis里堆积成山。这些问题背后的核心就一个Redis的过期时间到底是怎么生效的以及你在代码里写的那行expire调用是否真的在按你预期的语义执行。这篇文章不打算从安装配置开始讲直接聚焦redis设置过期时间这个主题把底层机制、Java/Spring Boot场景下的实操写法、批量场景下的最佳实践、以及踩坑经历统统过一遍。适合正在用Spring Data Redis做缓存、被TTL问题搞过、或者准备做分布式锁和缓存治理的开发者参考。1. 过期时间的底层原理两种删除策略和你必须知道的真相很多人在面试背八股文时都背过Redis的过期删除策略是定期删除惰性删除但真正出问题时能把这条原理和线上现象对应起来的人不多。这一节先说清楚机制后面所有坑的解释都依赖这部分。1.1 惰性删除读的时候才检查你给key设置了EXPIRERedis不会为这个key启动一个定时器去盯着它。Redis是单线程模型如果每个过期key都开一个Timer那内存和CPU开销直接爆炸。所以走的是另一条路当你尝试读取某个key时Redis会先检查它的过期时间如果已经过期直接删除这个key并返回nil这在源码层面叫expireIfNeeded。这个机制的直白推论是一个已经过期的key如果没有任何请求访问它它就会一直占着内存。比如你往Redis里塞了一百万个5分钟过期的key5分钟后这一百万个key并不会消失它们只是生物学上死亡、物理上还躺在内存里。只有等某个请求来读其中一个key时它才会被真正清理掉。所以在高写入低读取的场景里你会发现used_memory一直居高不下这不是内存泄漏是惰性删除的正常表现。1.2 定期删除时间换空间的补偿为了不让过期的key永远赖在内存里Redis还启用了一个周期性任务默认每100ms执行一次。它会从设置了过期时间的key集合中随机抽取一批默认20个删除其中已经过期的key然后看这批key里过期key的比例如果超过25%就再抽一批继续删直到比例降下来或者本次耗时超过25ms才停止。注意随机这个词。Redis的定期删除不是全表扫描而是从expires字典里随机取样。这个设计保证了每次操作的开销可控但也注定了它是一个概率性的清理过程——如果过期的key比例不高Redis可能压根扫不到你那个已经过期的key它会继续在内存里躺着等待主人来读它。1.3 内存淘汰策略是最后一层防线当内存达到maxmemory上限时Redis会依据maxmemory-policy触发淘汰。其中volatile-lru、volatile-ttl、volatile-random这几个策略只作用于设置了过期时间的key而allkeys-lru则不分青红皂白全表淘汰。这里有个很容易被忽略的坑如果一个key设置了过期时间但还没到期理论上它不应该被主动删除但在内存压力下LRU策略可能因为它很少被读而把它提前淘汰掉。所以如果你的缓存业务强依赖设置了多少秒就一定存活多少秒那就得注意这个前提条件内存不能触顶。提示判定一个key是否过期Redis存的是绝对时间戳毫秒级而不是剩余秒数。这解释了为什么修改系统时间会影响Redis的过期判断后面讲坑的时候细说。2. Spring Boot中通过RedisTemplate设置过期时间从入门到规范化大多数Java开发者不会直接写Jedis命令而是用Spring Data Redis的RedisTemplate。这一节讲最常用的写法以及每一行代码背后的真实语义。2.1 字符串值的标准写法set带上超时参数先看最推荐的写法redisTemplate.opsForValue().set(user:1001, userJson, 30, TimeUnit.MINUTES);这等价于Redis命令SETEX user:1001 1800 userJson。注意一个关键点这个操作是原子的key和过期时间同时写入。这个写法比先set再expire少了两次网络往返也不会出现中间态——如果先set成功但expire失败这条key就变成永不过期的脏数据非常隐蔽。我在实际项目里见过大量这样的代码redisTemplate.opsForValue().set(user:1001, userJson); redisTemplate.expire(user:1001, 30, TimeUnit.MINUTES);不是说不能用但如果你有强迫症在这种写法下脑子里要时刻绷根弦第二行有没有可能在业务异常分支里被跳过缓存预热脚本里是不是丢了expire一旦出现永不过期的脏key你只能靠内存淘汰或者手动清理兜底。2.2 设置过期时间的单独操作expire和expireAt如果你的业务是key已存在只是需要延长或设置过期时间那就直接调用redisTemplate.expire(user:1001, 30, TimeUnit.MINUTES); redisTemplate.expireAt(user:1001, new Date(System.currentTimeMillis() 1800_000L));这两个方法的区别本质是命令层面的差异expireAt底层走的是EXPIREAT设置的是一个绝对时间戳expire底层走的是EXPIRERedis内部会帮你换算成绝对时间戳。这里藏了一个经验性原则用expire传相对时间可读性好但如果你的业务里有这批key必须在凌晨两点整过期这种强时间对齐需求expireAt更直接比如每日结算的缓存key。2.3 其他数据类型怎么设置过期时间过期时间是key层面的属性不是value层面的。也就是说list、hash、set、zset这些容器类型的处理方式是相同的// hash整体设置过期时间 redisTemplate.expire(cart:2001, 24, TimeUnit.HOURS); // hash中某个field没有独立的TTL不能单独设置 // 如果你确实需要field级别过期只能用过期时间戳字段由业务代码判断 redisTemplate.opsForHash().put(cart:2001, sku_id, 3);有个高频反例有人问为什么我用opsForHash().put(hashKey, field, value)设置的field用expire(hashKey)没生效。不是没生效而是你把作用对象搞错了——expire管的是整个hash keyfield本身没有TTL。如果你真的需要field粒度的过期两个方案一是每个field拆成独立的string key用冒号分隔形式维护二是把过期时间作为value的一部分存在hash里查询时用业务代码判断。这种方式在秒杀购物车之类的场景很常见。2.4 序列化方式对过期时间的影响坑在key上RedisTemplate的key和value默认使用JdkSerializationRedisSerializer这会导致key在Redis里变成类似\xac\xed\x00\x05t\x00\x05user:1的乱码样式。这不是过期时间的问题但大概率会影响你排查过期时间——你在redis-cli里TTL user:1001查一个根本不存在乱码前缀的key返回-2你还以为是过期了其实压根没set对key。我建议项目里显式指定StringRedisSerializer作为key的序列化器value可以用Jackson或自定义的GenericJackson2JsonRedisSerializer。这样才能做到redis-cli里的裸key和代码里的key一一对应排查过期时间问题时一目了然。3. 批量处理循环里千万别直接expire用scan加pipeline真正让Redis设置过期时间这个问题从入门变成进阶的分水岭场景是批量为大量key设置过期时间。比如上线了一个新功能需要给以前写入的所有业务key统一补一个过期时间或者缓存治理时需要按前缀把所有永不过期的key批量改成7天过期。几十个key无所谓几百万个key就是另一回事了。3.1 为什么不能用KEYS命令网上很多方案开头是KEYS user:*然后循环expire。KEYS会阻塞Redis主线程在生产环境里几百万key的场景下这个命令能让你Redis实例的QPS直接掉到地板所有业务请求排队等待。这在低峰期小实例上可能看不太出来但在高QPS生产环境绝对会被监控告警砸脸。正确姿势是用SCAN命令配合游标迭代它在每次迭代时只返回一小部分key默认10条左右不会阻塞主线程可以安全地在生产环境执行。3.2 一次性给百万key续期的pipeline写法我实现过一个批量重置过期时间的工具类核心逻辑是这样的先用SCAN匹配业务前缀把符合条件的key捞出来然后通过pipeline批量执行EXPIRE。public void batchExpire(String prefix, long timeout, TimeUnit timeUnit) { ScanOptions options ScanOptions.scanOptions() .match(prefix *) .count(500) // 每次scan返回的条数不是匹配总数Space for debug .build(); try (Cursorbyte[] cursor redisTemplate.scan(options)) { // 分段收集避免一次性加载太多key ListString keys new ArrayList(500); while (cursor.hasNext()) { keys.add(new String(cursor.next(), StandardCharsets.UTF_8)); if (keys.size() 500) { flushExpire(keys, timeout, timeUnit); keys.clear(); } } if (!keys.isEmpty()) { flushExpire(keys, timeout, timeUnit); } } } private void flushExpire(ListString keys, long timeout, TimeUnit timeUnit) { redisTemplate.executePipelined((RedisCallbackObject) connection - { for (String key : keys) { connection.keyCommands().expire( key.getBytes(StandardCharsets.UTF_8), timeUnit.toSeconds(timeout) ); } return null; }); }关于count参数要澄清一下它并不是返回500个匹配key,只是让Redis单次迭代扫描哈希表槽位的数量参考值是给服务端一个尽量的提示不是精确限制。实测下来count设500和设5000在命中率上有差别但对阻塞时间影响不大建议设500~1000即可批量提交时每批500个key是比较均衡的选择。为什么用pipeline而不是单个循环expire单key操作虽然每个都很快但网络往返时间RTT在大批量场景下是杀手100万个key就是100万次网络交互用了pipeline之后一条连接一次发送耗时能降一个数量级以上。切记pipeline执行期间连接会被独占所以不要在一个共享连接的RedisTemplate上开着事务跑大批量会卡住其他线程的请求。3.3 大批量删除过期key的另一种思路不上Redis改上代理层如果这批key的量实在太大千万级而且分布在Redis Cluster的多个节点上pipeline也会面临跨节点的问题scan游标是按单节点维护的cluster模式下需要逐个节点scan。这时候可以把任务下发成多个分片任务或者干脆利用unlink命令把key删除让清理过程异步化。但删除和设置过期时间是两条路线如果目的是让数据不要永久占内存在写入源头改TTL永远是第一优先级的事后批量清理是补救手段成本高能少用就少用。4. 拷问细节TTL命令的返回值、refresh与续期逻辑踩过坑之后你会发现设置过期时间这件事远不止一条命令那么简单。这一节把命令层的细节和分布式锁场景下的核心问题串起来讲。4.1 TTL返回值的语义表排查问题时TTL命令返回的数字到底代表什么很多工作了三五年的人都得愣一下。整理成表返回值含义你该怎么办大于0的整数剩余存活秒数正常按需决定是否续期-1key存在但没有设置过期时间永不过期这就是脏key信号-2key不存在或已经过期并被清理区分是没写进去还是已过期需要结合exists判断注意-2这个值坑了不少人当key过期后还没有被惰性/定期清理时TTL的返回和key不存在是完全一样的-2你是分不清它已经过期了还是压根不存在的。如果业务上需要区分这两种情况只能结合场景推断或者干脆在value里塞一个写入时间戳字段从逻辑上判断。4.2 给分布式锁续期可重入锁、看门狗和你手写的续期循环聊到Redis分布式锁过期时间就是核心参数了而且它跟普通缓存key的语义还不一样。普通缓存key过期无所谓锁key提前过期就麻烦了持锁线程还在执行临界区锁已经没了另一个线程拿着新锁进来两个线程同时跑线上对账就出幺蛾子。Redisson的看门狗机制能自动续期默认leaseTime是30秒每过1/3的时间就自动续到满30秒直到业务执行完成释放锁保证了锁不过早释放。但很多人不用Redisson自己手撸分布式锁最简版本是这样Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);这里有个核心问题如果临界区执行超过了30秒锁就到期了这时候你手动续期来得及吗常见的优化是开一个续期守护线程在锁还剩1/3时间时周期续约业务结束再取消守护线程。ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { if (Boolean.TRUE.equals(redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS))) { // 续期成功继续持有锁 } else { // 锁已经不存在说明其他线程抢占了业务可能已经出问题 Thread.currentThread().interrupt(); } }, 10, 10, TimeUnit.SECONDS);这段代码是续期但它只解决了一半问题。真正的坑在于如果你的竞态是AB线程交替抢占A线程因为在临界区卡了60秒可能因为GC停顿中间的续期线程也停顿了锁释放了B线程拿走了锁。等到A恢复时它需要一种机制来发现自己已经不是锁持有者了。这个检测靠value里的requestId唯一标识来比对但expire命令本身没法做到仅在value匹配时续期。所以最终你会回到两个正路一是上Redisson这类的成熟客户端用看门狗解决续期二是把临界区缩短或者拆分让30秒成为永远碰不到的边界。我个人的建议是别去踩自研分布式锁续期的坑能把Redisson用起来就别自己造轮子。你可能会说Redisson太重了但你自研的续期逻辑出问题时凌晨值班的你不一定比Redisson的成熟实现更可靠。4.3 事务里设置过期时间和业务逻辑一起回滚吗Spring的Transactional只管理数据库事务Redis的操作默认不参与数据库事务回滚。这意味着你的代码可能是这样的Transactional public void updateUser(User user) { userMapper.updateById(user); redisTemplate.opsForValue().set(user: user.getId(), userJson, 30, TimeUnit.MINUTES); }如果数据库更新成功、Redis设置失败比如网络抖动会怎样数据库回滚了Redis那里可能没有写入或者写入了旧数据。如果你在这里用的是先更新DB再删缓存方案经典Cache Aside那就不存在这个坑因为删失败的影响有限下一次读会重新加载。所以不要试图在业务事务里用Redis的multi/exec做原子性保证过期时间的设置时机应该和缓存更新策略一并设计而不是依赖事务生效。5. 过期与持久化的纠缠重启后key还在不在主从切换会不会时光倒流顺着上面大半夜排查那个话题往下走还有一批极其隐蔽的坑集中在Redis持久化和主从复制对过期时间的影响上。这些问题不踩一次光靠原理很难体会。5.1 RDB或AOF持久化会影响过期时间吗先看RDB快照当Redis生成RDB文件时过期的key不会被写入文件已经过期但还没被清理的key待写入RDB时会进行检查过期的直接跳过。所以从RDB恢复过期的key一般都不会复活。再看AOF重写重写时Redis会在内存中遍历所有key已经过期的就不写进新的AOF文件。同时AOF文件里如果存在SETEX、EXPIRE这类带过期时间的命令恢复时会按语义重新执行。那重启后key还活着的错觉是怎么来的最常见的原因是你设置的过期时间是从命令执行时刻算起的但恢复时刻已经是第二天redis进程恢复后惰性删除还没碰到这个key它虽然理论上已过期但内存里还在用TTL一看发现是负数或一个很小的值就误以为持久化把过期时间吃了。其实它已经是僵尸key等你读它Redis会立刻判断过期并删除。所以排查这个问题时别只看key在不在要看TTL是正还是负。5.2 主从切换后过期时间会怎样Redis主从复制对过期key的处理也有自己的逻辑。从库自己不检测key是否过期它的一切过期行为跟着主库走主库惰性删除时会向从库发送DEL命令主库定期删除时也会同步DEL。这条设计避免主从之间因时钟偏差出现同一个key在不同的节点过期时间不一致。但如果你开启了主从切换比如Sentinel或Cluster的failover后果是你的业务从原来连主库变成了连从库。从库在没有读到主库的DEL同步时可能还保留着这个已过期的key而它的TLS判断是通过逻辑时钟来计算的主库和从库的系统时钟有偏差时从库的过期判断就可能有出入。所以如果业务强依赖key到点必须立即不可见那么在主从切换的瞬间有一小段时间窗口内从库可能还能读到过期的key。这个问题能通过sentinel配置min-replicas-to-write之类的手段规避部分风险但更彻底的做法是别让缓存key承担强一致性职责宁可多查一次数据库也别把一个需要精确日切的缓存key放在主从切换频繁的环境里。5.3 时钟回拨和过期时间的长短如何权衡上面提到Redis用绝对时间戳判断过期那么如果Redis所在服务器的系统时间被手动往前调了几分钟所有基于当前时间计算的过期时间都会跟着延后。换句话说过期时间不是一个独立于系统时钟的可靠概念它依赖服务器时间的连续性。这在云环境里容易踩因为NTP同步偏移或者手动校准都可能造成小范围回拨。那是不是说不能依赖过期时间也不是。普通业务场景下秒级甚至毫秒级的偏差完全可以接受。真正的教训是如果某个缓存key承载的是一天内必须失效的成本结算数据、活动开关、秒杀令牌这种时间即正确性的数据设计时要额外增加业务侧的时间判断比如在value里冗余存一个业务过期时间戳读到时对比当前时间决定是否采纳。把Redis TTL当作物理下限把业务时间戳当作逻辑上限两者互相兜底。6. 缓存治理视角下的过期时间气质穿透、击穿、雪崩与空值缓存聊到这里其实设置过期时间已经不只是怎么调用一个命令的问题了它直接关系到整个缓存体系的稳定性和成本。最后这节从缓存治理的视角来评估你的过期时间策略是否合格。6.1 缓存雪崩为什么大家偏偏在同一秒过期一个常见的雪崩事故是一批key设置的过期时间完全相同比如缓存预热时统一设了明天零点过期结果零点一到大量缓存同时失效流量瞬间穿透到数据库数据库连接被打爆。改善策略有三个按优先级排序设置过期时间时加随机扰动比如基准时间上叠加一个RandomUtils.nextInt(0, 300)秒的偏移。让缓存key过期时间跟随业务访问规律比如热点数据的TTL适当延长冷数据短TTL。高可用兜底——即使缓存全部失效也要有熔断限流保护数据库。第一点是最廉价的救火方案。顺手把redisTemplate封装一个工具方法public void setWithRandomTtl(String key, Object value, long baseTtl, TimeUnit unit, long randomRangeSeconds) { long random ThreadLocalRandom.current().nextLong(randomRangeSeconds 1); Duration ttl Duration.ofSeconds(unit.toSeconds(baseTtl) random); redisTemplate.opsForValue().set(key, value, ttl.toMillis(), TimeUnit.MILLISECONDS); }6.2 缓存穿透和空值缓存给查无此key也设上过期时间缓存穿透的本质是请求了一个Redis里和数据库里都不存在的数据Redis获取不到数据也获取不到下次请求仍然打数据库恶意攻击时数据库可能被打崩。通用的方案是把空结果也缓存起来并设置一个短一点的TTL比如60秒这样同一批穿透请求在一段时间内只打一次数据库ValueOperationsString, Object ops redisTemplate.opsForValue(); Object cached ops.get(cacheKey); if (cached ! null) { return cached; } // 模拟数据库查询 Object dbResult queryFromDb(key); if (dbResult null) { // 缓存空对象设置短过期时间 ops.set(cacheKey, , 60, TimeUnit.SECONDS); } else { ops.set(cacheKey, dbResult, 30, TimeUnit.MINUTES); }这里有个细节空值缓存如果TTL设置过长会导致大量空壳key占用内存设置过短就起不到拦截效果。经验值是60秒左右既有初步拦截能力又不会囤积太多无效key。另外为了防止Redis被大量不存在的key塞满配合前面讲的批量过期清理思想定期跑一次scan把空壳key批量设一个统一的短TTL也是可行的。6.3 缓存击穿热点key能否永久不过期热点Key过期的一瞬间大量请求同时打到数据库是缓存击穿的经典画像。相比雪崩是大规模key同时失效击穿是单个高频key失效引起的并发穿透。处理方案里有一种和过期时间背道而驰干脆不给这个热点key设置过期时间然后在value里存一个逻辑过期字段后台异步更新。// 缓存结构包含业务数据和逻辑过期时间 record HotCacheItem(Object value, LocalDateTime expireAt) {} public Object getHot(String key) { HotCacheItem item (HotCacheItem) redisTemplate.opsForValue().get(key); if (item null) return queryDbAndReload(key); if (item.expireAt().isBefore(LocalDateTime.now())) { // 已到逻辑过期时间异步刷新缓存当前线程返回旧值 asyncRefresh(key); return item.value(); } return item.value(); }这种逻辑过期方案能避免热点key在物理过期瞬间产生的并发击穿同时把真正的过期时间主动权掌握在业务代码里。代价是热点key占用的内存永不清零如果key的内容特别大需要权衡。放进value里的expireAt字段其实就是把Redis的过期策略搬了一层到业务上等于是自己实现TTL在一些极端场景下比Redis原生的TTL更好控制——因为你可以决定读旧值还是读新值而不是让缓存层一刀切返回空。7. 踩坑实录三个真实生产事故复盘理论说了一大堆最后用三个真实发生过的事故来收尾。这些案例的细节在周会上复盘过很多次能帮你省掉几宿没觉睡的代价。7.1 事故一过期时间被超时时间覆盖我们有个订单详情接口缓存逻辑是这样写的redisTemplate.opsForValue().set( orderKey, orderJson, 10, TimeUnit.MINUTES );后来产品要求订单详情页数据至少缓存30分钟开发和我说把10改成30就行他就真只改了数字。问题在于set(key, value, timeout, timeUnit)这个方法如果key已存在是会覆盖旧的value并重置过期时间的这没问题。但下一个版本里有人在这个方法之前加了一段synchronized逻辑把判断缓存是否存在和设置缓存拆成两行中间有并发穿透时后面的请求会覆盖前面请求刚写入的缓存导致数据不一致。这个事故的启示是过期时间的设置必须和key的写入保持原子性。如果你发现你的代码是先get判断再set重置过期时间那它天然就有并发漏洞。用setIfAbsent或者set一步到位别自己拆组件。7.2 事故二Redis服务器系统时间跳变全部缓存集体提前过期有一年我们线上做运维演练有人误操作把Redis服务器时间往后调了一天。次日凌晨所有固定过期时间的缓存key在业务上看起来全部失效了缓存命中率断崖式下跌数据库压力飙升。排查时看了RDB文件正常、内存没满、慢日志没有异常最后是监控上发现Redis服务器CPU时间片异常才追到系统时间问题。这台Redis上挂了300多个业务key的缓存清一色用的EXPIRE全部受影响。这个教训让我们在后续的缓存治理规范里加了一条硬性要求缓存key必须把TTL上限控制在24小时以内同时业务侧对重要缓存的读取做逻辑过期校验不能完全信任Redis的TTL。7.3 事故三集群模式下批量设置过期时间把网络打满给一批历史数据批量设置7天过期时间最初方案是循环单条expire用JMeter压到可能触发了集群节点的CPU毛刺和网络小包风暴。排查后先发现Redis节点CPU有一定升高网络出方向inbound包量很大然后再看应用端日志发现线程池队列积压。换成scan加pipeline方案后整体耗时从半小时降到两分钟节点CPU平稳。那次的结论是批量设置的性能瓶颈几乎都在网络和线程模型上Redis本身的expire命令极快但架不住海量小包把带宽打满。pipeline虽然好用但每次pipeline也确实会暂时占用连接大批量执行时建议和业务高峰错峰进行别选大促前10分钟跑批量任务。个人经验也是这句Redis的过期时间看着简单但当你把它放在真实业务里它就和并发、持久化、时钟、网络、缓存治理全搅在一起了。最好的实践是先把写入key时带着过期时间养成肌肉记忆再把批量操作加上pipeline和scan最后用业务侧的逻辑时间戳给关键数据兜底。这套组合拳打下来过期时间相关的坑大概率你再也踩不到了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进