缓存
在项目中,许多地方会用到缓存,尤其是在一些热点模块,例如点评网站中的商铺信息、短信登录的验证码等等。加了缓存后,可以让请求访问到数据库的操作大大减小,从而减轻数据库的压力。并且缓存一般都是在内存当中,因此,其访问速度要远高与传统数据库。而Redis用得最多的地方就是做数据的缓存。下面进行代码演示
给商铺查询添加缓存
-
Controller层代码
/** * 根据id查询商铺信息 * @param id 商铺id * @return 商铺详情数据 */ @GetMapping("/{id}") public Result queryShopById(@PathVariable("id") Long id) throws Exception { Result result = shopService.queryByid(id); return result; }
-
Service层代码
public Result queryByid(Long id) throws Exception { //先定义存入redis中的key String key = CACHE_SHOP_KEY + id; //1.从Redis中查询商铺缓存 String shopCash = stringRedisTemplate.opsForValue().get(key); //2.如果缓存中有直接返回 if(StrUtil.isNotBlank(shopCash)){ //2.2存在就直接返回 Shop shop = mapper.readValue(shopCash,Shop.class); return Result.ok(shop); } //3.缓存中没有的话查数据库 Shop shop = getById(id); //4.数据库为空,返回报错信息 if(shop == null){ return Result.fail("没有该商铺信息"); } //5.将对象序列化为String保存到缓存 String s = mapper.writeValueAsString(shop); stringRedisTemplate.opsForValue().set(key,s); return Result.ok(shop); }
上面的代码就是在第一次查询数据库时,若查到有效信息,就将其存入redis中,这样一来,以后再查这个数据时,会先去redis中查询,若查到的话就直接返回给前端,没查到再去数据库找。
缓存更新策略
缓存更新策略是redis为了节约缓存空间而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis中插入太多数据时,会导致缓存中的数据过多,影响性能以及后续新的需要缓存的数据。因此,redis会对部分数据进行更新,或者说是淘汰。一般来说,缓存更新有以下几种方式
内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护,利用redis的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存 | 给缓存添加过期时间,缓存到期自动剔除 | 一般在服务层编写业务逻辑,每当数据库修改时,更新缓存 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
在不同的业务场景,应该使用不同的淘汰机制。比如:
- 低一致性的需求:使用内存淘汰机制即可。例如店铺类型的缓存查询
- 高一致性的需求:主动更新,并以超时剔除方式做兜底处理。例如店铺详情查询的缓存、
数据库与缓存不一致解决方案
一般来说,在需要更新数据库时为了使缓存同步。需要同时更新缓存。在更新缓存的策略上,我们一般先更新数据库,再更新缓存。而在更新缓存的策略中一般会在更新完数据库后再删除当前缓存。这样在下次查询数据库时又会建立新的缓存。这种方式要比修改缓存内容大大减小对缓存的读写操作。
根据以上两点修改代码
-
给缓存添加超时剔除
public Result queryByid(Long id) throws Exception { //先定义存入redis中的key String key = CACHE_SHOP_KEY + id; //1.从Redis中查询商铺缓存 String shopCash = stringRedisTemplate.opsForValue().get(key); //2.如果缓存中有直接返回 if(StrUtil.isNotBlank(shopCash)){ //2.2存在就直接返回 Shop shop = mapper.readValue(shopCash,Shop.class); return Result.ok(shop); } //3.缓存中没有的话查数据库 Shop shop = getById(id); //4.数据库为空,返回报错信息 if(shop == null){ return Result.fail("没有该商铺信息"); } //5.将对象序列化为String保存到缓存 String s = mapper.writeValueAsString(shop); stringRedisTemplate.opsForValue().set(key,s,CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); }
-
在修改数据库操作时将缓存删除
/** * 修改缓存 * @param shop * @return */ @Override @Transactional public Result update(Shop shop) { Long id = shop.getId(); if(id == null){ return Result.fail("店铺id不能为空"); } //1.更新数据库 boolean tag = updateById(shop); if(!tag){ return Result.fail("修改商铺数据失败"); } //2.删除缓存 stringRedisTemplate.delete(CACHE_SHOP_KEY + id); return Result.ok(); }
以上操作数据库的方法是mybatisPlus所提供的。这里加上@Transactional是为了保证更新数据库和删除缓存操作具有原子性。这种方式只适合与单体项目,在分布式系统上,应该采用TCC等分布式事务方案。
解决缓存三大容灾
1.缓存穿透
缓存穿透是指当用户请求一个缓存和数据库中都没有的值时,这样缓存就永远不会生效,所有请求都会直接打到数据库中。常见的解决方式有两种
-
缓存空对象
- 优点:实现简单,方便维护
- 缺点:额外的内存消耗,可能造成短期的不一致
-
布隆过滤器
- 优点:内存占用小,没有多余的key
- 缺点:实现复杂,有误判的可能
-
使用缓存空对象解决缓存穿透的问题
/** * 解决缓存穿透 */ public Shop queryShop(Long id) throws Exception { log.info("缓存开始"); String key = CACHE_SHOP_KEY + id; //1.从Redis中查询商铺缓存 String shopCash = stringRedisTemplate.opsForValue().get(key); //2.如果缓存中有直接返回 if (StrUtil.isNotBlank(shopCash)) { //2.2存在就直接返回 Shop shop = mapper.readValue(shopCash, Shop.class); return shop; } //2.3判断命中的是否是空值 if (shopCash != null) { return null; } //3.缓存中没有的话查数据库 Shop shop = getById(id); //4.数据库为空,返回报错信息 if (shop == null) { //4.2将空值也存在数据库中,解决缓存穿透问题 stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } //5.将对象序列化为String保存到缓存 String s = mapper.writeValueAsString(shop); stringRedisTemplate.opsForValue().set(key, s, CACHE_SHOP_TTL, TimeUnit.MINUTES); return shop; }
在上面的代码中,当数据库查询到的信息为null时。我们在redis中也新增一个键值对将其作为空对象存到缓存中去,下次再进入查询时,若查到缓存中有值并且是空还不为null时就直接return。
2.缓存雪崩
缓存雪崩是指大量缓存在同一时间内失效,导致大量的请求直接打到数据库上,给服务器带来巨大压力。解决方案有以下几种
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
3.缓存穿透
缓存穿透是指热点key失效,无数的请求访问会在瞬间给数据库带来巨大的冲击。常见的解决方法有两种
- 互斥锁:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
- 逻辑过期:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
利用互斥锁来解决缓存穿透的问题:
利用Redis中的setnx方法,此方法是来创建key,若redis中无当前key则创建,否则无法创建。这一特性刚好符合互斥的效果
加锁方法
/**
* 获取互斥锁
* @param key
* @return
*/
private Boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
解锁方法
/**
* 释放互斥锁
* @param key
*/
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}
/**
* 解决缓存击穿
* @param id
* @return
* @throws Exception
*/
public Shop queryShop2(Long id) throws Exception {
String key = CACHE_SHOP_KEY + id;
//1.从Redis中查询商铺缓存
String shopCash = stringRedisTemplate.opsForValue().get(key);
//2.如果缓存中有直接返回
if(StrUtil.isNotBlank(shopCash)){
//2.2存在就直接返回
Shop shop = mapper.readValue(shopCash,Shop.class);
return shop;
}
//2.3判断命中的是否为空
if(shopCash != null){
return null;
}
//3.实现缓存重构
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
//3.1获取锁
Boolean flag = tryLock(lockKey);
if (!flag) {
//如果没有拿到互斥锁,休眠一段时间
Thread.sleep(20);
return queryShop2(id);
}
//3.2缓存中没有的话查数据库
shop = getById(id);
//4.数据库为空,返回报错信息
if (shop == null) {
//4.2将空值也存在数据库中,解决缓存穿透问题
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//5.将对象序列化为String保存到缓存
String s = mapper.writeValueAsString(shop);
stringRedisTemplate.opsForValue().set(key, s, CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (Exception e) {
e.printStackTrace();
} finally {
//6.释放互斥锁
unlock(lockKey);
}
//7.返回数据
return shop;
}
在上面方法中,当缓存中没有数据时,线程会去先获取锁,没有获取到的话就等待一段时间再去获取。直到拿到锁再去查询数据库并将数据添加到缓存中去。
利用逻辑过期的方式解决缓存穿透
创建一个类,用来当缓存对象
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
创建一个线程池
//创建线程池,在利用逻辑过期中使用
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 利用逻辑过期解决缓存击穿
* @param id
* @return
* @throws Exception
*/
public Shop queryShop3(Long id) throws Exception {
String key = CACHE_SHOP_KEY + id;
//1.从Redis中查询商铺缓存
String shopCash = stringRedisTemplate.opsForValue().get(key);
//2.如果缓存未命中
if(StrUtil.isBlank(shopCash)){
return null;
}
// 3.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopCash, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//判断缓存是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//未过期,直接返回店铺信息
return shop;
}
//已过期
//3.实现缓存重构
String lockKey = LOCK_SHOP_KEY + id;
Boolean isLock = tryLock(lockKey);
//判断获取锁是否成功
if(isLock){
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
//7.返回过期的数据
return shop;
}
/**
* 进行缓存预热的方法
* @param id
* @param expireSeconds
*/
public void saveShop2Redis(Long id,Long expireSeconds){
//1.店铺查询
Shop shop = getById(id);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
在上面的代码中,当获取到缓存对象后会先取出对象中的object类型的对象并将其序列化为想要的对象。再拿时间和当前时间做比较,若没有过期就返回到前端,否则就开启一个独立的线程去做缓存重构,并将原来的数据返回给前端。这样一来,当缓存重构完成后,缓存的过期时间也一并更新,下一次再进来查询时,就可以获取到未过期的缓存了。
封装解决方法
@Slf4j
@Component
public class CacheClient {
/**
* 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
* 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
* 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
* 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用互斥锁解决缓存击穿问题
* 方法5:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
*/
private final StringRedisTemplate stringRedisTemplate;
//线程池,在使用逻辑过期解决缓存击穿问题中使用
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 方法1,将对象存到redis中,并设置过期时间
* @param key
* @param value
* @param time
* @param unit
*/
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
log.info("缓存到reids成功");
}
/**
* 方法2,将对象存到redis中,并设置逻辑过期时间
* @param key
* @param value
* @param time
* @param unit
*/
public void setWithLogicalExpire(String key,Object value,Long time,TimeUnit unit){
//设置逻辑过期时间
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
redisData.setData(value);
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
/**
* 方法三:使用缓存空值解决缓存穿透问题
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit
){
String key = keyPrefix + id;
//1.从Redis中查询商铺缓存
String shopCash = stringRedisTemplate.opsForValue().get(key);
//2.如果缓存中有直接返回
if (StrUtil.isNotBlank(shopCash)) {
log.info("使用缓存数据");
//2.2存在就直接返回
return JSONUtil.toBean(shopCash,type);
}
//2.3判断命中的是否是空值
if (shopCash != null) {
return null;
}
//3.缓存中没有的话查数据库
R r = dbFallback.apply(id);
//4.数据库为空,返回报错信息
if (r == null) {
//4.2将空值也存在数据库中,解决缓存穿透问题
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//5.将对象序列化为String保存到缓存
this.set(key,r,time,unit);
return r;
}
/**
* 方法4,利用互斥锁解决缓存击穿
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
public <R,ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type,Function<ID,R> dbFallback,
Long time,TimeUnit unit){
String key = keyPrefix + id;
//1.从Redis中查询商铺缓存
String shopCash = stringRedisTemplate.opsForValue().get(key);
//2.如果缓存中有直接返回
if(StrUtil.isNotBlank(shopCash)){
//2.2存在就直接返回
return JSONUtil.toBean(shopCash,type);
}
//2.3判断命中的是否为空
if(shopCash != null){
return null;
}
//3.实现缓存重构
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
//3.1获取锁
Boolean flag = tryLock(lockKey);
if (!flag) {
//如果没有拿到互斥锁,休眠一段时间
Thread.sleep(20);
return queryWithMutex(key,id,type,dbFallback,time,unit);
}
//3.2缓存中没有的话查数据库
r = dbFallback.apply(id);
//4.数据库为空,返回报错信息
if (r == null) {
//4.2将空值也存在数据库中,解决缓存穿透问题
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//5.将对象序列化为String保存到缓存
this.set(key,r,time,unit);
} catch (Exception e) {
e.printStackTrace();
} finally {
//6.释放互斥锁
unlock(lockKey);
}
//7.返回数据
return r;
}
/**
* 方法5,利用逻辑过期解决缓存击穿
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
public <R,ID>R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallback,
Long time,TimeUnit unit){
String key = keyPrefix + id;
//1.从Redis中查询商铺缓存
String shopCash = stringRedisTemplate.opsForValue().get(key);
//2.如果缓存未命中
if(StrUtil.isBlank(shopCash)){
return null;
}
// 3.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopCash, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//判断缓存是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//未过期,直接返回店铺信息
return r;
}
//已过期
//3.实现缓存重构
log.info("缓存已过期");
String lockKey = LOCK_SHOP_KEY + id;
Boolean isLock = tryLock(lockKey);
//判断获取锁是否成功
if(isLock){
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//查询数据库
R nweR = dbFallback.apply(id);
//重建缓存
this.setWithLogicalExpire(key,nweR,time,unit);
log.info("缓存重建成功");
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
//7.返回过期的数据
return r;
}
/**
* 获取互斥锁
* @param key
* @return
*/
private Boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放互斥锁
* @param key
*/
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}
主要就是利用泛型的特性来做返回参数和传入参数。