🙊 前言:本文章为瑞_系列专栏之《Redis》的实战篇的商户查询缓存章节。由于博主是从B站黑马程序员的《Redis》学习其相关知识,所以本系列专栏主要是针对该课程进行笔记总结和拓展,文中的部分原理及图解等也是来源于黑马提供的资料,特此注明。本文仅供大家交流、学习及研究使用,禁止用于商业用途,违者必究!
- 主机操作系统:Windows10
- VMware版本: VMware Workstation 16.2.4
- Linux版本:CentOS 7 64位
- 远程连接工具:MobaXterm_Personal_23.2
- Redis版本:redis-6.2.6.tar.gz
- Redis客户端:resp-2022.2.0.0
- MySQL版本:8.0.29(5.7+均可)
- Navicat Premium:15.0.28
- JDK:1.8
相关链接:《瑞_Java所有相关环境及软件的安装和卸载_图文超详细(持续更新)》
相关链接:《瑞_Redis_短信登录》
项目介绍
本文基于B站黑马程序员的《黑马点评》项目进行学习笔记总结和拓展,项目的相关资源和课程视频可以到B站获取。
博主提供的该项目的相关资源的某度网盘链接:https://pan.baidu.com/s/1N-yr86yTRi3LbQdAL7prEQ?pwd=q0ry
本项目具有以下功能点,本文为《商户查询缓存》篇
-
短信登录
这一块我们会使用redis共享session来实现 -
商户查询缓存
通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容 -
优惠卷秒杀
通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列 -
附近的商户
我们利用Redis的GEOHash来完成对于地理坐标的操作 -
UV统计
主要是使用Redis来完成统计功能 -
用户签到
使用Redis的BitMap数据统计功能 -
好友关注
基于Set集合的关注、取消关注,共同关注等等功能,这一块知识咱们之前就讲过,这次我们在项目中来使用一下 -
达人探店
基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能
由于该项目主要是为了学习Redis,所以不会设计为微服务架构,简化代码复杂度,所以采用前后端分离的单体架构
说明
手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。
在 tomcat 支撑起并发流量后,我们如果让 tomcat 直接去访问 Mysql ,根据经验 Mysql 企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。
1 短信登录
2 商户查询缓存
本章节基于hm-dianping【1.3Redis代替session的业务流程】
的代码,需要请自取
链接:https://pan.baidu.com/s/1DomlH_sXyAkrciXk8-bWww?pwd=z6lu
提取码:z6lu
2.1 什么是缓存
缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。
2.1.1 缓存的应用场景
缓存的应用场景:浏览器缓存、应用层缓存(如Redis)、数据库缓存(如:索引)、CPU多级缓存、磁盘缓存
-
浏览器缓存:主要是存在于浏览器端的缓存
-
应用层缓存:可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
-
数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
-
CPU缓存:当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存
CPU多级缓存的诞生:在计算机中,主要的构造为CPU、内存、磁盘。由于CPU的运算能力随着科技的发展,其计算能力已经远远的超过内存和磁盘的读写数据的能力,但是CPU所做的任何运算都需要从内存或者磁盘中读到数据,再放到自己的寄存器里,才可以进行运算。正是由于这种数据读写的能力远远低于CPU的运算能力,导致计算机性能受到瓶颈。所以人们在CPU的内部添加了缓存,即CPU会把经常需要读写的数据放入CPU缓存中,当进行高速运算的时候,就不需要每次都去内存或磁盘中读取数据再运算,而是直接从缓存中获取数据直接运算,这样就可以充分释放CPU的运算能力。
缓存的常见使用示例:
例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()) 之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效。
2.1.2 为什么要使用缓存
一句话:因为速度快,好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大的数据量,如果没有缓存来作为"避震器",系统是几乎是撑不住的,所以企业会大量运用到缓存技术。
2.1.3 Web应用中缓存的作用
缓存的作用:
1️⃣ 降低后端负载:请求先进入缓存中查找数据,若缓存中不存在再将请求向数据库发送,大大降低后端数据库压力。
2️⃣ 提高读写效率,降低响应时间。
2.1.4 Web应用中缓存的成本
缓存的成本:
1️⃣ 数据一致性成本:数据本身只保存在数据库,现在将数据缓存了一份放到了内存中(如Redis),如果数据库中的数据发生了变化而缓存中的数据仍然是旧数据,由于请求先进入缓存中查找数据,就会造成数据的不一致性。
2️⃣ 代码维护成本:由于要保证数据一致性,自然会增加业务编码,且会出现缓存穿透、雪崩、击穿等问题,会大幅度提高代码复杂度。
3️⃣ 运维成本:为了避免缓存雪崩或缓存的高可用,需要搭建成缓存集群模式,提高了运维成本。
2.2 添加Redis缓存
2.2.1 背景
给访问MySQL数据库的接口添加缓存,提高查询性能。
在我们查询商户信息时,资料中ShopController
类的 queryShopById 方法,是调用 MyBatisPlus 的 getById 方法,从对应数据表通过主键 id 查询数据的方法。由于该方法是直接操作从数据库中去进行查询的,现在我们对其增加一层缓存,提高该接口的查询效率。
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return Result.ok(shopService.getById(id)); // 目前是直接查询数据库
}
2.2.2 缓存模型和思路
瑞:遇事不决,加一层
未添加缓存前的逻辑:客户端向服务器发起的请求,会直接发送到数据库,通过数据库查询后,将结果返回给客户端,如下图
添加缓存就相当于,在客户端和数据库之间添加了中间层(如Redis缓存),这样客户端的请求就有限到达缓存(Redis),如果Redis中有该查询结果,则直接返回,就不会到达数据库,这样数据库压力就大幅度减轻了。若Redis中无该查询结果(未命中),再将该请求发送至数据库,数据库将查询结果返回给客户端并写入缓存中
相对应的业务流程也要修改,如下图
2.2.3 代码实现
主要是改进ShopController
类的 queryShopById 方法,当前代码如下:
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return Result.ok(shopService.getById(id));
}
1️⃣ 将ShopController
类的 queryShopById 方法的业务搬至 Service 层中的自定义 queryByI d方法
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
2️⃣ IShopService
接口创建 queryById 方法
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
}
3️⃣ 在ShopServiceImpl
实现类中实现 queryById 方法
queryById 实现思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis
3️⃣➖1️⃣ 先注入StringRedisTemplate
。
@Resource
private StringRedisTemplate stringRedisTemplate;
3️⃣➖2️⃣ RedisConstants
中加入常量
public static final String CACHE_SHOP_KEY = "cache:shop:";
3️⃣➖3️⃣ 实现 queryById
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisConstants;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1.从Redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)){
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson,Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误
if (shop == null){
return Result.fail("商铺不存在!");
}
// 6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
// 7.返回
return Result.ok(shop);
}
}
2.2.4 测试
1️⃣ 重启后端服务,登录账户,进入首页,点击美食,选择103茶餐厅,查看请求状态
2️⃣ 打开Redis客户端查看数据是否存入缓存中
3️⃣ 清空后端控制台,重新刷新该页面发送请求,检测该请求是否不再访问数据库(没有输出商品查询SQL日志)
瑞:控制台此时只输出
VoucherMapper.queryVoucherOfShop
查询优惠券的SQL日志,而没有输出ShopMapper.selectById
的SQL日志,证明该请求已被缓存拦截
附:IDEA控制台输出自动换行设置
File ➡️Settings… ➡️ Editor ➡️ General ➡️ Console ➡️ Use soft wraps in console ➡️ 勾选 ➡️ Apply
2.3 缓存更新策略
瑞:缓存是一个双刃剑,带来好处的同时也导致了数据一致性等问题,缓存的更新策略就是为了解决这个问题
由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在
2.3.1 常见的缓存更新三大策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
常见的缓存更新策略见下表⬇️
内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存 | 给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存 | 编写业务逻辑,在修改数据库的同时,更新缓存 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
业务场景:
- 低一致性需求:使用内存淘汰机制、超时剔除。例如店铺类型的查询缓存。
- 高一致性需求:主动更新,并以超时剔除作为兜底方案,两者结合。例如店铺详情查询的缓存。
2.3.1.1 内存淘汰
内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
2.3.1.2 超时剔除
超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
2.3.1.3 主动更新
主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
2.3.2 主动更新策略——数据库缓存不一致的解决方案
主动更新策略的三种模式
1️⃣ Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
瑞:该模式在大多数场景中被采用,所以可以认为:数据库和缓存不一致采用的是双写方案。但双写方案需要注意下一节提到的三个问题。
2️⃣ Read/Write Through Pattern : 缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。由系统本身完成,数据库与缓存的问题交由系统本身去处理
瑞:该模式开发成本高
3️⃣ Write Behind Caching Pattern 写回:调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
瑞:该模式的好处在于,频繁的读写操作在缓存中进行,将多次的读写转化为1次,有效降低了数据库压力,相当于数据库的缓冲区。但问题比较多:该异步任务的开发困难;以及数据一致性难以保证,任务间隔中缓存和数据库的数据不一致;并且可靠性也存在问题,如果任务期间缓存服务器宕机,则可能会导致数据丢失
2.3.3 双写方案的三个注意事项
综合考虑使用方案1️⃣Cache Aside Pattern,但是方案1️⃣的调用者需要思考操作缓存和数据库的以下三个问题
2.3.3.1 删除缓存
- 1️⃣ 删除缓存还是更新缓存?
- ❌ 更新缓存:每次更新数据库都更新缓存,无效写操作较多 ❌
- ✅ 删除缓存:更新数据库时让缓存失效,查询时再更新缓存 ✅
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询(写多读少),那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,所以我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
2.3.3.2 保证缓存与数据库的操作的同时成功或失败
- 2️⃣ 如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务 ✅
- 分布式系统,利用TCC等分布式事务方案 ✅
瑞:分布式系统中也可以使用消息队列等方案处理
2.3.3.3 先操作数据库再删除缓存
- 3️⃣ 先操作缓存还是先操作数据库?
- ❌ 先删除缓存,再操作数据库 ❌
- ✅ 先操作数据库,再删除缓存 ✅
瑞:为尽量保证数据一致性,我们应当
先操作数据库,再删除缓存
。虽然两种方案都有可能会造成数据不一致性的问题,但方案二发生的概率远远小于方案一,且方案二的数据不一致问题容易得到解决。
下图为:先删除缓存,再操作数据库的正常情况
先删除缓存,再操作数据库。在正常情况下好像没问题,但在多线程环境下不安全,由于删缓存、查缓存、查数据库的操作较快(相对),而更新数据库即写操作较慢(相对),所以很容易在过程中被其它线程重新写入缓存,造成数据不一致的问题
下图为:先删除缓存,再操作数据库的异常情况
假设此时数据库是存储10,需要更新为20,在两个线程并发来访问时,线程1先来,线程1会先把缓存删了,此时线程2过来,他查询缓存数据并不存在(因为线程1并未更新数据库的值,线程1此刻只删除了缓存值10),由于线程2未命中,则会查询数据库(旧值10),此时他写入缓存(旧值10),当线程2写入缓存后,线程1再执行更新动作,把数据库的值改为了20,导致此时数据库的值为20,而缓存的值为10,数据不一致❗️ ❗️ ❗️
下图为:先操作数据库,再删除缓存的正常情况
先操作数据库,再删除缓存。在正常情况下也没问题,但在多线程环境下仍然可能不安全,但相对情况一要好很多。因为:首先线程1来时恰好缓存失效的概率低、其次在线程1查询缓存恰好失效的情况下,线程1查询到数据库的值之后,在更新缓存这微妙级别的时间范围内,突然来一个线程2,先更新数据库(较慢)然后线程2删除缓存,这么多操作要在微妙的时间内完成,才会造成数据不一致的问题,同时满足这三个巧合的概率相对低。且如果发生这种情况,只要加上超时时间即可有效解决
下图为:先操作数据库,再删除缓存的异常情况
假设此时数据库是10,由于某些原因,恰好缓存失效,线程1来查,则一定未命中,需要查询数据库,得到10,由于线程1未命中,要把10写入缓存,此时,线程2来更新数据库(将数据库修改为20),线程2在更新完数据库后再删除缓存,但此时缓存中其实已经失效,所以删除操作等于没删,然后线程1(得到的数据库是旧值10)将10写入数据库,导致了数据不一致❗️但这种概率极低,且如果万一发生了,只要加上超时时间,由于数据库的数据是正确的,过一段时间缓存便会同步,容易解决
2.3.4 缓存更新策略的最佳实践方案
1️⃣ 低一致性需求:使用Redis自带的内存淘汰机制
2️⃣ 高一致性需求:主动更新,并以超时剔除作为兜底方案
2️⃣➖1️⃣ 读操作:
2️⃣➖1️⃣➖1️⃣ 缓存命中则直接返回
2️⃣➖1️⃣➖2️⃣ 缓存未命中则查询数据库,并写入缓存,设定超时时间
2️⃣➖2️⃣ 写操作:
2️⃣➖2️⃣➖1️⃣ 先写数据库,然后再删除缓存
2️⃣➖2️⃣➖2️⃣ 要确保数据库与缓存操作的原子性
2.3.5 案例:添加超时剔除和主动更新策略
2.3.5.1 需求
修改代码中的ShopController
中的业务逻辑,满足下面的需求:
1️⃣ 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间(超时剔除)
2️⃣ 根据id修改店铺时,先修改数据库,再删除缓存(主动更新)
2.3.5.2 代码实现
1️⃣ 实现超时剔除
1️⃣➖1️⃣ 修改ShopServiceImpl
的 queryById 方法的业务逻辑,设置超时时间stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisConstants;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1.从Redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误
if (shop == null) {
return Result.fail("商铺不存在!");
}
// 6.存在,写入Redis,并设置超时时间(超时剔除)
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return Result.ok(shop);
}
}
2️⃣ 实现主动更新
2️⃣➖1️⃣ 修改ShopController
的 updateShop 方法的业务逻辑
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.update(shop);
}
2️⃣➖2️⃣ IShopService
接口中添加 update 方法
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
Result update(Shop shop);
}
2️⃣➖3️⃣ ShopServiceImpl
实现类中实现 update 方法
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
return Result.ok();
}
瑞:要注意事务,本例为单体项目中事务的处理,如果是分布式 \ 微服务项目,需要用消息队列通知其它服务等方式保证数据的一致性
2.3.5.3 测试
重启后端服务
- 测试超时剔除
1️⃣ 删除Redis中cache:shop:1
的数据,因为之前的章节中没有设置TTL,所以要将其删除
2️⃣ 前端登录账户,进入首页,点击美食,选择103茶餐厅,查看Redis中cache:shop:1
的TTL是否被设置为了1800左右(30分钟)图中显示1794是因为博主操作了6秒钟,导致过期时间不是1800
- 测试主动更新
1️⃣ 由于更新商铺信息接口 updateShop 在前端没有对普通用户直接开放,所以使用postman测试(注意请求是PUT)http://localhost:8081/shop
{
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
"address": "金华路锦昌文华苑29号",
"comments": 3035,
"avgPrice": 80,
"socrs": 37,
"name": "101茶餐厅",
"typeId": 1,
"id": 1
}
2️⃣ 在使用 postman 发送PUT更新请求后,不对页面进行其它操作,直接查看Redis客户端,刷新数据库,发现cache:shop:1
已被删除,说明主动更新代码执行成功
3️⃣ 对前端餐厅详情页面进行刷新,即访问ShopController
的 queryShopById 方法,发现前端信息更新成功,是数据库中更新的名字(103 修改为了 postman发送的请求,即改为了101),且Redis客户端中存储了cache:shop:1
的数据,并且半小时后该数据会被自动删除(不对该接口进行访问的前提下),说明超时剔除和主动更新实现成功
2.3.6 代码资源
hm-dianping【2.3缓存更新策略】
后端代码的某度网盘链接如下,需要请自取
链接:https://pan.baidu.com/s/1D06xA2IU6Atfp9fZeygpNA?pwd=dwi9
提取码:dwi9
2.4 缓存穿透
瑞:
缓存穿透是缓存和数据库都没数据,缓存失效的现象
缓存雪崩是数量级的非热点key失效
缓存击穿的重点在于一个点(热点key)的击穿
-
缓存穿透产生的原因:用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
-
缓存穿透的解决方案
1️⃣ 缓存null值
2️⃣ 布隆过滤
3️⃣ 增强id的复杂度,避免被猜测id规律
4️⃣ 做好数据的基础格式校验
5️⃣ 加强用户权限校验
6️⃣ 做好热点参数的限流
2.4.1 定义
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库
例如,通过商品 ID 查询商品信息的业务,假如某“坏人”创建无数的线程,并发的向一个不存在的数据疯狂发送请求,由于这个数据在缓存和数据库中都不存在,数据库返回空,自然也不会在缓存中存储该数据的信息,这就会导致所有请求都会到达数据库,最终数据库扛不住压力就崩了,这就是缓存穿透的危害。
2.4.2 缓存穿透的解决方案
常见的解决方案有两种
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
- 布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
2.4.2.1 缓存空对象
瑞:缓存空对象是一种简单暴力的解决方案。但由于简单,实际中经常被采用
缓存空对象思路分析:当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
优点:实现简单,维护方便
缺点:
1️⃣ 额外的内存消耗:容易导致 Redis 中存在很多缓存垃圾,当然可以通过设置合理且较短的 TTL 起到保护作用
2️⃣ 可能造成短期的不一致:当某个数据查询数据库不存在后,将其的 Null 设置到 Redis 后,其它业务真的在数据库插入了该数据,这就会导致在 TTL 时间段内,数据的不一致性。可以参考缓存更新策略章节,通过主动更新策略解决
2.4.2.2 布隆过滤
瑞:布隆过滤更像是一种算法(Hash),数据存在的时候不一定百分百判断准确,可能存在误判;但数据不存在的时候一定不存在
布隆过滤:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问 redis,哪怕此时 redis 中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到 redis 中,假设布隆过滤器判断这个数据不存在,则直接返回
优点:节约内存空间,内存占用较少,没有多余key
缺点:
1️⃣ 实现复杂,不过 Redis 中的 map 是自带布隆过滤的实现
2️⃣ 存在误判:由于布隆过滤器使用的是哈希思想,只要是哈希思想,就可能存在哈希冲突
2.4.3 案例:解决商品查询的缓存穿透
2.4.3.1 需求
本案例代码基于hm-dianping【2.3缓存更新策略】的ShopServiceImpl
的 queryById 方法
解决商品查询的缓存穿透核心思路如下⬇️
在原来的逻辑中,如果发现这个数据在mysql中不存在,直接返回404,这样是会存在缓存穿透问题的,因为如果下次再来同样的该请求时,请求还是会进入数据库而不是被缓存命中
优化后的逻辑:如果这个数据不存在,我们不会直接返回404 ,而是把这个数据写入到Redis中,并且将value设置为空。当再次发起查询时,如果发现命中之后,判断这个value是否是null,如果是null,则是之前写入的数据,证明是缓存穿透数据,如果不是,则直接返回数据。
2.4.3.2 代码实现
在ShopServiceImpl
的 queryById 方法代码中实现如下
@Override
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1.从Redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断Redis命中的是否为空值(因为前面必须是为null或者""的情况才会下来)
if (shopJson != null) {
// 返回一个错误信息
return Result.fail("店铺信息不存在!");
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误
if (shop == null) {
// 将空值写入 Redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("商铺不存在!");
}
// 6.存在,写入Redis,并设置超时时间(超时剔除)
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return Result.ok(shop);
}
2.4.3.2.3 测试
1️⃣ 重新启动后端程序,在浏览器中发送http://localhost:8080/api/shop/1
请求(id为1的数据在数据库中存在)数据库进行查询
2️⃣ 在浏览器中发送http://localhost:8080/api/shop/0
请求(id为0的数据在数据库中不存在)数据库第 1 次会进行查询,而第2、3、4…直到 TTL 过期,才会再次查询数据库,期间该请求都只走到缓存就返回结果,再次访问该请求没有打印查询日志
2.5 缓存雪崩
瑞:
缓存穿透是缓存和数据库都没数据,缓存失效的现象
缓存雪崩是数量级的非热点key失效
缓存击穿的重点在于一个点(热点key)的击穿
2.5.1 定义
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
正常情况下,应该是大量的数据请求达到Redis,少量请求访问数据库。
但是假如一瞬间几十万的key过期了,就会导致大量的请求短时间到达数据库,可能导致数据库崩溃。
在更严重的情况下,假如Redis服务宕机,那就等同于所有key都过期,所有请求都会到达数据库,更容易导致数据库崩溃。
2.5.2 缓存雪崩的解决方案
1️⃣ 给不同的Key的TTL添加随机值
2️⃣ 利用Redis集群提高服务的可用性
3️⃣ 给缓存业务添加降级限流策略
4️⃣ 给业务添加多级缓存
瑞:由于解决方案其实很简单,直接给TTL加上一个随机值即可,所以就没有案例演示
2.6 缓存击穿
瑞:
缓存穿透是缓存和数据库都没数据,缓存失效的现象
缓存雪崩是数量级的非热点key失效
缓存击穿的重点在于一个点(热点key)的击穿
2.6.1 定义
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
例如,有无数的线程访问一个热点的key,但这个key突然到期失效了,假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
2.6.2 缓存击穿的解决方案
常见的解决方案有两种,都是为了解决缓存重建的这一段时间内产生的并发问题
- 互斥锁
- 逻辑过期
2.6.2.1 互斥锁
1️⃣ 解决方案一:使用锁来解决
瑞:使用锁解决简单粗暴,但有一个很大的问题就是互相等待,造成性能下降
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用 tryLock 方法 + double check 来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
2.6.2.2 逻辑过期
2️⃣ 解决方案二:逻辑过期方案
瑞:逻辑过期思想可以类比MySQL中的逻辑删除。但由于该方案不设置过期时间,所以这种解决方案是针对热点key(缓存击穿),如果不是经常访问的key,则容易浪费内存。
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,所以我们可以采用逻辑过期方案。
我们把过期时间设置在 redis 的 value 中,注意:这个过期时间并不会直接作用于 redis ,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从 value 中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个线程2去进行以前的重构数据的逻辑,直到新开的线程2完成这个逻辑后,才释放锁,而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于:异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
2.6.2.3 互斥锁与逻辑过期对比
瑞:两种方案都是为了解决缓存重建的这一段时间内产生的并发问题。
互斥锁方案主要是在缓存重建的这一段时间串行执行(互相等待)从而确保数据的一次性,但是牺牲了性能。
逻辑过期方案保证了缓存重建这一段时间内的可用性,但是牺牲了一致性。
互斥锁选择一致性,逻辑过期选择可用性。这就是分布式系统中的CAP问题,在一致性和可用性之间要抉择,根据需求场景选择更合适的方案。
互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案 :线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | - 没有额外的内存消耗 - 保证一次性 - 实现简单 | - 线程需要等待,性能受影响 - 可能有死锁风险 |
逻辑过期 | - 线程无需等待,性能较好 | - 不保证一致性 - 有额外内存消耗 - 实现复杂 |
2.6.3 互斥锁解决缓存击穿问题
尽快更新中…
2.6.4 逻辑过期解决缓存击穿问题
尽快更新中…
2.7 缓存工具封装
尽快更新中…
如果觉得这篇文章对您有所帮助的话,请动动小手点波关注💗,你的点赞👍收藏⭐️转发🔗评论📝都是对博主最好的支持~