上周四凌晨,我被一条报警短信惊醒------核心服务的缓存命中率突然跌到70%以下,同时数据库负载飙升。登录服务器一看,大量请求在缓存中拿到了过期的用户资料,而Redis里这些Key的TTL明明设置的是30分钟。这场景是不是很熟悉?今天就来扒一扒这个看似简单却暗藏杀机的缓存过期问题。
现象:缓存"幽灵数据"之谜
在我们的会员系统中,用户资料缓存设计如下:
java
// 错误写法:典型的"先删后写"模式
public void updateUserProfile(long userId, UserProfile newProfile) {
// 先删缓存
redisTemplate.delete("user:" + userId);
// 再写数据库(耗时操作)
userDao.updateProfile(userId, newProfile);
// 理论上缓存失效后下次查询会回种
}
表面看逻辑没问题,但压测时发现:当更新操作密集时,约5%的请求会读到旧数据,尽管Redis中的Key确实设置了30分钟过期时间。你可能会问:"缓存不是删了吗?下次查询自然会回填新数据啊?"
根因:缓存失效的"时间差陷阱"
问题出在缓存删除到数据库更新完成这个时间窗口。在高并发场景下:
- 线程A删除缓存
- 线程B在A更新完DB前查询缓存,发现miss后回查数据库(此时读到的是旧数据)
- 线程B将旧数据写入缓存
- 线程A完成DB更新
- 结果*:缓存里停留的是脏数据,而30分钟的TTL会让这个错误持续半小时!这就是所谓的"缓存双写不一致"问题。
解决方案:从"先删后写"到"延迟双删"
修正后的代码引入二次删除 和短暂延迟:
java
// 正确写法:延迟双删策略
public void updateUserProfile(long userId, UserProfile newProfile) {
// 第一次删除
redisTemplate.delete("user:" + userId);
// 更新数据库
userDao.updateProfile(userId, newProfile);
// 提交事务后延迟二次删除
transactionTemplate.execute(status -> {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.schedule(() -> {
redisTemplate.delete("user:" + userId);
}, 500, TimeUnit.MILLISECONDS); // 延迟500ms
return null;
});
}
关键点:
- 第二次删除延迟500ms(根据业务压测调整),确保数据库主从同步完成
- 放在事务提交后执行,避免事务回滚导致误删
实测对比:
| 方案 | 脏数据概率 | 平均耗时 |
|---|---|---|
| 先删后写 | 5.2% | 78ms |
| 延迟双删 | 0.03% | 83ms |
深挖:那些比想象中复杂的缓存问题
你以为这就完了?还有更隐蔽的坑:
坑1:热点Key的"死锁回填"
当某个Key突然成为热点(比如明星离婚新闻触发用户主页访问暴涨),如果缓存同时失效,会导致所有请求穿透到DB。解决方案是用redisTemplate.opsForValue().setIfAbsent()加分布式锁,只允许一个线程回填缓存。
坑2:本地缓存的"时间漂移"
如果你的系统用了多级缓存(比如Redis+Guava Cache),Guava的过期时间是相对时间 (从写入开始算),而Redis是绝对时间。集群机器时钟不同步时,可能导致本地缓存比Redis存活更久。
坑3:TTL的"批量失效"风暴
给所有Key设置相同的TTL(比如30分钟整),可能导致缓存大规模同时失效。正确做法是加上随机扰动:
java
// 好的TTL设置:基础时间+随机偏移
int baseTtl = 1800; // 30分钟
int randomTtl = baseTtl + ThreadLocalRandom.current().nextInt(300); // 加0~5分钟随机
redisTemplate.opsForValue().set(key, value, randomTtl, TimeUnit.SECONDS);
避坑清单:缓存失效的生存法则
- 永远假设缓存会失效:代码要能容忍缓存miss和脏数据,做好降级策略
- 慎用"先删后写":优先考虑"先更新DB再删缓存"(Cache Aside Pattern)
- 监控缓存命中率与失效时间分布:突然的命中率下跌往往预示问题
- 区分冷热数据:热点数据用永不过期策略+后台刷新,避免集体失效
- 考虑消息队列保障最终一致:对于强一致性要求高的场景,可以用binlog监听触发缓存更新
最后说个扎心的事实:没有完美的缓存方案,只有适合当前业务场景的权衡。你在项目里还遇到过哪些诡异的缓存问题?欢迎在评论区分享你的"战损版"经验。