上周四凌晨,我们的订单服务在促销活动开始 5 分钟后彻底瘫痪 —— 不是因为流量过大,而是因为 Redis 集群突然拒绝服务。监控显示,所有缓存 key 在同一秒内失效,数据库瞬间被打满 10 万 QPS。你猜问题出在哪?正是我们自认为"优化"过的过期时间设置。
灾难现场:批量失效的缓存 Key
当时我们在处理一个电商秒杀场景,订单服务的商品库存缓存采用统一的 24 小时 TTL。这本是个常规操作,但问题出在我们为了"整洁"给所有 key 加上了相同的过期时间戳:
// 错误示范:批量设置相同过期时间 public void cacheProducts(List<Product> products) { try (Jedis jedis = jedisPool.getResource()) { Pipeline pipeline = jedis.pipelined(); long expireAt = System.currentTimeMillis() + 24 * 3600 * 1000; for (Product product : products) { String key = "product:" + product.getId(); pipeline.setex(key, 24 * 3600, serialize(product)); // 实际等效于全部在次日同一毫秒失效 } pipeline.sync(); } }当这批 key 在第 24 小时集体到期时,Redis 的主动淘汰机制瞬间触发大量内存回收操作,导致 CPU 飙升至 100%。更致命的是,雪崩效应让所有请求穿透到数据库 —— 一个看似无害的"代码整洁癖"引发了连锁故障。
过期时间的底层真相
很多人以为SETEX的过期时间是相对命令执行时间计算的,但这里藏着一个魔鬼细节:Redis 实际存储的是绝对时间戳(Unix timestamp)。当我们批量执行SETEX时:
- 所有 key 的过期时间戳高度集中
- Redis 的定期淘汰策略(activeExpireCycle)会扫描这些 key
- 在同一周期内遇到大量过期 key 时,主线程会阻塞处理淘汰逻辑
用redis-cli查看 key 的真实过期时间就能验证:
127. 0.0.1:6379> PTTL product:123 (integer) 86399957 # 剩余毫秒数(24小时 - 43毫秒)正确的随机化姿势
解决方案是给过期时间增加随机扰动。但注意,简单的Math.random()可能产生负值或破坏缓存一致性:
// 正确做法:基础过期时间 + 可控随机偏移 private int getRandomizedTtl(int baseTtlSeconds) { int jitter = ThreadLocalRandom.current().nextInt(-600, 600); // ±10分钟波动 return Math.max(60, baseTtlSeconds + jitter); // 兜底最小1分钟 } // 在原有代码中替换为: pipeline.setex(key, getRandomizedTtl(24 * 3600), serialize(product));实测显示,加入 10 分钟随机扰动后,Redis 的 CPU 使用率峰值下降 82%,数据库 QPS 波动从 10 万降至 3000 左右。
那些年我们踩过的 TTL 坑
- 批量导入陷阱
使用RESTORE或MSET时,默认不会继承过期时间。必须显式指定PTTL参数,否则会创建永久 key。
- 时钟漂移灾难
在容器化环境中,如果宿主机时钟发生跳变,Redis 的过期判断可能出现严重偏差。曾遇到过 NTP 同步导致 key 提前 2 小时失效。
- 持久化间隙
AOF 重写或 RDB 保存时,过期时间可能因持久化延迟而实际延长。对于严格时效性场景,需要配合EXPIREAT使用。
- 内存驱逐误判
当 Redis 作为 LRU 缓存使用时,volatile-ttl策略可能误杀还有很长 TTL 的 key。监控evicted_keys指标至关重要。
更聪明的过期策略
对于核心缓存,我们可以结合多级过期机制:
// 阶梯式过期 + 后台刷新 public void cacheWithRefresh(String key, Object value) { int initialTtl = getRandomizedTtl(3600); // 第一层1小时 int extendedTtl = getRandomizedTtl(24 * 3600); // 第二层24小时 try (Jedis jedis = jedisPool.getResource()) { jedis.setex(key, initialTtl, serialize(value)); } // 异步线程在过期前刷新 refreshExecutor.submit(() -> { sleep(initialTtl - 300); // 提前5分钟刷新 jedis.expire(key, extendedTtl); }); }这种模式尤其适合需要保证可用性又允许一定脏读的场景 —— 缓存永远不会集体失效,且后台刷新能保证数据最终一致性。
现在回头看我当初那个"整齐划一"的 TTL 设置,简直像在缓存系统里埋了颗定时炸弹。记住:在分布式系统里,有时候"不整齐"才是更高级的秩序。你团队里有没有类似的"过度优化"反而引发故障的案例?欢迎分享你的血泪史。