在高并发场景下,Redis缓存击穿是一个非常容易踩坑的问题。它的典型表现是:某个被大量访问的热点key在Redis中过期的同一时刻,成千上万个请求同时发现缓存失效,于是全部涌向数据库查询,数据库瞬间承受了远超正常水平的压力,轻则响应变慢,重则直接宕机。逻辑过期时间方案通过把过期时间"藏"在数据内部、让key永不物理过期的方式,从根本上规避了这个瞬间并发穿透的问题。

什么是缓存击穿,它和缓存穿透、雪崩有什么区别
缓存击穿指的是某个热点key在过期的瞬间,大量并发请求同时到达,这些请求都发现缓存中没有数据,于是全部去数据库查询,导致数据库压力骤增。注意它的前提是"热点key"且"某个key过期",这是它和缓存雪崩的最大区别——缓存雪崩是大量key同时过期或者Redis整体宕机,波及面更广。
缓存穿透则是完全不同的问题:请求查询的是一个数据库中根本不存在的数据,比如恶意构造的id为-1的请求。由于数据库查不到结果,缓存自然也无法写入,每次请求都会穿透到数据库。三者容易混淆,可以这样简单记忆:击穿是"一个key被集中攻击",雪崩是"一片key集体失效",穿透是"数据根本不存在"。
针对缓存击穿,常见的解决方案有两种思路:一种是互斥锁方案,保证同一时刻只有一个线程去数据库重建缓存,其他线程等待或返回空;另一种就是本文重点讲的逻辑过期方案,让key永不过期,用异步线程更新数据,请求线程永远能拿到数据,不会穿透到数据库。
互斥锁方案的原理与局限
在讲逻辑过期之前,先看看互斥锁方案,理解它的不足才能明白为什么需要逻辑过期。互斥锁的核心思想是:当线程发现缓存失效时,先尝试获取一把分布式锁,只有拿到锁的线程才有资格去数据库查询并重建缓存,其他没拿到锁的线程要么短暂休眠后重试读取缓存,要么直接返回一个兜底结果。
Java中通常使用setIfAbsent命令来实现这个锁,也就是Redis的SETNX语义,代码大致如下:
public String queryWithMutex(String key) {
String cache = stringRedisTemplate.opsForValue().get(key);
if (cache != null && !cache.isEmpty()) {
return cache;
}
// 缓存失效,尝试获取锁
String lockKey = "lock:" + key;
String lockValue = UUID.randomUUID().toString();
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
try {
if (Boolean.TRUE.equals(locked)) {
// 双重检查,防止重复重建
cache = stringRedisTemplate.opsForValue().get(key);
if (cache != null && !cache.isEmpty()) {
return cache;
}
// 查数据库并写回缓存
String dbData = queryFromDatabase(key);
stringRedisTemplate.opsForValue().set(key, dbData, 30, TimeUnit.MINUTES);
return dbData;
} else {
// 没拿到锁,短暂休眠后重试
Thread.sleep(50);
return queryWithMutex(key);
}
} finally {
// 只释放自己的锁,防止误删
if (Boolean.TRUE.equals(locked) && lockValue.equals(
stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
}
这个方案的优点是实现简单、一致性较强,重建完成后所有请求都能拿到最新数据。但缺点也很明显:拿到锁之前,所有等待线程都在自旋重试或阻塞,线程资源被白白消耗;在极端高并发下,大量线程休眠重试会造成线程池打满、响应时间抖动。更致命的是,如果重建过程本身很慢(比如数据库查询要几秒钟),等待的请求都要陪着等,整体吞吐量下降明显。
逻辑过期方案的核心设计思想
逻辑过期方案的思路和互斥锁完全相反:它干脆不让key在Redis层面过期。也就是说,写入缓存时不设置TTL,这个key会永远存在。那数据怎么更新呢?答案是把过期时间作为一个字段,序列化后和数据一起存在value里,这就是所谓的"逻辑过期时间"。
每次读取缓存时,程序先解析出value中的逻辑过期时间,和当前时间比较。如果还没到逻辑过期时间,说明数据还是新鲜的,直接返回即可;如果已经过了逻辑过期时间,说明数据需要更新,但当前线程不会自己去重建,而是尝试获取一把锁。拿到锁的线程开启一个异步线程去完成缓存重建,自己和其他没拿到锁的线程则直接返回旧的过期数据作为兜底。
这个设计的精妙之处在于:无论数据是否过期,请求线程永远都能立刻拿到返回值,数据库永远不会被并发请求直接冲击。代价是牺牲了一定的时效性——在重建完成之前,用户可能短暂读到旧数据,但这对绝大多数热点数据场景(比如商品详情、首页推荐)来说完全可接受。由于key永不过期,这个方案还要求热点数据必须提前预热加载进缓存,否则缓存中根本不存在这个key,方案就失效了。
逻辑过期方案的完整代码实现
首先定义一个包装缓存数据的实体类,包含真正的数据字段和逻辑过期时间字段:
@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 真正的业务数据
}
然后是核心的查询逻辑,注意其中的线程加锁和异步重建部分:
// 线程池建议作为Bean统一管理,这里为了演示直接创建
private static final ExecutorService CACHE_REBUILD_EXECUTOR =
Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {
String key = "cache:shop:" + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 逻辑过期方案要求热点key提前预热,未命中直接返回null
if (StringUtils.isBlank(json)) {
return null;
}
// 反序列化,取出数据和逻辑过期时间
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 未过期,直接返回
return shop;
}
// 已过期,尝试获取锁进行异步重建
String lockKey = "lock:shop:" + id;
Boolean locked = tryLock(lockKey);
if (Boolean.TRUE.equals(locked)) {
// 拿到锁,开启异步线程重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
saveShopToCache(id, 30L); // 查库并写入新的逻辑过期时间
} finally {
unlock(lockKey); // 重建完释放锁
}
});
}
// 无论是否拿到锁,都返回旧数据兜底
return shop;
}
private Boolean tryLock(String key) {
return stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
有几个实现细节需要注意。第一,锁的过期时间(代码中的10秒)一定要大于缓存重建的耗时,防止重建还没完成锁就失效,导致另一个线程又发起重建。第二,获取锁和释放锁要保证原子性配对,上面代码用try-finally确保异常时也能释放锁。第三,释放锁时如果对严格性有要求,可以像互斥锁方案那样给锁加唯一标识,用Lua脚本保证"判断加释放"的原子性,避免误删别的线程的锁。
逻辑过期方案的优缺点与适用场景
逻辑过期方案的优点非常突出:所有请求都能立即响应,不存在线程等待和自旋,吞吐量完全不受缓存重建影响;数据库被彻底保护起来,重建动作永远只由单个异步线程执行。它的缺点则是一致性较弱,重建期间返回的是旧数据;其次必须提前预热缓存,不适合那种无法预知哪些key会成为热点的场景;此外每个value都要多存一个过期时间字段,序列化结构稍显复杂。
因此,逻辑过期方案最适合的场景是:热点key可以提前识别(比如大促活动前已知的爆款商品)、数据允许短暂不一致、读并发极高。如果你的业务要求强一致性,比如库存扣减这类对准确性敏感的数据,那么逻辑过期方案并不合适,此时互斥锁方案或者干脆不做缓存才是更稳妥的选择。实际项目中也可以两者结合使用:普通数据用常规TTL缓存,识别出的热点数据升级为逻辑过期方案,兼顾性能和复杂度。
总结
缓存击穿的本质是热点key过期瞬间的高并发穿透。逻辑过期方案通过"key永不过期加数据内嵌过期时间加异步重建"三件套,让请求线程永远能秒回数据,从架构上消除了击穿的可能,代价是一段时间内的数据不一致和必须预先预热。理解了它的设计取舍之后,再结合业务对一致性的容忍度去选型,才能真正发挥这套方案的价值。