个人项目经历:
最近项目中遇到一个同步接口数据(类似爬虫)的需求,原来项目中的功能都是同步调用接口返回结果进行数据操作的,但是实时性很差,而且受第三方接口调用频率影响,导致我不得不换一个方案,最开始我是想用数据报表方式,也就是每个周期,定期把需要的数据同步到本地报表库里,然后我就可以直接从自己的数据库里进行操作了,这么做的好处就是减少接口调用网络IO提高效率,提高可用性,但是数据一致性是约定好的,所以能保证数据和第三方接口数据基本一致,坏处就是我每天都要重复同步这个报表,而且数据量大之后,我的同步数据的程序会变得越来越复杂和低效。
然后先说redis,我是用redis来维护一个一个阻塞队列,因为用了blpush,brpop,brpoplpush,用阻塞的形式是因为我的定时任务是多线程执行的(不是并行),因为我有多个task定时启动,每个task负责爬取不同的接口数据。
队列里面存放需要传递给数据库的数据结果,这个结果最后可能很大,严重超过redis可用性的要求了,所以我进行了优化(队列里面存放基础的任务参数,然后再真正执行任务的方法里去处理,减少redis内存占用)
虽然最后基本功能可以实现了,每个爬取接口的任务都做了单元测试,都可以测试通过。但是问题来了,我经过调试日志发现几个问题:1,消息堆积,就是redis任务列表偶发的某个任务处理失败或超时,就对堆积任务,这显然无法通过压力测试这道关。2,即时正常爬取所有接口,但是总耗时太长,因为爬取接口需要控制请求的频率,所以我在http请求方法里面做了延时等待,这个等待时间是刚需,无法再优化的,因为第三方接口限制住了,所以总耗时最多就是在这里,比如我一个接口需要每天10万个http请求,每个等待3秒就是30万秒+,那十个接口就是300万甚至更多。
因为本文章主要复盘redis,所以项目经历就介绍到这里,其实最后解决方案很简单,就是跟第三方接口协商,不通过接口形式调取数据,改用excel文件按照我需要数据结构,然后我写程序每天同步过来就可以了,省时省力。(自己白忙活了一个月,研究了redis,锁(信号量控制),多线程,最后还不如换个方案实在,看来技术方案选型才是最重要的)
个人项目中用到的Redis知识点:
RPOPLPUSH命令是一个原子命令,在一个原子时间内,会执行以下两个动作(都执行成功或者都执行失败):
- 将列表
source
中的表尾元素弹出,并返回给客户端。- 将列表
source
弹出的表尾元素插入到列表destination
的表头位置。- 如果
source
不存在,值(nil)
会被返回,并且不执行其他动作。- 如果
destination
不存在,RPOPLPUSH命令会先创建一个空的列表destination
,然后再执行该命令。- 如果列表
source
和列表destination
是同一个列表,会形成像一个无限循环的传送带的操作- 如果类型错误,会报错,一定要列表类型
BRPOPLPUSH命令是RPOPLPUSH的阻塞版本,不同之处是,当列表source为空时,BRPOPLPUSH命令将会阻塞连接,直到等待超时,或有另一个客户端对列表source执行LPUSH命令或RPUSH命令为止。
什么是Redis:
Redis是一个开源的内存中的数据结构存储系统,在实际的开发过程中,Redis已经成为不可或缺的组件之一,基于内存实现、合理的数据结构、合理的数据编码、合理的线程模型等特征不仅仅让Redis变得如此之快,同时也造就了Redis对更多或者复杂的场景的支持。
-
2009年由 Salvatore Sanfilippo(Redis之父)发布初始版本。
-
2013年5月之前,由VMare赞助。
-
2013年5月-2015年6月,由Pivotal赞助。
-
2015年6月起,由Redis Labs赞助。
版本:
-
2009年5月发布Redis初始版本;
-
2012年发布Redis 2.6.0;
-
2013年11月发布Redis 2.8.0;
-
2015年4月发布Redis 3.0.0,在该版本Redis引入集群;
-
2017年7月发布Redis 4.0.0,在该版本Redis引入了模块系统;
-
2018年10月发布Redis 5.0.0,在该版本引入了Streams结构;
-
2020年5月2日发布 6.0.1(稳定版),在该版本中引入多线程、RESP3协议、无盘复制副本;
-
2022年1月31日发布 7.0 RC1,在该版本中主要是对性能和内存进行优化,新的AOF模式。
它的初衷:
其实,redis的作者在他自己的著作中就已经说明了,他以前在一家小公司工作的时候,经常会发现,有些应用经常有各种小量数据从服务器A传给B再传给C,这种情况下,其实不如A、B、C放在一个共享存储中存取。其实,作者的初衷是这样设计redis的,它不适合共享大量数据,目标是小量数据的共享。但随着国内的电商微服务的兴起,redis的能力被重新发现了,形成了一套电商微服务的实战经验。但我们需要追本溯源,国内的电商,为了保证存取效率,设置了大量缓存,这是因为传统的数据库很难满足电商系统频繁的查询请求。所以,电商系统往往会引入缓存来处理,缓存基于内存来存取,而且缓存的粒度可以重新设计,比起mysql之类的数据库的页缓存,从效率和命中上都高出不少。
使用场景:
全局ID
计数器
分布式限流
分布式锁
消息队列
用户关注、推荐模型
统计活跃用户数
点赞、签到、打卡
排行榜
附近的人(地图数据结构应用场景)
秒杀
超卖
优缺点:
首先是单线程模型-避免了上下文切换造成的时间浪费,单线程指的是网络请求模块使用了一个线程,即一个线程处理所有网络请求,其他模块仍然会使用多线程;在使用多线程的过程中,如果没有一个良好的设计,很有可能造成在线程数增加的前期吞吐率增加,后期吞吐率反而增长没有那么明显了。多线程的情况下通常会出现共享一部分资源,当多个线程同时修改这一部分共享资源时就需要有额外的机制来进行保障,就会造成额外的开销。
另外一点则是I/O多路复用模型,在不了解原理的情况下,我们类比一个实例:在课堂上让全班30个人同时做作业,做完后老师检查,30个学生的作业都检查完成才能下课。如何在有限的资源下,以最快的速度下课呢?
- 第一种:安排一个老师,按顺序逐个检查。先检查A,然后是B,之后是C、D...这中间如果有一个学生卡住,全班都会被耽误。这种模式就好比用循环挨个处理socket,根本不具有并发能力。这种方式只需要一个老师,但是耗时时间会比较长。
- 第二种:安排30个老师,每个老师检查一个学生的作业。这种类似于为每一个socket创建一个进程或者线程处理连接。这种方式需要30个老师(最消耗资源),但是速度最快。
- 第三种:安排一个老师,站在讲台上,谁解答完谁举手。这时C、D举手,表示他们作业做完了,老师下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。这种方式可以在最小的资源消耗的情况下,最快的处理完任务。
多路I/O复用技术可以让单个线程高效的处理多个连接请求,而Redis使用epoll作为I/O多路复用技术的实现。并且,Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多的时间。
面试常问:(涉及到的知识点只是一个总结,在面试中如果不涉及深入的知识探讨,回答就够用了)
A.为什么要使用redis而不是把数据放在本地内存中?
如果使用本地缓存,则会引入一个新的难题,那就是很难保证一致性,也就是你很难在A、B、C三个服务器的本地缓存,修改某些字段时保证信息是一致的。所以,集中式缓存的思想就出现了,将缓存放在一起,再加上redis单线程执行命令的特性,可以使得数据的一致性更容易维护。当然,redis的功能已经不仅限于通常的存储概念了,更像是共享的概念,比如分布式锁、分布式流控等等,通过这些方式来保证程序运行的一致性。还有充当消息队列、观察订阅者等使用模式。redis本身虽说能扛下单机10wQPS,但网络瓶颈是一定存在的。在使用本地缓存还是redis的时候还是需要多加思考,对于不需要保持一致性的数据或许本地缓存才是最佳选择。
B.单线程为什么那么快?Redis为什么是单线程
redis是单线程的原因在于redis用单个CPU绑定一块内存的数据,然后针对这块内存的数据进行多次读写的时候,都是在一个CPU上完成的。redis核心就是 如果我的数据全都在内存里,我单线程的去操作就是效率最高的。所以,redis是单线程。
Redis为什么那么快
1、完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);
2、数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的;必须遵循它的数据结构来操作
3、采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
4、使用多路I/O复用模型,非阻塞IO;
5、使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;
C.什么是多路复用?把它和Redis的单线程简单分析下
多路 I/O 复用模型,多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈,主要由以上几点造就了 Redis 具有很高的吞吐量。
Redis是单线程的,因为没必要呀!首先要明白,上边的种种分析,都是为了营造一个Redis很快的氛围。官方FAQ表示,因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了。
这里我们一直在强调的单线程,只是在处理我们的网络请求的时候只有一个线程来处理,一个正式的Redis Server运行的时候肯定是不止一个线程的,这里需要大家明确的注意一下。例如Redis进行持久化的时候会以子进程或者子线程的方式执行。
D.如果突然机器掉电会怎样?
AOF 的持久化策略:AOF 日志是以文件形式存在的,里面记录的是内存的操作记录,它的实现是将操作系统内核为文件描述符分配的内存缓存,通过异步的方式刷新到数据磁盘中。这种操作是 glibc 的 fsync 操作,它是一个很慢的操作,与 Redis 的高性能是相反的。
AOF 提供了三种持久化策略:
no: 无 fsync,由系统保证数据刷新到磁盘,速度最快,但很不安全(通常不使用);
always: 每次 fsync,每一个修改内存的 Redis 指令都会执行一次 fsync,速度很慢(通常不使用);
everysec: 每秒进行一次 fsync,有可能丢失一秒的 fsync 的数据。通常选择 everysec 策略,兼顾安全性和效率。
持久化取决于 AOF 日志 sync 属性的配置,如果不要求性能,在每条写指令时都sync一下磁盘,就不会丢失数据。但是在高性能的要求下每次都sync是不现实的,一般都使用定时sync,比如1s1次,这个时候最多就会丢失1s的数据。
LRU 策略
Redis 的数据都是由 key-value 形式构成的,在实现 LRU 的内存淘汰机制时,除了 key-value,LRU 还需要维护一个列表,链表尾部的数据是最少被访问的数据。列表按照最近访问时间进行排序。当内存达到物理内存限制触发 LRU 回收时,对链表尾部的 k-v 进行回收。
Redis 使用了一种近似 LRU 算法。对于所有 Redis 对象,对象头中包含一个 24bit 的信息,作为对象热度的标志。在 LRU 淘汰算法中,该标志是一个时间戳,记录了最近一次访问该标志位的时间。
Redis 会随机抽取若干个(默认是 5 个)key,然后删掉最旧的 key。如果这时候内存依旧超出限制,则再次抽选、删除最旧的 Key 值,直到内存低于最大内存限制为止。
E.Redis跳跃表
-
跳跃表是Redis特有的数据结构,就是在链表的基础上,增加多级索引提升查找效率。
-
跳跃表支持平均 O(logN),最坏 O(N)复杂度的节点查找,还可以通过顺序性操作批量处理节点。
F.关于Redis雪崩,穿透,击穿你是怎么理解的?
Redis 雪崩:
雪崩就是指缓存中大批量热点数据过期后系统涌入大量查询请求,因为大部分数据在Redis层已经失效,请求渗透到数据库层,大批量请求犹如洪水一般涌入,引起数据库压力造成查询堵塞甚至宕机。
对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。
解决办法:
将缓存失效时间分散开,比如每个key的过期时间是随机,防止同一时间大量数据过期现象发生,这样不会出现同一时间全部请求都落在数据库层,如果缓存数据库是分布式部署,将热点数据均匀分布在不同Redis和数据库中,有效分担压力,别一个人扛。
简单粗暴,让Redis数据永不过期(如果业务准许,比如不用更新的名单类)。当然,如果业务数据准许的情况下可以,比如中奖名单用户,每期用户开奖后,名单不可能会变了,无需更新。
缓存雪崩的事前事中事后的解决方案如下。 - 事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。 - 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。 - 事后:redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
用户发送一个请求,系统 A 收到请求后,先查本地 ehcache 缓存,如果没查到再查 redis。如果 ehcache 和 redis 都没有,再查数据库,将数据库中的结果,写入 ehcache 和 redis 中。
限流组件,可以设置每秒的请求,有多少能通过组件,剩余的未通过的请求,怎么办?走降级!可以返回一些默认的值,或者友情提示,或者空白的值。
好处: - 数据库绝对不会死,限流组件确保了每秒只有多少个请求能通过。 - 只要数据库不死,就是说,对用户来说,2/5 的请求都是可以被处理的。 - 只要有 2/5 的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来一次。
缓存穿透
对于系统A,假设一秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。
黑客发出的那 4000 个攻击,缓存中查不到,每次你去数据库里查,也查不到。
举个栗子。数据库 id 是从 1 开始的,结果黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。
解决方式很简单,每次系统 A 从数据库中只要没查到,就写一个空值到缓存里去,比如 set -999 UNKNOWN。然后设置一个过期时间,这样的话,下次有相同的 key 来访问的时候,在缓存失效之前,都可以直接从缓存中取数据。
缓存击穿
缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。
解决方式也很简单,可以将热点数据设置为永远不过期;或者基于 redis or zookeeper 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据。
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
//设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
sleep(50);
get(key); //重试
}
else {
return value;
}
}