目录
高并发缓存三问 - 穿透
缓存穿透
概念
现象举例
解决方案
缓存穿透 - 预热架构
缓存穿透 - 布隆过滤器
布隆过滤器
布隆过滤器基本思想编辑
了解
高并发缓存三问 - 击穿
缓存击穿
高并发缓存三问 - 雪崩
缓存雪崩
解决方案
总结
为什么要使用数据字典?
创建数据字典
查询数据字典列表
修改删除数据字典
修改
删除
用户端查询数据字典列表
缓存同步策略
Canal工作原理与Binlog
Canal
Binlog是什么
Binlog类别
官方参考
Canal监听同步流程
开启MySql的binlog
步骤1 - 开启mysql的biglog模式
安装配置Canal
SpringBoot集成Canal实现监听
anal实现0侵入缓存同步 - 新增同步
Canal实现0侵入缓存同步 - 删除与修改
作业:缓存预热 - 刷入数据字典缓存数据编辑
发起审核创建公司
Feign远程查询企业HR数
远程调用
上传企业授权书编辑
绑定企业HR信息
提交企业审核Seata日期转换采坑
刷新用户最新信息
HR查询企业审核状态
高并发缓存三问 - 穿透
缓存穿透
概念
缓存穿透:是指查询一个在数据库中不存在的数据,缓存和数据库中都不会命中(也就是都查询不到),在我们编写代码的时候,一般数据库查询不到的是不会写入到redis的,但是前端请求却会绕过redis直接打在数据库上,高并发时造成数据库宕机。如此一来,就失去了redis存在的意义,往往黑客可能会处于某种目的来攻击服务器,又或者一些爬虫系统也会造成这个现象的出现。
可以看一下如下架构图,从图中可以看到,如果redis中没有这个key,那么所有请求都会绕过reids去访问数据库。
现象举例
打开淘宝,可以看到有很多分类。对于分类,我们可以把它们作为数据字典存储在数据库,然后再放入到缓存。当请求过来的时候直接查缓存就行了。
随便打开某一个分类,这个红框内每个分类都不一样,可以推断是分类的id。
这个id必须要缓存,肯定不可能查库的。但是这个id存在缓存的话,是可以查询到的。但是如果没有呢?没有的话如果代码逻辑不好就可能直接打在数据库上来。
我们来改一下这个id,随便改为一个不存在的数字,看看会发生什么变化:
这个时候直接跳转为404的页面了,因为不存在啊,所以返回了一个错误页面。
上面的流程是没有问题的,但是我们在编写代码的时候,可能会造成缓存穿透。可以看如下代码:
上面的红框代码,很有可能是我们平时会做的一个步骤,判空操作,但是正因为判空了,不往redis中设置,那么最后再查询的时候,redis反而会没有值,从而被请求绕过,进入到数据库。
解决方案
- 对不存在的key做空值处理。(不管是否为空,都存在redis中)
缓存穿透 - 预热架构
- 缓存预热。在项目启动的时候,预先把数据库的热点数据放入redis中,在前端请求的时候,只查redis,不查数据库。那么如此一来,哪怕查到空值,也不会绕过了redis,请求也不会到数据库了。
可以构建一个缓存服务,它是一个微服务节点,里面包含了很多的接口,比如刷新行业,可以直接手动发一个get请求某个接口,则刷新行业数据,或者其他数据都行,因为我们是直接查询redis的,把redis作为数据库,所以一旦有些数据在数据库里更新了,为了测试方便,我们会手动更新,也方便在线上的时候也能手动更新一些数据。
缓存穿透 - 布隆过滤器
- 布隆过滤器:
布隆过滤器可以查询并且判断某个key一定不存在,但是不能判断某个值一定存在
。
布隆过滤器
-
致命弱点:
- 无法删除
- 误判率
我们是做物流的,3天前的车和货,是属于过期信息,是要删除,删除以后就是不存在了,那么布隆过滤器无法删除。
代码不去写了,只需要知道原理是啥就行了,面试的时候大概率会被问到。
布隆过滤器基本思想
了解
布谷鸟过滤器:原理同布隆过滤器,优点是可以删除
高并发缓存三问 - 击穿
缓存击穿
如上图,redis作为数据库的前置缓存,抵挡一部分的流量,我们设置数据的时候往往都是 KV 键值对,他是可以设置一个expire的有效时间的。之前我们有讲过redis的过期key策略(LRU/LFU),当一个key成为冷缓存了,那么他会有可能被清理掉,对吧。 假设这个时候,正好用户会大量请求刚刚所说的那个key的缓存数据,但是他被清理掉了,这个时候会发生什么情况?看如下图:
这个时候,用户的请求无法从redis中获得缓存数据,那么此时由于并发量十分巨大,所有的查询请求都落在了数据库上,造成数据库压力巨大,db会极易挂掉。那么这就是缓存击穿
现象。 缓存击穿需要满足两个前置条件:
- 请求高并发;
- 正好某个key失效了,或者由于过期策略被删除了,还没来得及把数据缓存进redis的那一瞬间的时间。
既然有这样的现象出现,并且对整个系统造成一定的影响,如何解决?有木有解决方案?其实我们的目标其实就是要隔断请求,防止请求击穿。方案有如下:
- 由于redis是单线程的,用户请求其实是排队一个个去访问redis,那么可以让第一个请求在从redis里查询key没有后,setnx一下,如果setnx成功,则去查询数据库,查询到数据后重新设置到缓存中。如果后面的请求setnx失败,则可以让他们sleep一下,比如随机1秒以内的时间,随后再去重复检查,直到有key被重新设置进redis,那么这么一来,不论有1万个还是1亿个请求,都只会有1个请求打到数据库,这样就保护了redis。
- 但是这么做会有问题,就是如果在setnx之后,第一个请求后续操作失败了,那么setnx得不到释放,造成死锁,这样可以通过设置setnx的过期时间来做,如果第一个请求失败了,那么通过过期时间来释放锁也OK的。
- 当然更好的方式就是用多线程的方式来做,1个线程用于去请求数据库,第二个线程用于定时检查数据是否成功被设置进redis,如果没有,就适当延长setnx的过期时间,去重置它。为什么要这么做,因为查询数据库,有时候可能会有拥堵现象,当然这么做了,业务代码会相当多也比较复杂。
- 设置永不过期,如果你的内存够大,或者说缓存本来也不多,内存空间充足,那么所有key都是永不过期的,那么这样的击穿情况也不会出现。
- 缓存预热,在项目初期就已经把所有需要用到的数据放入到redis中,用户请求查询的时候只查redis,哪怕key不存在,也不会请求到数据库的,这样是完全进行了隔断,保证不会出现击穿现象。
高并发缓存三问 - 雪崩
缓存雪崩
雪崩和之前的击穿有点类似,击穿是一个key失效,而雪崩是有大量的key同时过期,因为一个redis其实很庞大,免不了会有很多的key会同时到期的,这样用户请求像个散弹枪一样打在数据库上。
如何预防?
- 均匀的随机的分配key的过期时间
- 缓存预热
第二种情况的雪崩就是和时间相关的,比如有些金融项目,前一天和后一天的一些相关金融业务数据啊,计算比例,利率啊啥的,每天都不一样,前一天的会在零点或者凌晨1点全部失效,那么这个时候咋办?两种方案:
- 在零点统一失效后,做个定时任务,跑一遍所有涉及到redis的接口,访问一遍,这么一来涉及到的缓存数据在查询db后会放入到redis中,那么这种方式比较low。
- 第二种,就是你的key携带日期,比如可以这么设置:
key=sales:amount-percent:20200505,
value=0.98
key=sales:amount-percent:20200506,
value=0.965
如此一来,在时间一过零点,获得的数据就可以立马生效,这样也就是提前设置好第二天的数据,前一天的所有数据全部失效就无所谓了。 - 其实还有一种方式,也捎带说一下,这种方式的业务代码相对复杂一些,就是你在零点这个失效点过后,业务层的读取redis数据做延时,只放一部分的请求过去,避免请求大量访问到数据库,待redis缓存设置好以后,后续的请求才会放行。这种方式也可以,了解一下即可。
解决方案
- 集群高可用,哨兵或Cluster
- 目的,保证最基本的高可用
- redis的持久化,再此回复后,缓存数据重新加载
- Redis + Ehcache 两两结合,不把鸡蛋放在同一个篮子里
- 第一次请求数据库,数据放入redis和ehcache
- 以后的请求,会先请求ehcache,如果有返回,如果没有,查询redis,如果有返回,如果没有再查看
- 虽然,ehcache没有redis那么强大,但是有缓存总比没有来的好。目的:两两保障
- 限流,限制大流量,我只保证一部分的用户可以正常的访问,而不至于整体奔溃
- 只接受更小流量的请求,这样数据库可以处理应付的过来
- 目的,让系统整体还能运作起来,赚小钱总比不赚来的好,系统也不会崩。
- 举例:一艘船,只能做1000人,来了1万人,我只开放1000人的票,不然1万人都来了,船就沉了。
- 降级,限流在外的请求,没有处理,这个时候会引导至一些简单的信息提示页面,可以友情提示说请稍后啊之类的。甚至404也行。
- 限流+降级保证数据库肯定能够运转的过来。
- 少赚钱总比不赚钱要来的好
- 保证一部分用户能够真正的被系统处理。
总结
一定要保证数据库不能死,数据库死了资损会相当严重,所以限流+降级是必要的手段。
为什么要使用数据字典?
前面我们围绕的都是和缓存相关的内容,那么业务层面,目前行业分类就已经OK了。
接下来,再来看数据字典。先看如下表,平时我们做一些分类选择的时候,可能都会创建诸如以下这样的表,这不仅仅是人员规模
,像企业福利待遇
、企业标签
等等,都可以设计这样的表。
但是,像这样的表虽然可以用,但是如果一个系统,或者我们很多系统都要设计类似的表,但是业务点有很多,我们可能需要设计五六十张类似的表甚至上百张表,很显然,太多了,也不好管理。这个时候,数据字典的需求就出现了。
我们平时使用新华字典的时候,是可以根据拼音或者偏旁来进行搜索查询的,同理,我们能不能也根据不同数据的类别来分类汇总后进行筛选能?很明显,是可以的,所以我们完全可以通过一张表来管理所有的分类数据,这样就更能够便于我们对所有字典数据的管理了,查询起来也不会有太大的麻烦,也节约了表空间。可以做出如下的设计:
最终,我们开发成如下的功能结构就行了:
附带一提,我们以前的数据字典是一个独立的服务,专门提供给其他系统做字典类别查询使用的,不仅仅只是自己的本系统,公司的所有项目都可以依赖这个数据字典服务。而且,我们的国际化,也是通过数据字典来做的。
创建数据字典
BO直接复制:
service与mapper直接从逆向工具复制过来。
创建Controller:
创建接口:
查询数据字典列表
controller:
service:
修改删除数据字典
修改
先查询,后修改:
删除
用户端查询数据字典列表
作业:自己结合之前做行业列表的时候,结合redis来实现接口的查询调用。
缓存同步策略
之前我们所做的,都是手动同步,不管是双删还是延迟队列。其实这种方式都不太好。接下来来我们看看如何自动同步缓存。
- 手动同步,过期时间:简单方便,但是时效性不及时,需要等到过期后才能查询到新的数据,而且有可能发送缓存击穿问题。
- 缓存双写双删:修改数据库的同时修改缓存,非常及时,缓存时效是最新的。但是代码耦合度很高,对代码的侵入性很强烈,我们之前所写的代码就是如此,太过于冗余了,裹脚布一样,又臭又长。
- 定时、(异步)延迟同步:低耦合,代码入侵少,可以同时对多个存储介质进行数据的更新,比如更新数据库的同时修改redis、mongodb、elasticsearch等,只需要一行代码发一个消息就行。但是时效性相对有一点点偏差,也就是中间同步过程有一定的数据不一致状态,同步完成后可以达到一致,在非金融场景业务下使用较多,多存储介质也可以使用。
- 0侵入自动同步:MQ异步同步可以做到一行代码解耦,代码入侵就少了,如此之外,其实还有一种0侵入方案。那就是canal异步监听,他是阿里巴巴的一个开源工具。
Canal工作原理与Binlog
Canal
阿里因为自身的业务需求,需要把国内数据同步给国外,所以他们是异地机房的数据同步需求问题,他们通过对数据库日志的解析,可以获得增量的变更,对此变更在进行数据同步,所以就衍生出了数据订阅和消费的中间件,也就是Canal同步工具。
Canal是基于java开发的中间件,主要支持对mysql的binlog进行解析,解析完成后再利用客户端工具来获得相关的数据,增删改的数据都可以。
Binlog是什么
全称是binary-log,二进制日志。
Canal是通过解析mysql的日志来进行数据同步的,那么日志其实就是binlog,也就是mysql的二进制日志,他会记录所有非查询语句(增删改),也会包含操作的执行消耗的时间,另外mysql的binlog日志是事务安全的。
一般来说,开启binlog的话呢,其实也会有一定的性能损耗的,因为本来是不要的,现在开启了,那么他就需要耗费经理去做别的事,就跟你老板让你加班多做点事一样,你的时间精力自然就流失了。
binlog有两个非常重要的目的:
- 数据库可以搭建主从,主从同步就是把master的binlog传递给slave来进行数据同步达到一致性的。
- 数据恢复,可以通过binlog进行恢复。我以前的私活就是遇到过,人家以为mysql是流氓软件结果直接删了,但是binlog还在,所以我给他恢复了数据,硬生生含泪收了几千块钱。。。
Binlog类别
- statement:记录每次增删改的语句,空间占用会比较节省。但是,数据恢复的时候可能会产生不同数据,因为执行的时间不同,尤其是
now()
这个函数的获取,所以为什么会有同学问我不使用数据库自己的时间,而是要通过我们手动在业务层去设置时间的原因。 - mixed:可以认为是混合模式,一定程度上能解决不一致问题,此外在进行函数使用的时候会按照row的模式进行处理。但是某些情况之下,不一致的情况还是依然存在的。
- row:行级别,biglog会记录每次操作后每行记录的变化。可以保证绝对的数据一致性,因为他只记录执行后的结果。缺点就是占用空间会大一些,但是磁盘空间是可以用钱解决的,能用钱解决的问题就不是问题。
这个有点类似与redis的存储模式,也就是rdb和aof以及混合模式。面试的时候可能会被问到,大家务必理解。
官方参考
https://github.com/alibaba/canal
Canal功能实现(使用场景):
- 数据库镜像
- 数据库实时备份
- 索引构建和实施维护
- 业务缓存刷新
- 带业务逻辑的增量数据处理
- 数据实时统计,大屏实时展示(数据采集)
数据库的实时备份是很有用的,以前我们客户就提出过这样的要求,而且我自己接的私活也有这样的要求,所以正因为canal,我们可以多赚一点。
Canal监听同步流程
参考如下图:
Canal可以监听数据库的变化,此时我们业务层代码是不需要有变动的,做好数据层面的监听,如果监听到数据变动了,比如增删改了,那么则在缓存中就可以做出对应的修改逻辑就行了。
所以,我们后面就会使用canal来落地实现咱们的0侵入方案,而且这种方案的时效性也比mq异步方案更好一些。
开启MySql的binlog
https://github.com/alibaba/canal/wiki/QuickStart
步骤1 - 开启mysql的biglog模式
并修改配置文件,添加如下内容:
[mysqld]
log-bin=/var/lib/mysql/mysql-bin # 开启 binlog,位置使用我们一开始配置的文件挂载位置
binlog-format=ROW # 选择 ROW 模式
server_id=1 # 配置 MySQL replaction 需要定义,不要和 canal 的 slaveId 重复
# 表示当前数据库开启biglog,不配置,表示所有数据库都开启
binlog-do-db=imooc-hire-dev
重启mysql:
docker restart mysql
查看data目录,会有一些biglog文件:
*.index
是二进制的索引文件,用于记录所有的二进制文件。*.00000
这个就是二进制文件,记录所有的增删改语句事件。
检测binlog是否开启,在navcat中查询:
show variables like '%log_bin%'
创建账号并且授权:
CREATE USER canal IDENTIFIED BY 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
-- GRANT ALL PRIVILEGES ON *.* TO 'canal'@'%' ;
FLUSH PRIVILEGES;
file:文件名
postion:日志的偏移量,主从差异就是根据这个来,可以理解为offset
安装配置Canal
docker pull canal/canal-server:v1.1.6
运行:
docker run -p 11111:11111 \
--name canal \
-e canal.destinations=imooc \
-e canal.instance.mysql.slaveId=20231111 \
-e canal.instance.master.address=192.168.1.121:3306 \
-e canal.instance.dbUsername=canal \
-e canal.instance.dbPassword=canal \
-e canal.instance.connectionCharset=UTF-8 \
-e canal.instance.filter.regex=imooc-hire-dev.data_dictionary \
--restart=always \
-d canal/canal-server:v1.1.6
- 11111:11111:端口映射
- –name canal:容器名称
- –restart=always:自动重启
- canal.destinations:集群名称,给集群取一个名字
- canal.instance.mysql.slaveId:区别master的id,保证唯一
- canal.instance.master.address:mysql主数据库地址
- canal.instance.dbUsername/dbPassword:用户名和密码,也就是之前上一节课所创建的用户以及授权,如果设置root用户其实也行。
- canal.instance.connectionCharset:字符集
- canal.instance.filter.regex:监听数据库数据表的表达式,多个可用逗号
,
连接
以上环境变量如果不设置,也可以,但是需要在canal内部去修改canal-server配置也是OK的。
最后查看是否运行成功日志:
docker logs -f canal
如下命令,可以进入canal容器内部:
docker exec -it canal bash
进入目录并且查看日志:
发现一个错误,这是mysql8的密码加密方式问题:
重新通过navcat更新密码:
ALTER USER 'canal'@'%' IDENTIFIED WITH mysql_native_password BY 'canal';
FLUSH PRIVILEGES;
查看结果:
docker restart canal
再次查看日志:
随后,随意修改数据字典中的任意记录,再次查看日志:
SpringBoot集成Canal实现监听
官网地址:GitHub - NormanGyllenhaal/canal-client: spring boot canal starter 易用的canal 客户端 canal client
在资源服务中添加依赖:
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
这个依赖要比官方的依赖好用一些。(最新的在maven仓库中目前下下载不来,只能使用老版本)
配置yml文件:
创建canal监听助手类:
创建一个canal映射对象,字段需要和数据库保持一致,否则监听不到
避免过多的打印日志信息:增删改后的测试:
anal实现0侵入缓存同步 - 新增同步
继承通用属性和声明缓存前缀:新增:
Canal实现0侵入缓存同步 - 删除与修改
删除:修改:
前端app查询:
作业:缓存预热 - 刷入数据字典缓存数据
- 增加一个按钮,叫做“刷新缓存”
- 点击后,把所有不同类别的数据字典以list形式存入缓存,此功能作为缓存预热。因为目前我们的数据字典数据都是通过sql脚本导入的,不能通过canal监听来同步数据。
删除数据字典表,重新以sql的脚本导入,此刻因为有canal了,也会监听到数据,从而刷入缓存中。
那么如此canal就在咱们这章节全部讲完,本来是要在后俩周讲的,这边前置了也没关系,canal在多级缓存的时候用来同步也是非常不错的。
发起审核创建公司
controller
service
Feign远程查询企业HR数
controller:
获得企业信息的公用方法:
private CompanySimpleVO getCompany(String companyId) {
if (StringUtils.isBlank(companyId)) {
return null;
}
String companyJson = redis.get(REDIS_COMPANY_BASE_INFO + ":" + companyId);
if (StringUtils.isBlank(companyJson)) {
// 为空,查询数据库
Company company = companyService.getById(companyId);
if (company == null) {
return null;
}
CompanySimpleVO companySimpleVO = new CompanySimpleVO();
BeanUtils.copyProperties(company, companySimpleVO);
redis.set(REDIS_COMPANY_BASE_INFO + ":" + companyId,
new Gson().toJson(companySimpleVO),
8 * 60 * 60);
return companySimpleVO;
} else {
// 不为空,转换为对象
return new Gson().fromJson(companyJson, CompanySimpleVO.class);
}
}
service:
远程调用
controller:
时间到了,容易造成缓存击穿
service:feign:别忘记在yml中添加redis的配置!!!
上传企业授权书
绑定企业HR信息
feign:
controller:
service:
企业服务调用:
企业表信息:
提交企业审核Seata日期转换采坑
service:
新版本1.5.x版本seata 中mysql8会出现序列化问题, 早起版本使用的是springboot自带的json序列化,而现在内部使用的是fastjson
需要添加依赖
分布式事务测试用seata测试是否成功回滚。出现问题,重新测试时undo_log表可能有脏数据,需要清理。
刷新用户最新信息
为了让用户信息在本地刷新,可以重新刷新一下,或者用户重登陆,在这里我们选择前者。
controller:
HR查询企业审核状态
获得企业信息,主要可以查询到企业的审核状态
企业controller:
提问:为什么这里通过用户id查询而不是企业id查询呢?企业id不是可以直接在用户信息中获得吗?
答曰:因为企业和用户可能会解绑,一旦解绑,但是前端可能也会缓存早期的企业信息,如此一来,这样前端会无法获得正确的企业信息。当然,偷懒的做法就是直接通过企业id去查,这是最简单的。
用户controller:feign: