为什么 Spring Cache 不够用
Spring Cache (@Cacheable/@CacheEvict) 是个好抽象——把"读数据先看缓存"这件事用注解优雅地表达出来。但用过的人都知道,它有几个
和生产场景对不上的硬伤:
- 不支持过期时间——原生
@Cacheable 没法在注解上指定 TTL,要么改全局配置,要么自定义 CacheManager - 不支持多级缓存——你要么全 Caffeine,要么全 Redis,没法"先查 Caffeine 再查 Redis"
- 没有自动刷新——缓存到期后第一个请求要等 DB,热点 key 必失效雪崩
- 没有防穿透——查询不存在的数据,每次都要打 DB
- 多实例 Caffeine 不一致——本地缓存各自一份,DB 改了一台缓存没刷
- 统计能力弱——命中率、QPS 都要自己埋点
阿里开源的 JetCache 把这些痛点几乎全方位补足。本文不写"JetCache 是什么",直接讲它在生产里怎么用、踩什么坑。
一、最小可运行 Demo
引入:
1
2
3
4
5
| <dependency>
<groupId>com.alicp.jetcache</groupId>
<artifactId>jetcache-starter-redis</artifactId>
<version>2.7.5</version>
</dependency>
|
application.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| jetcache:
statIntervalMinutes: 15 # 每 15 分钟打一次命中率统计日志
areaInCacheName: false
local:
default:
type: caffeine
keyConvertor: jackson
limit: 10000 # 本地最多缓存 10000 条
expireAfterWriteInMillis: 60000
remote:
default:
type: redis
keyConvertor: jackson
valueEncoder: java
valueDecoder: java
poolConfig:
maxTotal: 50
host: localhost
port: 6379
|
启动类加注解:
1
2
3
4
5
6
7
8
| @SpringBootApplication
@EnableCreateCacheAnnotation
@EnableMethodCache(basePackages = "com.app")
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
|
业务里直接用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| @Service
public class UserService {
@Cached(name = "user:", key = "#id",
cacheType = CacheType.BOTH,
expire = 300, localExpire = 60,
localLimit = 1000)
public User getById(Long id) {
return userMapper.selectById(id);
}
@CacheInvalidate(name = "user:", key = "#user.id")
public void update(User user) {
userMapper.updateById(user);
}
}
|
CacheType.BOTH 即为多级缓存——先查本地 Caffeine,没命中再查 Redis,再没有才打 DB。
二、多级缓存背后的访问模型
flowchart LR
Req([请求]) --> L1{本地 Caffeine}
L1 -->|命中| Return1[返回]
L1 -->|未命中| L2{远程 Redis}
L2 -->|命中| Fill1[回写 Caffeine] --> Return2[返回]
L2 -->|未命中| DB[(DB / RPC)]
DB --> Fill2[写 Redis + Caffeine] --> Return3[返回]这套模型解决了几个核心问题:
- 大部分热点请求被本地 Caffeine 拦截,QPS 飙升
- 本地缓存丢失(重启、扩容)时由 Redis 兜底,避免直接打 DB
- DB 压力被收敛到"每个 key 第一次访问"那一次
三、自动刷新:解决雪崩的杀手锏
普通缓存到期后,第一个请求要等 DB——如果 key 是热点,并发会打穿到 DB(缓存击穿)。JetCache 的 @Cached 提供 refresh 选项:
1
2
3
4
5
6
7
8
| @Cached(name = "hotConfig:", key = "#key",
cacheType = CacheType.BOTH,
expire = 600,
cacheNullValue = true)
@CacheRefresh(refresh = 60, stopRefreshAfterLastAccess = 600)
public Config get(String key) {
return configMapper.selectByKey(key);
}
|
参数解读:
refresh = 60:每 60 秒后台异步刷新一次(即便 key 还没过期)stopRefreshAfterLastAccess = 600:如果 600 秒没人访问这个 key 就停止刷新(避免无效刷新冷数据)cacheNullValue = true:缓存 null 也能加速防穿透
自动刷新让热点 key 永远不会"出现在缓存外"——即便 DB 短暂故障,前端拿到的也只是稍微旧一点的数据,不会瞬间打穿。
四、防穿透:缓存空值
查询一个不存在的 ID,按朴素实现每次都打 DB:
1
| 请求 user/9999999 → 缓存没有 → 查 DB 没有 → 不写缓存 → 下次还是打 DB
|
JetCache 解法:
1
2
3
4
5
6
7
| @Cached(name = "user:", key = "#id",
cacheType = CacheType.BOTH,
expire = 600,
cacheNullValue = true) // ★ 关键
public User getById(Long id) {
return userMapper.selectById(id);
}
|
cacheNullValue = true 让"DB 返回 null"也被缓存,短时间内连续查不存在的 key 不会重复打 DB。
注意:缓存 null 的 TTL 通常应该比正常数据短一些,避免数据从无到有时延迟太久才被前端看到。可以两个 @Cached 分开缓存,或者
TTL 设短点。
五、批量缓存:@CacheRefresh 不灵的场景用编程式 API
@Cached 注解适合"一个方法一个 key",但批量场景(一次查 100 个用户)用注解就别扭。JetCache 提供了编程式 API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| @CreateCache(name = "user:", expire = 300, cacheType = CacheType.BOTH)
private Cache<Long, User> userCache;
public Map<Long, User> getByIds(List<Long> ids) {
// 一次性批量查缓存
Map<Long, User> cached = userCache.getAll(new HashSet<>(ids));
Set<Long> missed = ids.stream()
.filter(id -> !cached.containsKey(id))
.collect(Collectors.toSet());
if (!missed.isEmpty()) {
Map<Long, User> fromDb = userMapper.selectByIds(missed)
.stream().collect(Collectors.toMap(User::getId, Function.identity()));
userCache.putAll(fromDb);
cached.putAll(fromDb);
}
return cached;
}
|
getAll / putAll 在 Redis 上是 pipeline 批量执行,比 N 次单独 get 快得多。
六、本地缓存一致性:广播失效
多级缓存最大的隐患是——多实例本地缓存各自一份,一台改了 DB 后只清自己本地的,另一台还是旧数据。
JetCache 内置了 广播失效(基于 Redis Pub/Sub):
1
2
3
4
5
6
7
8
9
10
11
| jetcache:
local:
default:
type: caffeine
keyConvertor: jackson
syncLocal: true # ⚠️ 关键:开启跨 JVM 本地失效同步
remote:
default:
broadcastChannel: app-cache-channel # 启用广播
type: redis.lettuce
...
|
broadcastChannel 与 syncLocal: true 缺一不可——只配 broadcastChannel 不开 syncLocal,本地 Caffeine 不会订阅广播,跨
JVM 失效不生效。
每次 @CacheInvalidate 触发时:
- 删 Redis
- 通过 Redis Pub/Sub 同时发广播
- 所有订阅方(包括发起方自己)收到广播 → 清各自本地
不是"先清自己再广播给别人"——本地清理与广播走的是同一条订阅路径,发起方也是订阅方之一。
sequenceDiagram
Instance1->>Redis: DEL user:1
Instance1->>Redis: PUBLISH "expire user:1"
Redis->>Instance1: 自己也收到广播
Redis->>Instance2: 收到广播
Redis->>Instance3: 收到广播
Instance1->>Caffeine: invalidate (本地)
Instance2->>Caffeine: invalidate
Instance3->>Caffeine: invalidate这是用本地缓存时绝对要打开的特性——不开它,多实例本地缓存几乎没法用。
七、监控统计:开箱即用
statIntervalMinutes: 15 配置后,每 15 分钟会在日志里打出每个 cache 的命中率:
1
2
3
| cache | qps | rate(local) | rate(remote) | hit | miss
user: | 12345 | 82.30% | 65.40% | 9876 | 234
hotConfig: | 321 | 95.20% | 80.00% | 300 | 21
|
把这个表周期性吐到监控系统(Prometheus / SkyWalking / 日志大盘),直接定位"哪个 cache 命中率低"——通常意味着 TTL 太短、key
设计有问题、或者业务实际并不重复。
八、踩坑提醒
1. keyConvertor 必须配
不配的话默认用对象的 toString() 当 key——对象 hash 变了 key 就不一样,缓存命中率瞬间归零。jackson / fastjson2
是常见选择。
2. 序列化版本兼容
valueEncoder: java 用 JDK 序列化,类字段加减会直接破坏序列化兼容。生产推荐 kryo 或 jackson:
1
2
3
4
| remote:
default:
valueEncoder: kryo5
valueDecoder: kryo5
|
3. 本地容量要算清楚
localLimit = 10000 说着轻巧,但每条数据多大决定了内存占用——10000 条用户对象可能 100MB。生产前务必算 size。
4. CacheType.LOCAL 慎用
只用本地缓存时数据一致性靠不住,强烈建议至少 BOTH——除非你的数据就是只读且全量加载的(如配置)。
5. 注解和方法签名
@Cached 的 key 不要写成 #user.id——null 时会 NPE。用 SpEL 的安全访问:'#user?.id'。
6. 不缓存 List<复杂对象>
复杂对象列表序列化反序列化都是开销,多个细粒度 cache 比一个大 cache 性能好。把"分页结果"缓存当反例——分页参数一变就
miss,命中率极低。
九、和其他方案对比
| 方案 | 多级 | 自动刷新 | 防穿透 | 一致性广播 | 注解友好 | 学习成本 |
|---|
| Spring Cache 原生 | ✗ | ✗ | △ | ✗ | ✓ | 低 |
| Caffeine 单层 | ✗ | △ | △ | ✗ | △ | 低 |
| Redis 单层 | ✗ | ✗ | △ | ✗ | △ | 低 |
| JetCache | ✓ | ✓ | ✓ | ✓ | ✓ | 中 |
| Redisson Cache | △ | △ | △ | ✓ | △ | 中高 |
JetCache 在"开箱即用程度"上目前在 Java 生态里比较突出——它的核心价值不是缓存本身,而是把生产经验固化进了注解。
十、实战建议
- 先 BOTH 后调优——默认就用本地 + Redis 两级,稳定后再针对低命中率 cache 单独配
- TTL 至少 5 分钟——TTL 过短的缓存几乎不会命中
- 热点 key 一律开
@CacheRefresh——避免雪崩 - 多实例必开广播失效——不开会让多实例本地缓存的数据一致性失控
cacheNullValue = true——除非业务明确不需要防穿透- 监控命中率——低于 70% 的 cache 都要重新审视设计
1
2
3
4
5
6
7
8
9
| // 一个生产推荐的"全功能"配置
@Cached(name = "user:", key = "#id",
cacheType = CacheType.BOTH,
expire = 600, localExpire = 60,
cacheNullValue = true)
@CacheRefresh(refresh = 120, stopRefreshAfterLastAccess = 1800)
public User getById(Long id) {
return userMapper.selectById(id);
}
|
小结
一句话压缩 JetCache 的价值:
把『多级缓存 + 自动刷新 + 防穿透 + 广播失效 + 监控』这五件该做但 Spring Cache 不做的事,全部塞进了注解里。
它解决的不是"缓存有没有"的问题,是"把缓存做对“的问题。
工程上记住几条:
- 默认
BOTH,TTL ≥ 5 分钟 - 热点开
@CacheRefresh - 多实例必开广播失效
cacheNullValue 防穿透- 命中率监控周期性看一眼
把这些吃透,缓存这层就基本不会再出大事故了。