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

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

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

相关推荐
java小白小1 小时前
SpringBoot(08):Redis 集成——5 分钟给你的项目加上缓存
后端
LiuMingXin1 小时前
意图与代码之间:AI编程范式全景解读
前端·后端·面试
用户34232323763172 小时前
边缘计算与云边协同——当采集不再只是“上传“
后端
壹方秘境2 小时前
ApiCatcher支持抓包HTTP传输大文件的实现原理分享
前端·后端·客户端
神奇小汤圆2 小时前
2026最新·最全·最实用|Java岗面试真题(已收录GitHub)
后端
神奇小汤圆2 小时前
面试官当场让我手写Java线程安全工具类,我写完直接拿到了35K offer
后端
久美子3 小时前
Qoder 使用指南:从配置到落地
后端
tyung3 小时前
Go 手写 Wait-Free MPSC 无界队列:SwapPointer 实现多生产者无锁入队
后端·go
张不才3 小时前
CPU 100% 了怎么办?Java 性能排障的标准化操作
java·后端