兄弟们,咱先聊个扎心的事儿 —— 你是不是也遇到过这样的情况:线上接口明明加了 Redis 缓存,可高峰期还是慢得要死?或者本地缓存用得挺爽,一搞分布式部署就各种数据不一致,心态直接崩了?
我前阵子就踩过这坑:负责的商品详情接口,一开始只怼了 Redis,QPS 低的时候还挺稳,结果大促前压测,接口响应时间直接从 20ms 飙到 200ms,运维大哥天天追着我问 “是不是缓存没生效”。后来查了半天发现,不是 Redis 不行,是每次请求都要跨网络去捞 Redis 的数据,量大了之后,那网络开销就跟堵车似的,越积越慢。
直到我把 Redis 和 Caffeine 凑到一块儿,嘿,你猜怎么着?接口响应直接打回 10ms 以内,大促峰值也稳得一批。今天就跟大家好好唠唠,这俩 “缓存神器” 组队之后,到底有多强,以及咱们怎么在项目里把它们用明白 —— 全程大白话,没那么多绕人的术语,放心看!
一、先搞懂:Redis 和 Caffeine,各自到底啥本事?
在说 “组队” 之前,咱得先摸清这俩货的底细。就跟打游戏组队一样,你得知道辅助能加血、输出能秒人,才能配合好不是?
1. Redis:“远程仓库”,能存还能扛,但跑起来费点劲
Redis 这哥们儿,咱 Java 开发者基本都熟,号称 “内存数据库”,但咱平时用得最多的还是它的缓存功能。它的优点那是相当突出:
首先是 “能存”—— 不仅能存字符串,还能存哈希、列表、集合这些复杂结构,比如你想缓存用户的基本信息 + 订单列表,直接用哈希或者列表就能搞定,不用自己再拆数据。而且它支持持久化,就算 Redis 服务重启,之前缓存的数据也能找回来,不用怕 “一重启缓存全没了,数据库瞬间被打崩” 的尴尬。
然后是 “能扛”—— 分布式部署的时候,Redis 能搞集群,多台机器一起分担压力,比如你搞个 3 主 3 从的集群,QPS 轻松上 10 万,应对大部分业务场景都没问题。而且它是独立于应用服务的,不管你应用部署多少台机器,都能共享一份 Redis 缓存,不会出现 “每台应用都缓存一份,内存浪费还数据不一致” 的情况。
但 Redis 也有个明显的短板:它是 “远程缓存”,也就是说你的应用要拿数据,得通过网络请求去 Redis 服务器捞。这网络开销看着不大,比如一次请求就 1-2ms,但架不住请求多啊!比如你接口 QPS 是 1 万,那光网络开销就占了 1-2 万 ms 的总耗时,要是再遇到网络波动,延迟直接翻倍,接口响应时间可不就上去了?
举个实际的例子:我之前做的用户中心接口,每次查用户信息都要先调 Redis,在 QPS 5000 的时候,接口响应时间大概是 15ms,其中有 3ms 都是花在网络传输上。后来把 QPS 提到 1 万,网络延迟直接涨到 5ms,接口响应也跟着到了 20ms—— 别小看这几毫秒,在高并发场景下,这就是 “能用” 和 “好用” 的区别。
2. Caffeine:“桌面快捷方式”,秒回但内存有限
再说说 Caffeine,这玩意儿可能有些兄弟用得少,但它在本地缓存里绝对是 “天花板” 级别的存在。啥是本地缓存?简单说就是把数据存在你应用服务自己的内存里,拿数据的时候不用走网络,直接从内存里读,那速度叫一个快 —— 基本上是微秒级别的,比如一次读取只要 0.1ms,比 Redis 快了至少 10 倍。
Caffeine 的厉害之处,除了快,还有它的 “缓存淘汰算法”——W-TinyLFU 算法。咱不用纠结这个算法的具体原理,用大白话讲就是:它能聪明地判断哪些数据是 “常用的”,哪些是 “冷门的”,当缓存满了的时候,优先把冷门数据删掉,保留常用数据。
比如你做电商网站,首页的 “热门商品” 每天被访问几十万次,而某些 “小众商品” 可能几天才被访问一次。Caffeine 就会把 “热门商品” 留在缓存里,把 “小众商品” 淘汰掉,这样既能保证常用数据的访问速度,又不会让缓存占满应用的内存。
对比一下之前的 “老古董” 本地缓存比如 Guava Cache,Caffeine 的性能直接碾压 —— 官方测试数据显示,在高并发场景下,Caffeine 的命中率比 Guava Cache 高 10%-20%,而且内存占用更省。我之前把项目里的 Guava Cache 换成 Caffeine,接口响应时间直接从 8ms 降到了 3ms,内存占用还少了 15%,简直是 “降维打击”。
但 Caffeine 也有个致命缺点:它是 “本地缓存”,只能存在单个应用实例里。比如你把应用部署了 10 台机器,那每台机器都会有一份 Caffeine 缓存,这就会出现两个问题:
一是 “内存浪费”——10 台机器都缓存同一份数据,相当于重复存了 10 遍,要是数据量大,内存直接扛不住;
二是 “数据不一致”—— 比如你更新了商品价格,只更了其中一台机器的 Caffeine 缓存,其他 9 台还是旧数据,用户访问不同机器就会看到不同的价格,这锅你可背不起。
二、为啥要把 Redis 和 Caffeine 凑一起?1+1>2 的秘密
看完上面俩货的优缺点,你是不是已经想到了:既然 Redis 能分布式共享、持久化,但慢在网络;Caffeine 快如闪电,但只能本地存、易不一致 —— 那把它们俩结合起来,不就能互补了吗?
还真就是这么回事!Redis+Caffeine 的组合,本质上是 “双层缓存架构”:
第一层:本地 Caffeine 缓存,存 “高频访问” 的数据(比如首页热门商品、用户会话信息),拿数据的时候先查这里,秒级响应;第二层:远程 Redis 缓存,存 “中频访问” 或者 “需要分布式共享” 的数据(比如商品详情、用户订单),当 Caffeine 里没有的时候,再去 Redis 里捞;最底层:数据库,只存 “原始数据”,当 Redis 里也没有的时候,才去数据库里查,查完之后再把数据往 Redis 和 Caffeine 里存一份,下次就不用再查数据库了。
这么一套组合拳打下来,好处可就太多了:
1. 速度快:大部分请求都被 Caffeine 拦住了
根据 “二八定律”,80% 的请求都是访问那 20% 的高频数据。比如电商网站,80% 的用户都是在看首页的热门商品,只有 20% 的用户会去看小众商品或者详情页。
要是只用 Redis,那 80% 的请求都要走网络去捞 Redis 的数据;但加了 Caffeine 之后,80% 的请求直接从本地内存里拿数据,根本不用碰网络,接口响应速度自然就上去了。
我之前做的商品首页接口,加 Caffeine 之前,平均响应时间是 18ms,其中 10ms 是网络开销;加了 Caffeine 之后,85% 的请求都命中 Caffeine,平均响应时间直接降到了 5ms,用户体验直接拉满。
2. 扛得住:Redis 压力大减,数据库更安全
既然 80% 的请求都被 Caffeine 拦住了,那打到 Redis 上的请求就只剩下 20% 了,Redis 的压力一下子就小了很多。比如之前 Redis 要扛 1 万 QPS,现在只需要扛 2000 QPS,就算遇到高峰期,也不容易出现 Redis 过载的情况。
而数据库就更安全了 —— 只有当 Caffeine 和 Redis 里都没有数据的时候,才会去查数据库,这种情况可能只占 1% 都不到。比如我之前做的订单查询接口,没加双层缓存的时候,数据库 QPS 高峰期能到 1000,加了之后直接降到 50,数据库再也没出现过 “连接超时” 的情况。
3. 数据稳:Redis 保证分布式一致性
虽然 Caffeine 是本地缓存,但我们可以通过 Redis 来保证分布式场景下的数据一致性。比如你更新了商品价格,流程可以是这样:
先更新数据库里的商品价格;然后删除 Redis 里对应的商品缓存(注意是删除,不是更新,避免并发问题);最后删除当前应用实例的 Caffeine 缓存。
这样一来,下次有请求来的时候:
先查 Caffeine,发现没有,去查 Redis;Redis 里也没有,去查数据库,拿到最新的价格;把最新价格存到 Redis 和 Caffeine 里,后续的请求就能拿到正确的数据了。
就算你部署了 10 台机器,只要每台机器在更新数据的时候,都删除自己的 Caffeine 缓存和 Redis 缓存,那所有机器后续都会从数据库里捞最新数据,不会出现数据不一致的情况。
三、实战:Spring Boot 项目里怎么集成 Redis+Caffeine?
光说不练假把式,接下来咱就手把手教大家,怎么在 Spring Boot 项目里把 Redis 和 Caffeine 集成起来,实现双层缓存。
1. 先搞依赖:把需要的 Jar 包引进来
首先,在 pom.xml 里加三个依赖:Spring Boot 的缓存 starter、Redis starter、Caffeine 的依赖。注意版本别太老,我这里用的是 Spring Boot 2.7.x 的版本,大家可以根据自己的项目版本调整。
复制
<!-- Spring Boot缓存核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Redis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Caffeine依赖 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.1</version> <!-- 这个版本比较稳定,大家可以用最新的 -->
</dependency>
<!-- Redis连接池依赖,提升Redis性能 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.
2. 配置文件:告诉项目 Redis 和 Caffeine 在哪
然后在 application.yml 里加配置,主要是 Redis 的连接信息和 Caffeine 的缓存配置。
复制
spring:
# Redis配置
redis:
host: 127.0.0.1 # 你的Redis地址,线上环境要填实际的IP
port: 6379 # Redis端口,默认6379
password: 123456 # 你的Redis密码,没设的话可以去掉
lettuce:
pool:
max-active: 8 # 最大连接数
max-idle: 8 # 最大空闲连接数
min-idle: 2 # 最小空闲连接数
max-wait: 1000ms # 最大等待时间,超过这个时间就报错
# 缓存配置
cache:
type: CAFFEINE # 默认缓存类型是Caffeine,后面我们会自定义双层缓存
caffeine:
# Caffeine的缓存配置:初始容量100,最大容量1000,过期时间5分钟
initial-capacity: 100
maximum-size: 1000
expire-after-write: 5m1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.
这里要注意:Caffeine 的配置要根据你的业务场景来调。比如 “初始容量” 可以设成你预估的常用数据量,避免频繁扩容;“最大容量” 不能设太大,不然会占满应用内存,比如你应用内存是 2G,那 Caffeine 最大容量别超过 5000(具体看每条数据的大小);“过期时间” 要看数据的更新频率,比如商品价格一天更一次,那过期时间可以设 1 天,要是实时性要求高,比如用户余额,那过期时间可以设 5 分钟。
3. 核心配置类:实现双层缓存逻辑
这一步是关键 —— 我们要自定义一个缓存管理器,让它同时支持 Caffeine 和 Redis,实现 “先查 Caffeine,再查 Redis,最后查数据库” 的逻辑。
首先,写一个 Redis 的配置类,主要是配置 RedisTemplate,避免默认的序列化问题(默认的 JdkSerializationRedisSerializer 会把数据序列化成乱码):
复制
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 配置连接工厂
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
// 配置Key的序列化器:用StringRedisSerializer,避免Key出现乱码
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
// 配置Value的序列化器:用GenericJackson2JsonRedisSerializer,把对象转成JSON
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
redisTemplate.setValueSerializer(jsonRedisSerializer);
redisTemplate.setHashValueSerializer(jsonRedisSerializer);
// 初始化RedisTemplate
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.
然后,写一个双层缓存的配置类,自定义缓存管理器:
复制
import com.github.ben-manes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.CompositeCacheManager;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCache;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@Configuration
public class CacheConfig {
// 1. 配置Caffeine缓存管理器
@Bean
public CacheManager caffeineCacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
List<Cache> caches = new ArrayList<>();
// 这里可以配置多个Caffeine缓存,比如针对不同业务场景设置不同的过期时间
// 示例1:商品热门缓存,过期时间5分钟
caches.add(new CaffeineCache("hotProductCache",
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(5))
.build()));
// 示例2:用户会话缓存,过期时间30分钟
caches.add(new CaffeineCache("userSessionCache",
Caffeine.newBuilder()
.initialCapacity(50)
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(30))
.build()));
cacheManager.setCaches(caches);
return cacheManager;
}
// 2. 配置Redis缓存管理器
@Bean
public CacheManager redisCacheManager(RedisTemplate<String, Object> redisTemplate) {
// Redis缓存配置:过期时间1小时,Key前缀加"cache:"
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 过期时间1小时
.prefixCacheNameWith("cache:") // Key前缀,比如"hotProductCache:123"
.serializeKeysWith(RedisCacheConfiguration.defaultCacheConfig().getKeySerializationPair())
.serializeValuesWith(RedisCacheConfiguration.defaultCacheConfig().getValueSerializationPair());
// 创建Redis缓存管理器
return RedisCacheManager.builder(redisTemplate.getConnectionFactory())
.cacheDefaults(config)
.build();
}
// 3. 配置复合缓存管理器:先查Caffeine,再查Redis
@Bean
public CacheManager compositeCacheManager(CacheManager caffeineCacheManager, CacheManager redisCacheManager) {
CompositeCacheManager compositeCacheManager = new CompositeCacheManager(caffeineCacheManager, redisCacheManager);
// 设置为"true"表示:如果前面的缓存(Caffeine)没命中,就继续查后面的缓存(Redis)
compositeCacheManager.setFallbackToNoOpCache(false);
return compositeCacheManager;
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.
这里解释一下:CompositeCacheManager 是 Spring 提供的复合缓存管理器,它会按照你传入的缓存管理器顺序去查询 —— 先查 caffeineCacheManager,要是没命中,再查 redisCacheManager。这样就实现了 “先本地,再远程” 的双层缓存逻辑。而且我们可以针对不同的业务场景,配置不同的缓存(比如 hotProductCache、userSessionCache),每个缓存的初始容量、最大容量、过期时间都可以单独设置,非常灵活。
4. 业务代码:用 @Cacheable 注解实现缓存
配置完之后,业务代码就简单了 —— 只需要在需要缓存的方法上加 @Cacheable 注解,指定用哪个缓存就行。
比如我们写一个商品服务,查询热门商品的方法:
复制
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
// 模拟从数据库查询商品信息
private Product getProductFromDb(Long productId) {
// 这里实际项目中会调用DAO层查数据库,这里模拟一下
System.out.println("从数据库查询商品:" + productId);
return new Product(productId, "iPhone 15", 5999.0);
}
/**
* 查询热门商品信息
* @Cacheable:表示这个方法的返回值会被缓存
* value:指定用哪个缓存(对应我们之前配置的"hotProductCache")
* key:缓存的Key,这里用商品ID作为Key,比如"hotProductCache:1"
*/
@Cacheable(value = "hotProductCache", key = "#productId")
public Product getHotProduct(Long productId) {
// 要是缓存没命中,就会执行下面的代码(查数据库)
return getProductFromDb(productId);
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.
再写一个 Controller,调用这个服务:
复制
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
public class ProductController {
@Resource
private ProductService productService;
@GetMapping("/product/hot/{productId}")
public Product getHotProduct(@PathVariable Long productId) {
long start = System.currentTimeMillis();
Product product = productService.getHotProduct(productId);
long end = System.currentTimeMillis();
System.out.println("接口响应时间:" + (end - start) + "ms");
return product;
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.
然后我们测试一下:
第一次访问http://localhost:8080/product/hot/1,控制台会打印 “从数据库查询商品:1” 和 “接口响应时间:20ms”(因为要查数据库);第二次访问同一个地址,控制台只会打印 “接口响应时间:1ms”(因为命中了 Caffeine 缓存,不用查数据库和 Redis);要是我们把应用重启,第一次访问会打印 “从数据库查询商品:1” 吗?不会!因为重启后 Caffeine 缓存没了,但 Redis 缓存还在,所以会先查 Caffeine(没命中),再查 Redis(命中),控制台会打印 “接口响应时间:5ms”,不用查数据库。
你看,这样就完美实现了双层缓存的效果 —— 第一次查数据库,之后查 Caffeine,重启后查 Redis,全程不用频繁碰数据库,速度又快又稳。
四、进阶:解决 Redis+Caffeine 的那些 “坑”
虽然 Redis+Caffeine 很好用,但实际项目中还是会遇到一些问题,比如缓存穿透、缓存雪崩、数据不一致这些,要是不解决,分分钟出线上事故。接下来咱就聊聊这些 “坑” 怎么填。
1. 缓存穿透:请求查 “不存在的数据”,怎么办?
啥是缓存穿透?就是有人故意请求一个不存在的数据,比如查 productId=9999 的商品(数据库里根本没有),这样每次请求都会穿透 Caffeine 和 Redis,直接打到数据库上,要是请求多了,数据库直接就崩了。
怎么解决?有两个办法:
(1)缓存 “空值”
当数据库里没有这个数据的时候,我们也把 “空值” 存到 Caffeine 和 Redis 里,比如存一个 null 或者一个空对象,这样下次再查这个数据的时候,就会命中缓存,不会再查数据库了。
修改一下 ProductService 里的 getHotProduct 方法:
复制
@Cacheable(value = "hotProductCache", key = "#productId", unless = "#result == null")
public Product getHotProduct(Long productId) {
Product product = getProductFromDb(productId);
// 要是数据库里没有这个商品,返回一个空对象(不是null)
if (product == null) {
return new Product(productId, "", 0.0);
}
return product;
}1.2.3.4.5.6.7.8.9.
这里的 unless = "#result == null" 表示:如果返回值是 null,就不缓存。所以我们返回一个空对象,这样就能把空对象缓存起来,下次再查的时候,就会命中缓存。
(2)布隆过滤器:提前拦截 “不存在的数据”
要是请求的 “不存在的数据” 太多,缓存空值会浪费 Redis 和 Caffeine 的内存,这时候就可以用布隆过滤器。布隆过滤器就像一个 “黑名单”,里面存了所有存在的 productId,当有请求来的时候,先过布隆过滤器:
要是布隆过滤器说 “这个 productId 不存在”,直接返回 404,不用查缓存和数据库;要是布隆过滤器说 “这个 productId 存在”,再走缓存和数据库的流程。
Spring Boot 里集成布隆过滤器也很简单,用 Redisson 的布隆过滤器就行:
首先加 Redisson 依赖:
复制
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.6</version>
</dependency>1.2.3.4.5.
然后写一个布隆过滤器的配置类:
复制
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
publicclass BloomFilterConfig {
// 布隆过滤器的预期元素数量(比如有10000个商品)
privatestaticfinallong EXPECTED_INSERTIONS = 10000;
// 布隆过滤器的误判率(越小越准,但占用内存越大,一般设0.01)
privatestaticfinaldouble FALSE_POSITIVE_RATE = 0.01;
@Bean
public RBloomFilter<Long> productBloomFilter(RedissonClient redissonClient) {
// 获取布隆过滤器(名字叫"productBloomFilter")
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("productBloomFilter");
// 初始化布隆过滤器
bloomFilter.tryInit(EXPECTED_INSERTIONS, FALSE_POSITIVE_RATE);
// 这里要把数据库里所有的productId加到布隆过滤器里(实际项目中可以在项目启动时加载)
// 模拟添加10000个商品ID
for (long i = 1; i <= 10000; i++) {
bloomFilter.add(i);
}
return bloomFilter;
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.
然后修改 Controller 里的方法,先过布隆过滤器:
复制
@GetMapping("/product/hot/{productId}")
public Product getHotProduct(@PathVariable Long productId) {
// 先查布隆过滤器,要是不存在,直接返回404
if (!productBloomFilter.contains(productId)) {
throw new RuntimeException("商品不存在");
}
long start = System.currentTimeMillis();
Product product = productService.getHotProduct(productId);
long end = System.currentTimeMillis();
System.out.println("接口响应时间:" + (end - start) + "ms");
return product;
}1.2.3.4.5.6.7.8.9.10.11.12.13.
这样一来,要是有人查 productId=10001 的商品,布隆过滤器直接拦截,不会再走缓存和数据库,完美解决缓存穿透问题。
2. 缓存雪崩:大量缓存同时过期,怎么办?
啥是缓存雪崩?就是你设置的缓存都在同一个时间点过期,比如你把所有商品缓存的过期时间都设成 1 小时,那 1 小时后,所有缓存都会过期,这时候大量请求会一下子全打到数据库上,数据库直接扛不住。
怎么解决?有两个办法:
(1)给过期时间加 “随机值”
不要让所有缓存都在同一个时间过期,比如在基础过期时间上再加一个 0-300 秒的随机值,这样缓存过期时间就分散了,不会出现 “同时过期” 的情况。
修改 CacheConfig 里的 Caffeine 缓存配置:
复制
// 示例1:商品热门缓存,过期时间5分钟+随机0-300秒
caches.add(new CaffeineCache("hotProductCache",
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(5).plus(Duration.ofSeconds((long) (Math.random() * 300))))
.build()));1.2.3.4.5.6.7.
Redis 的过期时间也一样,修改 RedisCacheConfiguration:
复制
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// 过期时间1小时+随机0-300秒
.entryTtl(Duration.ofHours(1).plus(Duration.ofSeconds((long) (Math.random() * 300))))
.prefixCacheNameWith("cache:")
.serializeKeysWith(RedisCacheConfiguration.defaultCacheConfig().getKeySerializationPair())
.serializeValuesWith(RedisCacheConfiguration.defaultCacheConfig().getValueSerializationPair());1.2.3.4.5.6.
这样一来,每个缓存的过期时间都不一样,就不会出现大量缓存同时过期的情况了。
(2)Redis 集群:避免 Redis 单点故障
要是 Redis 服务器挂了,那所有依赖 Redis 的请求都会打到数据库上,也会造成 “雪崩”。所以线上环境一定要搞 Redis 集群,比如 3 主 3 从,就算其中一台主节点挂了,从节点会自动顶上,保证 Redis 服务不中断。
3. 数据不一致:更新数据后,缓存没同步,怎么办?
这个问题我们之前提过一嘴,就是更新数据库后,要及时删除对应的缓存,避免缓存里存的是旧数据。但实际项目中,可能会遇到 “并发更新” 的问题,比如:
线程 A 查询商品 1,缓存没命中,去查数据库,拿到旧价格 5999;线程 B 更新商品 1 的价格,改成 6999,然后删除了缓存;线程 A 查完数据库,把旧价格 5999 又存到缓存里;之后的请求查缓存,拿到的都是旧价格 5999,出现数据不一致。
怎么解决?有两个办法:
(1)“更新数据库→删除缓存” 改成 “删除缓存→更新数据库”
先删除缓存,再更新数据库,这样就算有线程在更新前查询,也会因为缓存被删除,去查数据库,拿到的是最新的数据。
修改一下更新商品的方法:
复制
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
@Service
publicclass ProductService {
// 模拟更新数据库里的商品信息
private void updateProductInDb(Product product) {
System.out.println("更新数据库商品:" + product.getId() + ",新价格:" + product.getPrice());
}
/**
* 更新商品信息
* @CacheEvict:表示更新后删除对应的缓存
* value:要删除的缓存名称
* key:要删除的缓存Key
*/
@CacheEvict(value = "hotProductCache", key = "#product.id")
public void updateProduct(Product product) {
// 1. 先删除缓存
// (@CacheEvict注解会帮我们做这件事)
// 2. 再更新数据库
updateProductInDb(product);
// 3. 再删除Redis里的缓存(因为@CacheEvict默认只删除Caffeine缓存)
redisTemplate.delete("cache:hotProductCache:" + product.getId());
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.
这里要注意:@CacheEvict 注解默认只会删除 Caffeine 缓存,所以我们还要手动删除 Redis 里的缓存,保证两者都被删除。
(2)加互斥锁:避免并发更新导致的问题
要是并发量特别大,就算先删除缓存再更新数据库,还是可能出现问题,这时候就可以加互斥锁,比如用 Redis 的 SETNX 命令(只有当 Key 不存在的时候才能设置成功),保证同一时间只有一个线程能更新商品。
修改 updateProduct 方法:
复制
public void updateProduct(Product product) {
String lockKey = "lock:product:" + product.getId();
// 加互斥锁,过期时间5秒,避免死锁
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
// 1. 删除Caffeine缓存
caffeineCacheManager.getCache("hotProductCache").evict(product.getId());
// 2. 删除Redis缓存
redisTemplate.delete("cache:hotProductCache:" + product.getId());
// 3. 更新数据库
updateProductInDb(product);
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 没拿到锁,重试(可以用循环重试,或者返回失败让前端重试)
thrownew RuntimeException("更新太频繁,请稍后再试");
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.
这样一来,同一时间只有一个线程能更新商品,就不会出现 “线程 A 存旧数据” 的问题了。
五、总结:Redis+Caffeine,该用在哪些场景?
聊了这么多,最后咱总结一下,Redis+Caffeine 的双层缓存架构,到底适合哪些场景:
高并发、低延迟要求的场景:比如电商首页、秒杀活动、直播带货,这些场景 QPS 高,用户对响应时间敏感,用双层缓存能把响应时间压到 10ms 以内;数据访问频率差异大的场景:比如大部分请求访问高频数据,少部分请求访问低频数据,用 Caffeine 存高频数据,Redis 存低频数据,既能保证速度,又能节省内存;分布式部署的场景:比如应用部署多台机器,需要共享缓存数据,用 Redis 保证分布式一致性,用 Caffeine 提升单机访问速度。
当然,也不是所有场景都适合用双层缓存。比如数据实时性要求极高的场景(比如股票行情、实时订单数),缓存过期时间要设得很短,甚至不用缓存,直接查数据库;或者数据访问频率很低的场景(比如后台管理系统),用单 Redis 缓存就够了,没必要加 Caffeine。