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

57 阅读20分钟

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

img

凌晨 3 点,手机疯狂告警。打开监控一看:Redis 连接数正常,但数据库连接池满了,CPU 飙到 95%。查日志发现大量查询走穿了缓存,全打到数据库。最后定位到原因:一个爬虫用不存在的 ID 疯狂请求商品接口,每次都穿透缓存打到数据库。修了一个空值缓存,5 分钟恢复正常。这是缓存穿透的典型案例。生产环境用缓存,只考虑"读写"远远不够。穿透、雪崩、击穿这三个问题不处理,迟早要出线上事故。

问题:缓存为什么不是万能的

上一篇文章讲了 Redis 集成和 @Cacheable 的用法。大多数教程到这里就结束了。但线上环境真正的坑不在"怎么用缓存",而在"缓存不生效时系统会怎样"。

看一个典型场景:

@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 个请求全去查数据库。

三个问题的后果一样:缓存没了,请求全打到数据库,数据库扛不住,挂了。

images/cache-three-problems.svg

三大问题详解

缓存穿透

定义:请求的数据在缓存和数据库中都不存在,每次请求都绕过缓存直接打到数据库。

产生原因

  • 业务层没做参数校验,传了非法 ID(如 -1、0、超范围 ID)
  • 攻击者用脚本批量探测不存在的数据
  • 数据被物理删除,但缓存中没有同步清理

危害:攻击者用 1 台机器就能发起 10000 次/s 的无效请求,数据库直接被打挂。而且这个问题在常规监控里不容易发现——Redis 命中率可能并不低(因为有效缓存还在),只是无效请求全部穿透了。

// 穿透场景模拟
@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 集中过期的时刻,数据库负载瞬间飙升。雪崩效应会让数据库连接池在几秒内耗尽,整个系统不可用。

// 雪崩场景:所有 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 加起来还高。

// 击穿场景:秒杀商品
@GetMapping("/seckill/{productId}")
public Product getSeckillProduct(@PathVariable Long productId) {
    // 秒杀商品缓存过期的一瞬间,可能有上千个并发请求
    // 全部发现缓存为空,全部去查数据库
    return productService.getProduct(productId);
}

三者对比

维度穿透雪崩击穿
根本原因数据不存在大量 key 同时失效热点 key 过期
请求特征无效请求打到 DB有效请求集中打到 DB有效请求集中打到 DB
涉及 key 数量不存在的 key大量正常 key单个热点 key
发生条件持续发生特定时刻爆发热点 key 过期瞬间
发现难度难(有效缓存指标正常)易(监控可见)中等(需关注热点 key)

解决方案总览

images/cache-solution-overview.svg

解决缓存穿透

方案一:缓存空值

最直接的办法:查不到数据也缓存,缓存一个空值。

@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 直接拒绝。

@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:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.0.0-jre</version>
</dependency>
@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 层加上布隆过滤器校验:

@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 可枚举、允许少量误判

方案三:参数校验 + 限流

最基本的防线:在入口处拦截非法请求。

@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);
    }
}

配合限流,防止高频请求:

@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 在同一时刻过期。

@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 里处理:

@Cacheable(value = "products", key = "#id", unless = "#result == null")
public Product getProduct(Long id) {
    return productMapper.selectById(id);
}

配合 TTL 随机配置:

@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();
    }
}

方案二:缓存永不过期 + 异步刷新

让缓存永不过期(或设很长的过期时间),由后台任务定期刷新。

@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));
        }
    }
}

更好的方式是用双缓存策略:主缓存 + 备缓存。

@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 宕机。用高可用架构 + 熔断保护来兜底。

@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 做熔断:

@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)

最常用的方案:只让一个请求去加载数据,其他请求等结果。

@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 脚本保证原子性(判断 + 删除是一个操作)
  • 等待的线程用轮询而不是阻塞,避免线程池耗尽
  • 等待超时后降级到直接查数据库

方案二:逻辑过期

不设物理过期时间,在数据里存一个逻辑过期时间。发现逻辑过期后,异步刷新缓存,当前请求返回旧数据。

@Data
public class CacheData<T> implements Serializable {
    private T data;
    private LocalDateTime expireTime;

    public boolean isExpired() {
        return expireTime != null && LocalDateTime.now().isAfter(expireTime);
    }
}
@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(秒杀商品、热门文章),干脆设永不过期,通过业务事件触发刷新。

@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;
    }
}

通过消息队列同步刷新多节点缓存:

@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

完整的缓存防护体系

把三个问题的解决方案组合起来,构建完整的防护体系。

多级缓存架构

images/cache-defense-layers.svg

统一缓存服务

@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);
    }
}

使用:

@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 拦截器,拦截所有标注了缓存注解的方法。

// 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 拦截流程

// 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() 中:

// 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() 的同步版本来防击穿:

// 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 为例):

// 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 模式:

@Cacheable(value = "products", key = "#id", sync = true)
public Product getProduct(Long id) {
    return productMapper.selectById(id);
}

加上 sync = true 后,Spring Cache 自动帮你防击穿。但它只解决了击穿问题,穿透和雪崩需要另外处理。

RedisCache 源码:TTL 的处理

// 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

实战案例:电商商品缓存

把所有方案整合到一个完整的电商商品缓存服务中。

配置类

@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();
    }
}

商品缓存服务

@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);
            }
        }
    }
}

监控缓存命中率

@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%需要清理或扩容

一个生产级别的缓存防护配置模板

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
@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

缓存三大问题,思路只有一个:把打到数据库的请求降到最少。穿透靠前置过滤和空值兜底,雪崩靠打散过期时间和备份,击穿靠加锁排队或用旧数据过渡。三套方案组合起来,再配上监控告警,线上基本稳了。