redis数据结构
- 对外数据结构:string,list,set,hast,sortset都只是数据的保存形式。底层的数据结构是:简单动态字符串,双向链表,压缩列表,哈希表,跳表,整数数组
- 五种数据形式的底层实现
a.string:简单动态字符串
b.list:双向链表,压缩列表 o(N)
c.hash:压缩列表,哈希表
d.Sorted Set:压缩列表,跳表O(logN)
e.set:哈希表,整数数组 - 集合类型的键和值之间的结构组织
a:Redis使用一个哈希表保存所有键值对,一个哈希表实则是一个数组,数组的每个元素称为哈希桶。
b:哈希桶中的元素保存的不是值的本身,而是指向具体值的指针 - 哈希冲突解决
a:Redis的hash表是全局的,所以当写入大量的key时,将会带来哈希冲突,已经rehash可能带来的操作阻塞
b:Redis解决hash冲突的方式,是链式哈希:同一个哈希桶中的多个元素用一个链表来保存
c:当哈希冲突链过长时,Redis会对hash表进行rehash操作。rehash就是增加现有的hash桶数量,分散entry元素。(和hashmap扩容原理很像) - rehash机制
a:为了使rehash操作更高效,Redis默认使用了两个全局哈希表:哈希表1和哈希表2,起始时hash2没有分配空间
b:随着数据增多,Redis执行分三步执行rehash;
1. 给hash2分配更大的内存空间,如是hash1的两倍
2. 把hash1中的数据重新映射并拷贝到哈希表2中
3. 释放hash1的空间 - 渐进式rehash
a:由于步骤2重新映射非常耗时,会阻塞redis
b:讲集中迁移数据,改成每处理一个请求时,就从hash1中的第一个索引位置,顺带将这个索引位置上的所有entries拷贝到hash2中。
9,要点7 :压缩列表,跳表的特点
a:压缩列表类似于一个数组,不同的是:压缩列表在表头有三个字段zlbytes,zltail和zllen分别表示长度,列表尾的偏移量和列表中的entry的个数,压缩列表尾部还有一个zlend,表示列表结束
所以压缩列表定位第一个和最后一个是O(1),但其他就是O(n)
b:跳表:是在链表的基础上增加了多级索引,通过索引的几次跳转,实现数据快速定位
redis单线程?
Redis 的单线程指 Redis 的网络 IO 和键值对读写由一个线程来完成的(这是 Redis 对外提供键值对存储服务的主要流程)
Redis 的持久化、异步删除、集群数据同步等功能是由其他线程而不是主线程来执行的,所以严格来说,Redis 并不是单线程
为什么用单线程?
多线程会有共享资源的并发访问控制问题,为了避免这些问题,Redis 采用了单线程的模式,而且采用单线程对于 Redis 的内部实现的复杂度大大降低
为什么单线程就挺快?
1.Redis 大部分操作是在内存上完成,并且采用了高效的数据结构如哈希表和跳表
2.Redis 采用多路复用,能保证在网络 IO 中可以并发处理大量的客户端请求,实现高吞吐率
Redis 6.0 版本为什么又引入了多线程?
Redis 的瓶颈不在 CPU ,而在内存和网络,内存不够可以增加内存或通过数据结构等进行优化
但 Redis 的网络 IO 的读写占用了发部分 CPU 的时间,如果可以把网络处理改成多线程的方式,性能会有很大提升
所以总结下 Redis 6.0 版本引入多线程有两个原因
1.充分利用服务器的多核资源
2.多线程分摊 Redis 同步 IO 读写负荷
执行命令还是由单线程顺序执行,只是处理网络数据读写采用了多线程,而且 IO 线程要么同时读 Socket ,要么同时写 Socket ,不会同时读写。
AOF日志
aof日志记录了redis所有增删改的操作,保存在磁盘上,当redis宕机,需要恢复内存中的数据时,可以通过读取aop日志恢复数据,从而避免因redis异常导致的数据丢失。
AOF是写后日志,这样带来的好处是,记录的所有操作命令都是正确的,不需要额外的语法检查,确保redis重启时能够正确的读取回复数据
AOF日志写入磁盘是比较影响性能的,为了平衡性能与数据安全,开发了三种机制:①:立即写入(每次都落盘,性能差)②:按秒写入(每秒落盘)③:系统写入(过一段时间落盘)
AOF日志会变得巨大,所以Redis提供了日志重整的机制,通过读取内存中的数据重新产生一份数据写入日志
AOF日志重写工作原理:
1、Redis 执行 fork() ,现在同时拥有父进程和子进程。
2、子进程开始将新 AOF 文件的内容写入到临时文件。
3、对于所有新执行的写入命令,父进程一边将它们累积到一个内存缓存中,一边将这些改动追加到现有 AOF 文件的末尾,这样样即使在重写的中途发生停机,现有的 AOF 文件也还是安全的。
4、当子进程完成重写工作时,它给父进程发送一个信号,父进程在接收到信号之后,将内存缓存中的所有数据追加到新 AOF 文件的末尾。
5、搞定!现在 Redis 原子地用新文件替换旧文件,之后所有命令都会直接追加到新 AOF 文件的末尾。
内存快照RDB
Redis持久化
AOF
AOF是一种通过记录操作命令的的方式来达到持久化的目的,正是因为记录操作命令而不是数据,所以在恢复数据的时候需要 redo 这些操作命令(当然 AOF 也有重写机制从而使命令减少),如果操作日志太多,恢复过程就会很慢,可能会影响 Redis 的正常使用
RDB
RDB 是一种内存快照,把内存的数据在某一时刻的状态记录下来,所以通过 RDB 恢复数据只需要把 RDB 文件读入内存就可以恢复数据(具体实现细节还没去了解)
但这里有两个需要注意的问题
1.对哪些数据做快照,关系到快照的执行顺序
2.做快照时,还能对数据做增删改吗?这会关系到 Redis 是否阻塞,因为如果在做快照时,还能对数据做修改,说明 Redis 还能处理写请求,如果不能对数据做修改,就不能处理写请求,要等执行完快照才能处理写请求,这样会影响性能
来看第一个问题
RDB 是对全量数据需要快照,全量数据会使 RDB 文件大,发文件写到磁盘就会耗时,因为 Redis 是单线程,会不会阻塞主线程?(这一点始终都要考虑的点)
Redis 实现 RDB 的方式有两种
①save:在主线程中执行,会导致阻塞
②bgsave:创建子线程来执行,不会阻塞,这是默认的
所以可以使用 bgsave 来对全量内存做快照,不影响主进程
来看第二个问题
在做快照时,我们是不希望数据被修改的,但是如果在做快照过程中,Redis 不能处理写操作,这对业务是会造成影响的,但上面刚说完 bgsave 进行快照是非阻塞的呀,这是一个常见的误区:避免阻塞和正常的处理写操作不是一回事。用 bgsave 时,主线程是没有被阻塞,可以正常处理请求,但为了保证快照的完整性,只能处理读请求,因为不能修改正在执行快照的数据。显然为了快照而暂停写是不能被接受的。*Redis 采用操作系统提供的写时复制技术(Copy-On-Write 即 COW),在执行快照的同时,可以正常的处理写操作
bgsave 子线程是由主线程 fork 生成,可以共享主线程的所有内存数据,所以 bgsave 子线程是读取主线程的内存数据并把他们写入 RDB 文件的
如果主线程对这些数据只执行读操作,两个线程是互不影响的。*如果主线程要执行写造作,那么这个数据就会被复制一份,生成一个副本,然后 bgsave 子线程会把这个副本数据写入 RDB 文件,这样主线程就可以修改原来的数据了。这样既保证了快照的完整性,也保证了正常的业务进行
那如何更好的使用 RDB 呢?
RDB 的频率不好把握,如果频率太低,两次快照间一旦有宕机,就可能会丢失很多数据。如果频率太高,又会产生额外开销,主要体现在两个方面
①频繁将全量数据写入磁盘,会给磁盘带来压力,多个快照竞争有效的磁盘带宽
②bgsave 子线程是通过 fork 主线程得来,前面也说了,bgsave 子线程是不会阻塞主线程的,但 fork 这个创建过程是会阻塞主线程的,而且主线程内存越大,阻塞时间越长
最好的方法是全量快照+增量快照,即进行一次 RDB 后,后面的增量修改用 AOF 记录两次全量快照之间的写操作,这样可以避免频繁 fork 对主线程的影响。同时避免 AOF 文件过大,重写的开销
主从同步
1、Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。
2、**全量同步:**第一次复制全量同步。具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。
runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。
offset,此时设为 -1,表示第一次复制。
3、主从库间网络断了怎么办?
增量同步:发送给主库的offset,主库查询不到就进行了全量复制,查询的到就进行增量复制。
增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于 repl_backlog_buffer 这个缓冲区。我们先来看下它是如何用于增量命令的同步的。
1、repl_backlog_buffer 代表增量同步,环形,记录主库当前缓存的数据(master_repl_offset),从库记录当前复制到的offset。
2、网络断了以后,从库保存一个salve_repl_offset,重启网络会同步给主库,主库接受到offset,开始复制。
3、因为是环形写入,如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。
4、repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。
有同学对repl_backlog_buffer和replication buffer理解比较混淆,我大概解释一下:
- repl_backlog_buffer用于主从间的增量同步。主节点只有一个repl_backlog_buffer缓冲区,各个从节点的offset偏移量都是相对该缓冲区而言的。
- replication buffer用于主节点与各个从节点间 数据的批量交互。主节点为各个从节点分别创建一个缓冲区,由于各个从节点的处理能力差异,各个缓冲区数据可能不同。
哨兵模式和切片集群
1、如何判断主库下线?**
a. 检测主库是否在线,ping主库。当有 N 个哨兵实例时,最好要有 N/2 + 1 个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”。
b.判断连接状态。你使用配置项 down-after-milliseconds * 10。其中,down-after-milliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。
2、如何推选新的从库作为主库?
- 第一轮:用户通过设置slave-priority配置项,代表优先级。
- 第二轮:和旧主库同步程度最接近的从库得分高。得分相同就进入下一轮。
- 第三轮:ID 号小的从库得分高。
在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。
3、哨兵在操作主从切换的过程中,客户端能否正常地进行请求操作?
- 如果不想让业务感知到异常,客户端只能把写失败的请求先缓存起来或写入消息队列中间件中,等哨兵切换完主从后,再把这些写请求发给新的主库,但这种场景只适合对写入请求返回值不敏感的业务,而且还需要业务层做适配,另外主从切换时间过长,也会导致客户端或消息队列中间件缓存写请求过多,切换完成之后重放这些请求的时间变长。
- 当哨兵完成主从切换后,客户端需要及时感知到主库发生了变更,然后把缓存的写请求写入到新库中,保证后续写请求不会再受到影响。客户端主动获取新的主库地址;
4、切片集群是什么意思?
切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存
5、数据切片和实例是怎么的对应关系?
具体来说,Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。
6、切片集群,客户端如何定位数据?
各个实例之间会同步各自实例的solt,每个实例保存一整份solt,会同步给客户端,客户端保存solt,计算数据的solt然后决定请求哪个实例。
7、关于集群的一些理解?(大神的理解)
Redis使用集群方案就是为了解决单个节点数据量大、写入量大产生的性能瓶颈的问题。多个节点组成一个集群,可以提高集群的性能和可靠性,但随之而来的就是集群的管理问题,最核心问题有2个:请求路由、数据迁移(扩容/缩容/数据平衡)。
1、请求路由:一般都是采用哈希槽的映射关系表找到指定节点,然后在这个节点上操作的方案。
Redis Cluster在每个节点记录完整的映射关系(便于纠正客户端的错误路由请求),同时也发给客户端让客户端缓存一份,便于客户端直接找到指定节点,客户端与服务端配合完成数据的路由,这需要业务在使用Redis Cluster时,必须升级为集群版的SDK才支持客户端和服务端的协议交互。
其他Redis集群化方案例如Twemproxy、Codis都是中心化模式(增加Proxy层),客户端通过Proxy对整个集群进行操作,Proxy后面可以挂N多个Redis实例,Proxy层维护了路由的转发逻辑。操作Proxy就像是操作一个普通Redis一样,客户端也不需要更换SDK,而Redis Cluster是把这些路由逻辑做在了SDK中。当然,增加一层Proxy也会带来一定的性能损耗。
2、数据迁移:当集群节点不足以支撑业务需求时,就需要扩容节点,扩容就意味着节点之间的数据需要做迁移,而迁移过程中是否会影响到业务,这也是判定一个集群方案是否成熟的标准。
Twemproxy不支持在线扩容,它只解决了请求路由的问题,扩容时需要停机做数据重新分配。而Redis Cluster和Codis都做到了在线扩容(不影响业务或对业务的影响非常小),重点就是在数据迁移过程中,客户端对于正在迁移的key进行操作时,集群如何处理?还要保证响应正确的结果?
Redis Cluster和Codis都需要服务端和客户端/Proxy层互相配合,迁移过程中,服务端针对正在迁移的key,需要让客户端或Proxy去新节点访问(重定向),这个过程就是为了保证业务在访问这些key时依旧不受影响,而且可以得到正确的结果。由于重定向的存在,所以这个期间的访问延迟会变大。等迁移完成之后,Redis Cluster每个节点会更新路由映射表,同时也会让客户端感知到,更新客户端缓存。Codis会在Proxy层更新路由表,客户端在整个过程中无感知。
除了访问正确的节点之外,数据迁移过程中还需要解决异常情况(迁移超时、迁移失败)、性能问题(如何让数据迁移更快、bigkey如何处理),这个过程中的细节也很多。
Redis Cluster的数据迁移是同步的,迁移一个key会同时阻塞源节点和目标节点,迁移过程中会有性能问题。而Codis提供了异步迁移数据的方案,迁移速度更快,对性能影响最小,当然,实现方案也比较复杂。
redis作为缓存和数据库一致性问题(一个大佬总结的,非常棒)
1、先更新数据库,再更新缓存:如果更新数据库成功,但缓存更新失败,此时数据库中是最新值,但缓存中是旧值,后续的读请求会直接命中缓存,得到的是旧值。
2、先更新缓存,再更新数据库:如果更新缓存成功,但数据库更新失败,此时缓存中是最新值,数据库中是旧值,后续读请求会直接命中缓存,但得到的是最新值,短期对业务影响不大。但是,一旦缓存过期或者满容后被淘汰,读请求就会从数据库中重新加载旧值到缓存中,之后的读请求会从缓存中得到旧值,对业务产生影响。
同样地,针对这种其中一个操作可能失败的情况,也可以使用重试机制解决,把第二步操作放入到消息队列中,消费者从消息队列取出消息,再更新缓存或数据库,成功后把消息从消息队列删除,否则进行重试,以此达到数据库和缓存的最终一致。
以上是没有并发请求的情况。如果存在并发读写,也会产生不一致,分为以下4种场景。
1、先更新数据库,再更新缓存,写+读并发:线程A先更新数据库,之后线程B读取数据,此时线程B会命中缓存,读取到旧值,之后线程A更新缓存成功,后续的读请求会命中缓存得到最新值。这种场景下,线程A未更新完缓存之前,在这期间的读请求会短暂读到旧值,对业务短暂影响。
2、先更新缓存,再更新数据库,写+读并发:线程A先更新缓存成功,之后线程B读取数据,此时线程B命中缓存,读取到最新值后返回,之后线程A更新数据库成功。这种场景下,虽然线程A还未更新完数据库,数据库会与缓存存在短暂不一致,但在这之前进来的读请求都能直接命中缓存,获取到最新值,所以对业务没影响。
3、先更新数据库,再更新缓存,写+写并发:线程A和线程B同时更新同一条数据,更新数据库的顺序是先A后B,但更新缓存时顺序是先B后A,这会导致数据库和缓存的不一致。
4、先更新缓存,再更新数据库,写+写并发:与场景3类似,线程A和线程B同时更新同一条数据,更新缓存的顺序是先A后B,但是更新数据库的顺序是先B后A,这也会导致数据库和缓存的不一致。
场景1和2对业务影响较小,场景3和4会造成数据库和缓存不一致,影响较大。也就是说,在读写缓存模式下,写+读并发对业务的影响较小,而写+写并发时,会造成数据库和缓存的不一致。
针对场景3和4的解决方案是,对于写请求,需要配合分布式锁使用。写请求进来时,针对同一个资源的修改操作,先加分布式锁,这样同一时间只允许一个线程去更新数据库和缓存,没有拿到锁的线程把操作放入到队列中,延时处理。用这种方式保证多个线程操作同一资源的顺序性,以此保证一致性。
综上,使用读写缓存同时操作数据库和缓存时,因为其中一个操作失败导致不一致的问题,同样可以通过消息队列重试来解决。而在并发的场景下,读+写并发对业务没有影响或者影响较小,而写+写并发时需要配合分布式锁的使用,才能保证缓存和数据库的一致性。
另外,读写缓存模式由于会同时更新数据库和缓存,优点是,缓存中一直会有数据,如果更新操作后会立即再次访问,可以直接命中缓存,能够降低读请求对于数据库的压力(没有了只读缓存的删除缓存导致缓存缺失和再加载的过程)。缺点是,如果更新后的数据,之后很少再被访问到,会导致缓存中保留的不是最热的数据,缓存利用率不高(只读缓存中保留的都是热数据),所以读写缓存比较适合用于读写相当的业务场景
redis淘汰策略
1. redis缓存有哪些淘汰策略?
不进行数据淘汰的策略,只有 noeviction 这一种。会进行淘汰的 7 种其他策略。
会进行淘汰的 7 种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。
在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 后新增)三种。
volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。
volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对。
volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对。
allkeys-random 策略,从所有键值对中随机选择并删除数据;
allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。
allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。
LRU 算法的全称是 Least Recently Used,从名字上就可以看出,这是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中。
如何解决缓存雪崩、击穿、穿透难题?
-
雪崩?
存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。
缓存中大量过期:
a.设置大量数据过期时间不一致,通过expire参数微调过期时间。
b.服务降级,非核心业务直接返回错误,核心业务继续访问数据库。
redis实例宕机:
a.业务需要有服务熔断或者是限流机制。
熔断就是不让请求访问redis实例,直接返回。
b.高可用集群主库宕机,从库上来保证不大面积雪崩。 -
击穿?
缓存中的热点数据失效,访问到了数据库。
解决方案:热点数据尽可能不设置过期时间。 -
穿透?
缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。
缓存穿透会发生在什么时候呢?一般来说,有两种情况。
业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
恶意攻击:专门访问数据库中没有的数据。
解决方案:
a.缓存空值
b.存到数据库的值都进行布隆过滤器判断,请求到布隆过滤器就可以了。
c.前端对频繁请求进行空值,空值恶意请求。
是否可以采用服务熔断、服务降级、请求限流的方法来应对缓存穿透问题?
我觉得需要区分场景来看。
如果缓存穿透的原因是恶意攻击,攻击者故意访问数据库中不存在的数据。这种情况可以先使用服务熔断、服务降级、请求限流的方式,对缓存和数据库层增加保护,防止大量恶意请求把缓存和数据库压垮。在这期间可以对攻击者进行防护,例如封禁IP等操作。
如果缓存穿透的原因是,业务层误操作把数据从缓存和数据库都删除了,如果误删除的数据很少,不会导致大量请求压到数据库的情况,那么快速恢复误删的数据就好了,不需要使用服务熔断、服务降级、请求限流。如果误操作删除的数据范围比较广,导致大量请求压到数据库层,此时使用服务熔断、服务降级、请求限流的方法来应对是有帮助的,使用这些方法先把缓存和数据库保护起来,然后使用备份库快速恢复数据,在数据恢复期间,这些保护方法可以为数据库恢复提供保障。
我觉得并没有必要:采用服务熔断、服务降级、请求限流的方法来应对缓存穿透的场景;
因为缓存穿透的场景实质上是因为查询到了Redis和数据库中没有的数据。
熔断、降级、限流,本质上是为了解决Redis实例没有起到缓存层作用这种情况;在损失业务吞吐量的代价下,在时间的作用下,随着过期key慢慢填充,Redis实例可以自行恢复缓存层作用。
而缓存穿透的场景,是因为用户要让Redis和数据库提供一个它没有的东西。这种场景下,如果没有人工介入,不论时间过去多久,都不太可能会自然恢复。
采用这种有损业务吞吐量的行为,会拖慢系统响应、降低用户体验、给公司一种系统“勉强能用”的错觉;但对问题的解决没有帮助。
最好的办法是事前拦截,降低这种类型的请求打到系统上的可能。布隆过滤器虽然判别数据存在可能有误判的情况,但判别数据不存在不会误判。可以降低数据库无效的访问。