一、缓存穿透(Cache Penetration)- 查不存在的数据
1. 什么是缓存穿透?
// 场景:黑客恶意攻击
// 请求数据库中根本不存在的key
// 正常流程:
public User getUser(Long userId) {
// 1. 先查缓存
User user = redis.get("user:" + userId);
if (user != null) {
return user; // 缓存命中
}
// 2. 缓存没有,查数据库
user = userDao.findById(userId);
if (user != null) {
// 3. 写入缓存
redis.set("user:" + userId, user);
}
return user;
}
// 攻击:黑客一直请求不存在的userId
// 结果:每次请求都穿透到数据库,数据库压力巨大!
2. 什么时候会出现?
出现时机:
1. 恶意攻击:黑客故意请求不存在的数据
2. 业务逻辑错误:程序bug导致生成不存在的ID
3. 爬虫抓取:爬虫遍历ID,遇到不存在的
常见场景:
- 商品详情页:请求不存在的商品ID
- 用户详情:请求不存在的用户ID
- 订单查询:查询不存在的订单号
3. 解决方案
方案1:缓存空对象
// 将不存在的key也缓存起来,但设置较短的过期时间
public User getUserSafe(Long userId) {
String key = "user:" + userId;
// 1. 从缓存读取
User user = redis.get(key);
// 如果是空对象标记
if (user != null && user.getId() == null) {
return null; // 明确知道不存在
}
if (user != null) {
return user; // 正常用户
}
// 2. 查数据库
user = userDao.findById(userId);
if (user == null) {
// 缓存空对象,防止穿透
User emptyUser = new User(); // 空对象
emptyUser.setId(null); // 标记为空
redis.setex(key, 60, emptyUser); // 只缓存60秒
return null;
}
// 3. 缓存正常数据
redis.setex(key, 300, user); // 缓存5分钟
return user;
}
方案2:布隆过滤器(Bloom Filter)- 推荐
// 布隆过滤器:判断元素"可能存在"或"肯定不存在"
@Component
public class BloomFilterService {
// 使用Redisson的布隆过滤器
@Autowired
private RedissonClient redisson;
private RBloomFilter<Long> userBloomFilter;
@PostConstruct
public void init() {
// 初始化布隆过滤器
userBloomFilter = redisson.getBloomFilter("user:bloomfilter");
// 预期元素数量100万,误判率0.1%
userBloomFilter.tryInit(1000000L, 0.001);
// 预热:加载所有存在的用户ID
List<Long> allUserIds = userDao.findAllIds();
for (Long id : allUserIds) {
userBloomFilter.add(id);
}
}
public User getUserWithBloom(Long userId) {
String key = "user:" + userId;
// 1. 先用布隆过滤器判断
if (!userBloomFilter.contains(userId)) {
// 肯定不存在,直接返回
System.out.println("布隆过滤器说:用户" + userId + "不存在");
return null;
}
// 2. 走正常缓存流程
User user = redis.get(key);
if (user != null) {
return user;
}
// 3. 查数据库
user = userDao.findById(userId);
if (user != null) {
redis.setex(key, 300, user);
} else {
// 数据库也没有,可能是误判
// 记录日志,可能需要调整布隆过滤器
log.warn("布隆过滤器误判,用户ID: {}", userId);
}
return user;
}
// 新增用户时添加到布隆过滤器
public void addUser(User user) {
userDao.save(user);
userBloomFilter.add(user.getId());
}
}
方案3:接口层校验
// 在请求进入业务逻辑前就过滤
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/{id}")
public Result getUser(@PathVariable Long id) {
// 1. 参数校验
if (id == null || id <= 0) {
return Result.error("参数错误");
}
// 2. ID范围校验(如果业务有范围)
if (id > 1000000L) { // 假设用户ID最大100万
return Result.error("用户不存在");
}
// 3. 模式校验(如果是特殊格式)
if (!isValidUserId(id)) {
return Result.error("用户ID格式错误");
}
return Result.success(userService.getUser(id));
}
private boolean isValidUserId(Long id) {
// 可以根据业务规则校验
// 比如:必须是数字、长度限制、不能是特殊字符等
return id.toString().matches("\\d{1,8}");
}
}
二、缓存击穿(Cache Breakdown)- 热点key失效
1. 什么是缓存击穿?
// 场景:一个热点key在缓存过期瞬间
// 大量并发请求同时来查这个key
// 问题代码:
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 缓存刚好过期
Product product = redis.get(key);
if (product == null) {
// 大量请求同时到达这里!
// 都会去查数据库
product = productDao.findById(productId);
redis.setex(key, 300, product); // 重新缓存
}
return product;
}
// 结果:瞬间大量请求打到数据库,可能压垮数据库
2. 什么时候会出现?
出现时机:
1. 热点数据过期:秒杀商品、热门文章
2. 定时缓存刷新:缓存统一过期
3. 缓存主动删除:管理员操作
常见场景:
- 秒杀商品详情
- 首页推荐商品
- 热搜排行榜
- 热门文章
3. 解决方案
方案1:互斥锁(Mutex Lock)- 推荐
@Service
public class ProductService {
@Autowired
private RedissonClient redisson;
public Product getProductWithLock(Long productId) {
String key = "product:" + productId;
// 1. 先查缓存
Product product = redis.get(key);
if (product != null) {
return product;
}
// 2. 获取分布式锁
String lockKey = "product:lock:" + productId;
RLock lock = redisson.getLock(lockKey);
try {
// 尝试获取锁,最多等待100ms
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 双重检查(Double Check)
product = redis.get(key);
if (product != null) {
return product;
}
// 3. 查数据库
product = productDao.findById(productId);
if (product == null) {
return null;
}
// 4. 写入缓存
redis.setex(key, 300, product);
return product;
} finally {
lock.unlock(); // 释放锁
}
} else {
// 没获取到锁,说明其他线程在重建缓存
// 等待一会再重试
Thread.sleep(50);
return redis.get(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
方案2:逻辑过期(不设置过期时间)
// 缓存不设置过期时间,由程序控制更新
public class ProductServiceV2 {
public Product getProductLogicalExpire(Long productId) {
String key = "product:" + productId;
// 1. 查缓存
ProductCacheWrapper wrapper = redis.get(key);
if (wrapper != null) {
// 检查是否逻辑过期
if (wrapper.getExpireTime() > System.currentTimeMillis()) {
// 未过期,直接返回
return wrapper.getProduct();
} else {
// 已过期,异步更新缓存
asyncUpdateCache(productId);
return wrapper.getProduct(); // 先返回旧数据
}
}
// 2. 缓存没有,查数据库
Product product = productDao.findById(productId);
if (product == null) {
return null;
}
// 3. 写入缓存(设置逻辑过期时间)
ProductCacheWrapper newWrapper = new ProductCacheWrapper(
product,
System.currentTimeMillis() + 300000 // 5分钟后逻辑过期
);
redis.set(key, newWrapper); // 不设置Redis过期时间
return product;
}
// 异步更新缓存
private void asyncUpdateCache(Long productId) {
CompletableFuture.runAsync(() -> {
try {
Product product = productDao.findById(productId);
if (product != null) {
ProductCacheWrapper wrapper = new ProductCacheWrapper(
product,
System.currentTimeMillis() + 300000
);
redis.set("product:" + productId, wrapper);
}
} catch (Exception e) {
log.error("异步更新缓存失败", e);
}
});
}
// 包装类
@Data
@AllArgsConstructor
static class ProductCacheWrapper {
private Product product;
private Long expireTime; // 逻辑过期时间戳
}
}
方案3:永不过期 + 后台刷新
// 热点数据永不过期,后台定时刷新
@Service
@Slf4j
public class HotDataService {
// 热点数据列表
private Set<Long> hotProductIds = new ConcurrentHashSet<>();
// 初始化热点数据
@PostConstruct
public void initHotData() {
// 从数据库或配置文件加载热点数据
List<Product> hotProducts = productDao.findHotProducts();
for (Product product : hotProducts) {
hotProductIds.add(product.getId());
}
// 启动后台刷新线程
startRefreshThread();
}
// 获取商品
public Product getHotProduct(Long productId) {
String key = "product:" + productId;
// 1. 查缓存
Product product = redis.get(key);
if (product != null) {
return product;
}
// 2. 如果是热点商品,用锁重建
if (hotProductIds.contains(productId)) {
return getProductWithLock(productId);
}
// 3. 普通商品,正常流程
return getNormalProduct(productId);
}
// 后台刷新线程
private void startRefreshThread() {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// 每30秒刷新一次热点数据
scheduler.scheduleAtFixedRate(() -> {
for (Long productId : hotProductIds) {
try {
Product product = productDao.findById(productId);
if (product != null) {
redis.set("product:" + productId, product);
log.info("刷新热点商品缓存:{}", productId);
}
} catch (Exception e) {
log.error("刷新热点商品失败:{}", productId, e);
}
}
}, 0, 30, TimeUnit.SECONDS);
}
}
三、缓存雪崩(Cache Avalanche)- 大量key同时失效
1. 什么是缓存雪崩?
// 场景:大量缓存key在同一时间过期
// 导致所有请求都打到数据库
// 问题代码:
public class CacheService {
public void initCache() {
// 初始化缓存,设置相同过期时间
for (int i = 1; i <= 10000; i++) {
String key = "product:" + i;
Product product = productDao.findById((long)i);
redis.setex(key, 3600, product); // 都1小时后过期
}
}
}
// 1小时后,所有缓存同时过期
// 瞬间大量请求打到数据库,数据库可能崩溃
2. 什么时候会出现?
出现时机:
1. 缓存预热:批量设置相同过期时间
2. 缓存刷新:定时任务同时刷新
3. Redis宕机:缓存全部失效
4. 网络分区:集群脑裂
常见场景:
- 系统启动时缓存预热
- 定时刷新全量缓存
- Redis集群故障
- 大促期间缓存集中过期
3. 解决方案
方案1:随机过期时间
@Service
public class CacheService {
private Random random = new Random();
// 设置缓存,使用随机过期时间
public void setCacheWithRandomExpire(String key, Object value) {
// 基础过期时间:1小时
int baseExpireSeconds = 3600;
// 随机增加0-300秒(5分钟)
int randomAddSeconds = random.nextInt(300);
int totalExpireSeconds = baseExpireSeconds + randomAddSeconds;
redis.setex(key, totalExpireSeconds, value);
log.info("设置缓存 {} 过期时间:{}秒",
key, totalExpireSeconds);
}
// 批量设置缓存
public void batchSetCache(Map<String, Object> data) {
for (Map.Entry<String, Object> entry : data.entrySet()) {
setCacheWithRandomExpire(entry.getKey(), entry.getValue());
}
}
// 更高级的随机策略
public void setCacheSmart(String key, Object value) {
int baseExpire;
// 根据key的重要性设置不同过期时间
if (key.startsWith("hot:")) {
// 热点数据:过期时间短,但会续期
baseExpire = 600; // 10分钟
} else if (key.startsWith("important:")) {
// 重要数据:中等过期时间
baseExpire = 3600; // 1小时
} else {
// 普通数据:长时间
baseExpire = 7200; // 2小时
}
// 加上随机时间
int randomAdd = random.nextInt(600); // 0-10分钟随机
int totalExpire = baseExpire + randomAdd;
redis.setex(key, totalExpire, value);
}
}
方案2:二级缓存
// 使用本地缓存 + Redis
@Component
public class TwoLevelCacheService {
// 本地缓存(Guava Cache)
private Cache<String, Object> localCache = CacheBuilder.newBuilder()
.maximumSize(10000) // 最多缓存10000个
.expireAfterWrite(5, TimeUnit.MINUTES) // 5分钟过期
.build();
public Object getWithTwoLevelCache(String key) {
// 1. 先查本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 2. 再查Redis
value = redis.get(key);
if (value != null) {
// 写入本地缓存
localCache.put(key, value);
return value;
}
// 3. 查数据库
value = loadFromDB(key);
if (value != null) {
// 写入Redis(随机过期时间)
int expire = 300 + new Random().nextInt(60); // 5-6分钟
redis.setex(key, expire, value);
// 写入本地缓存
localCache.put(key, value);
}
return value;
}
// 更新缓存
public void updateCache(String key, Object value) {
// 1. 更新数据库
updateDB(key, value);
// 2. 删除Redis缓存
redis.delete(key);
// 3. 删除本地缓存
localCache.invalidate(key);
// 4. 异步重新加载
CompletableFuture.runAsync(() -> {
Object newValue = loadFromDB(key);
if (newValue != null) {
redis.setex(key, 300, newValue);
localCache.put(key, newValue);
}
});
}
}
方案3:缓存永不过期 + 异步更新
// 缓存不设置过期时间,由程序控制更新
@Service
@Slf4j
public class NeverExpireCacheService {
// 记录key的最后更新时间
private Map<String, Long> keyUpdateTime = new ConcurrentHashMap<>();
// 获取数据
public Object getData(String key) {
// 1. 从Redis获取
Object value = redis.get(key);
if (value != null) {
// 检查是否需要异步更新
checkAndUpdateAsync(key);
return value;
}
// 2. 缓存没有,加载数据
return loadAndCache(key);
}
// 检查并异步更新
private void checkAndUpdateAsync(String key) {
Long lastUpdate = keyUpdateTime.get(key);
long now = System.currentTimeMillis();
// 如果超过30分钟没更新,异步更新
if (lastUpdate == null || (now - lastUpdate) > 30 * 60 * 1000) {
CompletableFuture.runAsync(() -> {
try {
Object newValue = loadFromDB(key);
if (newValue != null) {
redis.set(key, newValue); // 永不过期
keyUpdateTime.put(key, now);
log.info("异步更新缓存:{}", key);
}
} catch (Exception e) {
log.error("异步更新缓存失败:{}", key, e);
}
});
}
}
// 热点数据续期
@Scheduled(fixedRate = 60000) // 每分钟执行
public void renewHotKeys() {
// 获取热点key列表
Set<String> hotKeys = getHotKeys();
for (String key : hotKeys) {
// 如果key存在,就续期
if (redis.exists(key)) {
// 可以重新设置,或者什么都不做(永不过期)
log.debug("热点key续期:{}", key);
}
}
}
}
方案4:服务降级和熔断
// 当缓存雪崩发生时,启用降级策略
@Component
public class FallbackService {
@Autowired
private CircuitBreakerFactory circuitBreakerFactory;
public Product getProductWithFallback(Long productId) {
CircuitBreaker circuitBreaker = circuitBreakerFactory.create("product-service");
return circuitBreaker.run(
() -> {
// 正常业务逻辑
return getProductFromCache(productId);
},
throwable -> {
// 降级逻辑
return getProductFromFallback(productId);
}
);
}
// 正常逻辑
private Product getProductFromCache(Long productId) {
String key = "product:" + productId;
// 1. 查缓存
Product product = redis.get(key);
if (product != null) {
return product;
}
// 2. 查数据库(这里可能压力大)
product = productDao.findById(productId);
if (product != null) {
redis.setex(key, 300, product);
}
return product;
}
// 降级逻辑
private Product getProductFromFallback(Long productId) {
// 1. 返回静态数据
Product fallbackProduct = new Product();
fallbackProduct.setId(productId);
fallbackProduct.setName("商品加载中...");
fallbackProduct.setPrice(BigDecimal.ZERO);
// 2. 记录日志
log.warn("服务降级,返回兜底数据,productId: {}", productId);
// 3. 异步尝试恢复
recoverAsync(productId);
return fallbackProduct;
}
// 异步恢复
private void recoverAsync(Long productId) {
CompletableFuture.runAsync(() -> {
try {
Product product = productDao.findById(productId);
if (product != null) {
redis.setex("product:" + productId, 300, product);
log.info("异步恢复缓存成功:{}", productId);
}
} catch (Exception e) {
log.error("异步恢复缓存失败:{}", productId, e);
}
});
}
}
四、实战:综合解决方案
1. 完整的缓存服务
@Component
@Slf4j
public class ComprehensiveCacheService {
@Autowired
private RedissonClient redisson;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 布隆过滤器
private RBloomFilter<Long> bloomFilter;
// 热点key集合
private Set<String> hotKeys = new ConcurrentHashSet<>();
@PostConstruct
public void init() {
// 初始化布隆过滤器
bloomFilter = redisson.getBloomFilter("product:bloom");
bloomFilter.tryInit(1000000L, 0.001);
// 加载热点key
loadHotKeys();
// 启动监控线程
startMonitor();
}
/**
* 综合解决方案:获取商品
*/
public Product getProductComprehensive(Long productId) {
String key = "product:" + productId;
// 1. 参数校验
if (productId == null || productId <= 0) {
return null;
}
// 2. 布隆过滤器校验
if (!bloomFilter.contains(productId)) {
log.info("布隆过滤器拦截,productId: {}", productId);
return null;
}
// 3. 一级缓存:本地缓存(如果有)
Product product = getFromLocalCache(key);
if (product != null) {
return product;
}
// 4. 二级缓存:Redis
product = getFromRedis(key);
if (product != null) {
// 如果是热点key,续期
if (hotKeys.contains(key)) {
renewHotKey(key);
}
return product;
}
// 5. 缓存没有,用互斥锁保护数据库
return getFromDBWithLock(key, productId);
}
/**
* 从Redis获取
*/
private Product getFromRedis(String key) {
try {
return (Product) redisTemplate.opsForValue().get(key);
} catch (Exception e) {
log.error("Redis获取失败,key: {}", key, e);
// Redis异常时,直接查数据库
return null;
}
}
/**
* 用锁保护数据库查询
*/
private Product getFromDBWithLock(String key, Long productId) {
String lockKey = "lock:" + key;
RLock lock = redisson.getLock(lockKey);
try {
// 尝试获取锁
if (lock.tryLock(100, 10000, TimeUnit.MILLISECONDS)) {
try {
// 双重检查
Product product = getFromRedis(key);
if (product != null) {
return product;
}
// 查询数据库
product = productDao.findById(productId);
if (product == null) {
// 数据库也没有,记录到布隆过滤器
// 注意:布隆过滤器不能删除,这里不记录
return null;
}
// 写入Redis(随机过期时间)
int expire = generateExpireTime(key);
redisTemplate.opsForValue().set(key, product, expire, TimeUnit.SECONDS);
// 如果是热点key,加入集合
if (isHotProduct(product)) {
hotKeys.add(key);
}
return product;
} finally {
lock.unlock();
}
} else {
// 没获取到锁,等待后重试
Thread.sleep(50);
return getFromRedis(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
/**
* 生成随机过期时间
*/
private int generateExpireTime(String key) {
int baseExpire;
if (key.startsWith("product:hot:")) {
baseExpire = 300; // 热点商品5分钟
} else if (key.startsWith("product:important:")) {
baseExpire = 1800; // 重要商品30分钟
} else {
baseExpire = 3600; // 普通商品1小时
}
// 加上随机时间(0-300秒)
Random random = new Random();
int randomAdd = random.nextInt(300);
return baseExpire + randomAdd;
}
/**
* 热点key续期
*/
private void renewHotKey(String key) {
// 如果剩余时间小于60秒,就续期
Long expire = redisTemplate.getExpire(key);
if (expire != null && expire < 60) {
redisTemplate.expire(key, 300, TimeUnit.SECONDS);
log.debug("热点key续期:{}", key);
}
}
/**
* 启动监控
*/
private void startMonitor() {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// 每分钟监控一次
scheduler.scheduleAtFixedRate(() -> {
try {
monitorCacheHealth();
} catch (Exception e) {
log.error("监控任务异常", e);
}
}, 1, 1, TimeUnit.MINUTES);
}
/**
* 监控缓存健康度
*/
private void monitorCacheHealth() {
// 监控命中率
// 监控热点key
// 监控Redis连接
// 发送到监控系统
log.info("缓存监控:热点key数量={}", hotKeys.size());
}
}
2. Spring Boot配置
# application.yml
spring:
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 20
max-wait: -1ms
max-idle: 10
min-idle: 5
timeout: 2000ms
# 缓存配置
cache:
# 默认过期时间(秒)
default-ttl: 3600
# 热点数据过期时间
hot-ttl: 300
# 是否启用布隆过滤器
bloom-filter:
enabled: true
expected-insertions: 1000000
false-probability: 0.001
# 是否启用二级缓存
two-level:
enabled: true
local-size: 10000
local-ttl: 300
五、不同场景的解决方案选择
决策树
问题类型 → 解决方案
─────────────────────────────────────────────
缓存穿透 → 布隆过滤器 + 缓存空值
缓存击穿 → 互斥锁 + 逻辑过期
缓存雪崩 → 随机过期 + 二级缓存 + 永不过期
场景匹配表
| 业务场景 | 主要问题 | 推荐方案 | 注意事项 |
|---|---|---|---|
| 用户查询 | 穿透(查不存在用户) | 布隆过滤器 | 注意用户ID范围 |
| 商品详情 | 击穿(热点商品) | 互斥锁 | 设置合理的锁超时 |
| 商品列表 | 雪崩(批量过期) | 随机过期 | 避免同时加载大量数据 |
| 秒杀库存 | 击穿+雪崩 | 本地缓存+Redis | 考虑使用Redis原子操作 |
| 配置信息 | 雪崩 | 永不过期+主动刷新 | 更新时注意双写一致性 |
六、监控和告警
1. 关键监控指标
@Component
@Slf4j
public class CacheMonitor {
// 命中率统计
private AtomicLong hitCount = new AtomicLong(0);
private AtomicLong missCount = new AtomicLong(0);
// 穿透统计
private AtomicLong penetrationCount = new AtomicLong(0);
public <T> T getWithMonitor(String key, Supplier<T> loader) {
T value = getFromCache(key);
if (value != null) {
hitCount.incrementAndGet();
return value;
} else {
missCount.incrementAndGet();
// 布隆过滤器拦截
if (bloomFilter != null && !bloomFilter.contains(key)) {
penetrationCount.incrementAndGet();
return null;
}
value = loader.get();
if (value == null) {
penetrationCount.incrementAndGet();
}
return value;
}
}
// 计算命中率
public double getHitRate() {
long total = hitCount.get() + missCount.get();
if (total == 0) return 0.0;
return (double) hitCount.get() / total;
}
// 每分钟上报指标
@Scheduled(fixedRate = 60000)
public void reportMetrics() {
double hitRate = getHitRate();
long penetration = penetrationCount.get();
// 上报到监控系统
Metrics.gauge("cache.hit.rate", hitRate);
Metrics.counter("cache.penetration.count", penetration);
// 告警
if (hitRate < 0.8) {
alert("缓存命中率过低: " + hitRate);
}
if (penetration > 1000) {
alert("缓存穿透严重: " + penetration);
}
}
}
七、最佳实践总结
1. 防御策略组合
// 完整的防御策略
public class CacheDefense {
// 1. 防穿透:布隆过滤器 + 空值缓存
private RBloomFilter<String> bloomFilter;
// 2. 防击穿:分布式锁 + 双重检查
private RLock getLock(String key) { ... }
// 3. 防雪崩:随机过期 + 热点续期
private int randomExpire() { ... }
// 4. 降级:熔断 + 兜底数据
public Object getWithFallback(String key) { ... }
// 5. 监控:命中率 + 告警
public void monitor() { ... }
}
2. 一句话记住解决方案
缓存穿透 → 布隆过滤器拦无效请求
缓存击穿 → 分布式锁保单点重建
缓存雪崩 → 随机过期防集体失效
3. 选型建议
初创公司 → 缓存空值 + 互斥锁(简单有效)
中型公司 → 布隆过滤器 + 二级缓存(平衡方案)
大型公司 → 完整监控体系 + 智能调度(全面防御)
记住 :没有银弹,根据业务特点选择合适的组合方案,加上完善的监控,才是王道。