SpringBoot(09):缓存实战------穿透、雪崩、击穿的解决方案

凌晨 3 点,手机疯狂告警。打开监控一看:Redis 连接数正常,但数据库连接池满了,CPU 飙到 95%。查日志发现大量查询走穿了缓存,全打到数据库。最后定位到原因:一个爬虫用不存在的 ID 疯狂请求商品接口,每次都穿透缓存打到数据库。修了一个空值缓存,5 分钟恢复正常。这是缓存穿透的典型案例。生产环境用缓存,只考虑"读写"远远不够。穿透、雪崩、击穿这三个问题不处理,迟早要出线上事故。
问题:缓存为什么不是万能的
上一篇文章讲了 Redis 集成和 @Cacheable 的用法。大多数教程到这里就结束了。但线上环境真正的坑不在"怎么用缓存",而在"缓存不生效时系统会怎样"。
看一个典型场景:
kotlin
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
public Product getProduct(Long productId) {
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
}
}
这段代码在正常情况下没任何问题。但三种异常场景会把它打穿:
场景一:缓存穿透 --- 有人用不存在的 ID 发起 10000 次请求。缓存里没有,数据库里也没有,每次请求都穿透到数据库。
场景二:缓存雪崩 --- 10000 个 key 的过期时间都设在同一时刻(比如凌晨 0 点批量导入的)。到点后所有 key 同时失效,瞬间 10000 个请求全打到数据库。
场景三:缓存击穿 --- 一个热点 key(比如秒杀商品)过期的那一瞬间,500 个并发请求同时发现缓存没了,500 个请求全去查数据库。
三个问题的后果一样:缓存没了,请求全打到数据库,数据库扛不住,挂了。

三大问题详解
缓存穿透
定义:请求的数据在缓存和数据库中都不存在,每次请求都绕过缓存直接打到数据库。
产生原因:
- 业务层没做参数校验,传了非法 ID(如 -1、0、超范围 ID)
- 攻击者用脚本批量探测不存在的数据
- 数据被物理删除,但缓存中没有同步清理
危害:攻击者用 1 台机器就能发起 10000 次/s 的无效请求,数据库直接被打挂。而且这个问题在常规监控里不容易发现------Redis 命中率可能并不低(因为有效缓存还在),只是无效请求全部穿透了。
less
// 穿透场景模拟
@GetMapping("/product/{id}")
public Product getProduct(@PathVariable Long id) {
// 正常用户请求 id=1,2,3... 这些数据存在
// 攻击者请求 id=99999999,99999998... 这些数据不存在
// 每次都穿透到数据库
return productService.getProduct(id);
}
缓存雪崩
定义:大量缓存在同一时刻失效,或者 Redis 节点宕机,导致大量请求同时打到数据库。
产生原因:
- 缓存 key 的过期时间设成一样(批量导入、统一设置 TTL)
- Redis 集群某个节点宕机,该节点上的缓存全部失效
- 业务高峰期进行缓存重建或热更新,导致大批 key 同时被清除
危害:正常流量下系统好好的,一到 key 集中过期的时刻,数据库负载瞬间飙升。雪崩效应会让数据库连接池在几秒内耗尽,整个系统不可用。
scss
// 雪崩场景:所有 key 过期时间相同
public void batchImport() {
List<Product> products = productMapper.selectAll();
for (Product p : products) {
// 全部设 30 分钟过期,30 分钟后一起失效
redisTemplate.opsForValue().set("product:" + p.getId(), JSON.toJSONString(p), 30, TimeUnit.MINUTES);
}
}
缓存击穿
定义:某个热点 key 过期的瞬间,大量并发请求同时发现缓存失效,全部去加载数据并回写缓存,造成数据库瞬时压力骤增。
产生原因:
- 热点数据(秒杀商品、热门文章、排行榜)过期
- 缓存重建耗时较长(复杂 SQL、远程调用)
- 并发量高,过期瞬间积压了大量请求
危害:和雪崩类似,但范围更集中。雪崩是大面积 key 失效,击穿是单个热点 key 失效。但单个热点 key 的并发量可能比几百个普通 key 加起来还高。
less
// 击穿场景:秒杀商品
@GetMapping("/seckill/{productId}")
public Product getSeckillProduct(@PathVariable Long productId) {
// 秒杀商品缓存过期的一瞬间,可能有上千个并发请求
// 全部发现缓存为空,全部去查数据库
return productService.getProduct(productId);
}
三者对比
| 维度 | 穿透 | 雪崩 | 击穿 |
|---|---|---|---|
| 根本原因 | 数据不存在 | 大量 key 同时失效 | 热点 key 过期 |
| 请求特征 | 无效请求打到 DB | 有效请求集中打到 DB | 有效请求集中打到 DB |
| 涉及 key 数量 | 不存在的 key | 大量正常 key | 单个热点 key |
| 发生条件 | 持续发生 | 特定时刻爆发 | 热点 key 过期瞬间 |
| 发现难度 | 难(有效缓存指标正常) | 易(监控可见) | 中等(需关注热点 key) |
解决方案总览

解决缓存穿透
方案一:缓存空值
最直接的办法:查不到数据也缓存,缓存一个空值。
kotlin
@Service
public class ProductService {
private static final String NULL_CACHE = "NULL";
private static final long NULL_TTL_MINUTES = 5;
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
public Product getProduct(Long productId) {
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if (NULL_CACHE.equals(cached)) {
return null;
}
return JSON.parseObject(cached, Product.class);
}
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
} else {
// 缓存空值,防止穿透
redisTemplate.opsForValue().set(key, NULL_CACHE, NULL_TTL_MINUTES, TimeUnit.MINUTES);
}
return product;
}
}
注意事项:
- 空值的 TTL 要设短一些(2-5 分钟),避免数据新增后还是返回空
- 空值会占用 Redis 内存,攻击者构造大量不同 ID 时要注意 Redis 内存容量
- 用一个特定的标记值(如 "NULL")而不是缓存 null,防止和正常缓存混淆
方案二:布隆过滤器
在缓存之前加一层布隆过滤器,把所有合法 ID 存进去。请求进来先过布隆过滤器,不合法的 ID 直接拒绝。
ini
@Service
public class BloomFilterService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String BLOOM_KEY = "product:bloom";
private static final long EXPECTED_INSERTIONS = 1000000L;
private static final double FALSE_POSITIVE_RATE = 0.01;
public void initBloomFilter() {
List<Long> allProductIds = productMapper.selectAllIds();
for (Long id : allProductIds) {
add(id);
}
}
public void add(Long id) {
long[] offsets = getOffsets(id);
for (long offset : offsets) {
redisTemplate.opsForValue().setBit(BLOOM_KEY, offset, true);
}
}
public boolean mightContain(Long id) {
long[] offsets = getOffsets(id);
for (long offset : offsets) {
if (!redisTemplate.opsForValue().getBit(BLOOM_KEY, offset)) {
return false;
}
}
return true;
}
private long[] getOffsets(Long id) {
long[] offsets = new long[7];
long hash = id.hashCode();
for (int i = 0; i < 7; i++) {
hash = hash * 31 + i;
offsets[i] = Math.abs(hash % (EXPECTED_INSERTIONS * 14));
}
return offsets;
}
}
或者用 Guava 的 BloomFilter:
xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.0.0-jre</version>
</dependency>
typescript
@Component
public class ProductBloomFilter {
private BloomFilter<Long> bloomFilter;
private static final long EXPECTED_INSERTIONS = 1000000L;
@Autowired
private ProductMapper productMapper;
@PostConstruct
public void init() {
bloomFilter = BloomFilter.create(Funnels.longFunnel(), EXPECTED_INSERTIONS, 0.01);
List<Long> allIds = productMapper.selectAllIds();
allIds.forEach(bloomFilter::put);
}
public boolean mightExist(Long productId) {
return bloomFilter.mightContain(productId);
}
public void addProduct(Long productId) {
bloomFilter.put(productId);
}
}
在 Service 层加上布隆过滤器校验:
kotlin
@Service
public class ProductService {
@Autowired
private ProductBloomFilter productBloomFilter;
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
public Product getProduct(Long productId) {
// 第一层:布隆过滤器判断
if (!productBloomFilter.mightExist(productId)) {
return null;
}
// 第二层:Redis 缓存
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 第三层:数据库
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
}
}
布隆过滤器的特点:
| 特性 | 说明 |
|---|---|
| 空间效率 | 100 万数据只需约 1.2MB 内存 |
| 判断结果 | 可能存在(有误判率) / 一定不存在(绝不误判) |
| 误判率 | 可配置,通常 1%,增大空间可降低 |
| 删除支持 | 不支持删除(可用 Counting Bloom Filter) |
| 适用场景 | 数据量大、ID 可枚举、允许少量误判 |
方案三:参数校验 + 限流
最基本的防线:在入口处拦截非法请求。
less
@RestController
@RequestMapping("/product")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/{id}")
public Result<Product> getProduct(@PathVariable Long id) {
// 参数校验:拒绝明显非法的 ID
if (id == null || id <= 0) {
return Result.fail("无效的商品ID");
}
Product product = productService.getProduct(id);
return Result.success(product);
}
}
配合限流,防止高频请求:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int permits() default 100;
int seconds() default 1;
}
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
RateLimit rateLimit = ((HandlerMethod) handler).getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
return true;
}
String key = "rate_limit:" + request.getRequestURI() + ":" + getClientIp(request);
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
redisTemplate.expire(key, rateLimit.seconds(), TimeUnit.SECONDS);
}
if (count != null && count > rateLimit.permits()) {
response.setStatus(429);
return false;
}
return true;
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty()) {
ip = request.getRemoteAddr();
}
return ip;
}
}
穿透方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 缓存空值 | 实现简单 | 占用内存、数据一致性问题 | 穿透量不大、ID 集合有限 |
| 布隆过滤器 | 空间小、性能高 | 有误判率、不支持删除 | 数据量大、ID 可枚举 |
| 参数校验 + 限流 | 入口层拦截 | 无法处理合法 ID 的穿透 | 第一道防线 |
生产环境推荐组合:参数校验 + 限流 + 布隆过滤器 + 缓存空值,四层防御。
解决缓存雪崩
方案一:过期时间加随机值
这是最简单的方案:给 TTL 加一个随机偏移,避免大量 key 在同一时刻过期。
java
@Service
public class ProductService {
private static final Random RANDOM = new Random();
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
public void refreshCache() {
List<Product> products = productMapper.selectAll();
for (Product p : products) {
String key = "product:" + p.getId();
String value = JSON.toJSONString(p);
// 基础 30 分钟 + 随机 0~10 分钟
long ttl = 30 + RANDOM.nextInt(10);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.MINUTES);
}
}
}
用 @Cacheable 的话,可以在 KeyGenerator 里处理:
kotlin
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public Product getProduct(Long id) {
return productMapper.selectById(id);
}
配合 TTL 随机配置:
dart
@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// 不同业务设置不同的过期时间
cacheConfigurations.put("products", config.entryTtl(Duration.ofMinutes(30)));
cacheConfigurations.put("users", config.entryTtl(Duration.ofMinutes(60)));
cacheConfigurations.put("categories", config.entryTtl(Duration.ofHours(2)));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
方案二:缓存永不过期 + 异步刷新
让缓存永不过期(或设很长的过期时间),由后台任务定期刷新。
typescript
@Service
public class CacheRefresher {
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Scheduled(fixedRate = 20 * 60 * 1000) // 每 20 分钟刷新一次
public void refreshProductCache() {
List<Product> products = productMapper.selectHotProducts();
for (Product p : products) {
String key = "product:hot:" + p.getId();
redisTemplate.opsForValue().set(key, JSON.toJSONString(p));
}
}
}
更好的方式是用双缓存策略:主缓存 + 备缓存。
java
@Service
public class DualCacheService {
private static final String MAIN_KEY = "product:main:";
private static final String BACKUP_KEY = "product:backup:";
private static final long MAIN_TTL = 30;
private static final long BACKUP_TTL = 60;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProduct(Long productId) {
// 先查主缓存
String mainKey = MAIN_KEY + productId;
String cached = redisTemplate.opsForValue().get(mainKey);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 主缓存没有,查备缓存
String backupKey = BACKUP_KEY + productId;
cached = redisTemplate.opsForValue().get(backupKey);
if (cached != null) {
// 异步刷新主缓存,不阻塞当前请求
asyncRefresh(productId);
return JSON.parseObject(cached, Product.class);
}
// 两层缓存都没有,查数据库
Product product = productMapper.selectById(productId);
if (product != null) {
setDualCache(productId, product);
}
return product;
}
private void setDualCache(Long productId, Product product) {
String value = JSON.toJSONString(product);
redisTemplate.opsForValue().set(MAIN_KEY + productId, value, MAIN_TTL, TimeUnit.MINUTES);
redisTemplate.opsForValue().set(BACKUP_KEY + productId, value, BACKUP_TTL, TimeUnit.MINUTES);
}
@Async
public void asyncRefresh(Long productId) {
Product product = productMapper.selectById(productId);
if (product != null) {
setDualCache(productId, product);
}
}
}
方案三:Redis 高可用 + 熔断降级
雪崩的另一个原因是 Redis 宕机。用高可用架构 + 熔断保护来兜底。
kotlin
@Service
public class ProductCircuitBreakerService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
private static final String CIRCUIT_BREAKER_NAME = "redisCache";
public Product getProduct(Long productId) {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(CIRCUIT_BREAKER_NAME,
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10)
.build());
Supplier<Product> cacheSupplier = CircuitBreaker.decorateSupplier(circuitBreaker, () -> {
String cached = redisTemplate.opsForValue().get("product:" + productId);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
throw new RuntimeException("cache miss");
});
// 缓存走不通就走数据库(降级策略)
Try<Product> result = Try.ofSupplier(cacheSupplier)
.recover(e -> {
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set("product:" + productId,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
});
return result.get();
}
}
或者用 Hystrix / Sentinel 做熔断:
kotlin
@SentinelResource(value = "getProduct",
fallback = "getProductFallback",
blockHandler = "getProductBlockHandler")
public Product getProduct(Long productId) {
String cached = redisTemplate.opsForValue().get("product:" + productId);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set("product:" + productId,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
}
public Product getProductFallback(Long productId, Throwable throwable) {
// Redis 挂了,直接查数据库
return productMapper.selectById(productId);
}
public Product getProductBlockHandler(Long productId, BlockException ex) {
// 被限流,返回默认值
return Product.defaultProduct();
}
雪崩方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TTL 加随机值 | 实现简单 | 无法完全避免 | 预防为主,常规场景 |
| 永不过期 + 异步刷新 | 不存在失效瞬间 | 数据有延迟、实现复杂 | 核心数据、对一致性要求不高 |
| 双缓存 | 切换平滑 | 内存翻倍 | 高并发核心链路 |
| 高可用 + 熔断 | 兜底保护 | 引入额外组件 | Redis 不稳定的环境 |
解决缓存击穿
方案一:互斥锁(Mutex Lock)
最常用的方案:只让一个请求去加载数据,其他请求等结果。
java
@Service
public class ProductService {
private static final String LOCK_PREFIX = "lock:product:";
private static final long LOCK_WAIT_SECONDS = 3;
private static final long LOCK_EXPIRE_SECONDS = 10;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProduct(Long productId) {
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 缓存未命中,尝试获取锁
String lockKey = LOCK_PREFIX + productId;
String lockValue = UUID.randomUUID().toString();
try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 获取锁成功,查数据库
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
}
return product;
} else {
// 获取锁失败,等待并重试
return waitForResult(key, productId);
}
} finally {
// 释放锁(只能释放自己加的锁)
releaseLock(lockKey, lockValue);
}
}
private Product waitForResult(String key, Long productId) {
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < LOCK_WAIT_SECONDS * 1000) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if ("NULL".equals(cached)) {
return null;
}
return JSON.parseObject(cached, Product.class);
}
}
// 超时,降级查数据库
return productMapper.selectById(productId);
}
private void releaseLock(String lockKey, String lockValue) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else " +
"return 0 " +
"end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue);
}
}
关键点:
- 锁的过期时间要大于数据库查询耗时,否则锁提前释放会导致并发问题
- 释放锁用 Lua 脚本保证原子性(判断 + 删除是一个操作)
- 等待的线程用轮询而不是阻塞,避免线程池耗尽
- 等待超时后降级到直接查数据库
方案二:逻辑过期
不设物理过期时间,在数据里存一个逻辑过期时间。发现逻辑过期后,异步刷新缓存,当前请求返回旧数据。
typescript
@Data
public class CacheData<T> implements Serializable {
private T data;
private LocalDateTime expireTime;
public boolean isExpired() {
return expireTime != null && LocalDateTime.now().isAfter(expireTime);
}
}
java
@Service
public class LogicalExpireCacheService {
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
private static final ExecutorService REBUILD_EXECUTOR =
new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy());
public Product getProduct(Long productId) {
String key = "product:logic:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached == null) {
// 第一次加载,不存在旧数据,直接查数据库
return loadAndCache(productId, key);
}
CacheData<Product> cacheData = JSON.parseObject(cached,
new TypeReference<CacheData<Product>>() {});
if (!cacheData.isExpired()) {
// 未过期,直接返回
return cacheData.getData();
}
// 逻辑过期,异步刷新
REBUILD_EXECUTOR.submit(() -> loadAndCache(productId, key));
// 先返回旧数据
return cacheData.getData();
}
private Product loadAndCache(Long productId, String key) {
Product product = productMapper.selectById(productId);
if (product != null) {
CacheData<Product> cacheData = new CacheData<>();
cacheData.setData(product);
cacheData.setExpireTime(LocalDateTime.now().plus(CACHE_TTL));
redisTemplate.opsForValue().set(key, JSON.toJSONString(cacheData));
}
return product;
}
}
逻辑过期 vs 互斥锁:
| 维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 一致性 | 强一致,所有请求拿到最新数据 | 最终一致,短暂返回旧数据 |
| 性能 | 等待锁的线程有延迟 | 不等待,直接返回旧数据 |
| 实现复杂度 | 中等 | 较高 |
| 线程安全 | 锁保证 | 需要处理并发重建 |
| 适用场景 | 对一致性要求高 | 对性能要求高、能容忍旧数据 |
方案三:热点 key 永不过期 + 手动刷新
对于明确的少数热点 key(秒杀商品、热门文章),干脆设永不过期,通过业务事件触发刷新。
typescript
@Service
public class HotKeyCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProduct(Long productId) {
String key = "product:hot:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
return loadAndCache(productId, key);
}
public void onProductUpdate(Long productId) {
// 商品更新时主动刷新缓存
String key = "product:hot:" + productId;
loadAndCache(productId, key);
}
public void onProductDelete(Long productId) {
// 商品删除时主动删除缓存
redisTemplate.delete("product:hot:" + productId);
}
private Product loadAndCache(Long productId, String key) {
Product product = productMapper.selectById(productId);
if (product != null) {
// 热点 key 不设过期时间
redisTemplate.opsForValue().set(key, JSON.toJSONString(product));
}
return product;
}
}
通过消息队列同步刷新多节点缓存:
less
@Component
public class CacheSyncListener {
@Autowired
private HotKeyCacheService hotKeyCacheService;
@Autowired
private StringRedisTemplate redisTemplate;
@RabbitListener(queues = "cache.sync.queue")
public void onCacheSync(CacheSyncMessage message) {
if ("REFRESH".equals(message.getAction())) {
hotKeyCacheService.onProductUpdate(message.getProductId());
} else if ("DELETE".equals(message.getAction())) {
redisTemplate.delete("product:hot:" + message.getProductId());
}
}
}
击穿方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 互斥锁 | 强一致 | 等待锁有延迟 | 对一致性要求高 |
| 逻辑过期 | 不等待 | 有短暂脏数据 | 对性能要求高 |
| 热点永不过期 | 简单可靠 | 需要手动管理 | 明确的热点 key |
完整的缓存防护体系
把三个问题的解决方案组合起来,构建完整的防护体系。
多级缓存架构
统一缓存服务
kotlin
@Service
@Slf4j
public class UnifiedCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductBloomFilter productBloomFilter;
private static final String NULL_CACHE = "NULL";
private static final long NULL_TTL_MINUTES = 5;
private static final String LOCK_PREFIX = "lock:";
private static final long LOCK_EXPIRE_SECONDS = 10;
private static final Random RANDOM = new Random();
public <T> T get(String key, Class<T> clazz, Supplier<T> dbLoader) {
return get(key, clazz, dbLoader, 30, TimeUnit.MINUTES);
}
public <T> T get(String key, Class<T> clazz, Supplier<T> dbLoader,
long ttl, TimeUnit timeUnit) {
// 第一层:查缓存
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if (NULL_CACHE.equals(cached)) {
return null;
}
return JSON.parseObject(cached, clazz);
}
// 第二层:互斥锁防击穿
String lockKey = LOCK_PREFIX + key;
String lockValue = UUID.randomUUID().toString();
try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 双重检查:拿到锁后再查一次缓存
cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if (NULL_CACHE.equals(cached)) {
return null;
}
return JSON.parseObject(cached, clazz);
}
// 查数据库
T data = dbLoader.get();
if (data != null) {
// TTL 加随机偏移防雪崩
long randomTtl = ttl + RANDOM.nextInt((int) (ttl / 3));
redisTemplate.opsForValue().set(key, JSON.toJSONString(data),
randomTtl, timeUnit);
} else {
// 缓存空值防穿透
redisTemplate.opsForValue().set(key, NULL_CACHE,
NULL_TTL_MINUTES, TimeUnit.MINUTES);
}
return data;
} else {
// 等待其他线程加载缓存
return waitForResult(key, clazz);
}
} finally {
releaseLock(lockKey, lockValue);
}
}
private <T> T waitForResult(String key, Class<T> clazz) {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if (NULL_CACHE.equals(cached)) {
return null;
}
return JSON.parseObject(cached, clazz);
}
}
return null;
}
private void releaseLock(String lockKey, String lockValue) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue);
}
}
使用:
kotlin
@Service
public class ProductService {
@Autowired
private UnifiedCacheService cacheService;
@Autowired
private ProductMapper productMapper;
@Autowired
private ProductBloomFilter bloomFilter;
public Product getProduct(Long productId) {
// 布隆过滤器前置判断
if (!bloomFilter.mightExist(productId)) {
return null;
}
return cacheService.get("product:" + productId, Product.class,
() -> productMapper.selectById(productId));
}
}
Spring Cache 源码分析:缓存注解的执行流程
@Cacheable 注解解析
Spring Cache 的核心是 CacheInterceptor,它是一个 AOP 拦截器,拦截所有标注了缓存注解的方法。
java
// org.springframework.cache.annotation.Cacheable
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Cacheable {
@AliasFor("cacheNames")
String[] value() default {};
String key() default "";
String keyGenerator() default "";
String cacheManager() default "";
String cacheResolver() default "";
String condition() default "";
String unless() default "";
boolean sync() default false;
}
注意 sync 参数------Spring Cache 内置了同步模式来防击穿。
CacheInterceptor 拦截流程
scala
// org.springframework.cache.interceptor.CacheInterceptor
public class CacheInterceptor extends CacheAspectSupport implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
CacheOperationInvoker aopInvoker = () -> {
try {
return invocation.proceed();
} catch (Throwable ex) {
throw new CacheOperationInvoker.ThrowableWrapper(ex);
}
};
// 执行缓存操作
return execute(aopInvoker, invocation.getThis(), method, invocation.getArguments());
}
}
核心逻辑在父类 CacheAspectSupport.execute() 中:
scss
// org.springframework.cache.interceptor.CacheAspectSupport(简化)
private Object execute(CacheOperationInvoker invoker, Object target, Method method, Object[] args) {
// 1. 解析注解,获取 CacheOperationMetadata
CacheOperationContexts contexts = createOperationContexts(operations, method, args, target, targetClass);
// 2. 执行 @Cacheable 的查询
Cache.ValueWrapper result = findCachedItem(contexts);
if (result == null) {
// 3. 缓存未命中,执行原方法
Object returnValue = invokeOperation(invoker);
// 4. 执行 @CachePut(如果有)
cachePut(contexts, returnValue);
// 5. 执行 @CacheEvict(如果有)
cacheEvict(contexts);
}
return result != null ? result.get() : returnValue;
}
sync 模式的实现
sync = true 时,Spring Cache 使用 Cache.get() 的同步版本来防击穿:
kotlin
// org.springframework.cache.interceptor.CacheAspectSupport
private Object findCachedItem(CacheOperationContexts contexts) {
for (CacheOperationContext context : contexts.get(CacheableOperation.class)) {
CacheableOperation operation = (CacheableOperation) context.getOperation();
if (operation.isSync()) {
// 同步模式:只允许一个线程加载,其他线程等待
return context.getCache().get(context.getKey(), () -> {
return invokeOperation(invoker);
});
} else {
// 非同步模式:并发时多个线程都会加载
Cache.ValueWrapper wrapper = context.getCache().get(context.getKey());
if (wrapper != null) {
return wrapper.get();
}
}
}
return null;
}
Cache.get(key, valueLoader) 的底层实现(以 RedisCache 为例):
scss
// org.springframework.data.redis.cache.RedisCache
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
byte[] cacheKey = createCacheKey(key);
byte[] rawValue = cacheWriter.get(name, cacheKey);
if (rawValue != null) {
return deserialize(rawValue);
}
// 缓存未命中,使用同步块加载
synchronized (key) {
// 双重检查
rawValue = cacheWriter.get(name, cacheKey);
if (rawValue != null) {
return deserialize(rawValue);
}
// 加载数据
T value = valueLoader.call();
put(key, value);
return value;
}
}
使用 sync 模式:
kotlin
@Cacheable(value = "products", key = "#id", sync = true)
public Product getProduct(Long id) {
return productMapper.selectById(id);
}
加上 sync = true 后,Spring Cache 自动帮你防击穿。但它只解决了击穿问题,穿透和雪崩需要另外处理。
RedisCache 源码:TTL 的处理
scala
// org.springframework.data.redis.cache.RedisCache
class RedisCache extends AbstractValueAdaptingCache {
private final RedisCacheConfiguration cacheConfig;
private final RedisCacheWriter cacheWriter;
private final String name;
@Override
public void put(Object key, Object value) {
byte[] cacheKey = createCacheKey(key);
byte[] cacheValue = serialize(value);
if (cacheConfig.getTtl().isZero()) {
cacheWriter.write(name, cacheKey, cacheValue);
} else {
// 用配置的 TTL 写入
cacheWriter.write(name, cacheKey, cacheValue, cacheConfig.getTtl());
}
}
}
默认的 RedisCacheConfiguration 使用固定的 TTL。如果要让每个 key 有不同的 TTL,需要自定义 RedisCacheWriter。
实战案例:电商商品缓存
把所有方案整合到一个完整的电商商品缓存服务中。
配置类
less
@Configuration
@EnableCaching
@EnableScheduling
public class CacheConfiguration {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
Map<String, RedisCacheConfiguration> configs = new HashMap<>();
configs.put("hotProducts", defaultConfig.entryTtl(Duration.ofHours(1)));
configs.put("categories", defaultConfig.entryTtl(Duration.ofHours(4)));
configs.put("searchResults", defaultConfig.entryTtl(Duration.ofMinutes(10)));
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configs)
.transactionAware()
.build();
}
}
商品缓存服务
typescript
@Service
@Slf4j
public class ProductCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
@Autowired
private ProductBloomFilter bloomFilter;
@Autowired
private UnifiedCacheService cacheService;
private static final String HOT_KEY_PREFIX = "product:hot:";
private static final String NORMAL_KEY_PREFIX = "product:";
public Product getProduct(Long productId) {
// 第一层:布隆过滤器
if (!bloomFilter.mightExist(productId)) {
log.debug("布隆过滤器拦截,productId={}", productId);
return null;
}
// 第二层:查缓存
String key = NORMAL_KEY_PREFIX + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 第三层:互斥锁 + 数据库
return cacheService.get(key, Product.class,
() -> productMapper.selectById(productId), 30, TimeUnit.MINUTES);
}
public Product getHotProduct(Long productId) {
// 热点商品:逻辑过期
String key = HOT_KEY_PREFIX + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
CacheData<Product> cacheData = JSON.parseObject(cached,
new TypeReference<CacheData<Product>>() {});
if (!cacheData.isExpired()) {
return cacheData.getData();
}
// 逻辑过期,异步刷新
CompletableFuture.runAsync(() -> {
Product product = productMapper.selectById(productId);
if (product != null) {
CacheData<Product> newData = new CacheData<>();
newData.setData(product);
newData.setExpireTime(LocalDateTime.now().plusMinutes(30));
redisTemplate.opsForValue().set(key, JSON.toJSONString(newData));
}
});
return cacheData.getData();
}
return loadHotProduct(productId, key);
}
private Product loadHotProduct(Long productId, String key) {
Product product = productMapper.selectById(productId);
if (product != null) {
CacheData<Product> cacheData = new CacheData<>();
cacheData.setData(product);
cacheData.setExpireTime(LocalDateTime.now().plusMinutes(30));
redisTemplate.opsForValue().set(key, JSON.toJSONString(cacheData));
}
return product;
}
@Scheduled(fixedRate = 20 * 60 * 1000)
public void refreshHotProducts() {
List<Long> hotIds = productMapper.selectHotProductIds();
for (Long id : hotIds) {
try {
loadHotProduct(id, HOT_KEY_PREFIX + id);
} catch (Exception e) {
log.error("刷新热点商品缓存失败, productId={}", id, e);
}
}
}
}
监控缓存命中率
ini
@Component
@Slf4j
public class CacheMonitor {
@Autowired
private StringRedisTemplate redisTemplate;
@Scheduled(fixedRate = 60 * 1000)
public void monitorCacheHitRate() {
Properties info = redisTemplate.getRequiredConnectionFactory()
.getConnection().info("stats");
String keyspaceHits = info.getProperty("keyspace_hits");
String keyspaceMisses = info.getProperty("keyspace_misses");
long hits = Long.parseLong(keyspaceHits != null ? keyspaceHits : "0");
long misses = Long.parseLong(keyspaceMisses != null ? keyspaceMisses : "0");
if (hits + misses > 0) {
double hitRate = (double) hits / (hits + misses) * 100;
log.info("缓存命中率: {:.2f}% (hits={}, misses={})", hitRate, hits, misses);
if (hitRate < 80) {
log.warn("缓存命中率低于 80%,可能存在穿透问题!");
}
}
}
}
最佳实践总结
| 场景 | 方案 | 配置建议 |
|---|---|---|
| 预防穿透 | 布隆过滤器 + 空值缓存 | 空值 TTL 2-5 分钟,布隆过滤器误判率 1% |
| 预防雪崩 | TTL 加随机值 + 双缓存 | 基础 TTL + 随机 1/3,备缓存 TTL 是主缓存的 2 倍 |
| 预防击穿 | 互斥锁或逻辑过期 | 锁超时 10 秒,逻辑过期比物理过期短 1/3 |
| 热点 key | 永不过期 + 异步刷新 | 定时刷新间隔 < 物理过期时间的 1/2 |
| 兜底保护 | 熔断降级 | 失败率 50% 触发熔断,等待 30 秒半开 |
监控告警指标:
| 指标 | 告警阈值 | 说明 |
|---|---|---|
| 缓存命中率 | < 80% | 可能存在穿透 |
| Redis 连接数 | > 80% 最大连接数 | 连接池可能不够 |
| 数据库 QPS | > 正常值 2 倍 | 缓存可能大面积失效 |
| 接口平均耗时 | > 100ms | 缓存可能未命中 |
| Redis 内存使用率 | > 80% | 需要清理或扩容 |
一个生产级别的缓存防护配置模板:
yaml
spring:
redis:
host: 127.0.0.1
port: 6379
password: ${REDIS_PASSWORD}
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 10
max-wait: 3000ms
shutdown-timeout: 200ms
app:
cache:
null-ttl: 5m
default-ttl: 30m
hot-ttl: 60m
lock-timeout: 10s
lock-wait-timeout: 3s
bloom-filter:
expected-insertions: 1000000
false-positive-rate: 0.01
circuit-breaker:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
ini
@Configuration
@ConfigurationProperties(prefix = "app.cache")
@Data
public class CacheProperties {
private Duration nullTtl = Duration.ofMinutes(5);
private Duration defaultTtl = Duration.ofMinutes(30);
private Duration hotTtl = Duration.ofMinutes(60);
private Duration lockTimeout = Duration.ofSeconds(10);
private Duration lockWaitTimeout = Duration.ofSeconds(3);
private BloomFilterProperties bloomFilter = new BloomFilterProperties();
private CircuitBreakerProperties circuitBreaker = new CircuitBreakerProperties();
@Data
public static class BloomFilterProperties {
private long expectedInsertions = 1000000;
private double falsePositiveRate = 0.01;
}
@Data
public static class CircuitBreakerProperties {
private int failureRateThreshold = 50;
private Duration waitDurationInOpenState = Duration.ofSeconds(30);
private int slidingWindowSize = 10;
}
}
总结
| 问题 | 根因 | 核心方案 | 关键代码 |
|---|---|---|---|
| 穿透 | 数据不存在 | 布隆过滤器 + 缓存空值 | bloomFilter.mightContain() + set(key, "NULL", 5min) |
| 雪崩 | 大量 key 同时失效 | TTL 随机 + 双缓存 + 高可用 | ttl + random(ttl/3) + backup cache |
| 击穿 | 热点 key 过期 | 互斥锁 / 逻辑过期 | setIfAbsent(lockKey) / CacheData.expireTime |
缓存三大问题,思路只有一个:把打到数据库的请求降到最少。穿透靠前置过滤和空值兜底,雪崩靠打散过期时间和备份,击穿靠加锁排队或用旧数据过渡。三套方案组合起来,再配上监控告警,线上基本稳了。