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

《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:设计、穿透击穿防护与一致性方案》

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

Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:设计、穿透击穿防护与一致性方案

在业务系统里,缓存几乎是“性能优化必答题”。但真正上线后,大家会发现问题并不只是“把数据放进 Redis”这么简单。

我自己做过几次缓存改造,最开始也走过一条很典型的路:
先上 Redis,压住数据库压力;随后发现单机访问频繁的热点数据,其实每次还要走一次网络;再后来又踩到缓存穿透、热点 Key 击穿、更新后短暂脏读这些坑。最后才慢慢把方案演化成:

  • 一级缓存:应用内本地缓存
  • 二级缓存:Redis 分布式缓存
  • 回源:数据库
  • 配套机制:空值缓存、互斥加载、逻辑过期/TTL、一致性删除

这篇文章不讲概念堆砌,而是从一个可运行的 Spring Boot 实战方案出发,把核心原理、代码落地、坑点排查和取舍分析一起讲清楚。


背景与问题

假设我们有一个商品查询接口:

  • 商品详情读多写少
  • 某些热点商品 QPS 很高
  • 多个应用实例同时部署
  • 数据更新后,希望尽快一致
  • 不能因为缓存失效把数据库打挂

如果只使用单层 Redis,会碰到几个典型问题:

1. 网络开销依然存在

Redis 很快,但它仍然是远程调用。
对于“极热数据”,如果每次都走网络,RT 仍然高于本地内存缓存。

2. 缓存穿透

查询一个根本不存在的数据,比如恶意请求 id=-1、随机 ID。
缓存没命中,数据库也查不到,所有请求都打到 DB。

3. 缓存击穿

某个热点 Key 恰好过期,大量并发请求同时回源数据库。
这种情况很常见,尤其是详情页、配置项、首页聚合数据。

4. 缓存雪崩

大量 Key 在同一时间失效,回源流量瞬间冲到数据库。
如果再叠加促销高峰,DB 很容易顶不住。

5. 缓存一致性

更新数据库后,缓存什么时候删?先删缓存还是先写库?
这里不是“有没有标准答案”,而是要基于业务接受度做取舍。


方案目标与整体设计

这次我们采用一个中级项目里比较实用的组合:

  • Spring Cache:统一缓存注解与缓存抽象
  • Caffeine:本地一级缓存
  • Redis:分布式二级缓存
  • 自定义 CacheManager / Cache:实现多级缓存联动
  • 空值缓存:防缓存穿透
  • 本地锁/分布式锁简化版互斥加载:防击穿
  • TTL 随机抖动:防雪崩
  • 更新后删除缓存 + 可选消息通知:做一致性控制

架构图

flowchart LR
    A[客户端请求] --> B[Spring Cache]
    B --> C{一级缓存 Caffeine}
    C -- 命中 --> R1[直接返回]
    C -- 未命中 --> D{二级缓存 Redis}
    D -- 命中 --> E[回填一级缓存]
    E --> R2[返回结果]
    D -- 未命中 --> F[查询数据库]
    F --> G[写入 Redis]
    G --> H[写入 Caffeine]
    H --> R3[返回结果]

更新链路图

sequenceDiagram
    participant C as Client
    participant S as Service
    participant DB as Database
    participant R as Redis
    participant L1 as Local Cache

    C->>S: 更新商品
    S->>DB: 先更新数据库
    DB-->>S: 更新成功
    S->>R: 删除 Redis 缓存
    S->>L1: 删除本地缓存
    S-->>C: 返回成功

核心原理

这一部分决定你后面写代码时会不会“只是跑起来”。

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

Spring Cache 本身不是缓存实现,它更像一层统一门面
它帮我们把 @Cacheable@CachePut@CacheEvict 这些注解转成具体的缓存操作。

也就是说:

  • Caffeine 可以接到 Spring Cache 下面
  • Redis 也可以接到 Spring Cache 下面
  • 多级缓存也可以包装成 Spring Cache 的一个实现

所以真正的关键,不是会不会写 @Cacheable,而是如何设计底层 Cache 的行为


2. 多级缓存的读写策略

这里我们采用最常见也最稳的策略。

读策略

  1. 先查本地缓存(L1)
  2. L1 未命中,再查 Redis(L2)
  3. L2 命中,回填到 L1
  4. L2 也未命中,查数据库
  5. DB 结果写入 Redis 和 L1

这样做的好处:

  • 热点数据优先走本地内存
  • 多实例之间还能通过 Redis 共享缓存
  • Redis 重启后,本地缓存仍可短暂兜底一部分流量

写策略

更新数据时:

  1. 先写数据库
  2. 再删 Redis
  3. 再删本地缓存

这是常用的Cache Aside 模式变种。
原因是:数据库是真实来源,缓存只是副本


3. 穿透、击穿、雪崩的防护思路

缓存穿透:空值缓存 + 参数校验

如果数据不存在,可以缓存一个空对象或者特殊标记,TTL 设短一点,比如 1~5 分钟。
这样同样的“无效请求”不会持续打 DB。

缓存击穿:互斥加载

热点 Key 过期时,不要让所有线程同时回源。
典型做法:

  • 单机场景:本地锁
  • 分布式场景:Redis 分布式锁

本文示例为了清晰,先用按 Key 粒度的本地锁演示思路。

缓存雪崩:TTL 加随机值

不要把所有缓存都设置成整齐划一的 30 分钟。
建议:

  • 基础 TTL + 随机偏移
  • 比如 30min + random(0~5min)

这样能显著降低同一时刻集中失效的概率。


4. 一致性方案的边界

缓存一致性没有银弹,通常是“最终一致”。

为什么常说“更新数据库后删除缓存”

因为:

  • 如果先删缓存再写数据库,期间有并发读请求,可能把旧值重新写回缓存
  • 先写数据库再删缓存,旧值窗口会更短

但它也不是 100% 严格一致。
极端情况下仍然可能出现:

  1. 线程 A 更新 DB
  2. 线程 B 读旧缓存未命中,查到旧数据
  3. 线程 A 删除缓存
  4. 线程 B 把旧数据回填缓存

解决方式通常是增强而不是幻想绝对无误:

  • 延迟双删
  • 基于 MQ 的失效通知
  • binlog 订阅做缓存清理
  • 对强一致数据放弃缓存或缩小缓存范围

方案对比与取舍分析

方案优点缺点适用场景
单 Redis 缓存简单、部署快热点场景仍有网络开销中小系统、非极热读场景
本地缓存 + DB延迟低多实例数据不一致明显单机应用、配置类缓存
本地缓存 + Redis 多级缓存性能好、可扩展、热点处理更稳实现复杂度更高读多写少、中高并发业务
旁路缓存 + MQ 通知一致性更好引入消息链路复杂度多实例、大规模系统

我的经验是:

  • 读多写少:优先多级缓存
  • 写频繁、强一致要求高:缓存要保守,别“为了缓存而缓存”
  • 热点极高:一定要有 L1,不然 Redis 也会成为瓶颈点

容量估算思路

做多级缓存前,建议至少粗估一下量,不然很容易“缓存比数据库还贵”。

Redis 容量估算

假设:

  • 单个商品缓存平均 2 KB
  • 计划缓存 50 万商品
  • 预留 30% 冗余

估算:

2 KB * 500000 ≈ 1000 MB
预留后约 1.3 GB

再考虑 Redis 自身对象元数据、过期字典、序列化开销,实际需要再多留一些空间。

本地缓存估算

如果单实例缓存 2 万个热点商品:

2 KB * 20000 ≈ 40 MB

再算上对象包装和 JVM 开销,实际可能到 60~100 MB。
所以本地缓存不能无限大,要结合堆内存大小设置 maximumSize


实战代码(可运行)

下面给一个精简但能跑通的示例。
使用:

  • Spring Boot
  • Spring Cache
  • Caffeine
  • Redis
  • JPA + H2 演示数据库

说明:为了让代码聚焦核心逻辑,示例里没有把所有生产级细节都展开,比如 Redis Pub/Sub 失效通知、分布式锁重入等。


1. Maven 依赖

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>multi-cache-demo</artifactId>
    <version>1.0.0</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.14</version>
    </parent>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <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-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

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

2. 配置文件

spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

  redis:
    host: 127.0.0.1
    port: 6379

server:
  port: 8080

3. 启动类

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

4. 实体与 Repository

package com.example.multicache.entity;

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class Product {

    @Id
    private Long id;
    private String name;
    private Integer price;

    public Product() {}

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Integer getPrice() {
        return price;
    }

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

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

    public void setPrice(Integer price) {
        this.price = price;
    }
}
package com.example.multicache.repository;

import com.example.multicache.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}

5. 初始化测试数据

package com.example.multicache.init;

import com.example.multicache.entity.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class DataInitializer implements CommandLineRunner {

    private final ProductRepository productRepository;

    public DataInitializer(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    public void run(String... args) {
        productRepository.save(new Product(1L, "iPhone", 6999));
        productRepository.save(new Product(2L, "MacBook", 12999));
    }
}

6. Redis 序列化配置

这个地方非常重要。
如果不配,默认 JDK 序列化很容易让 Key/Value 不直观,也不利于排查。

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.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.*;

import java.time.Duration;

@Configuration
public class RedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);

        GenericJackson2JsonRedisSerializer serializer =
                new GenericJackson2JsonRedisSerializer(mapper);

        return RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(serializer))
                .disableCachingNullValues()
                .entryTtl(Duration.ofMinutes(30));
    }
}

7. 多级缓存实现

这里是核心。我们实现一个 Cache,内部组合 Caffeine 和 Redis。

7.1 CacheManager

package com.example.multicache.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;

import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class MultiLevelCacheManager implements CacheManager {

    private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
    private final Cache redisCache;
    private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;

    public MultiLevelCacheManager(Cache redisCache,
                                  com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache) {
        this.redisCache = redisCache;
        this.caffeineCache = caffeineCache;
    }

    @Override
    public Cache getCache(String name) {
        return cacheMap.computeIfAbsent(name,
                n -> new MultiLevelCache(n, redisCache, caffeineCache));
    }

    @Override
    public Collection<String> getCacheNames() {
        return cacheMap.keySet();
    }
}

7.2 Cache 实现

package com.example.multicache.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;

import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;

public class MultiLevelCache implements Cache {

    private static final Object NULL_VALUE = new Object();
    private static final Map<Object, Object> KEY_LOCKS = new ConcurrentHashMap<>();

    private final String name;
    private final Cache redisCache;
    private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;

    public MultiLevelCache(String name,
                           Cache redisCache,
                           com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache) {
        this.name = name;
        this.redisCache = redisCache;
        this.caffeineCache = caffeineCache;
    }

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

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

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

    @Override
    public ValueWrapper get(Object key) {
        String realKey = buildKey(key);

        Object localValue = caffeineCache.getIfPresent(realKey);
        if (localValue != null) {
            if (localValue == NULL_VALUE) {
                return new SimpleValueWrapper(null);
            }
            return new SimpleValueWrapper(localValue);
        }

        ValueWrapper redisValue = redisCache.get(realKey);
        if (redisValue != null) {
            Object value = redisValue.get();
            caffeineCache.put(realKey, value == null ? NULL_VALUE : value);
            return redisValue;
        }

        return null;
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T get(Object key, Class<T> type) {
        ValueWrapper wrapper = get(key);
        return wrapper == null ? null : (T) wrapper.get();
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T get(Object key, Callable<T> valueLoader) {
        ValueWrapper wrapper = get(key);
        if (wrapper != null) {
            return (T) wrapper.get();
        }

        String realKey = buildKey(key);
        Object lock = KEY_LOCKS.computeIfAbsent(realKey, k -> new Object());

        synchronized (lock) {
            wrapper = get(key);
            if (wrapper != null) {
                return (T) wrapper.get();
            }

            try {
                T value = valueLoader.call();
                if (value == null) {
                    put(key, null);
                    return null;
                }
                put(key, value);
                return value;
            } catch (Exception e) {
                throw new RuntimeException("load cache value failed", e);
            } finally {
                KEY_LOCKS.remove(realKey);
            }
        }
    }

    @Override
    public void put(Object key, Object value) {
        String realKey = buildKey(key);
        Object storeValue = value == null ? NULL_VALUE : value;
        caffeineCache.put(realKey, storeValue);
        redisCache.put(realKey, value);
    }

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

    @Override
    public void evict(Object key) {
        String realKey = buildKey(key);
        caffeineCache.invalidate(realKey);
        redisCache.evict(realKey);
    }

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

注意:这里为了配合 Spring Cache 的接口,示例里把空值在本地缓存中用 NULL_VALUE 表示,而 Redis 侧仍然直接存 null 会受实现限制。
在生产里更推荐显式定义一个 NullValue 对象或单独封装 Redis 访问逻辑,避免不同缓存层的空值语义不一致。


8. 缓存配置类

package com.example.multicache.config;

import com.example.multicache.cache.MultiLevelCacheManager;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheManager;

import java.time.Duration;

@Configuration
public class CacheConfig {

    @Bean
    public com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache() {
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(5))
                .recordStats()
                .build();
    }

    @Bean
    public CacheManager cacheManager(RedisCacheManager redisCacheManager,
                                     com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache) {
        Cache redisCache = redisCacheManager.getCache("product");
        return new MultiLevelCacheManager(redisCache, caffeineCache);
    }
}

9. Service 层

这里体现两件事:

  • 查询使用 @Cacheable
  • 更新使用“先写库,再删缓存”
package com.example.multicache.service;

import com.example.multicache.entity.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
    public Product getById(Long id) {
        simulateSlowQuery();
        return productRepository.findById(id).orElse(null);
    }

    @Transactional
    @CacheEvict(cacheNames = "product", key = "#product.id")
    public Product update(Product product) {
        Product saved = productRepository.save(product);
        return saved;
    }

    private void simulateSlowQuery() {
        try {
            Thread.sleep(300);
        } catch (InterruptedException ignored) {
        }
    }
}

如果你要做“空值缓存”,这里的 unless = "#result == null" 需要取消,改为由自定义缓存层显式存储空对象标记。
很多人卡在这里:注解层排除了 null,底层却想缓存 null,二者策略冲突。


10. Controller

package com.example.multicache.controller;

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

@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}")
    public Product update(@PathVariable Long id, @RequestBody Product product) {
        product.setId(id);
        return productService.update(product);
    }
}

11. 验证步骤

第一次查询

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

会慢一些,因为会查 DB。

第二次查询

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

会明显更快,因为大概率直接命中本地缓存或 Redis。

更新后查询

curl -X PUT http://localhost:8080/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"iPhone Pro","price":7999}'

然后再查:

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

会触发缓存失效后重新加载。


穿透与击穿增强实现建议

上面的代码能跑,但如果直接搬去生产,我会继续补下面两个点。

1. 空值缓存

对于不存在的数据,可以这样设计:

  • Redis 中存一个特殊对象,比如 {"__null__":true}
  • TTL 设短一点,例如 2 分钟
  • 本地缓存也同步存特殊对象

伪代码如下:

if (dbResult == null) {
    redis.set(key, NULL_MARKER, 120s);
    localCache.put(key, NULL_MARKER);
    return null;
}

2. TTL 加随机值

比如写入 Redis 时:

int randomSeconds = ThreadLocalRandom.current().nextInt(0, 300);
Duration ttl = Duration.ofMinutes(30).plusSeconds(randomSeconds);

这样能减少集中过期。

3. 分布式锁替代本地锁

如果是多实例部署,仅靠 JVM 内的 synchronized 还不够。
应该用 Redis 锁、Redisson 或者基于 Lua 的安全释放锁方案。


常见坑与排查

这部分很重要,我自己排线上问题时,80% 时间都花在这。

1. @Cacheable 不生效

常见原因

  • 方法不是 public
  • 同类内部调用,绕过了 Spring 代理
  • 忘了加 @EnableCaching
  • Bean 没被 Spring 管理

排查方式

先看日志,再确认是否经过代理对象。
最直接的判断是:第一次调用和第二次调用执行时间是否一致。


2. 本地缓存和 Redis 键不一致

如果本地缓存 Key 是 product::1,Redis 用的却是别的格式,就会出现:

  • Redis 明明有值
  • 程序却总查不到

建议

统一 Key 生成规则,至少包含:

  • cacheName
  • 业务 key
  • 必要时包含版本或租户信息

3. 空值缓存策略冲突

这个坑特别常见:

  • 注解层 unless = "#result == null" 不缓存 null
  • 自定义缓存层又想缓存 null

结果就是“你以为缓存了,其实没缓存”。

建议

空值缓存要么:

  • 全部在注解层禁掉,底层不做
  • 要么底层统一接管,注解层不要排除 null

不要混搭。


4. 更新后短暂读到旧数据

这不是一定是 Bug,而是缓存一致性的天然窗口。
关键是判断业务是否能接受。

排查路径

  1. 看更新时间线
  2. 看删缓存是否成功
  3. 看是否存在并发回填旧值
  4. 看是否有多个应用实例本地缓存未同步失效

5. Redis 序列化导致反序列化失败

如果你更换了类结构、包名、字段类型,旧缓存可能会读不出来。

建议

  • 优先 JSON 序列化
  • 不要依赖 JDK 默认序列化
  • 大版本变更时做好缓存清理预案

6. 本地缓存内存膨胀

很多人只盯 Redis,忘了 L1 在 JVM 堆里。
如果对象很大、条目很多,很容易触发频繁 GC。

建议

  • 明确 maximumSize
  • 不要把大对象整包塞缓存
  • 监控 hit rate、eviction、heap usage

多实例下的一致性增强

如果服务有多台,本地缓存最大的挑战是:
A 实例更新了缓存,B 实例的本地缓存并不知道。

常见解法有三种:

1. 短 TTL

简单但粗糙。
适合允许几秒到几十秒不一致的场景。

2. Redis Pub/Sub 通知各实例清理本地缓存

删除 Redis 后,顺便发布一个“缓存失效事件”。
所有实例订阅到后删除自己的本地缓存。

flowchart TD
    A[实例A更新DB] --> B[删除Redis缓存]
    B --> C[发布失效消息]
    C --> D[实例A删除本地缓存]
    C --> E[实例B删除本地缓存]
    C --> F[实例C删除本地缓存]

3. MQ / binlog 订阅

更可靠,但复杂度更高。
适合大规模系统,尤其是多个服务都会修改同一份数据的场景。


安全/性能最佳实践

1. 不要把缓存当权限边界

缓存只是性能组件,不是安全隔离组件。
如果数据有租户、用户维度,Key 必须带上隔离信息,否则会串数据。

例如:

product::tenant_1001::1

而不是简单的:

product::1

2. 对查询参数做合法性校验

缓存穿透很多时候不是技术问题,而是入口没拦好。

建议至少校验:

  • ID 是否为空
  • ID 是否小于等于 0
  • 参数长度是否异常
  • 是否存在明显恶意模式

3. 热点 Key 要单独治理

如果某个 Key 非常热,可以考虑:

  • 提高本地缓存 TTL
  • 单独预热
  • 后台异步刷新
  • 限流降级

不要把所有 Key 一视同仁。


4. 缓存值尽量瘦身

缓存里不要存“查了 10 张表拼起来的大对象”,除非真的有价值。
更稳妥的做法是:

  • 只缓存高频字段
  • 大字段拆开缓存
  • 以 DTO 为缓存对象,不直接缓存完整实体图

5. 指标监控一定要补齐

至少要监控:

  • 本地缓存命中率
  • Redis 命中率
  • DB 回源 QPS
  • Key 数量和内存使用
  • 过期/淘汰数量
  • 热点 Key 分布

如果没有这些指标,出了问题只能靠猜。


6. 预热和降级要提前设计

上线新版本、重启服务、Redis 切换主从时,缓存命中率都会抖动。
建议提前准备:

  • 核心数据预热脚本
  • 回源限流
  • 熔断降级策略
  • 兜底静态数据

这类“非功能设计”,往往比写缓存代码本身更决定系统是否稳定。


什么时候不建议上多级缓存

虽然多级缓存很好用,但也不是所有场景都值得。

以下情况我一般会谨慎:

  • 数据写多读少
  • 对强一致要求极高
  • 数据体量很小,DB 本身已经很快
  • 团队没有足够监控和排障能力
  • 业务简单,用单 Redis 已经足够

一句话:
缓存是用来解决瓶颈的,不是为了追求“架构高级感”。


总结

基于 Spring Boot 的多级缓存实践,可以概括成一条很实用的链路:

  • Caffeine 做一级缓存,吃掉热点请求
  • Redis 做二级缓存,承担分布式共享
  • Spring Cache 统一编程模型
  • 空值缓存防穿透
  • 互斥加载防击穿
  • 随机 TTL 防雪崩
  • 更新后删缓存做最终一致性

如果你准备在项目里真正落地,我建议按这个顺序推进:

  1. 先做单 Redis 缓存,跑通基础链路
  2. 再补本地缓存,专门优化热点
  3. 补空值缓存、互斥加载、随机 TTL
  4. 多实例时加入本地缓存失效通知
  5. 最后再根据业务要求增强一致性方案

这样做的好处是,你不会一上来就把系统做得太复杂,也更容易定位每一层带来的收益。

如果只记住一句话,我希望是这个:

多级缓存的重点从来不是“缓存了没有”,而是“失效时是否稳、更新后是否可控、出问题时能不能查明白”。

这才是真正在生产环境里站得住的缓存方案。


分享到:

上一篇
《从源码到部署:基于 MinIO 搭建企业级开源对象存储服务的实践指南》
下一篇
《微服务架构下的分布式事务实战:基于 Saga 模式实现订单与库存一致性》