文章目录
一、缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,导致每一次请求都会直接打在数据库上,缓存形同虚设。如果这类请求量很大,数据库将承受巨大压力,甚至可能被压垮。例如使用不存在的ID(如负数、超大数字)大量请求,频繁访问数据库以恶意攻击。
解决办法主要包括缓存空对象和布隆过滤器
1.缓存空对象
当数据库中查不到该数据时,也向缓存中写入一条记录,但值设为一个特殊标记(如空字符串 ""),并设一个较短的过期时间。后续相同请求命中这个"空值缓存",直接返回,不再查询数据库。
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗与短期不一致问题
- 额外的内存消耗:缓存中增加了大量空值 key,虽然单个很小,但大量累积也会占用内存
- 可能造成短期的不一致:若数据库后来插入该数据,而缓存空值尚未过期,会短暂返回"无数据",直到过期或主动删除。

2.布隆过滤
布隆过滤器是一种空间效率很高的概率型数据结构,用于判断一个元素"必定不存在"或"可能存在"。在查询缓存之前,先用布隆过滤器判断 key 是否可能存在于数据库中:若布隆过滤器判定不存在,则直接返回,不再查数据库;若判定可能存在,才继续查询缓存和数据库。
- 优点:内存占用较少,没有多余key
- 内存占用极低,不存在额外的 key 写入 Redis
- 可以过滤掉绝大部分非法 key 的请求
- 缺点:实现复杂;存在误判可能
- 实现较复杂(需维护布隆过滤器的位图、哈希函数)。
- 存在误判:布隆过滤器有可能把不存在的 key 误判为"可能存在",但不会把存在的 key 漏掉。误判率可通过增大位图容量来降低。

3.其他辅助措施
- 增强 ID 的复杂度,避免被猜测 ID 规律(如使用 UUID 或雪花 ID)。
- 做好数据的基础格式校验(如 ID 必须为正整数、长度限定等),拦截明显非法的请求。
- 加强用户权限校验,确保用户只能访问其有权查看的数据。
- 对热点参数做限流,防止单一参数恶意高并发导致穿透。
二、缓存雪崩
缓存雪崩是指在同一时间段内,大量缓存的 key 同时失效,或者 Redis 服务不可用,导致所有请求直接落到数据库上,数据库瞬间压力剧增,可能引发宕机。
解决方法:
- 给不同的 Key 的 TTL 添加随机值:例如原本统一设置 30 分钟过期,改为 30 ± 5 分钟,避免大量 key 集中过期。
- 利用 Redis 集群提高服务的可用性:采用主从 + 哨兵模式,或 Redis Cluster,避免单机故障导致全部缓存不可用。
- 给缓存业务添加降级限流策略:当缓存不可用时,直接限流或返回默认值,保护数据库不被压垮。
- 给业务添加多级缓存:如 Nginx 本地缓存 → Redis → JVM 本地缓存 → 数据库,层层拦截,降低数据库压力。

三、缓存击穿
缓存击穿也叫热点 Key 问题:一个被高并发访问且缓存重建业务较复杂的 key 突然失效,大量请求会直接击穿到数据库,给数据库带来巨大冲击。
两个关键特征:
- 热点 key 过期:缓存中没有该数据。
- 高并发访问,重建时间较长:业务逻辑复杂,数据库查询耗时。
如下例所示,线程高并发访问热点key,但是缓存中没有,并发访问数据库,带来压力

解决方案主要有两种:互斥锁和逻辑过期。
1.互斥锁
当热点 key 失效时,多个线程同时发现缓存不存在,此时通过分布式锁保证只有一个线程去执行数据库查询和缓存重建,其他线程等待或快速失败。拿到锁的线程完成重建后释放锁,其他线程从新缓存中读取。
让访问数据库的线程串行化执行,拿不到锁就等待
- 问题:互斥等待,性能下降。在高并发下,大量线程被阻塞等待锁,虽然保护了数据库,但降低了系统吞吐量。

2.逻辑过期
缓存数据中额外存储一个逻辑过期时间,实际存入 Redis 时不设置 TTL(或设置较长 TTL)。当检测到数据逻辑过期时,返回旧数据(保证可用性),同时异步起一个线程去数据库查询并重建缓存,更新逻辑过期时间。这样请求永远不会因为缓存失效而直接击穿到数据库。
- 优点:高可用,用户请求几乎无等待,直接拿到旧数据。
- 缺点:短期内返回的是旧数据,不能保证强一致性;实现复杂,需额外维护逻辑过期时间字段 。

3.方案对比
互斥锁保证一致性,性能差,无额外内存开销,可能死锁,实现简单
逻辑过期不保证一致性,性能优,有额外内存开销,无死锁风险,实现复杂
| 对比维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 一致性 | 强一致,拿到的一定是数据库最新数据 | 最终一致,短期可能返回旧数据 |
| 可用性/性能 | 串行执行,线程等待,性能较差 | 异步重建,几乎无等待,性能好 |
| 额外开销 | 仅分布式锁,无额外内存 | 需维护逻辑过期字段,稍增内存 |
| 死锁风险 | 若锁未释放可能死锁(需设过期时间) | 无死锁风险 |
| 实现复杂度 | 较简单 | 较复杂,需线程池+异步重建 |
| 适用场景 | 对一致性要求高、重建较快 | 对可用性要求高、重建较慢、可接受旧数据 |
四、设计锁的方案
使用 Redis 的 SETNX 实现互斥锁,并给锁设置一个超时时间,防止服务宕机导致死锁。
SETNX:当且仅当 key 不存在时,才设置 key 并返回成功;若 key 已存在,则不做任何操作并返回失败。多个并发请求同时执行 SETNX,只有一个能成功,其余都会失败,这就天然形成了一个分布式互斥锁------成功的那个线程获得锁,去执行数据库查询和缓存重建;失败的线程则等待或重试。
1.基于互斥锁
使用 setnx 作为互斥锁,首次到达的线程设置 setnx lock 1,后续线程设置 setnx 失败,直到delete lock。
java
/* *//**
* 获取锁
*
* @param key 关键
* @return boolean
*//*
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
*//**
* 释放锁
*
* @param key 关键
*//*
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
互斥锁查询商铺示例
java
private Shop queryWithMutex(Long id) {
String shopKey = CACHE_SHOP_KEY + id;
//从redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
//判断是否存在
if (StringUtils.isNotEmpty(shopJson)) {
//存在直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断控值
if ("".equals(shopJson)) {
return null;
}
//实现缓存重建
//获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//是否获取成功
if (!isLock) {
//获取失败 休眠并且重试
Thread.sleep(50);
return queryWithMutex(id);
}
//成功 通过id查询数据库
shop = getById(id);
//模拟重建延时
Thread.sleep(200);
if (shop == null) {
//redis写入空值
stringRedisTemplate.opsForValue().set(shopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//数据库不存在 返回错误
return null;
}
//数据库存在 写入redis
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//释放互斥锁
unLock(lockKey);
}
//返回
return shop;
}
2.基于逻辑过期
需要先准备一个数据包装类 RedisData
java
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
热点数据预先存入Redis(设置逻辑过期时间)
java
/**
* 存入redis 携带逻辑过期时间
*/
public void saveShopToRedis(Long id, Long expireSeconds) throws InterruptedException {
//查询店铺数据
Shop shop = getById(id);
Thread.sleep(200);
//封装逻辑过期
RedisDate redisDate = new RedisDate();
redisDate.setData(shop);
redisDate.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisDate));
}
逻辑过期查询商铺方法
java
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑过期解决缓存击穿
*
* @param id id
* @return {@link Shop}
*/
private Shop queryWithLogicalExpire(Long id) {
String shopKey = CACHE_SHOP_KEY + id;
//从redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
//判断是否存在
if (StringUtils.isEmpty(shopJson)) {
//不存在返回空
return null;
}
//命中 反序列化
RedisDate redisDate = JSONUtil.toBean(shopJson, RedisDate.class);
JSONObject jsonObject = (JSONObject) redisDate.getData();
Shop shop = BeanUtil.toBean(jsonObject, Shop.class);
LocalDateTime expireTime = redisDate.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//未过期 直接返回
return shop;
}
//已过期
//获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
//是否获取锁成功
if (flag) {
//成功 异步重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShopToRedis(id, 20L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//返回过期商铺信息
return shop;
}
五、工具类封装
将缓存查询和重建逻辑封装为通用工具类 CacheClient,支持缓存穿透和逻辑过期两种模式,便于复用。
java
//缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//逻辑过期解决缓存击穿
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
- set(key, value, time, unit):将对象序列化为 JSON 存入 Redis,设置实际过期时间。
- setWithLogicalExpire(key, value, time, unit):存储对象时携带逻辑过期时间,不依赖 Redis 自身的 TTL。
- queryWithPassThrough(...):解决缓存穿透,查询缓存 → 查询数据库,数据库不存在时缓存空值。
- queryWithLogicalExpire(...):解决缓存击穿,发现逻辑过期时返回旧数据并异步重建。
java
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
@Autowired
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将任意对象序列化成json存入redis
*
* @param key 关键
* @param value 价值
* @param time 时间
* @param unit 单位
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 将任意对象序列化成json存入redis 并且携带逻辑过期时间
*
* @param key 关键
* @param value 价值
* @param time 时间
* @param unit 单位
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
//存入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 设置空值解决缓存穿透
*
* @param keyPrefix 关键前缀
* @param id id
* @param type 类型
* @param dbFallback db回退
* @param time 时间
* @param unit 单位
* @return {@link R}
*/
public <R, ID> R queryWithPassThrough(
String keyPrefix
, ID id
, Class<R> type
, Function<ID, R> dbFallback
, Long time
, TimeUnit unit) {
String key = keyPrefix + id;
//从redis中查询
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StringUtils.isNotEmpty(json)) {
//存在直接返回
return JSONUtil.toBean(json, type);
}
//判断空值
if ("".equals(json)) {
return null;
}
//不存在 查询数据库
R r = dbFallback.apply(id);
if (r == null) {
//redis写入空值
this.set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
//数据库不存在 返回错误
return null;
}
//数据库存在 写入redis
this.set(key, r, time, unit);
//返回
return r;
}
/**
* 逻辑过期解决缓存击穿
*
* @param id id
* @return {@link Shop}
*/
public <R, ID> R queryWithLogicalExpire(String keyPrefix
, ID id
, Class<R> type
, Function<ID, R> dbFallback
, Long time
, TimeUnit unit) {
String key = keyPrefix + id;
//从redis中查询
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StringUtils.isEmpty(json)) {
//不存在返回空
return null;
}
//命中 反序列化
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject jsonObject = (JSONObject) redisData.getData();
R r = BeanUtil.toBean(jsonObject, type);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//未过期 直接返回
return r;
}
//已过期
//获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
//是否获取锁成功
if (flag) {
//成功 异步重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//查询数据库
R newR = dbFallback.apply(id);
//写入redis
this.setWithLogicalExpire(key,newR,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//返回过期商铺信息
return r;
}
/**
* 简易线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 获取锁
*
* @param key 关键
* @return boolean
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key 关键
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
}