什么是缓存
简单来说,缓存就是数据交换的缓存区(Cache),是存贮数据的临时地方,一般都是基于内存进行存储,所以读写性能一般会比较高。
缓存看起来视乎很好,但是它也有一些问题. 如下图:缓存可能会出现数据一致性问题、代码维护成本高、运维成本高等等问题。那我们肯定不会因为这些缺点就不去使用它吧,毕竟带来的效果还是非常好的。可以降低后端负载、减小数据库压力,同时提高读写效率、降低响应时间,带给用户非常好的体验。
添加Redis缓存
如何使用Redis缓存,这里就不去说明啦,具体安装可以去看我的另一篇文章~(里面有详细的安装教程).
在Java程序中使用的话,我们只需要导入对于的依赖、配置相关连接信息即可。
下面主要介绍如何在业务中添加Redis缓存
:
简单流程:客户端发送请求 —> Redis缓存中查找数据 —>命中缓存则直接返回数据
—>未命中缓存则去数据库中查找数据返回,并把查找到的数据保存到缓存中。如下图:
下面贴一张查询商铺的方法,实现了如上的具体流程。
缓存更新策略
缓存的更新策略主要有三种:
1)内存淘汰:不用自己维护,利用Redis的内存淘汰机制
,当内存不足时自动淘汰部分数据下次查询时更新缓存。
2)超时剔除:给缓存数据添加TTL时间
,到期后自动删除缓存,下次查询时更新缓存。这种数据一致性一般,维护成本低,只需要缓存建立完之后设置expire
即可。
3)主动更新:编写业务逻辑,当我们修改数据库的同时去更新缓存,这种方法数据一致性最好,同时维护成本也是最高的。
不同业务场景的使用:
- 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存(这种一般不太会改变)
- 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
所以,我们需要还是需要根据不同的业务场景去选择不同的缓存更新策略,这是最优的。
因为其余两种使用起来比较简单,所以下面主要说明一下缓存的主动更新策略:
如上图,缓存的主动更新策略其实也有三种方式。
1)Cache Aside Pattern:这种是比较常见的,就是当我们在进行增删改操作数据库的时候,同时把缓存的数据进行修改/删除。
2)依赖某种模式读写:这种的话,需要统一去维护一个服务。
3)Write Behind Caching Pattern:操作者只去操作缓存,这种方法是很容易出问题的,而且实现起来也比较复杂,维护成本较高。
所以第一种方式Cache Aside Pattern
这种方式目前来说是最好的,当然这个要看其他人如何使用,尽量和团队成员保持一致。
既然我们采用主动更新缓存的策略去操作,并且在我们更新数据库的同时更新缓存。那么就会出现以下问题需要去考虑。
1)删除缓存还是去更新缓存呢?
- 更新缓存:每次更新数据库都去更新缓存,如果一个业务操作对数据的增删改是比较多的话,那么就会造成大量无效的写操作。
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
2)如何保证缓存和数据库的操作同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
3)先操作数据库还是先操作缓存?
无论是先操作数据库,还是先操作缓存都会出现数据一致性问题。但是相比之下,先操作数据库发生异常的概率比较低,所以一般都是先去操作数据库、再删除缓存。
如下图,为总结:
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
主要防止有人多线程、并发的请求一个不存在的数据这些所有请求都会到数据库 增加数据库压力,使系统瘫痪。
解决这种问题主要有两种方案:
1)缓存空对象
实现简单,维护方便。但是会占用额外的内存空间,可能会出现短期的数据不一致问题。就比如:我们数据库中突然新增了一个数据,这时候客户端请求,命中了缓存得到了null值。
2)布隆过滤
布隆过滤器是一种空间效率很高的概率型数据结构,用于判断一个元素“可能存在于集合中”或“一定不存在于集合中”。它通过多个哈希函数将元素映射到位数组中的多个位置,并将这些位置置为 1。查询时,若所有对应位均为 1,则认为元素可能存在(有误判可能);若任意一位为 0,则元素一定不存在。
由于多个不同元素可能共同设置相同的位,因此存在“假阳性”(False Positive),但不会有“假阴性”。布隆过滤器常用于缓存穿透防护,在访问数据库前快速过滤掉大量不存在的请求。
Redis 本身不原生支持布隆过滤器,但可通过 RedisBloom 模块实现。布隆过滤器可以部署在客户端、Redis 服务端或中间层,具体取决于系统架构。
布隆过滤器的核心是用空间换时间 + 接受可控误判率
下面是某个业务具体的代码实现,可以参考:
/**
* 解决缓存穿透(设置null值)
* @param id
* @return 店铺数据
*/
private Shop queryWithPassThrough(Long id){
Shop shop = new Shop();
String key = RedisConstants.CACHE_SHOP_KEY + id;
//1.先去Redis缓存中查找数据
String shopCacheData = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否命中
if(shopCacheData != null) {
if(StrUtil.isNotBlank(shopCacheData)) {
//3.查找到店铺数据 反序列化直接返回
return JSON.parseObject(shopCacheData, Shop.class);
} else {
// 返回空值缓存
return null;
}
}
//5.去数据库中查询店铺数据
shop = shopMapper.getById(id);
//6.数据库中不存在该数据
if(shop == null){
//在redis中设置空值 防止缓存穿透 + TTL过期时间 超时剔除
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//7.将数据库中查询到的数据保存到Redis中
stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
//8.shop信息
return shop;
}
缓存雪崩
缓存击穿
互斥锁
查询缓存未命中时,给当前线程加互斥锁,如果未获取到互斥锁,则休眠重试,重新尝试获取互斥锁。获取到互斥锁的线程,则会查询数据库中的数据,然后进行缓存重建(这个地方的缓存Key可能比较复杂 重建需要一定的时间) ,重建完后则释放锁。其他线程便会查询到缓存继续执行。
这种方法可以保证数据的一致性,但是会浪费一定的时间,适用于对数据一致性要求较高的业务。
逻辑过期
逻辑过期的过程比较复杂。当我们查询缓存时,如果发现缓存未过期则直接返回即可,若缓存已经过期,则我们会去获取互斥锁,如果未能获取互斥锁那么会返回旧数据,获取到互斥锁后,会单独开一个线程去查询数据库并且重建缓存数据,然后释放锁。其他线程,这个时候如果获取到互斥锁失败,则同样返回旧数据。
这种方法没有保证数据的一致性,会出现短暂的不一致性,但是速度较快,对用户体验感较好。
实现模拟互斥锁代码:
/**
* 解决缓存击穿问题(Key)(模拟互斥锁)
* @param id
* @return
*/
private Shop queryWithMutex(Long id) {
Shop shop = new Shop();
String key = RedisConstants.CACHE_SHOP_KEY + id;
//1.先去Redis缓存中查找数据
String shopCacheData = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否命中
if(shopCacheData != null) {
if(StrUtil.isNotBlank(shopCacheData)) {
//3.查找到店铺数据 反序列化直接返回
return JSON.parseObject(shopCacheData, Shop.class);
} else {
// 返回空值缓存
return null;
}
}
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
try {
//尝试获取互斥锁
Boolean isLock = tryLock(lockKey);
if(!isLock){
//未获取到互斥锁
Thread.sleep(50);
return queryWithMutex(id);
}
//获取到互斥锁之后 二次检查 防止其他线程正好获取到锁
String doubleCheckCache = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(doubleCheckCache)){
return JSON.parseObject(doubleCheckCache,Shop.class);
}
//5.去数据库中查询店铺数据
Thread.sleep(200); //模拟重构该缓存 所需要的时间
shop = shopMapper.getById(id);
//6.数据库中不存在该数据
if(shop == null){
//在redis中设置空值 + TTL过期时间 超时剔除 防止缓存穿透
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//7.将数据库中查询到的数据保存到Redis中
stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
//8.shop信息
return shop;
}
实现逻辑过期代码:
//定义一个线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑过期解决缓存击穿问题
* @param id
* @return
*/
private Shop queryWithLogicDelete(Long id){
//先去Redis中查找数据
String key = RedisConstants.CACHE_SHOP_KEY + id;
String stringJson = stringRedisTemplate.opsForValue().get(key);
//缓存未命中
if(stringJson == null){
return null;
}
//缓存命中 判断缓存是否过期
RedisData<Shop> redisData = JSON.parseObject(stringJson, new TypeReference<RedisData<Shop>>(){});
//RedisData<Shop> redisData = JSON.parseObject(stringJson, RedisData.class);
Shop shop = redisData.getData();
LocalDateTime expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDateTime.now())){
//未过期
return shop;
}
//缓存过期 重建缓存
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
//获取锁
Boolean flag = tryLock(lockKey);
if(!flag){
//未获取到锁 返回旧数据
return shop;
}
//开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() ->{
//重建缓存
try {
this.saveShop2Redis(id,20L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放锁
unlock(lockKey);
}
});
return shop;
}
缓存工具封装
package com.hmdp.utils;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* @author 灵感蛙
* @create 2025/9/10 19:36
*/
@Component
@Slf4j
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将任意java对象 序列化成json后保存到redis中 并设置TTL过期时间
* @param key
* @param data
* @param time
* @param timeUnit
*/
public void set(String key, Object data, Long time, TimeUnit timeUnit){
//将data序列化成json
String json = JSON.toJSONString(data);
//写入缓存 并设置TTL过期时间
stringRedisTemplate.opsForValue().set(key,json,time,timeUnit);
}
/**
* 将任意java对象 序列化成json保存到redis 并设置逻辑逻辑过期时间 防止缓存击穿
* @param key
* @param data
* @param time
* @param timeUnit
*/
public void setWithLogicExpire(String key, Object data, Long time ,TimeUnit timeUnit){
//设置逻辑过期
RedisData<Object> redisData = new RedisData<>();
redisData.setData(data);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
//序列化
String json = JSON.toJSONString(redisData);
//存入缓存
stringRedisTemplate.opsForValue().set(key,json);
}
/**
/**
* 根据指定key查询缓存
* 反序列化成指定类型利用缓存空值解决缓存穿透问题
* @param keyPrefix
* @param id
* @param type
* @param dbFallBack
* @param time
* @param timeUnit
* @return
* @param <R>
* @param <T>
*/
private <R,T> R getWithPassThrough(String keyPrefix,
T id,
Class<R> type,
Function<T,R> dbFallBack,
Long time ,
TimeUnit timeUnit){
String key = keyPrefix + id;
//1.先去Redis缓存中查找数据
String cacheData = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否命中
if(cacheData != null) {
if(StrUtil.isNotBlank(cacheData)) {
//3.查找到店铺数据 反序列化直接返回
return JSON.parseObject(cacheData, type);
} else {
// 返回空值缓存
return null;
}
}
//5.去数据库中查询店铺数据
R r = dbFallBack.apply(id);
//6.数据库中不存在该数据
if(r == null){
//在redis中设置空值 + TTL过期时间 超时剔除 防止缓存穿透
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//7.将数据库中查询到的数据保存到Redis中
set(key,r,time,timeUnit);
//8.shop信息
return r;
}
//定义一个线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑过期解决缓存击穿问题
* @param id
* @return
*/
private <R,T> R getWithLogicDelete(String keyPrefix,
T id,
Class<R> type,
Function<T,R> dbFallBack,
Long time ,
TimeUnit timeUnit){
//先去Redis中查找数据
String key = keyPrefix + id;
String stringJson = stringRedisTemplate.opsForValue().get(key);
//缓存未命中
if(stringJson == null){
return null;
}
//缓存命中 判断缓存是否过期
RedisData<R> redisData = JSON.parseObject(stringJson, new TypeReference<RedisData<R>>() {});
//RedisData<Shop> redisData = JSON.parseObject(stringJson, RedisData.class);
R r = redisData.getData();
LocalDateTime expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDateTime.now())){
//未过期
return r;
}
//缓存过期 重建缓存
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
//获取锁
Boolean flag = tryLock(lockKey);
if(!flag){
//未获取到锁 返回旧数据
return r;
}
//开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() ->{
//重建缓存
try {
//查询数据库
R r1 = dbFallBack.apply(id);
this.setWithLogicExpire(key,r1,time,timeUnit);
} finally {
//释放锁
unlock(lockKey);
}
});
return r;
}
/**
* 加锁
* @param key
* @return
*/
private Boolean tryLock(String key){
//给锁加TTL 超时时间 防止死锁
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}