跳转到内容
123xiao | 无名键客

《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:缓存穿透、击穿与雪崩的完整治理方案》

字数: 0 阅读时长: 1 分钟

Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:缓存穿透、击穿与雪崩的完整治理方案

在业务量还不大时,很多项目的缓存方案都很“朴素”:
查 Redis,没有就查数据库,再回填 Redis。

一开始没问题,但一旦流量上来,问题会一个个冒出来:

  • 某些不存在的数据被频繁查询,数据库白白挨打:缓存穿透
  • 某个热点 key 恰好过期,大量请求同时打到数据库:缓存击穿
  • 一批 key 同时过期,整个缓存层像“塌方”一样失效:缓存雪崩
  • 只有 Redis 一层缓存时,应用本地重复反序列化、重复网络 IO,也会让吞吐上不去

这篇文章我会带你从一个可运行的 Spring Boot 项目出发,搭出一个常见而实用的方案:

  • Spring Cache 统一缓存编程模型
  • Caffeine 作为本地一级缓存
  • Redis 作为分布式二级缓存
  • 配套治理:
    • 空值缓存防穿透
    • 布隆过滤器进一步拦截非法 key
    • 分布式锁/逻辑过期防击穿
    • TTL 加随机值、防雪崩
    • 热点 key 预热与监控

这不是“概念堆砌”的文章,我尽量按“真要上线该怎么做”的顺序来讲。


背景与问题

先明确一个常见误区:
用了 Redis,不等于缓存问题解决了。

Redis 解决的是“远程高性能缓存”,但在高并发业务里,通常还会遇到两个现实问题:

  1. 每次都走网络访问 Redis

    • 对于极热数据,网络 RTT、序列化/反序列化仍有开销
    • 同一个应用实例内频繁访问同一个 key,其实很适合先走本地缓存
  2. 单靠一个缓存层难以扛住异常流量

    • 恶意或脏请求会穿透缓存直达数据库
    • 热点 key 失效时会形成“瞬时并发洪峰”
    • 大量 key 统一过期会造成数据库雪崩式压力

所以,多级缓存的目标不是“把架构搞复杂”,而是为了同时解决:

  • 低延迟
  • 高命中
  • 可控失效
  • 异常流量兜底

前置知识与环境准备

本文示例技术栈:

  • JDK 17
  • Spring Boot 3.x
  • Spring Cache
  • Redis
  • Caffeine
  • Maven

示例场景:
我们实现一个商品查询接口:

GET /products/{id}

缓存策略:

  • L1 本地缓存:Caffeine
  • L2 分布式缓存:Redis
  • 最终数据源:MySQL(这里用内存 Map 模拟)

Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

核心原理

先看整体流程图。

flowchart TD
    A[请求 /products/id] --> B{L1 Caffeine 命中?}
    B -- 是 --> C[直接返回]
    B -- 否 --> D{L2 Redis 命中?}
    D -- 是 --> E[回填 L1 并返回]
    D -- 否 --> F{布隆过滤器存在?}
    F -- 否 --> G[直接返回空/404]
    F -- 是 --> H{尝试获取分布式锁}
    H -- 成功 --> I[查询数据库]
    I --> J[写入 Redis 含随机TTL]
    J --> K[写入 L1]
    K --> L[释放锁并返回]
    H -- 失败 --> M[短暂等待后重试 Redis]
    M --> D

1. 多级缓存的职责划分

L1:Caffeine 本地缓存

适合存放:

  • 超高频热点数据
  • 访问延迟要求非常低的数据
  • 单实例内重复请求很多的数据

优点:

  • 速度快,基本是进程内访问
  • 减少 Redis 压力
  • 减少网络与序列化开销

缺点:

  • 数据只在当前实例有效
  • 多实例之间不天然一致

L2:Redis 分布式缓存

适合存放:

  • 多实例共享的数据
  • 容量比本地缓存大得多
  • 具备统一失效与共享能力

优点:

  • 跨实例共享
  • 性能高
  • 生态成熟

缺点:

  • 仍是远程访问
  • 大规模失效、热点争抢时仍可能出问题

2. Spring Cache 在这里扮演什么角色

Spring Cache 的价值不是“性能更高”,而是:

  • 统一注解式缓存入口
  • 解耦业务代码与缓存实现
  • 方便扩展不同 CacheManager

典型注解:

  • @Cacheable:查缓存,未命中则执行方法并缓存结果
  • @CachePut:执行方法并更新缓存
  • @CacheEvict:删除缓存

不过要注意一点:
Spring Cache 默认并不直接提供“多级缓存联动”能力。
我们通常会自己实现一个 Cache,内部组合 Caffeine + Redis。


3. 三大缓存问题的治理思路

缓存穿透

现象:查询一个根本不存在的数据,每次缓存都 miss,最终都打到数据库。

治理手段:

  • 缓存空对象(短 TTL)
  • 布隆过滤器拦截不存在 ID
  • 参数校验,拦掉明显非法请求

缓存击穿

现象:某个热点 key 过期瞬间,大量并发同时查库。

治理手段:

  • 分布式锁
  • 热点 key 逻辑过期 + 后台异步刷新
  • 永不过期 + 主动更新(适合少量核心热点)

缓存雪崩

现象:大量 key 同时过期,短时间内数据库被打爆。

治理手段:

  • TTL 加随机抖动
  • 多级缓存兜底
  • 缓存预热
  • Redis 高可用、限流降级

实战代码(可运行)

下面我们实现一个简化版但结构完整的示例。


1. application.yml

server:
  port: 8080

spring:
  cache:
    type: none
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms

logging:
  level:
    root: info

这里把 spring.cache.type 设为 none,是因为我们会手动注册多级 CacheManager


2. 启动类

package com.example.multicache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication
@EnableCaching
public class MultiCacheApplication {
    public static void main(String[] args) {
        SpringApplication.run(MultiCacheApplication.class, args);
    }
}

3. 商品实体

package com.example.multicache.model;

import java.io.Serializable;
import java.math.BigDecimal;

public class Product implements Serializable {
    private Long id;
    private String name;
    private BigDecimal price;

    public Product() {
    }

    public Product(Long id, String name, BigDecimal price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }
}

4. 模拟数据库

为了让示例可直接运行,我们先用内存 Map 模拟数据库。

package com.example.multicache.repository;

import com.example.multicache.model.Product;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class ProductRepository {

    private final Map<Long, Product> db = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        db.put(1L, new Product(1L, "iPhone", new BigDecimal("6999")));
        db.put(2L, new Product(2L, "MacBook", new BigDecimal("12999")));
        db.put(3L, new Product(3L, "AirPods", new BigDecimal("1499")));
    }

    public Product findById(Long id) {
        try {
            Thread.sleep(100); // 模拟数据库耗时
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return db.get(id);
    }

    public Product save(Product product) {
        db.put(product.getId(), product);
        return product;
    }

    public void deleteById(Long id) {
        db.remove(id);
    }

    public boolean exists(Long id) {
        return db.containsKey(id);
    }
}

5. Redis 序列化配置

这是很多人第一次接 Spring Cache 会踩的坑:
默认序列化不友好,调试困难,还可能出现类型转换问题。
建议直接用 JSON。

package com.example.multicache.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.*;

@Configuration
public class RedisSerializerConfig {

    @Bean
    public RedisSerializer<Object> redisValueSerializer() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        return new GenericJackson2JsonRedisSerializer(mapper);
    }

    @Bean
    public RedisSerializer<String> redisKeySerializer() {
        return new StringRedisSerializer();
    }
}

6. 简单布隆过滤器实现

生产环境建议用 Redisson BloomFilter 或 RedisBloom。
这里为了示例自包含,我们实现一个极简版本地布隆过滤器。

package com.example.multicache.support;

import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;

import java.util.BitSet;

@Component
public class SimpleBloomFilter {

    private final BitSet bits = new BitSet(1 << 24);
    private static final int SIZE = 1 << 24;

    @PostConstruct
    public void init() {
        add("1");
        add("2");
        add("3");
    }

    public void add(String value) {
        int h1 = hash1(value);
        int h2 = hash2(value);
        bits.set(h1 % SIZE);
        bits.set(h2 % SIZE);
    }

    public boolean mightContain(String value) {
        int h1 = hash1(value);
        int h2 = hash2(value);
        return bits.get(h1 % SIZE) && bits.get(h2 % SIZE);
    }

    private int hash1(String value) {
        return Math.abs(value.hashCode());
    }

    private int hash2(String value) {
        int h = 0;
        for (char c : value.toCharArray()) {
            h = 31 * h + c + 7;
        }
        return Math.abs(h);
    }
}

7. 分布式锁工具

这里用 Redis SETNX + EXPIRE 的方式简化实现。

package com.example.multicache.support;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.UUID;

@Component
public class RedisLockSupport {

    private final StringRedisTemplate stringRedisTemplate;

    public RedisLockSupport(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public String tryLock(String key, Duration duration) {
        String token = UUID.randomUUID().toString();
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, token, duration);
        return Boolean.TRUE.equals(success) ? token : null;
    }

    public void unlock(String key, String token) {
        String value = stringRedisTemplate.opsForValue().get(key);
        if (token != null && token.equals(value)) {
            stringRedisTemplate.delete(key);
        }
    }
}

严格来说,释放锁最好用 Lua 保证“比较 token + 删除”原子性。后面“常见坑”会专门讲。


8. 多级缓存核心实现

我们自定义一个 Cache,内部同时操作 Caffeine 和 Redis。

package com.example.multicache.cache;

import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.data.redis.core.RedisTemplate;

import java.time.Duration;
import java.util.concurrent.Callable;
import java.util.concurrent.ThreadLocalRandom;

public class MultiLevelCache extends AbstractValueAdaptingCache {

    public static final String NULL_VALUE = "__NULL__";

    private final String name;
    private final Cache<Object, Object> caffeineCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final Duration ttl;
    private final Duration nullTtl;

    public MultiLevelCache(String name,
                           Cache<Object, Object> caffeineCache,
                           RedisTemplate<String, Object> redisTemplate,
                           Duration ttl,
                           Duration nullTtl) {
        super(true);
        this.name = name;
        this.caffeineCache = caffeineCache;
        this.redisTemplate = redisTemplate;
        this.ttl = ttl;
        this.nullTtl = nullTtl;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public Object getNativeCache() {
        return this;
    }

    private String buildKey(Object key) {
        return name + "::" + key;
    }

    @Override
    protected Object lookup(Object key) {
        Object local = caffeineCache.getIfPresent(key);
        if (local != null) {
            return fromStoreValue(local);
        }

        Object remote = redisTemplate.opsForValue().get(buildKey(key));
        if (remote != null) {
            caffeineCache.put(key, remote);
            return fromStoreValue(remote);
        }
        return null;
    }

    @Override
    public void put(Object key, Object value) {
        Object storeValue = toStoreValue(value);
        caffeineCache.put(key, storeValue);

        Duration expire = (value == null) ? nullTtl : withJitter(ttl);
        redisTemplate.opsForValue().set(buildKey(key), storeValue, expire);
    }

    @Override
    public ValueWrapper putIfAbsent(Object key, Object value) {
        ValueWrapper existing = get(key);
        if (existing != null) {
            return existing;
        }
        put(key, value);
        return null;
    }

    @Override
    public void evict(Object key) {
        caffeineCache.invalidate(key);
        redisTemplate.delete(buildKey(key));
    }

    @Override
    public void clear() {
        caffeineCache.invalidateAll();
    }

    @Override
    public <T> T get(Object key, Callable<T> valueLoader) {
        ValueWrapper wrapper = get(key);
        if (wrapper != null) {
            return (T) wrapper.get();
        }
        try {
            T value = valueLoader.call();
            put(key, value);
            return value;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private Duration withJitter(Duration base) {
        long extraSeconds = ThreadLocalRandom.current().nextLong(30);
        return base.plusSeconds(extraSeconds);
    }
}

9. CacheManager 配置

package com.example.multicache.config;

import com.example.multicache.cache.MultiLevelCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.transaction.AbstractTransactionSupportingCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;

import java.time.Duration;
import java.util.Collection;
import java.util.List;

@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
        return new AbstractTransactionSupportingCacheManager() {
            @Override
            protected Collection<? extends Cache> loadCaches() {
                MultiLevelCache productCache = new MultiLevelCache(
                        "product",
                        Caffeine.newBuilder()
                                .maximumSize(10_000)
                                .expireAfterWrite(Duration.ofMinutes(5))
                                .build(),
                        redisTemplate,
                        Duration.ofMinutes(10),
                        Duration.ofMinutes(2)
                );
                return List.of(productCache);
            }

            @Override
            protected Cache getMissingCache(String name) {
                return null;
            }
        };
    }
}

10. RedisTemplate 配置

package com.example.multicache.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

@Configuration
public class RedisTemplateConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory factory,
            RedisSerializer<String> redisKeySerializer,
            RedisSerializer<Object> redisValueSerializer) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(redisKeySerializer);
        template.setHashKeySerializer(redisKeySerializer);
        template.setValueSerializer(redisValueSerializer);
        template.setHashValueSerializer(redisValueSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

11. 业务服务

这里是重点:

  • 先用 @Cacheable 走多级缓存
  • 再结合布隆过滤器 + 分布式锁治理穿透与击穿
package com.example.multicache.service;

import com.example.multicache.model.Product;
import com.example.multicache.repository.ProductRepository;
import com.example.multicache.support.RedisLockSupport;
import com.example.multicache.support.SimpleBloomFilter;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.Duration;

@Service
public class ProductService {

    private final ProductRepository repository;
    private final SimpleBloomFilter bloomFilter;
    private final RedisLockSupport redisLockSupport;

    public ProductService(ProductRepository repository,
                          SimpleBloomFilter bloomFilter,
                          RedisLockSupport redisLockSupport) {
        this.repository = repository;
        this.bloomFilter = bloomFilter;
        this.redisLockSupport = redisLockSupport;
    }

    @org.springframework.cache.annotation.Cacheable(
            cacheNames = "product",
            key = "#id",
            unless = "#result == null"
    )
    public Product getById(Long id) {
        if (id == null || id <= 0) {
            return null;
        }

        if (!bloomFilter.mightContain(String.valueOf(id))) {
            return null;
        }

        String lockKey = "lock:product:" + id;
        String token = redisLockSupport.tryLock(lockKey, Duration.ofSeconds(5));

        try {
            if (token == null) {
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
            return repository.findById(id);
        } finally {
            redisLockSupport.unlock(lockKey, token);
        }
    }

    @CachePut(cacheNames = "product", key = "#result.id")
    public Product updatePrice(Long id, BigDecimal price) {
        Product product = repository.findById(id);
        if (product == null) {
            return null;
        }
        product.setPrice(price);
        repository.save(product);
        return product;
    }

    @CacheEvict(cacheNames = "product", key = "#id")
    public void delete(Long id) {
        repository.deleteById(id);
    }
}

这里我故意保留了一个值得讨论的点:
@Cacheable(unless = "#result == null") 会导致 null 不缓存
而我们上面的 MultiLevelCache 是支持 null 缓存的。
这两者矛盾怎么办?

答案是:在穿透治理场景里,不要写 unless = "#result == null"
否则空值缓存能力根本没启用。

所以更合理的版本应该是:

@org.springframework.cache.annotation.Cacheable(
        cacheNames = "product",
        key = "#id"
)
public Product getById(Long id) {
    ...
}

这也是 Spring Cache 实战中非常容易被忽略的地方。我自己第一次接手别人项目时,就因为这个表达式把“防穿透”能力直接写没了。


12. Controller

package com.example.multicache.controller;

import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public Product get(@PathVariable Long id) {
        return productService.getById(id);
    }

    @PutMapping("/{id}/price")
    public Product updatePrice(@PathVariable Long id, @RequestParam BigDecimal price) {
        return productService.updatePrice(id, price);
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable Long id) {
        productService.delete(id);
    }
}

逐步验证清单

你可以按这个顺序验证方案是否生效。

1. 验证缓存命中路径

第一次请求:

curl http://localhost:8080/products/1

预期:

  • L1 miss
  • L2 miss
  • 查“数据库”
  • 写 Redis
  • 写 Caffeine

第二次请求同一个 key:

curl http://localhost:8080/products/1

预期:

  • 直接命中 L1

2. 验证缓存穿透

请求不存在的商品:

curl http://localhost:8080/products/999

如果布隆过滤器里没有 999

  • 直接返回空
  • 不查数据库

如果你把它加入布隆过滤器,但数据库仍不存在:

  • 第一次查库后缓存 null
  • 后续短时间内不再打数据库

3. 验证缓存更新一致性

更新价格:

curl -X PUT "http://localhost:8080/products/1/price?price=7999"

再次查询:

curl http://localhost:8080/products/1

预期:

  • 返回新价格
  • L1 和 L2 都已更新

4. 验证缓存删除

curl -X DELETE http://localhost:8080/products/1
curl http://localhost:8080/products/1

预期:

  • 删除后缓存被驱逐
  • 后续查询返回空

多级缓存与击穿治理时序图

下面这张图更适合理解热点 key 失效时的行为。

sequenceDiagram
    participant U as User
    participant A as App
    participant L1 as Caffeine
    participant L2 as Redis
    participant B as BloomFilter
    participant R as RedisLock
    participant DB as Database

    U->>A: GET /products/1
    A->>L1: get(1)
    L1-->>A: miss
    A->>L2: get(product::1)
    L2-->>A: miss
    A->>B: mightContain(1)
    B-->>A: true
    A->>R: tryLock(lock:product:1)
    R-->>A: success
    A->>DB: select by id = 1
    DB-->>A: Product
    A->>L2: set(product::1, value, ttl+jitter)
    A->>L1: put(1, value)
    A-->>U: Product

常见坑与排查

这一节我尽量说“真坑”。

1. @Cacheable 不生效

常见原因:

  • 启动类没加 @EnableCaching
  • 方法是 private
  • 同类内部自调用,绕过 Spring 代理
  • 异常被吞掉导致你以为缓存没命中

排查建议:

  • 先在方法里打日志,看是否每次都进入方法体
  • 再看 Bean 是否由 Spring 管理
  • 检查是不是 this.getById() 这种内部调用

2. 空值缓存失效

症状:

  • 不存在的数据每次都查数据库
  • 你明明写了 null 缓存逻辑,但没有生效

高频原因:

@Cacheable(unless = "#result == null")

这句会直接阻止 null 写入缓存。
如果你要防穿透,请把它删掉,或者单独封装结果对象。


3. 本地缓存与 Redis 不一致

比如:

  • 某实例更新了 Redis
  • 另一实例的 Caffeine 还保留旧值

这是多级缓存的经典问题。

解决思路:

  • 更新时主动删除/更新 L1 + L2
  • 借助 Redis Pub/Sub 通知其他实例清理本地缓存
  • 缩短 L1 TTL,接受短暂不一致
  • 对强一致要求高的数据,不要用本地缓存

如果你是订单状态、库存这类敏感数据,我一般建议: 少做本地缓存,宁可多走 Redis。


4. 分布式锁释放不安全

前面的示例里:

String value = stringRedisTemplate.opsForValue().get(key);
if (token != null && token.equals(value)) {
    stringRedisTemplate.delete(key);
}

这不是原子操作。
极端情况下可能发生:

  1. 线程 A 锁超时
  2. 线程 B 获取了新锁
  3. 线程 A 再执行 delete,把线程 B 的锁删了

生产建议使用 Lua:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

5. TTL 设计不合理

我见过两种极端:

  • TTL 太短:缓存刚建好就频繁失效
  • TTL 太长:脏数据留很久

经验建议:

  • 热点数据:5~30 分钟,配随机值
  • 空值缓存:1~5 分钟
  • 本地缓存:比 Redis 更短,一般 1~5 分钟
  • 强一致数据:优先删缓存 + 回源,不要一味拉长 TTL

6. Redis 序列化兼容问题

症状:

  • 反序列化报错
  • 类型变成 LinkedHashMap
  • 升级版本后历史缓存读不出来

建议:

  • 统一 JSON 序列化策略
  • 缓存对象尽量用稳定 DTO
  • 大版本变更时带版本号前缀,例如 v2:product::1

安全/性能最佳实践

这一节偏落地。

1. 参数校验是第一道防线

不要把所有非法请求都交给缓存系统处理。

例如:

  • id <= 0 直接拦截
  • 过长字符串、非法字符直接拒绝
  • 对公开接口增加限流和黑名单策略

缓存不是防火墙,别让它背锅。


2. 布隆过滤器适合“大量不存在 key”场景

适用场景:

  • 商品 ID、用户 ID、券 ID 这类可枚举主键
  • 恶意扫描、脏请求较多

不适用场景:

  • 条件查询、组合查询
  • key 变化频繁,维护成本高

边界条件要清楚:
布隆过滤器有误判,但不能漏判。
也就是“可能把不存在判断成存在”,但不会把存在判断成不存在。


3. 热点 key 用“逻辑过期”比“硬过期”更稳

对于极热点数据,推荐逻辑过期模式:

  • Redis 中保存数据 + 逻辑过期时间
  • 读取时即使过期,也先返回旧值
  • 后台异步刷新缓存
  • 避免大量请求同时阻塞在查库上

适合:

  • 商品详情
  • 配置中心数据
  • 首页聚合信息

不太适合:

  • 强一致金融数据
  • 实时库存扣减结果

逻辑过期状态可以理解为这样:

stateDiagram-v2
    [*] --> Fresh
    Fresh --> LogicalExpired: 到达逻辑过期时间
    LogicalExpired --> Rebuilding: 后台线程获得锁
    Rebuilding --> Fresh: 刷新成功
    LogicalExpired --> LogicalExpired: 未获得锁,继续返回旧值

4. 更新策略优先“删缓存”,再“由读请求回填”

很多人喜欢“先更新 DB,再更新缓存”。
问题是,一旦缓存更新失败,脏数据会留很久。

更稳妥的通用思路:

  1. 更新数据库
  2. 删除缓存
  3. 后续查询时重新加载

如果是超热点数据,再配合主动预热。


5. 监控指标一定要上

没有监控,缓存问题往往是“数据库先报警”。

至少监控这些指标:

  • L1 命中率
  • L2 命中率
  • Redis QPS / RT
  • 数据库回源 QPS
  • 热点 key 排行
  • 锁竞争次数
  • 空值缓存数量
  • 布隆过滤器误判率(可抽样)

我自己的经验是:
只看 Redis 命中率远远不够。因为很多时候 Redis 命中率看着不错,但数据库回源峰值仍然异常,原因可能是某一类 key 集中失效了。


6. 多实例场景下建议加本地缓存失效广播

如果项目有多个 Spring Boot 实例,推荐在更新数据后做一层通知:

  • Redis Pub/Sub
  • RocketMQ / Kafka
  • Canal 监听 binlog 后驱动缓存失效

这样各实例的 Caffeine 才不会“各自保留旧值”。


一个更贴近生产的配置建议

如果你的业务是典型读多写少,可以参考下面这个思路:

层级类型TTL用途
L1Caffeine1~3 分钟实例内热点读
L2Redis10~30 分钟 + 随机值多实例共享缓存
Null CacheRedis1~5 分钟防穿透
Hot Key逻辑过期自定义防击穿
更新策略删除缓存即时控制不一致窗口

一个经验法则:

  • 越靠近用户,请求越快,但一致性越弱
  • 越靠近数据库,一致性越强,但代价越高

所以别追求“所有数据都多级缓存”。
真正值得缓存的,往往只是那 20% 的热点读场景。


总结

我们把这套方案再压缩成一句话:

Spring Cache 负责统一编程入口,Caffeine 做本地一级缓存,Redis 做分布式二级缓存,再用空值缓存、布隆过滤器、分布式锁、随机 TTL 和逻辑过期去治理穿透、击穿与雪崩。

你可以按下面这个顺序落地:

  1. 先接入 Spring Cache + Redis
  2. 再加 Caffeine 本地缓存 做多级缓存
  3. 增加 空值缓存 + 参数校验 防穿透
  4. 对明显非法 ID 加 布隆过滤器
  5. 对热点 key 加 分布式锁或逻辑过期
  6. 所有 TTL 加 随机抖动
  7. 最后补齐 监控、广播失效、限流降级

最后给几个很实用的边界建议:

  • 强一致场景:减少本地缓存,优先 Redis 或直接查库
  • 超热点数据:优先逻辑过期,不要只靠硬 TTL
  • 公开接口:必须配参数校验和限流,别让缓存单独抗攻击
  • 多实例部署:要考虑本地缓存失效通知,否则迟早遇到脏读

如果你现在项目里只有“查 Redis,miss 就查库”这一层,别急着一次把方案堆满。
先把空值缓存、随机 TTL、热点锁这三件事补齐,收益通常就已经很明显了。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》