1 什么是缓存?
前言:什么是缓存?
就像自行车,越野车的避震器
举个例子:越野车,山地自行车,都拥有"避震器",防止车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样;
同样,实际开发中,系统也需要"避震器",防止过高的数据访问猛冲系统,导致其操作线程无法及时处理信息而瘫痪;
这在实际开发中对企业讲,对产品口碑,用户评价都是致命的;所以企业非常重视缓存技术;
缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码(例如:
例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发
例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存
例3:Static final Map<K,V> map = new HashMap(); 本地缓存
由于其被Static修饰,所以随着类的加载而被加载到内存之中,作为本地缓存,由于其又被final修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;
1.1 为什么要使用缓存
一句话:因为速度快,好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
但是缓存也会增加代码复杂度和运营的成本:
1.2 如何使用缓存
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用
浏览器缓存:主要是存在于浏览器端的缓存
应用层缓存:可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
CPU缓存:当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存
2 添加商户缓存
在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢咯,所以我们需要增加缓存
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
//这里是直接查询数据库
return shopService.queryById(id);
}
2.1 、缓存模型和思路
标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。
2.1 、代码如下
代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。
在前端可以发现速度明显加快 :
3 . 添加商户类型缓存
对于店铺类型的数据,一般来说变动是很小的,那么可以添加到redis中进行缓存,提高效率 ;
完整代码 (利用String类型实现):
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryType() {
// 1 . 先查询redis缓存
String typeList = stringRedisTemplate.opsForValue().get(CACHE_SHOP_TYPE_KEY) ;
// 2 . 判断是否缓存命中
if(StrUtil.isNotBlank(typeList)){
// 2 . 1 存在,直接返回
List<ShopType> list = JSONUtil.toList(typeList,ShopType.class) ;
return Result.ok(list) ;
}
// 2 . 2 缓存未命中,查数据库
List<ShopType> list = query().orderByAsc("sort").list() ;
// 3 . 判断数据库中是否存在
if(list == null){
// 3 . 1 数据库也为空 , 直接返回false ;
return Result.fail("分类不存在") ;
}
// 3 . 2 数据库中存在 , 则将查询到的数据存入redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_TYPE_KEY , JSONUtil.toJsonStr(list)) ;
// 3 . 3 返回
return Result.ok(list) ;
}
}
这里要注意的一个小错误就是 : 在Controller中返回,直接返回typeService.queryType()即可,因为你的service中的方法返回的结果就是一个Result.ok(lsit),否者可能会出现前端不显示商户分类的情况 :
实现效果 :
4 . 缓存更新策略
缓存更新策略 :
对于主动更新有三种方案 :
第三种,对于数据一致性难以维护 ;
一般采取第一种 :
对于Cache Aside Pattern需要考虑的三个问题 :
对于两种策略 :
从线程安全的角度上来讲,第二种发生问题的概率小,后面再加上一个超时时间即可 ;
总结
5 . 实现商铺和缓存与数据库双写一致
修改的核心思路 :
修改ShopController中的业务逻辑 , 满足下面的需求,
根据id查询店铺时 , 如果缓存未命中 , 则查询数据库 , 将数据库结果写入缓存 , 并设置超时时间
根据id修改店铺时 ,先修改数据库 , 再删除缓存 ;
查询代码修改 :
加一个过期时间即可 :
@Override
public Result queryById(Long id) { // 根据id查商户
String key = CACHE_SHOP_KEY + id ;
// 1 . 从redis中查询商户缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2 . 判断是否存在
if(StrUtil.isNotBlank(shopJson)) {
// 3 . 存在 , 根据id查询数据库
Shop shop = JSONUtil.toBean(shopJson,Shop.class) ;
return Result.ok(shop) ;
}
// 4 . 不存在 , 根据id查询数据库
Shop shop = getById(id) ;
// 4 . 不存在 , 返回错误
if(shop==null){
return Result.fail("店铺不存在!") ;
}
// 6 . 存在 , 写入redis
// 加入缓存过期时间
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
测试 : 查询之后 , redis中出现相应数据,并且时间刷新 :
对于修改用户 :
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空") ;
}
// 1 . 更新数据库
updateById(shop) ;
// 2 . 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId()) ;
return Result.ok() ;
}
- 首先更新数据库 , 然后删除缓存 ;
- 需要加上@Transactional注解,来保持数据的一致性;
测试,这里需要用postman工具来测试,本次测试修改名字 (原本103修改为102):
{
"area":"大关",
"openHours":"10:00-22:00",
"sold":4215,
"address":"金华路29号",
"comments":3035,
"avgPrice":80,
"score":37,
"name":"102茶餐厅",
"typeId":1,
"id":1
}
首先发现数据库相关位置进行了修改 :
然后查看redis中相应数据没了 :
然后再去网页端刷新界面 :
发现数据已经修改 :
6 . 缓存穿透问题解决思路 :
原理参考 : Redis -- 缓存穿透问题解决思路-CSDN博客
解决缓存穿透逻辑修改 :
这里采用缓存空对象 + 设置过期时间的方法来操作 ;
首先修改查询逻辑:
当缓存未命中并且数据库中没有相应数据的时候,将空值存入redis中,设置2min的过期时间 :
如果命中空的,直接返回一个错误信息 :
完整代码 :
@Resource
private StringRedisTemplate stringRedisTemplate ;
@Override
public Result queryById(Long id) { // 根据id查商户
String key = CACHE_SHOP_KEY + id ;
// 1 . 从redis中查询商户缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2 . 判断是否存在
if(StrUtil.isNotBlank(shopJson)) {
// 3 . 存在 , 根据id查询数据库
Shop shop = JSONUtil.toBean(shopJson,Shop.class) ;
return Result.ok(shop) ;
}
// 判断命中的是否是空值
if(shopJson != null){ // 一定是空字符串 , 前面isNotBlnak只有当shopJon存在且非空的情况下返回true
// 命中空值
return Result.fail("店铺不存在") ;
}
// 4 . 不存在 , 根据id查询数据库
Shop shop = getById(id) ;
// 4 . 不存在 , 返回错误
if(shop==null){
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key , "" , CACHE_NULL_TTL,TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("店铺不存在!") ;
}
// 6 . 存在 , 写入redis
// 加入缓存过期时间
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
这里直接查询一个异常数据作为测试 :
也可以看到redis多了一个空的数据 :
7 . Redis 缓存雪崩问题
原理参考 : Redis -- 缓存雪崩问题-CSDN博客
8 . 缓存击穿问题
原理参考 : Redis -- 缓存击穿问题-CSDN博客
问题引入 :
常用解决方案 :
- 互斥锁 : 性能较差 , 后面来的线程等待时间可能过长 ;
- 逻辑过期 : 逻辑上添加过期时间,不主动设置过期时间 ;
两种方案对比 :
9 . 利用互斥锁解决缓存击穿问题
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿
需求 : 修改根据id查询商铺的业务 , 基于互斥锁方式来解决缓存击穿问题 ;
这里的互斥锁采用redis中的setnx实现;
setnx的特点 :
当第一个人用setnx设置了lock,其它线程就不能够进行修改,保证了互斥的条件 ;
获取锁和释放锁的代码 :
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key , "1",10,TimeUnit.SECONDS) ;
return BooleanUtil.isTrue(flag) ;
}
private void unlock(String key){
stringRedisTemplate.delete(key) ;
}
注意在获取锁的时候,别直接return false, 因为在拆箱的过程中,可能会有空指针,造成异常 ;
用互斥锁实现缓存击穿完整代码 :
@Resource
private StringRedisTemplate stringRedisTemplate ;
@Override
public Result queryById(Long id) throws InterruptedException { // 根据id查商户
// 缓存穿透
// Shop shop = queryWithPassThrough(id) ;
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id) ;
if(shop == null){
return Result.fail("店铺不存在!") ;
}
return Result.ok(shop);
}
public Shop queryWithMutex(Long id) throws InterruptedException { // 缓存穿透代码
String key = CACHE_SHOP_KEY + id ;
// 1 . 从redis中查询商户缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2 . 判断是否存在
if(StrUtil.isNotBlank(shopJson)) {
// 3 . 存在 , 根据id查询数据库
Shop shop = JSONUtil.toBean(shopJson,Shop.class) ;
return shop ;
}
// 判断命中的是否是空值
if(shopJson != null){ //上面isNotBlank方法判断""和null都是返回false,因为缓存空对象为"",所以判断不是null
// 命中空值
// return Result.fail("店铺不存在") ;
return null ;
}
// 实现缓存重建
// 4. 1 获取互斥锁
String lockKey = "lock:shop:" + id ;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey) ;
// 4 .2 判断是否获取成功
if(!isLock){
// 4 . 3 失败 , 则休眠并重试
Thread.sleep(50) ;
return queryWithMutex(id) ;
}
// 4 . 4 成功 , 根据id查询数据库
// 模拟重建的超时
Thread.sleep(200);
shop = getById(id);
// 5 . 不存在 , 返回错误
if(shop==null){
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key , "" , CACHE_NULL_TTL,TimeUnit.MINUTES);
// 返回错误信息
// return Result.fail("店铺不存在!") ;
return null ;
}
// 6 . 存在 , 写入redis
// 加入缓存过期时间
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7 . 释放互斥锁
unlock(lockKey);
}
// 8 . 返回shop
return shop;
}
测试 :
在重建的步骤中,加入休眠时间 :
清空redis库 :
这里本来要用Jmeter来测试,但是懒得搞,然后补上,学习网址 : Jmeter自动化测试工具从入门到进阶6小时搞定,适合手工测试同学学习_哔哩哔哩_bilibili
这里随便测一测 : 发现数据只查询了一次 :
10 . 利用逻辑过期解决缓存击穿问题
这里key是不会过期的 ;
流程 :
先去定义一个类,专门定义逻辑过期时间 :
其中data用来存放shop ;
首先进行数据准备 :
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));
}
然后用单元测试的方式,来写入数据 , 进行热点key的数据预热 :
完整代码 :
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10) ;
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 这里不需要考虑缓存穿透
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
数据库修改之后,发现redis中也修改了:
这里会有一段时间的不一致 ;
11 . 封装redis工具类
需求 :
完整代码 (值得细细体会):
package com.hmdp.utils;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;
@Component
public class CacheClient {
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
private StringRedisTemplate stringRedisTemplate ;
private CacheClient(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate = stringRedisTemplate ;
}
/**
* 将传过来的对象序列化为json并存储在string类型的key中,并且可以设置过期时间
* @param key
* @param value // 任意java对象
* @param time // 过期时长
* @param unit // 时间单位
*/
public void set(String key , Object value , Long time , TimeUnit unit){
stringRedisTemplate.opsForValue().set(key , JSONUtil.toJsonStr(value),time , unit);
}
/**
* 将传过来的对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
* @param key
* @param value // 任意java对象
* @param time // 过期时长
* @param unit // 时间单位
*/
public void setWithLogicalExpire(String key , Object value , Long time , TimeUnit unit){
// 设置RedisData对象
RedisData redisData = new RedisData() ;
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key , JSONUtil.toJsonStr(redisData));
}
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 json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
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 json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
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 shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}