一、前言
在高并发系统中,缓存是提升性能的核心手段。Redis 作为最常用的分布式缓存中间件,在 Java 后端架构中扮演着关键角色。然而,引入缓存的同时也带来了新的风险——缓存击穿、缓存穿透、缓存雪崩。这三个问题一旦发生,轻则数据库压力激增,重则服务雪崩、系统瘫痪。
本文将从实际生产经验出发,结合 Java 代码示例,深入剖析这三个问题的本质及应对方案。
前置说明
依赖:SpringBoot 2.7+/3.x,
spring-boot-starter-data-redis,jackson 序列化,本地缓存 Caffeine,分布式锁 Redisson场景:商品详情查询接口,根据商品 id 查询商品信息,数据库 MySQL,缓存 Redis
实体类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDO implements Serializable {
private Long id;
private String productName;
private Long price;
private Integer stock;
}二、缓存穿透(Cache Penetration)
2.1 什么是缓存穿透?
大量请求查询一个不存在的数据,Redis 查不到,请求直接打到数据库。 比如传商品 id=-1、id=99999999(数据库没有这条记录),Redis 没有 key,每次都访问 DB。 攻击者可以批量发送不存在 id,压垮数据库。
2. 解决方案
缓存空值(最简单):数据库查不到,往 Redis 写入空对象,设置较短过期时间。缺点:会产生大量无效 key。
布隆过滤器(生产推荐,高并发场景):把所有合法商品 id 预加载到布隆过滤器,请求先过过滤器,不存在直接拒绝,不访问 Redis 和 DB。缺点:布隆过滤器不能删除数据,有微小误判率。
3. 生产代码实现
方案一:缓存空值(推荐)
将查询不到的结果也缓存起来,设置一个较短的过期时间。
@Service
public class ProductServiceImpl implements ProductService {
private static final String PRODUCT_KEY_PREFIX = "product:";
// 空值过期时间 2分钟,防止大量无效key常驻redis
private static final long EMPTY_EXPIRE_SECONDS = 120L;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
@Override
public ProductDO getProductById(Long productId) {
String key = PRODUCT_KEY_PREFIX + productId;
// 1. 查询redis缓存
ProductDO cacheObj = (ProductDO) redisTemplate.opsForValue().get(key);
// 缓存命中,并且不是空标记,直接返回
if (cacheObj != null) {
// 判断是否是空标记
if ("NULL".equals(cacheObj.getProductName())) {
return null;
}
return cacheObj;
}
// 2. 缓存未命中,查询数据库
ProductDO dbProduct = productMapper.selectById(productId);
if (dbProduct != null) {
// 写入缓存,设置正常过期时间
redisTemplate.opsForValue().set(key, dbProduct, 30, TimeUnit.MINUTES);
return dbProduct;
} else {
// 数据库不存在,写入空标记,短过期,防止穿透
ProductDO nullMark = new ProductDO();
nullMark.setProductName("NULL");
redisTemplate.opsForValue().set(key, nullMark, EMPTY_EXPIRE_SECONDS, TimeUnit.SECONDS);
return null;
}
}
}
优点:实现简单,效果明显 缺点:会占用一定内存,需设置合理的空值过期时间
方案 2:布隆过滤器(生产高并发)
依赖:Redisson 自带布隆过滤器,不用自己造轮子
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.5</version>
</dependency>@Service
public class ProductServiceImpl implements ProductService {
private static final String PRODUCT_KEY_PREFIX = "product:";
private static final String BLOOM_FILTER_NAME = "product_bloom_filter";
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private ProductMapper productMapper;
// 项目启动时初始化布隆过滤器,加载全部商品id
@PostConstruct
public void initBloomFilter() {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
// 预期插入100万条,误判率0.01
bloomFilter.tryInit(1000000L, 0.01);
List<Long> allProductIds = productMapper.selectAllProductId();
allProductIds.forEach(bloomFilter::add);
}
@Override
public ProductDO getProductById(Long productId) {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
// 布隆过滤器判断不存在,直接返回,禁止访问Redis和DB
if (!bloomFilter.contains(productId)) {
return null;
}
String key = PRODUCT_KEY_PREFIX + productId;
ProductDO cacheObj = (ProductDO) redisTemplate.opsForValue().get(key);
if (cacheObj != null) {
return cacheObj;
}
ProductDO dbProduct = productMapper.selectById(productId);
if (dbProduct != null) {
redisTemplate.opsForValue().set(key, dbProduct, 30, TimeUnit.MINUTES);
}
return dbProduct;
}
}
优点:内存占用极小,拦截效果好 缺点:存在误判率(可接受),不支持删除元素(可用 Counting Bloom Filter 改进)
三、缓存击穿(Cache Breakdown)
1. 什么是缓存击穿
热点 key 过期瞬间,大量并发请求同时打到数据库。 比如秒杀爆款商品,key 设置 30 分钟过期,过期那一瞬间,上千请求同时进来,缓存失效,全部请求访问数据库。
和穿透区分:击穿是数据真实存在,只是缓存刚好过期;穿透是数据本身不存在。
2. 解决方案
互斥锁(分布式锁,Redisson):缓存失效时,只放行一个线程去查 DB、更新缓存,其余线程等待。适合一致性要求高,并发量不是极端爆炸场景。
热点 key 永不过期(逻辑过期):Redis 不删除 key,value 里面存过期时间,后台异步更新数据。适合超高并发热点商品,允许短暂数据不一致。
3. 生产代码实现
方案 1:Redisson 分布式锁(互斥锁方案)
@Service
public class ProductServiceImpl implements ProductService {
private static final String PRODUCT_KEY_PREFIX = "product:";
private static final String LOCK_KEY_PREFIX = "product:lock:";
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private ProductMapper productMapper;
@Override
public ProductDO getProductById(Long productId) {
String cacheKey = PRODUCT_KEY_PREFIX + productId;
ProductDO cacheObj = (ProductDO) redisTemplate.opsForValue().get(cacheKey);
// 缓存命中直接返回
if (cacheObj != null) {
return cacheObj;
}
String lockKey = LOCK_KEY_PREFIX + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,最多等待3秒,锁持有时间10秒
boolean acquire = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!acquire) {
// 获取锁失败,短暂休眠后重试,或者返回降级
Thread.sleep(50);
return getProductById(productId);
}
// 获取锁成功,再次查缓存(防止别的线程已经更新完缓存)
cacheObj = (ProductDO) redisTemplate.opsForValue().get(cacheKey);
if (cacheObj != null) {
return cacheObj;
}
// 查询数据库,回填缓存
ProductDO dbProduct = productMapper.selectById(productId);
if (dbProduct != null) {
redisTemplate.opsForValue().set(cacheKey, dbProduct, 30, TimeUnit.MINUTES);
}
return dbProduct;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("查询商品异常", e);
} finally {
// 必须判断当前线程持有锁,才释放,防止释放别人的锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
优点:有效防止数据库被击穿 缺点:加锁期间会有少量请求等待,实现复杂度稍高
方案 2:逻辑过期(热点 key 永不过期,超高并发)
封装缓存对象,增加逻辑过期时间字段,redis key 不设置 TTL
@Data
public class RedisData<T> implements Serializable {
private T data;
// 逻辑过期时间戳,毫秒
private Long expireTime;
}
@Service
public class ProductServiceImpl implements ProductService {
private static final String PRODUCT_KEY_PREFIX = "product:";
private static final String LOCK_KEY_PREFIX = "product:lock:";
// 逻辑过期时间30分钟
private static final long LOGIC_EXPIRE = 30 * 60 * 1000L;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private ProductMapper productMapper;
@Autowired
private ExecutorService executorService;
@Override
public ProductDO getHotProductById(Long productId) {
String cacheKey = PRODUCT_KEY_PREFIX + productId;
RedisData<ProductDO> redisData = (RedisData<ProductDO>) redisTemplate.opsForValue().get(cacheKey);
// 缓存不存在直接返回
if (redisData == null) {
return null;
}
// 判断是否逻辑过期
long now = System.currentTimeMillis();
ProductDO product = redisData.getData();
if (redisData.getExpireTime() > now) {
// 未过期直接返回
return product;
}
// 已经逻辑过期,尝试获取分布式锁,异步更新
String lockKey = LOCK_KEY_PREFIX + productId;
RLock lock = redissonClient.getLock(lockKey);
boolean acquire = lock.tryLock();
if (acquire) {
// 拿到锁,开启新线程异步更新缓存,主线程直接返回旧数据
executorService.submit(() -> {
try {
// 查询数据库
ProductDO dbProduct = productMapper.selectById(productId);
RedisData<ProductDO> newRedisData = new RedisData<>();
newRedisData.setData(dbProduct);
newRedisData.setExpireTime(System.currentTimeMillis() + LOGIC_EXPIRE);
redisTemplate.opsForValue().set(cacheKey, newRedisData);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
});
}
// 不管有没有拿到锁,直接返回旧数据,保证高可用,允许短暂不一致
return product;
}
// 预热热点商品,项目启动提前加载到redis
public void preLoadHotProduct(Long productId) {
ProductDO dbProduct = productMapper.selectById(productId);
RedisData<ProductDO> redisData = new RedisData<>();
redisData.setData(dbProduct);
redisData.setExpireTime(System.currentTimeMillis() + LOGIC_EXPIRE);
redisTemplate.opsForValue().set(PRODUCT_KEY_PREFIX + productId, redisData);
}
}
特点:永远不会出现大量请求同时打 DB,适合秒杀、爆款;代价是数据有短暂延迟。
四、缓存雪崩(Cache Avalanche)
1. 什么是缓存雪崩
大量 Redis key 同一时间集体过期,或者 Redis 服务宕机,大量请求全部打到数据库,数据库瞬间压力爆满,甚至宕机。
击穿:单个热点 key 过期;雪崩:大批量 key 同时失效。
2. 解决方案
过期时间加随机值(最基础):在原有 TTL 基础上增加随机偏移量,避免大量 key 同时过期。
Redis 集群高可用(主从 + 哨兵 / Cluster),防止 Redis 单点宕机。
多级缓存:本地 Caffeine 缓存 + Redis 缓存,Redis 挂了,本地缓存兜底。
服务熔断、限流、降级:使用 Sentinel 或者 Resilience4j,DB 压力大的时候直接返回兜底数据,拒绝新请求。
3. 生产代码实现
方案 1:过期时间增加随机偏移
// 设置缓存,基础30分钟 + 随机0~300秒,打散过期时间
public void setProductCache(ProductDO productDO) {
String key = PRODUCT_KEY_PREFIX + productDO.getId();
long baseSeconds = 30 * 60L;
// 随机偏移 0~300秒
long randomOffset = new Random().nextLong(300);
redisTemplate.opsForValue().set(key, productDO, baseSeconds + randomOffset, TimeUnit.SECONDS);
}
方案 2:Caffeine 本地缓存 + Redis 多级缓存(兜底防雪崩)
<dependency>
<groupId>com.github.benmanes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
@Service
public class ProductServiceImpl implements ProductService {
private static final String PRODUCT_KEY_PREFIX = "product:";
// Caffeine本地缓存,最大容量1000,写入后5分钟过期
private final LoadingCache<Long, ProductDO> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(this::loadFromRedis);
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
// 本地缓存未命中,从Redis加载
private ProductDO loadFromRedis(Long productId) {
String key = PRODUCT_KEY_PREFIX + productId;
ProductDO cacheObj = (ProductDO) redisTemplate.opsForValue().get(key);
if (cacheObj != null) {
return cacheObj;
}
ProductDO dbProduct = productMapper.selectById(productId);
if (dbProduct != null) {
long baseSeconds = 30 * 60L;
long randomOffset = new Random().nextLong(300);
redisTemplate.opsForValue().set(key, dbProduct, baseSeconds + randomOffset, TimeUnit.SECONDS);
}
return dbProduct;
}
@Override
public ProductDO getProductById(Long productId) {
try {
// 优先读取本地缓存
return localCache.get(productId);
} catch (Exception e) {
// 本地缓存异常降级,直接查询数据库(生产可以替换成固定兜底返回)
return productMapper.selectById(productId);
}
}
}
方案 3:Sentinel 限流降级(防止数据库被打崩)
核心思路:当数据库异常、QPS 超过阈值,直接降级返回默认兜底数据,不再访问 DB。
@SentinelResource(value = "getProduct", fallback = "getProductFallback")
public ProductDO getProductByIdSentinel(Long productId) {
// 查询逻辑
return localCache.get(productId);
}
// 降级兜底方法
public ProductDO getProductFallback(Long productId, Throwable ex) {
ProductDO fallback = new ProductDO();
fallback.setProductName("商品查询繁忙,请稍后重试");
return fallback;
}
三者对比总结
生产落地注意事项
RedisTemplate 序列化一定要用 Jackson,不要用 JDK 序列化,否则 key 乱码,线上踩坑很多。
分布式锁务必使用 Redisson,不要自己手写 setnx 锁,存在锁续期、死锁问题。
布隆过滤器适合读多写少场景,频繁删数据不要用。
逻辑过期方案牺牲一致性,商品库存、金额这类强一致性场景不能用。
雪崩防护一定是组合拳:Redis 集群 + TTL 随机 + 本地缓存 + 限流降级,单靠一个方案不安全。
评论