Java 基础体系 · 第 92/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring Cache:抽象、Key、TTL、穿透、一致性和多级缓存

1. 先建立缓存问题的完整模型

缓存不是数据库的替代品,而是位于“数据源”和“调用方”之间的一份派生数据。以查询商品为例:

请求
  │
  ▼
应用服务 ── 命中 ──► 缓存
  │                  │
  │ 未命中           │ 返回缓存值
  ▼
数据库 ── 查询成功 ──► 回填缓存

设数据库中的真实状态为 D(k),缓存中键 k 对应的值为 C(k)。一次读请求通常希望得到:

返回值 = C(k),如果缓存命中且仍然可接受
      = D(k),如果缓存未命中,再将结果写入 C(k)

因此,缓存系统至少涉及以下问题:

  1. 抽象:Spring 如何把缓存调用统一成 @Cacheable@CachePut@CacheEvict
  2. Key:不同方法参数如何映射为同一个缓存键,如何避免碰撞。
  3. TTL:缓存值保存多久,过期时发生什么。
  4. 穿透:请求不存在的数据时,为什么每次都打到数据库。
  5. 一致性:数据库更新后,缓存中的旧值何时、如何消失。
  6. 多级缓存:本地缓存、分布式缓存和数据库如何协同。
  7. 并发与故障:大量请求同时未命中、缓存故障、回填失败时系统如何退化。

后文中的“缓存一致”并不等于分布式系统中的强一致。它通常表示:在规定的时间窗口和更新协议内,缓存不会长期偏离数据源。


2. Spring Cache 抽象到底抽象了什么

2.1 核心接口

Spring Cache 的核心抽象是:

org.springframework.cache.Cache
org.springframework.cache.CacheManager

Cache 提供的主要操作包括:

  • get(key):读取缓存;
  • put(key, value):写入缓存;
  • evict(key):删除一个键;
  • clear():清空缓存;
  • get(key, Callable<T>):未命中时通过回调加载值。

CacheManager 根据缓存名称获取 Cache

Cache cache = cacheManager.getCache("books");

这里的 "books" 是逻辑名称,不必等于 Redis 的实际 key。具体缓存实现通常还会增加前缀、序列化格式、过期时间等信息。

Spring Cache 抽象不规定

  • 缓存必须位于本地还是远程;
  • 必须使用何种序列化格式;
  • 是否支持 TTL;
  • TTL 的精度;
  • 是否允许缓存 null
  • 是否自动解决缓存穿透、击穿或一致性;
  • 多个请求同时加载同一个键时是否一定只执行一次加载。

这些行为由底层实现和配置决定。ConcurrentMapCache、Caffeine、Redis 等实现的能力并不相同。

2.2 注解如何参与调用

常用注解有三个:

@Cacheable
@CachePut
@CacheEvict

它们通常通过 Spring AOP 代理拦截方法调用。

@Cacheable 为例,逻辑可以抽象为:

调用代理方法
  │
  ├─ 根据 cacheNames 和 key 找到缓存
  ├─ 缓存命中 ─────► 直接返回缓存值,不执行目标方法
  │
  └─ 缓存未命中
       ├─ 执行目标方法
       ├─ 将返回值写入缓存
       └─ 返回方法结果

因此,下面的目标方法在命中缓存时不会执行:

@Cacheable(cacheNames = "books", key = "#isbn")
public Book findByIsbn(String isbn) {
    return repository.findByIsbn(isbn)
            .orElseThrow(() -> new BookNotFoundException(isbn));
}

@CachePut@Cacheable 的关键区别是:@CachePut 始终执行目标方法,然后用返回值更新缓存:

@CachePut(cacheNames = "books", key = "#result.isbn")
public Book update(Book book) {
    return repository.save(book);
}

@CacheEvict 删除缓存:

@CacheEvict(cacheNames = "books", key = "#isbn")
public void delete(String isbn) {
    repository.deleteByIsbn(isbn);
}

如果一个操作涉及多个缓存或多个键,可以使用 allEntries = true 清空指定缓存,但这可能导致瞬时的大量未命中,不应把它当作普通更新策略。

2.3 代理边界是生命周期的一部分

缓存注解不是编译器指令,而是代理拦截行为。因此,以下调用通常不会经过缓存拦截:

@Service
public class BookService {

    public Book outer(String isbn) {
        return findByIsbn(isbn); // 类内部直接调用
    }

    @Cacheable(cacheNames = "books", key = "#isbn")
    public Book findByIsbn(String isbn) {
        // ...
        return null;
    }
}

outer() 中的 this.findByIsbn() 是对象内部调用,没有经过 Spring 代理,所以缓存注解可能不生效。

常见解决方式是:

  • 从另一个 Spring Bean 调用缓存方法;
  • 将缓存方法拆到独立的服务中;
  • 在特殊场景下注入代理自身,但这会增加耦合;
  • 使用程序化缓存 API,而不是依赖注解。

另外,private 方法、构造器调用也不能依靠常规代理方式获得缓存拦截。工程中诊断“注解明明写了但没有缓存”时,首先检查调用是否穿过了 Spring Bean 代理。

2.4 conditionunless 的执行时机

condition 在调用前判断,条件不满足时不查缓存、不写缓存,也通常不影响方法执行:

@Cacheable(
    cacheNames = "books",
    key = "#isbn",
    condition = "#isbn != null && !#isbn.isBlank()"
)
public Book findByIsbn(String isbn) {
    // ...
}

unless 在方法返回后判断,常用于不缓存某些结果:

@Cacheable(
    cacheNames = "books",
    key = "#isbn",
    unless = "#result == null"
)
public Book findByIsbn(String isbn) {
    // ...
}

unless 能否正确处理 #result,取决于方法返回值和具体表达式。对于 Optional、响应包装对象和异常结果,应明确规定缓存的是“业务对象”“空结果”还是“不缓存”。


3. Key:缓存正确性的第一道边界

3.1 Key 的数学含义

缓存键应当是一个函数:

K = f(方法身份, 方法参数, 业务上下文)

如果两个逻辑上不同的请求产生同一个键:

f(request1) = f(request2)

就会发生缓存碰撞,后一个请求可能读到前一个请求的数据。

反过来,如果逻辑上相同的请求产生不同的键:

request1 ≡ request2,但 f(request1) ≠ f(request2)

就会产生重复缓存和大量无效未命中。

因此,一个合格的 Key 设计需要同时满足:

  1. 区分不同数据
  2. 稳定:相同语义的请求生成相同键;
  3. 可序列化:远程缓存能够存储;
  4. 与租户、语言、权限等上下文一致
  5. 在不同方法之间避免意外碰撞

3.2 默认 Key 不是业务协议

Spring 的默认 Key 生成策略会根据参数数量生成键。常见行为可以概括为:

  • 无参数:使用共享的空参数键;
  • 单参数:通常直接使用该参数;
  • 多参数:使用包含参数的复合键。

具体对象的 equals()hashCode() 会影响本地缓存命中;远程缓存还会经过序列化或字符串化。因此,不应把默认 Key 当作跨服务、跨语言的稳定协议。

尤其需要注意这种方法:

@Cacheable("books")
public Book find(String isbn) {
    // ...
}

它没有显式写 key。如果另一个方法也使用同一个缓存名、同样的单参数结构,可能产生难以发现的键空间重叠。

更明确的写法是:

@Cacheable(
    cacheNames = "books",
    key = "'book:isbn:' + #isbn"
)
public Book find(String isbn) {
    // ...
}

结果类似:

book:isbn:978-7-000-00000-0

3.3 多参数和上下文参数

下面的查询结果受租户和语言影响:

@Cacheable(
    cacheNames = "productNames",
    key = "'tenant:' + #tenantId + ':lang:' + #language + ':product:' + #productId"
)
public String productName(
        Long tenantId,
        Long productId,
        String language) {
    // ...
    return null;
}

如果忽略 tenantId,租户 A 的数据可能被租户 B 读取;如果忽略 language,中文请求可能返回英文缓存。

权限也是同样的问题。一个按用户权限裁剪字段的方法不能简单使用 resourceId 作为 Key,否则不同权限的响应会互相污染。此时应当:

  • 把权限范围纳入 Key;
  • 或只缓存与权限无关的原始数据,把裁剪放到缓存外;
  • 或禁止缓存该响应。

3.4 Key 的类型和序列化

本地 Caffeine 可以直接使用对象 Key;Redis 则通常需要将 Key 编码为字节或字符串。生产环境更适合使用可读、可观测的字符串 Key:

应用名:环境:缓存名:业务类型:业务键
shop:prod:books:book:isbn:9787000000000

但前缀不能包含未经限制的用户输入,否则恶意请求可能制造海量不同 Key。应限制 Key 的长度、字符集和参数范围。

对于对象 Key,以下代码存在风险:

public record Query(String keyword, List<String> tags) {}

如果 tags 是可变列表,放入本地缓存后又被修改,Key 的 hashCode() 可能变化,导致无法找到原来的缓存项。用于 Key 的对象应当不可变,或在生成键时转换成规范化字符串。

3.5 自定义 KeyGenerator

当多个方法采用统一的键规则时,可以配置 KeyGenerator

@Bean
public KeyGenerator businessKeyGenerator() {
    return (target, method, params) -> {
        String arguments = Arrays.stream(params)
                .map(String::valueOf)
                .collect(Collectors.joining(","));
        return target.getClass().getSimpleName()
                + ":" + method.getName()
                + ":" + arguments;
    };
}

使用:

@Cacheable(
    cacheNames = "books",
    keyGenerator = "businessKeyGenerator"
)
public Book find(String isbn) {
    // ...
    return null;
}

自定义生成器必须明确:

  • 方法名是否参与键;
  • 类名是否参与键;
  • 参数是否规范化;
  • null 如何表示;
  • 数字、日期、集合如何序列化;
  • 是否与其他服务共享格式。

如果 Redis 中的键还要被脚本、运维工具或其他语言服务使用,显式字符串 Key 通常比 Java 对象 Key 更容易维护。


4. TTL:缓存值为什么会过期

4.1 TTL 的定义

TTL(Time To Live)表示一个缓存条目从写入或刷新开始,允许存活的时间。

若写入时刻为 t₀,TTL 为 Δ,则理想过期时刻是:

t_expire = t₀ + Δ

当前时间 t 满足:

t < t_expire:条目可用
t ≥ t_expire:条目过期

TTL 解决的是“缓存不能永久存在”的问题,但它不保证过期瞬间数据库和缓存自动同步。过期后通常只是下一次读取触发重新加载。

4.2 Spring Cache 抽象本身不定义 TTL

@Cacheable 没有一个通用、跨缓存实现的标准 TTL 属性。TTL 一般由底层缓存配置:

  • Caffeine:通过 expireAfterWriteexpireAfterAccess
  • Redis:通过 RedisCacheConfiguration.entryTtl(...)
  • 其他缓存产品:使用各自的配置或 API。

下面是 Spring Data Redis 的典型配置:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaults =
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(10))
                        .disableCachingNullValues();

        Map<String, RedisCacheConfiguration> perCache = Map.of(
                "books",
                defaults.entryTtl(Duration.ofMinutes(30)),
                "bookSummaries",
                defaults.entryTtl(Duration.ofMinutes(2))
        );

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaults)
                .withInitialCacheConfigurations(perCache)
                .build();
    }
}

这段配置表达的是:

  • books 的值写入后最多保留 30 分钟;
  • bookSummaries 最多保留 2 分钟;
  • 不缓存 null
  • 具体 Redis Key 前缀、序列化方式等由配置继续决定。

disableCachingNullValues() 是一个重要选择:它避免把空值写进 Redis,但会使不存在的数据在每次请求时重新查询数据库。

4.3 expireAfterWriteexpireAfterAccess

两者语义不同:

expireAfterWrite:
  写入后固定时间过期

expireAfterAccess:
  每次访问都会延长存活时间

对于热点数据,expireAfterAccess 可能让它长期不失效;对于必须周期性刷新、不能长期陈旧的数据,通常需要写入后过期或主动刷新。

Redis 的 TTL 通常是按条目保存的;Caffeine 的过期检查可能在访问或维护操作时发生,不应把“物理删除时刻”理解成严格的定时器回调时刻。对业务而言,重要的是过期条目不能继续作为有效命中返回。

4.4 TTL 与数据新鲜度的关系

假设数据更新后,缓存最多保留 TTL 时间,且缓存更新没有额外通知,那么旧值的最大陈旧时间大致受 TTL 控制:

最大陈旧时间 ≈ TTL + 时钟、调度、请求到达等误差

但这个推导成立需要前提:

  1. 更新后没有再次把旧值写回缓存;
  2. TTL 确实从正确时刻开始计算;
  3. 所有读路径都使用同一缓存;
  4. 没有永久缓存或错误恢复逻辑绕过 TTL。

如果更新请求发生在旧缓存 TTL 到期前,TTL 并不会因为数据库发生变化而自动缩短。因此,TTL 只是最终收敛机制,不是实时一致性机制。


5. 一个可运行的 Spring Boot + Redis 缓存示例

下面示例展示单级 Redis 缓存的完整调用路径。它使用 Spring Boot 的缓存自动配置和 Spring Data Redis。实际项目应使用与 Java 25 LTS 兼容的 Spring Boot、Spring Framework 和 Spring Data 版本;版本号由项目的依赖管理统一控制,不应把示例中的 API 版本理解为固定版本承诺。

5.1 依赖和 Redis

依赖至少包括:

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

启动本地 Redis:

docker run --name demo-redis \
  -p 6379:6379 \
  -d redis

配置:

spring:
  data:
    redis:
      host: localhost
      port: 6379

5.2 启用缓存和服务方法

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

    private final BookRepository repository;

    public BookService(BookRepository repository) {
        this.repository = repository;
    }

    @Cacheable(
        cacheNames = "books",
        key = "'book:isbn:' + #isbn",
        unless = "#result == null"
    )
    public Book findByIsbn(String isbn) {
        System.out.println("query database: " + isbn);

        return repository.findByIsbn(isbn)
                .orElseThrow(() -> new BookNotFoundException(isbn));
    }

    @CachePut(
        cacheNames = "books",
        key = "'book:isbn:' + #result.isbn"
    )
    public Book update(Book book) {
        return repository.save(book);
    }

    @CacheEvict(
        cacheNames = "books",
        key = "'book:isbn:' + #isbn"
    )
    public void delete(String isbn) {
        repository.deleteByIsbn(isbn);
    }
}

调用同一个 ISBN 两次时,预期现象是:

第一次:
query database: 978-7-000-00000-0

第二次:
没有 database 日志,直接返回缓存值

使用 Redis CLI 查看:

redis-cli --scan --pattern '*books*'
redis-cli ttl '<实际键名>'

实际键名取决于缓存前缀配置,不应假定一定是某个固定字符串。TTL 命令返回:

  • >= 0:剩余秒数;
  • -1:没有设置过期时间;
  • -2:键不存在。

如果发现缓存键是 -1,说明 TTL 配置没有应用到这个 Cache,或者写入路径绕过了配置的 RedisCacheManager。这是比“感觉缓存没有过期”更可靠的诊断证据。

5.3 异常和缓存写入

默认情况下,目标方法抛出异常时不会把异常结果写入缓存;缓存通常只处理正常返回值。对于不存在的数据,如果方法通过异常表示 404@Cacheable 不会自动缓存这个异常。

这会直接引出缓存穿透问题,下一节会处理。


6. 缓存穿透:不存在的数据为什么反复访问数据库

6.1 穿透的状态变化

缓存穿透指请求一个数据库中不存在的键,而系统又不缓存“不存在”这个事实:

请求 k
  │
  ├─ 缓存没有 k
  ├─ 数据库也没有 k
  ├─ 返回 404
  └─ 没有写入缓存

下一次请求 k 重复以上过程

若攻击者持续请求大量随机 ID,数据库会持续承受查询压力,即使所有请求最终都是无效的。

这与缓存击穿不同:

  • 穿透:请求的数据本来不存在;
  • 击穿:一个真实存在的热点键过期,大量请求同时回源;
  • 雪崩:大量缓存条目同时失效或缓存整体不可用,造成大规模回源。

6.2 方案一:缓存空值

可以把“查询结果为空”也作为缓存状态保存:

Cache(k) = NOT_FOUND

之后的请求可以直接返回不存在,而不查询数据库。

但 Spring Cache 的空值行为由具体实现决定。Redis 缓存常见做法是使用框架的空值占位对象;也可以在业务层返回统一的结果对象。不过需要注意:

@Cacheable(cacheNames = "books", key = "#isbn")
public Optional<Book> findOptional(String isbn) {
    return repository.findByIsbn(isbn);
}

这里是否缓存 Optional.empty()、如何序列化,取决于缓存实现和序列化配置。不能仅凭 Optional 就推断所有实现都会正确缓存空结果。

缓存空值时通常使用较短 TTL,例如:

真实对象:30 分钟
不存在标记:30 秒

原因是“现在不存在”可能在稍后变成“存在”。空值 TTL 太长,会让新创建的数据在一段时间内仍然不可见。

6.3 方案二:布隆过滤器

布隆过滤器用位数组和多个哈希函数判断一个键是否“可能存在”。

它具有这样的性质:

实际不存在的键:
  可能被误判为存在(假阳性)

实际存在且已正确加入过滤器的键:
  不应被误判为不存在(假阴性概率理论上为 0)

因此请求流程是:

请求 k
  │
  ├─ 布隆过滤器判断“不可能存在”
  │       └─ 直接返回不存在
  │
  └─ 判断“可能存在”
          ├─ 查缓存
          └─ 未命中后查数据库

布隆过滤器不能单独替代缓存:

  • 它不保存完整对象;
  • 删除元素困难,通常需要可删除变体或重建;
  • 数据新增后必须及时加入;
  • 初始化不完整会产生假阴性,错误地阻断真实数据;
  • 假阳性只会多查一次缓存或数据库,不会直接导致错误数据。

适合把它作为大量随机 ID 攻击下的第一层防线,而不是一致性协议。

6.4 方案三:请求参数校验和资源边界

如果 ID 的格式有明确约束,应在进入缓存和数据库前校验:

if (isbn == null || isbn.length() > 32) {
    throw new IllegalArgumentException("invalid isbn");
}

这只能过滤明显非法请求,不能阻止格式正确但数据库不存在的 ID,因此通常与空值缓存或布隆过滤器组合使用。


7. 一致性:更新数据库后,缓存中的旧值怎么办

7.1 先区分几种一致性目标

设一次写操作把数据库从 v1 更新为 v2。常见目标不同:

  1. 最终一致:经过一段时间后缓存最终变为 v2 或失效。
  2. 写后读一致:同一请求链路在写成功后立即读取,必须看到 v2
  3. 会话一致:同一个用户后续请求不能读到比自己之前看过的版本更旧的数据。
  4. 强一致:任何成功写入后,所有读都立即返回 v2

普通 @Cacheable 加 TTL 通常只能提供有限的最终一致性,不能自动提供强一致。

7.2 更新缓存还是删除缓存

有两种基本协议。

协议 A:先更新数据库,再更新缓存

1. UPDATE database -> v2
2. PUT cache -> v2

成功时读请求很快看到新值,但有一个失败窗口:

数据库更新成功
缓存更新失败

此时缓存仍可能是 v1,直到 TTL 到期或其他机制修复。

并发时还存在旧值覆盖新值的风险:

T1:数据库写入 v2
T2:数据库写入 v3
T2:缓存写入 v3
T1:缓存写入 v2
结果:数据库是 v3,缓存却是 v2

如果更新可能并发,必须使用版本号、数据库更新时间或顺序化机制判断“旧写不能覆盖新写”。

协议 B:先更新数据库,再删除缓存

1. UPDATE database -> v2
2. DELETE cache

下一次读取缓存未命中,再从数据库加载 v2

这是常见的 Cache-Aside 删除策略,因为删除比更新缓存更容易处理对象转换和并发问题。但它也不是天然强一致:

T1:数据库更新为 v2
T2:读请求发现缓存未命中,开始查数据库
T3:另一个流程把旧值 v1 回填缓存
T4:T1 删除缓存

具体是否出现旧值回填,取决于实际时序;在高并发下通常需要更严谨的删除、延迟双删、版本校验或串行化方案。延迟双删也只是降低窗口概率,不是强一致证明。

7.3 @CachePut@CacheEvict 的事务边界

下面的写法看起来合理:

@Transactional
@CachePut(cacheNames = "books", key = "'book:isbn:' + #result.isbn")
public Book update(Book book) {
    return repository.save(book);
}

但需要区分两个时刻:

  • 数据库事务提交;
  • 缓存更新执行。

如果缓存更新发生在数据库事务真正提交前,随后事务回滚,缓存可能保存一个数据库中不存在的值。

更稳妥的做法是让缓存操作在事务成功提交后发生,例如:

  • 使用事务同步;
  • 发布领域事件,在 AFTER_COMMIT 阶段删除或更新缓存;
  • 使用可靠消息或 Outbox,将数据库变更和事件记录放进同一个事务。

事件最终消费失败时,还需要重试、死信和人工修复,否则“提交后删除缓存”的可靠性仍然不足。

7.4 读写并发的版本校验

如果缓存值包含版本号:

public record CachedBook(Book book, long version) {}

回填缓存时可以使用条件写入:

只有新版本 version_new >= 当前缓存版本 version_old 时才允许覆盖

形式化地说,缓存写入条件是:

write(v_new) 只有在 v_new >= v_cache 时成立

这样可以防止前文的 v2 覆盖 v3。Redis 可以通过 Lua 脚本或事务机制实现比较与写入的原子性;仅在 Java 代码中先 GET、再判断、再 SET,中间仍可能被其他线程插入更新。


8. 缓存击穿和并发加载

8.1 同一个热点键同时未命中

假设热点键 k 在时刻 t 过期,同时有 N 个请求到达:

请求 1 ─┐
请求 2 ─┤
请求 3 ─┼─ 缓存未命中 ─► N 次数据库查询
...    ─┤
请求 N ─┘

这就是典型缓存击穿。即使数据库平时能够承受流量,短时间的并发回源也可能造成连接池耗尽。

8.2 sync = true 的边界

可以写:

@Cacheable(
    cacheNames = "books",
    key = "#isbn",
    sync = true
)
public Book findByIsbn(String isbn) {
    return repository.findByIsbn(isbn)
            .orElseThrow();
}

sync = true 是对缓存提供商的同步加载提示,目标是让同一缓存键的并发加载尽量合并。它有几个边界:

  • 是否真正实现、并发范围多大,取决于底层 Cache
  • 通常只解决单个应用实例内部的并发;
  • 不能天然协调多个 JVM;
  • 不能替代分布式锁;
  • 不能解决数据库更新与缓存回填的版本顺序问题;
  • 与某些 @Cacheable 选项存在实现限制,不能假设所有组合都支持。

如果部署了多个实例,实例 A 和实例 B 仍可能同时回源。跨实例的请求合并需要分布式锁、租约、单飞协议,或者接受一定的重复加载。

8.3 TTL 抖动和逻辑过期

如果大量 Key 在同一时刻批量写入并使用完全相同的 TTL,它们可能在同一时间集中失效。可以给 TTL 增加随机抖动:

实际 TTL = 基础 TTL + random(0, 抖动范围)

这只能降低同时过期的概率,不能解决单个热点 Key 的击穿。

另一种方法是逻辑过期:

缓存中的对象仍然保留,但附带 expireAt
  │
  ├─ 未逻辑过期:直接返回
  └─ 已逻辑过期:
       一个请求异步刷新
       其他请求暂时返回旧值

这种方案牺牲部分新鲜度,换取更稳定的数据库压力。必须明确允许返回多旧的数据,并防止刷新线程无限堆积。


9. 多级缓存:L1、L2 和数据库的完整数据流

9.1 组件角色

典型多级缓存结构如下:

请求
  │
  ▼
L1:进程内缓存(Caffeine)
  │ 未命中
  ▼
L2:分布式缓存(Redis)
  │ 未命中
  ▼
数据库

各层特点不同:

层级 位置 优点 风险
L1 当前 JVM 延迟低,不依赖网络 每个实例各自一份,容量有限,容易与其他实例不一致
L2 Redis 等 多实例共享,容量较大 网络开销、序列化、集群故障
数据库 持久数据源 权威、可恢复 延迟和并发成本更高

读取流程必须明确:

1. 查 L1
2. L1 未命中,查 L2
3. L2 命中,回填 L1
4. L2 未命中,查数据库
5. 回填 L2
6. 回填 L1
7. 返回结果

如果只回填 L2、不回填 L1,L1 就失去主要价值。如果只清除 L2、不清除其他实例的 L1,旧数据仍可能被本地缓存返回。

9.2 为什么两个 @Cacheable 不等于可靠多级缓存

有人会尝试:

@Cacheable(cacheNames = "l1")
@Cacheable(cacheNames = "l2")
public Book find(String isbn) {
    // ...
}

这会引入代理顺序和回填语义问题:

  • 外层缓存命中时,内层不会执行;
  • 某层命中时,另一层是否被回填取决于调用顺序;
  • 注解执行顺序可能受配置影响;
  • 删除和更新需要同时操作两层;
  • 多实例的 L1 失效仍然没有解决。

因此,多级缓存更适合实现成一个逻辑 Cache,或者由一个明确的缓存服务编排,而不是依赖多个注解叠加来猜测时序。

9.3 一个简化的层级缓存实现

下面展示核心实现思路。实际项目应补充指标、异常策略、空值策略和并发控制。

public final class TieredCache implements Cache {

    private final Cache l1;
    private final Cache l2;

    public TieredCache(Cache l1, Cache l2) {
        this.l1 = l1;
        this.l2 = l2;
    }

    @Override
    public String getName() {
        return l1.getName();
    }

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

    @Override
    public ValueWrapper get(Object key) {
        ValueWrapper value = l1.get(key);
        if (value != null) {
            return value;
        }

        value = l2.get(key);
        if (value != null) {
            // L2 命中后回填 L1
            l1.put(key, value.get());
        }
        return value;
    }

    @Override
    public <T> T get(Object key, Class<T> type) {
        ValueWrapper value = get(key);
        if (value == null) {
            return null;
        }
        return type.cast(value.get());
    }

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

        T loaded;
        try {
            loaded = valueLoader.call();
        } catch (Exception ex) {
            throw new ValueRetrievalException(key, valueLoader, ex);
        }

        put(key, loaded);
        return loaded;
    }

    @Override
    public void put(Object key, Object value) {
        // 写入顺序、失败补偿和一致性策略必须结合业务确定
        l2.put(key, value);
        l1.put(key, value);
    }

    @Override
    public void evict(Object key) {
        l1.evict(key);
        l2.evict(key);
    }

    @Override
    public void clear() {
        l1.clear();
        l2.clear();
    }
}

这段代码的关键路径是:

get:
  L1 命中 -> 返回
  L1 未命中、L2 命中 -> 写入 L1 -> 返回
  两层都未命中 -> 调用 valueLoader -> 写入两层 -> 返回

但它还存在几个生产边界:

  1. l2.get() 失败时,是直接失败还是降级到数据库,必须显式决定;
  2. l2.put() 成功、l1.put() 失败时,两层暂时不一致;
  3. get(Object, Callable) 的并发合并能力取决于实现,当前简单代码不能保证跨实例单飞;
  4. L1 过期后,如果 L2 仍有旧值,L1 会被旧值重新填充;
  5. null 和“缓存中明确存在空值”需要统一约定;
  6. 删除必须广播到所有实例的 L1,否则其他 JVM 仍可返回旧值。

9.4 L1 失效广播

当数据库更新后删除 Redis,当前实例的 L1 可以直接删除,但其他实例的 L1 不会自动知道。常见同步方式包括:

更新数据库
  │
  ├─ 删除 Redis
  └─ 发布 cache-evict 事件
          │
          ├─ 实例 A 清理 L1
          ├─ 实例 B 清理 L1
          └─ 实例 C 清理 L1

事件可以通过 Redis Pub/Sub、消息队列或其他广播机制传递。Redis Pub/Sub 通常不提供可靠持久化消费语义;如果消费者短暂断线,可能丢失失效消息。因此对不能容忍旧数据的场景,应使用可重放消息、版本校验或定期校准,而不是只依赖瞬时广播。


10. 故障路径:缓存坏了,系统应该怎样表现

10.1 缓存读取失败

缓存读取失败时有两种基本策略:

严格模式:
  缓存异常 -> 请求失败
降级模式:
  缓存异常 -> 记录指标和日志 -> 查询数据库

降级模式提高可用性,但可能把数据库瞬间推入过载状态。若 Redis 故障导致所有请求同时回源,就会形成“缓存故障雪崩”。

因此降级必须配合:

  • 数据库连接池和并发限制;
  • 超时和熔断;
  • 回源限流;
  • 热点保护;
  • 明确的错误预算。

不能简单地写:

try {
    return cache.get(key);
} catch (Exception ignored) {
    return database.query(key);
}

这种代码会吞掉故障证据,也没有防止数据库被打爆。

10.2 CacheErrorHandler

注解驱动的缓存操作可以通过 CacheErrorHandler 定义读取、写入、删除等失败时的行为。示意配置如下:

@Configuration
@EnableCaching
public class CacheErrorConfig
        implements CachingConfigurer {

    @Override
    public CacheErrorHandler errorHandler() {
        return new CacheErrorHandler() {
            @Override
            public void handleCacheGetError(
                    RuntimeException exception,
                    Cache cache,
                    Object key) {
                // 记录指标;是否降级由整体架构决定
            }

            @Override
            public void handleCachePutError(
                    RuntimeException exception,
                    Cache cache,
                    Object key,
                    Object value) {
                // 写缓存失败通常不能回滚已提交的数据库事务
            }

            @Override
            public void handleCacheEvictError(
                    RuntimeException exception,
                    Cache cache,
                    Object key) {
                // 删除失败需要告警和补偿
            }

            @Override
            public void handleCacheClearError(
                    RuntimeException exception,
                    Cache cache) {
                // 清空失败需要明确影响范围
            }
        };
    }
}

降级时最重要的不是“让请求继续”,而是证明系统仍处在可控范围内:数据库有余量、缓存错误率可观测、回源流量有上限,并且缓存恢复后不会因为积压请求再次冲垮服务。


11. 事务、删除和更新的推荐推导

考虑一个写操作:

数据库旧值:v1
缓存旧值:v1
目标值:v2

若采用“更新数据库后删除缓存”:

t1:开始数据库事务
t2:数据库提交 v2
t3:删除缓存
t4:后续读请求从数据库读取 v2 并回填

这是一个清晰的最终一致流程。

如果 t2 之前删除缓存:

t1:删除缓存
t2:数据库事务回滚

后续读会重新加载旧值 v1,虽然结果最终可能正确,但会产生额外回源;更严重的是,如果数据库尚未提交而读请求查到不一致的中间状态,协议会变得复杂。

如果 t2 成功后 t3 删除失败:

数据库:v2
缓存:v1

此时需要:

  • 删除重试;
  • 延迟重试任务;
  • 失效事件;
  • TTL 作为最终兜底;
  • 监控缓存删除失败数。

因此,生产系统中通常把缓存失效视作一个需要可靠投递和重试的副作用,而不是把一条 @CacheEvict 当作事务的一部分。


12. 监控和诊断:从现象定位机制

缓存问题不应只看平均响应时间,至少要记录:

cache_get_total{cache, result=hit|miss|error}
cache_put_total{cache, result=success|error}
cache_evict_total{cache, result=success|error}
cache_load_seconds{cache}
cache_load_concurrent{cache}
cache_entries{cache}
cache_evictions{cache}

排查顺序可以按数据流进行:

Key 错误

表现:

  • 命中率异常低;
  • Redis 中出现大量相似但不同的键;
  • 不同租户或语言读到错误数据。

验证:

redis-cli --scan --pattern '*books*'
redis-cli get '<某个实际键>'

再将请求参数和程序生成的 Key 打到结构化日志中,确认是否包含了所有影响结果的上下文。

TTL 未生效

表现:

  • 数据长期不更新;
  • Redis TTL 返回 -1
  • 本地缓存和 Redis 的过期行为不同。

验证:

redis-cli ttl '<实际键>'

同时确认该缓存名称是否由预期的 CacheManager 创建,是否存在多个 CacheManager 或配置覆盖。

穿透

表现:

  • 大量 404;
  • 这些请求的缓存命中率接近零;
  • 数据库查询量与无效请求量同步增长。

验证:

  • 统计不存在键的回源次数;
  • 检查是否缓存空值;
  • 检查布隆过滤器是否覆盖完整数据集;
  • 检查攻击请求是否具有随机 ID 特征。

击穿

表现:

  • 某个热门 Key 在过期时数据库 QPS 突然尖峰;
  • 大量请求同时出现缓存 miss;
  • 单个键的加载并发数很高。

验证:

  • 按 Key 统计 miss 和加载耗时;
  • 对比过期时间分布;
  • 检查 sync = true 是否被实际缓存实现支持;
  • 检查多个 JVM 是否同时回源。

一致性错误

表现:

  • 数据库已经是新值,接口仍返回旧值;
  • 某些实例正常,另一些实例异常;
  • 重启某个实例后问题消失。

验证:

  1. 读取数据库版本;
  2. 读取 L2 值和版本;
  3. 读取各实例 L1 值;
  4. 检查更新日志、失效事件、消费确认和重试记录;
  5. 对照时间线判断是旧值未删除、旧值回填,还是 Key 指向了错误数据。

13. 常见误解和边界

13.1 “加了 @Cacheable 就有 TTL”

错误。TTL 属于具体缓存实现的配置,不是 @Cacheable 的通用保证。

13.2 “缓存命中就一定是最新值”

错误。命中只表示缓存中存在一个被实现视为有效的值,不代表数据库刚刚提交的值已经传播到缓存。

13.3 “@CacheEvict 和数据库更新天然原子”

错误。Spring Cache 操作和数据库事务不是自动的跨资源原子事务。缓存删除失败、事务回滚、消息丢失都可能造成短暂或长期不一致。

13.4 “缓存 null 就彻底解决穿透”

错误。空值缓存只能覆盖已经查询过的不存在键;随机键第一次访问仍然会回源。空值 TTL 过长还会阻塞后来创建的数据。

13.5 “Caffeine 和 Redis 放在一起就自动是多级缓存”

错误。两个缓存管理器并不会自动形成 L1/L2 回填、双层失效和故障降级协议。多级缓存必须明确读路径、写路径、失效广播和异常策略。

13.6 “缓存可以保存任意方法结果”

不一定。结果可能包含:

  • 用户权限相关字段;
  • 请求时间、随机数;
  • 数据库懒加载对象;
  • 不可序列化对象;
  • 与事务上下文绑定的状态。

缓存前必须确定结果是否具有稳定语义,以及序列化后是否仍能正确反序列化。


14. 一个可验证的选择框架

可以从数据特征反推缓存方案:

数据允许短暂陈旧

采用:

Cache-Aside + TTL

读取未命中时查数据库并回填;更新时删除缓存;TTL 作为最终兜底。

数据不存在请求很多

采用:

参数校验 + 空值缓存

在随机 ID 攻击明显时增加:

布隆过滤器 + 空值缓存

单个热点键过期会造成过载

采用:

请求合并 / 分布式锁 / 逻辑过期 / 预热

具体选项取决于是否允许返回旧值。

多实例且要求快速失效本地数据

采用:

L1 Caffeine + L2 Redis + 失效广播

同时为失效消息设计重试、重放或版本校验。

数据必须写后立即可见

不能只依赖 TTL。应考虑:

数据库提交后失效或更新
+ 版本控制
+ 可靠事件
+ 必要时绕过缓存读取

最终是否达到强一致,需要对所有读路径和写路径做时序证明,而不是由注解名称推断。


Spring Cache 的价值在于统一了缓存调用模型,让业务代码不必直接依赖某一种缓存产品;但它只抽象了“如何访问缓存”,没有替应用决定 Key 语义、过期策略、穿透防护、数据一致性和多级协同。真正可靠的缓存设计,必须把缓存当作一个带有状态、时序、并发和故障路径的系统来设计:先定义 Key 和数据语义,再定义 TTL 和未命中行为,最后补上更新、失效、回填、降级和监控协议。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。