缓存是一种优化技术,它通过存储数据的副本以减少对原始数据源(如数据库、远程服务等)的访问次数,从而提高应用程序的性能和响应速度。
什么时候情况下适合使用缓存?
读多写少 的场景下适合缓存。
当某些数据被大量用户频繁请求,而这些数据又很少发生变化时,缓存可以大大减轻后端系统的压力。
例如:
- 网站上的图片加载
- 外部服务调用:与第三方API或其他微服务交互通常会带来网络延迟和不可控的服务可用性问题
- 会话管理和认证信息
缓存的实现首先可以想到用Redis。
Redis分布式缓存
什么是Redis?
看看官方介绍:
Redis is often referred to as a data structures server. What this means is that Redis provides access to mutable data structures via a set of commands, which are sent using a server-client model with TCP sockets and a simple protocol. So different processes can query and modify the same data structures in a shared way.
Redis的重要特性
- Redis cares to store them on disk, even if they are always served and modified into the server memory. This means that Redis is fast, but that it is also non-volatile.
- The implementation of data structures emphasizes memory efficiency, so data structures inside Redis will likely use less memory compared to the same data structure modelled using a high-level programming language.
- Redis offers a number of features that are natural to find in a database, like replication, tunable levels of durability, clustering, and high availability.
总结
- 存储在内存
- 更高效的数据结构(用c语言编写,足够底层O(∩_∩)O)
- 支持复制,集群;高可用!
面试题:Redis为什么这么快?
可以从官方给出的这几个方面回答。
Redis 缓存key设计
1.设计key
假设要给存储图片设计一个key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest); // 用json串存储,让key的可读性高
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = String.format("haodePicture:listPictureVOByPage:%s", hashKey);
原则:要保证key的长度适中,可读性要高,唯一性**。**
可以让redis存储的缓存格式用json串表示:
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
缺点就是一定程度上造成存储空间的浪费。但是更好看(ゝ∀・)b
为避免前端传来的查询条件使key过大,用md5哈希算法压缩key。
操作Redis缓存
步骤0:引入spring-boot-starter-data-redis(对Redis的操作的高级封装)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
包含如下:
步骤1:设计redis key
步骤2:先从redis 查缓存,命中缓存则返回结果,不然则直接查询数据库
// 操作redis 从缓存中查询
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
String cachedValueFromRedis = opsForValue.get(cacheKey);
if (cachedValueFromRedis != null) {
// 如果缓存命中,缓存结果
Page<PictureVO> pictureVOPage = JSONUtil.toBean(cachedValueFromRedis, Page.class);
return ResultUtils.success(pictureVOPage);
}
步骤3:如果没有命中缓存,查完数据库后将返回结果写进redis
// 设置缓存 value 给redis
String cacheValueFetchedFromDB = JSONUtil.toJsonStr(pictureVOPage);
// 设置过期时间 , 为了防止缓存雪崩,设置随机过期时间
int cachedExpiredTime = 300 + RandomUtil.randomInt(0 , 300);
// 设置缓存
opsForValue.set(cacheKey, cacheValueFetchedFromDB, cachedExpiredTime, TimeUnit.SECONDS);
注意一定要设置过期时间:
int cachedExpiredTime = 300 + RandomUtil.randomInt(0 , 300);
为了防止缓存雪崩(同一时间内大量key同时过期),设置随机过期时间!
示例代码如下
@PostMapping("/cache")
public BaseResponse<Page<PictureVO>> listPictureVOByPageWithCache(@RequestBody PictureQueryRequest pictureQueryRequest , HttpServletRequest request) {
// 查询缓存,缓存中没有再查询数据库
// 构建缓存的key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = String.format("haodePicture:listPictureVOByPage:%s", hashKey);
// 操作redis 从缓存中查询
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
String cachedValueFromRedis = opsForValue.get(cacheKey);
if (cachedValueFromRedis != null) {
// 如果缓存命中,缓存结果
Page<PictureVO> pictureVOPage = JSONUtil.toBean(cachedValueFromRedis, Page.class);
return ResultUtils.success(pictureVOPage);
}
// 数据库操作
Page<Picture> picturePage = pictureService.page(new Page<>(current, pageSize), pictureService.getQueryWrapper(pictureQueryRequest));
Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);
// 设置缓存 value 给redis
String cacheValueFetchedFromDB = JSONUtil.toJsonStr(pictureVOPage);
// 设置过期时间 , 为了防止缓存雪崩,设置随机过期时间
int cachedExpiredTime = 300 + RandomUtil.randomInt(0 , 300);
// 设置缓存
opsForValue.set(cacheKey, cacheValueFetchedFromDB, cachedExpiredTime, TimeUnit.SECONDS);
// 获取封装类
return ResultUtils.success(pictureVOPage);
}
到此,Redis缓存的部分结束!◝( ゚∀ ゚ )◟。
面试题:什么是缓存雪崩?缓存雪崩的解决方案?
到此,方案1:设置key的随机过期时间,避免大量key在同一时间间隔内大量失效!
思考
引入了Redis缓存就万事大吉了吗?毕竟它这么快!
问题如下:
1.缓存穿透如果请求的数据在数据库中不存在,并且每次查询都尝试从缓存获取,这将导致对数据库的频繁访问,增加负载。
2.缓存击穿高并发情况下,大量请求同时访问一个即将过期的缓存项,在它过期后瞬间涌入数据库,造成瞬时压力。
3.远程调用开销如果你的应用程序和 Redis 位于不同的机器上,那么每次访问缓存都会产生网络延迟,特别是在跨数据中心的情况下。
4.redis宕机:redis突然死了,不就没有缓存了吗?当然,可以通过充值使用第三方服务的Redis来解决(๑´ㅁ`)
本地缓存Caffeine
Caffeine is a high performance, near optimal caching library.
优势
相比直接操作redis , 本地缓存缓存优势如下:
- 低延迟:由于数据存储在应用程序的内存中,因此访问速度极快,几乎没有网络延迟。
- 减少远程调用:可以显著降低对外部服务(如 Redis、数据库)的请求次数,减轻系统负担。
- 简化架构:引入一个redis就多引入一个中间件,这样系统的稳定性一定会收到影响。而本地缓存Caffeine没有这个烦恼。
本地缓存Caffeine的应用
0.引入依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.3</version>
</dependency>
注意:JDK 11 版本及以上用3.X版本,否则用2.X版本
- 构造本地缓存LOCAL_CACHE(相当于RedisTemplate)
/**
* 本地缓存caffine
*/
private final Cache<String, String> LOCAL_CACHE = Caffeine.newBuilder()
.initialCapacity(1024)
.maximumSize(10_000L) // 最大10000条
.expireAfterWrite(Duration.ofMinutes(5)) // 5分钟过期
.build();
2.设计缓存Key
// 查询缓存,缓存中没有再查询数据库
// 构建缓存的key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = String.format("haodePicture:listPictureVOByPage:%s", hashKey);
3.先查询本地缓存,如果命中缓存,则返回结果
// 先查本地缓存
String localCache = LOCAL_CACHE.getIfPresent(cacheKey);
if (localCache != null){
Page<PictureVO> cachePage = JSONUtil.toBean(localCache, Page.class);
return ResultUtils.success(cachePage);
}
4.如果没缓存,就查询数据库,并写入缓存
// 设置缓存 value caffine 用
String cacheValueFetchedFromDB = JSONUtil.toJsonStr(pictureVOPage);
LOCAL_CACHE.put(cacheKey, cacheValueFetchedFromDB);
完整示例代码:
@PostMapping("/cache")
public BaseResponse<Page<PictureVO>> listPictureVOByPageWithCache(@RequestBody PictureQueryRequest pictureQueryRequest , HttpServletRequest request) {
// 查询缓存,缓存中没有再查询数据库
// 构建缓存的key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = String.format("haodePicture:listPictureVOByPage:%s", hashKey);
// 先查本地缓存
String localCache = LOCAL_CACHE.getIfPresent(cacheKey);
if (localCache != null){
Page<PictureVO> cachePage = JSONUtil.toBean(localCache, Page.class);
return ResultUtils.success(cachePage);
}
Page<Picture> picturePage = pictureService.page(new Page<>(current, pageSize), pictureService.getQueryWrapper(pictureQueryRequest));
Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);
// 设置缓存 value caffine 用
String cacheValueFetchedFromDB = JSONUtil.toJsonStr(pictureVOPage);
LOCAL_CACHE.put(cacheKey, cacheValueFetchedFromDB);
// 获取封装类
return ResultUtils.success(pictureVOPage);
}
可以看到,操作本地缓存相比Redis更简单了,只需要引入一个pom文件,就能开始编写。不用引入一个额外的中间件!O(∩_∩)O。
所以只引入一级缓存的情况下,首先考虑Caffeine本地缓存!
多级缓存
为什么要引入多级缓存?
引入 Redis + Caffeine 多级缓存架构的主要目的是为了结合两者的优势,以解决单一缓存解决方案可能带来的局限性。
- 分担压力:不是所有的请求都会直接打到 Redis 上,只有当本地缓存未命中时才会查询 Redis,从而分散了流量,避免 Redis 成为瓶颈。
- 本地缓存加速:Caffeine 作为本地缓存,位于应用程序内存中,能够以极低的延迟提供数据访问,极大提高了热数据的获取速度。
- 减少远程调用:通过将频繁访问的数据存储在本地,减少了对 Redis 或数据库等远程服务的请求次数,降低了网络延迟的影响。
- 增加容错性: 即使 Redis 出现问题(如网络分区、实例崩溃),由于存在本地缓存层,应用程序仍然可以从 Caffeine 中获取部分常用数据,保持基本功能不受影响。
- 优化持久化策略:对于那些不需要长期保存的数据,可以直接存放在本地缓存中,而不必写入 Redis,减少了持久化的开销。
多级缓存实现步骤
1.先查本地缓存,如果本地缓存命中,则直接返回
// 先查本地缓存
String localCache = LOCAL_CACHE.getIfPresent(cacheKey);
if (localCache != null){
Page<PictureVO> cachePage = JSONUtil.toBean(localCache, Page.class);
return ResultUtils.success(cachePage);
}
2.如果本地缓存没有命中,查redis缓存,如果命中Redis缓存,则先将从redis查到的数据设置给Caffeine
// 操作redis 从缓存中查询
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
String cachedValueFromRedis = opsForValue.get(cacheKey);
if (cachedValueFromRedis != null) {
// 设置本地缓存
LOCAL_CACHE.put(cacheKey, cachedValueFromRedis);
// 如果缓存命中,缓存结果
Page<PictureVO> pictureVOPage = JSONUtil.toBean(cachedValueFromRedis, Page.class);
return ResultUtils.success(pictureVOPage);
}
3.如果两个缓存都没有命中,则直接查询数据库,并设置缓存。
// 设置缓存 value 给redis 和 caffine 用
String cacheValueFetchedFromDB = JSONUtil.toJsonStr(pictureVOPage);
// 设置过期时间 , 为了防止缓存雪崩,设置随机过期时间
int cachedExpiredTime = 300 + RandomUtil.randomInt(0 , 300);
// 设置缓存
LOCAL_CACHE.put(cacheKey, cacheValueFetchedFromDB);
opsForValue.set(cacheKey, cacheValueFetchedFromDB, cachedExpiredTime, TimeUnit.SECONDS);
完整代码示例:
@PostMapping("/cache")
public BaseResponse<Page<PictureVO>> listPictureVOByPageWithCache(@RequestBody PictureQueryRequest pictureQueryRequest , HttpServletRequest request) {
// 查询缓存,缓存中没有再查询数据库
// 构建缓存的key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = String.format("haodePicture:listPictureVOByPage:%s", hashKey);
// 先查本地缓存
String localCache = LOCAL_CACHE.getIfPresent(cacheKey);
if (localCache != null){
Page<PictureVO> cachePage = JSONUtil.toBean(localCache, Page.class);
return ResultUtils.success(cachePage);
}
// 操作redis 从缓存中查询
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
String cachedValueFromRedis = opsForValue.get(cacheKey);
if (cachedValueFromRedis != null) {
// 设置本地缓存
LOCAL_CACHE.put(cacheKey, cachedValueFromRedis);
// 如果缓存命中,缓存结果
Page<PictureVO> pictureVOPage = JSONUtil.toBean(cachedValueFromRedis, Page.class);
return ResultUtils.success(pictureVOPage);
}
Page<Picture> picturePage = pictureService.page(new Page<>(current, pageSize), pictureService.getQueryWrapper(pictureQueryRequest));
Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);
// 设置缓存 value 给redis 和 caffine 用
String cacheValueFetchedFromDB = JSONUtil.toJsonStr(pictureVOPage);
// 设置过期时间 , 为了防止缓存雪崩,设置随机过期时间
int cachedExpiredTime = 300 + RandomUtil.randomInt(0 , 300);
// 设置缓存
LOCAL_CACHE.put(cacheKey, cacheValueFetchedFromDB);
opsForValue.set(cacheKey, cacheValueFetchedFromDB, cachedExpiredTime, TimeUnit.SECONDS);
// 获取封装类
return ResultUtils.success(pictureVOPage);
}
注意:先查本地缓存,在查Redis。
面试题:引入多级缓存可以解决redis的什么问题?
- 缓存雪崩:除了设置随机过期时间,还能设置本地缓存解决redis缓存雪崩的问题。即便大量的key同时在redis里过期了,也能让本地缓存扛一会。◝( ゚∀ ゚ )◟
- 缓存穿透:即便请求的数据在数据库中不存在,也能把它放在本地缓存中。这样不存在的请求就不会一直打到数据库。(todo)
- 缓存击穿:高并发情况下,大量请求同时访问一个即将过期的缓存项,在它过期后瞬间涌入数据库,造成瞬时压力。有了本地缓存,即便出现上述现象,也能靠本地缓存顶一会,不至于直接让数据库宕机。✧◝(⁰▿⁰)◜✧
- 减少远程调用开销:如果你的应用程序和 Redis 位于不同的机器上,那么每次访问缓存都会产生网络延迟,而caffeine本地缓存则不用考虑网络传输的问题,减少开销。O(∩_∩)O
扩展
如何利用本地缓存解决缓存穿透?
当前代码仍然会面临缓存穿透的问题,如果用户频繁请求一个不存在的资源,这些请求还是会传递给 Redis 和数据库,造成不必要的负载。
优化点:
对于确实不存在的数据(如 null 或者自定义的空对象),可以在本地 Caffeine 缓存中短暂地存储这些值,并设置较短的 TTL。这样,在接下来的一段时间内,相同的请求将直接返回这个特殊值而不是再次查询 Redis 或数据库。
完整代码改进如下:
@PostMapping("/cache")
public BaseResponse<Page<PictureVO>> listPictureVOByPageWithCache(@RequestBody PictureQueryRequest pictureQueryRequest, HttpServletRequest request) {
// 构建缓存的key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = String.format("haodePicture:listPictureVOByPage:%s", hashKey);
// 先查本地缓存
String localCache = LOCAL_CACHE.getIfPresent(cacheKey);
if ("NULL".equals(localCache)) {
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "查询结果为空");
} else if (localCache != null) {
Page<PictureVO> cachePage = JSONUtil.toBean(localCache, Page.class);
return ResultUtils.success(cachePage);
}
// 操作redis 从缓存中查询
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
String cachedValueFromRedis = opsForValue.get(cacheKey);
if (cachedValueFromRedis != null) {
// 设置本地缓存
LOCAL_CACHE.put(cacheKey, cachedValueFromRedis);
// 如果缓存命中,缓存结果
Page<PictureVO> pictureVOPage = JSONUtil.toBean(cachedValueFromRedis, Page.class);
return ResultUtils.success(pictureVOPage);
}
// 查询数据库
Page<Picture> picturePage = pictureService.page(new Page<>(current, pageSize), pictureService.getQueryWrapper(pictureQueryRequest));
if (picturePage.getRecords().isEmpty()) {
// 如果查询结果为空,缓存空结果并返回失败
LOCAL_CACHE.put(cacheKey, "NULL");
return ResultUtils.error(ErrorCode.NOT_FOUND_ERROR, "No data found");
}
// 脱敏
Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);
// 设置缓存 value 给redis 和 caffine 用
String cacheValueFetchedFromDB = JSONUtil.toJsonStr(pictureVOPage);
// 设置过期时间 , 为了防止缓存雪崩,设置随机过期时间
int cachedExpiredTime = 300 + RandomUtil.randomInt(0, 300);
// 设置缓存
LOCAL_CACHE.put(cacheKey, cacheValueFetchedFromDB);
opsForValue.set(cacheKey, cacheValueFetchedFromDB, cachedExpiredTime, TimeUnit.SECONDS);
// 获取封装类
return ResultUtils.success(pictureVOPage);
}
在数据库查询失败的时候,写入一个NULL值进本地缓存,这样,下次再有查询不存在数据的请求时,就能够直接走本地缓存,而不用在打爆数据库了O(∩_∩)O!
异步化执行写缓存
// 异步设置缓存
CompletableFuture.runAsync(() -> {
try {
String cacheValueFetchedFromDB = JSONUtil.toJsonStr(pictureVOPage);
int cachedExpiredTime = 300 + RandomUtil.randomInt(0, 300);
LOCAL_CACHE.put(cacheKey, cacheValueFetchedFromDB);
opsForValue.set(cacheKey, cacheValueFetchedFromDB, cachedExpiredTime, TimeUnit.SECONDS);
} catch (Exception e) {
// 记录异常信息
log.error("Failed to set cache for key: {}", cacheKey, e);
// 可选:根据具体需求决定是否重试或采取其他行动
}
}, customThreadPool); // 使用自定义线程池