Featured image of post JetCache 实战:Spring 项目里的多级缓存利器

JetCache 实战:Spring 项目里的多级缓存利器

本地 + 远程两级缓存、统一注解、自动 refresh——JetCache 把 Spring Cache 的『难用』全方位补足

为什么 Spring Cache 不够用

Spring Cache (@Cacheable/@CacheEvict) 是个好抽象——把"读数据先看缓存"这件事用注解优雅地表达出来。但用过的人都知道,它有几个 和生产场景对不上的硬伤:

  1. 不支持过期时间——原生 @Cacheable 没法在注解上指定 TTL,要么改全局配置,要么自定义 CacheManager
  2. 不支持多级缓存——你要么全 Caffeine,要么全 Redis,没法"先查 Caffeine 再查 Redis"
  3. 没有自动刷新——缓存到期后第一个请求要等 DB,热点 key 必失效雪崩
  4. 没有防穿透——查询不存在的数据,每次都要打 DB
  5. 多实例 Caffeine 不一致——本地缓存各自一份,DB 改了一台缓存没刷
  6. 统计能力弱——命中率、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


二、多级缓存背后的访问模型

这套模型解决了几个核心问题:

  • 大部分热点请求被本地 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
      ...

broadcastChannelsyncLocal: true 缺一不可——只配 broadcastChannel 不开 syncLocal,本地 Caffeine 不会订阅广播,跨 JVM 失效不生效。

每次 @CacheInvalidate 触发时:

  1. 删 Redis
  2. 通过 Redis Pub/Sub 同时发广播
  3. 所有订阅方(包括发起方自己)收到广播 → 清各自本地

不是"先清自己再广播给别人"——本地清理与广播走的是同一条订阅路径,发起方也是订阅方之一。

这是用本地缓存时绝对要打开的特性——不开它,多实例本地缓存几乎没法用。


七、监控统计:开箱即用

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 序列化,类字段加减会直接破坏序列化兼容。生产推荐 kryojackson

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 生态里比较突出——它的核心价值不是缓存本身,而是把生产经验固化进了注解


十、实战建议

  1. 先 BOTH 后调优——默认就用本地 + Redis 两级,稳定后再针对低命中率 cache 单独配
  2. TTL 至少 5 分钟——TTL 过短的缓存几乎不会命中
  3. 热点 key 一律开 @CacheRefresh——避免雪崩
  4. 多实例必开广播失效——不开会让多实例本地缓存的数据一致性失控
  5. cacheNullValue = true——除非业务明确不需要防穿透
  6. 监控命中率——低于 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 防穿透
  • 命中率监控周期性看一眼

把这些吃透,缓存这层就基本不会再出大事故了。

使用 Hugo 构建
主题 StackJimmy 设计