Java 基础体系 · 第 92/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Spring Cache:抽象、Key、TTL、穿透、一致性和多级缓存
1. 先建立缓存问题的完整模型
缓存不是数据库的替代品,而是位于“数据源”和“调用方”之间的一份派生数据。以查询商品为例:
请求
│
▼
应用服务 ── 命中 ──► 缓存
│ │
│ 未命中 │ 返回缓存值
▼
数据库 ── 查询成功 ──► 回填缓存
设数据库中的真实状态为 D(k),缓存中键 k 对应的值为 C(k)。一次读请求通常希望得到:
返回值 = C(k),如果缓存命中且仍然可接受
= D(k),如果缓存未命中,再将结果写入 C(k)
因此,缓存系统至少涉及以下问题:
- 抽象:Spring 如何把缓存调用统一成
@Cacheable、@CachePut和@CacheEvict。 - Key:不同方法参数如何映射为同一个缓存键,如何避免碰撞。
- TTL:缓存值保存多久,过期时发生什么。
- 穿透:请求不存在的数据时,为什么每次都打到数据库。
- 一致性:数据库更新后,缓存中的旧值何时、如何消失。
- 多级缓存:本地缓存、分布式缓存和数据库如何协同。
- 并发与故障:大量请求同时未命中、缓存故障、回填失败时系统如何退化。
后文中的“缓存一致”并不等于分布式系统中的强一致。它通常表示:在规定的时间窗口和更新协议内,缓存不会长期偏离数据源。
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 condition 和 unless 的执行时机
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 设计需要同时满足:
- 区分不同数据;
- 稳定:相同语义的请求生成相同键;
- 可序列化:远程缓存能够存储;
- 与租户、语言、权限等上下文一致;
- 在不同方法之间避免意外碰撞。
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:通过
expireAfterWrite或expireAfterAccess; - 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 expireAfterWrite 与 expireAfterAccess
两者语义不同:
expireAfterWrite:
写入后固定时间过期
expireAfterAccess:
每次访问都会延长存活时间
对于热点数据,expireAfterAccess 可能让它长期不失效;对于必须周期性刷新、不能长期陈旧的数据,通常需要写入后过期或主动刷新。
Redis 的 TTL 通常是按条目保存的;Caffeine 的过期检查可能在访问或维护操作时发生,不应把“物理删除时刻”理解成严格的定时器回调时刻。对业务而言,重要的是过期条目不能继续作为有效命中返回。
4.4 TTL 与数据新鲜度的关系
假设数据更新后,缓存最多保留 TTL 时间,且缓存更新没有额外通知,那么旧值的最大陈旧时间大致受 TTL 控制:
最大陈旧时间 ≈ TTL + 时钟、调度、请求到达等误差
但这个推导成立需要前提:
- 更新后没有再次把旧值写回缓存;
- TTL 确实从正确时刻开始计算;
- 所有读路径都使用同一缓存;
- 没有永久缓存或错误恢复逻辑绕过 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。常见目标不同:
- 最终一致:经过一段时间后缓存最终变为
v2或失效。 - 写后读一致:同一请求链路在写成功后立即读取,必须看到
v2。 - 会话一致:同一个用户后续请求不能读到比自己之前看过的版本更旧的数据。
- 强一致:任何成功写入后,所有读都立即返回
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 -> 写入两层 -> 返回
但它还存在几个生产边界:
l2.get()失败时,是直接失败还是降级到数据库,必须显式决定;l2.put()成功、l1.put()失败时,两层暂时不一致;get(Object, Callable)的并发合并能力取决于实现,当前简单代码不能保证跨实例单飞;- L1 过期后,如果 L2 仍有旧值,L1 会被旧值重新填充;
null和“缓存中明确存在空值”需要统一约定;- 删除必须广播到所有实例的 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 是否同时回源。
一致性错误
表现:
- 数据库已经是新值,接口仍返回旧值;
- 某些实例正常,另一些实例异常;
- 重启某个实例后问题消失。
验证:
- 读取数据库版本;
- 读取 L2 值和版本;
- 读取各实例 L1 值;
- 检查更新日志、失效事件、消费确认和重试记录;
- 对照时间线判断是旧值未删除、旧值回填,还是 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 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Spring 异常处理:Problem Detail、全局处理、错误码和日志边界
- 下一篇:Spring 调度任务:线程池、Cron、时区、防重入和分布式锁
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论