铜仁市论坛

首页 » 分类 » 问答 » 面试必问的Redis数据结构和基础概念
TUhjnbcbe - 2021/2/27 17:58:00
乌鲁木齐白癜风专科医院 http://m.39.net/pf/a_4409824.html

前言

在Java后端的面试中,redis应该是目前所有框架/中间件中被问到频率最高的,至少也是之一。

就算把范围扩大到整个Java后端面试知识体系,面试中出现频率比redis高的也不多,可能就那么几个:HashMap、线程池之类的。

综上,redis在面试中的重要程度评分可以给到9分,接近满分。

由于比较重要,知识点也比较多,所以这边预计分为三篇来呈现。

除了本文之外,另外两篇,一篇围绕高可用,主要是持久化、主从复制、哨兵、集群模式等。

另一篇围绕redis的实践,主要是分布式锁、缓存穿透、缓存雪崩、缓存击穿等。

正文

redis是单线程还是多线程

这个问题应该已经看到过无数次了,最近redis6出来之后又被翻出来了。

redis4.0之前,redis是完全单线程的。

redis4.0时,redis引入了多线程,但是额外的线程只是用于后台处理,例如:删除对象,核心流程还是完全单线程的。这也是为什么有些人说4.0是单线程的,因为他们指的是核心流程是单线程的。

这边的核心流程指的是redis正常处理客户端请求的流程,通常包括:接收命令、解析命令、执行命令、返回结果等。

而在最近,redis6.0版本又一次引入了多线程概念,与4.0不同的是,这次的多线程会涉及到上述的核心流程。

redis6.0中,多线程主要用于网络I/O阶段,也就是接收命令和写回结果阶段,而在执行命令阶段,还是由单线程串行执行。由于执行时还是串行,因此无需考虑并发安全问题。

值得注意的时,redis中的多线程组不会同时存在“读”和“写”,这个多线程组只会同时“读”或者同时“写”。

redis6.0加入多线程I/O之后,处理命令的核心流程如下:

1、当有读事件到来时,主线程将该客户端连接放到全局等待读队列

2、读取数据:1)主线程将等待读队列的客户端连接通过轮询调度算法分配给I/O线程处理;2)同时主线程也会自己负责处理一个客户端连接的读事件;3)当主线程处理完该连接的读事件后,会自旋等待所有I/O线程处理完毕

3、命令执行:主线程按照事件被加入全局等待读队列的顺序(这边保证了执行顺序是正确的),串行执行客户端命令,然后将客户端连接放到全局等待写队列

4、写回结果:跟等待读队列处理类似,主线程将等待写队列的客户端连接使用轮询调度算法分配给I/O线程处理,同时自己也会处理一个,当主线程处理完毕后,会自旋等待所有I/O线程处理完毕,最后清空队列。

大致流程图如下:

相关源码在networking.c,核心的方法是:

IOThreadMain、handleClientsWithPendingReadsUsingThreads、

handleClientsWithPendingWritesUsingThreads

为什么redis是单线程

在redis6.0之前,redis的核心操作是单线程的。

因为redis是完全基于内存操作的,通常情况下CPU不会是redis的瓶颈,redis的瓶颈最有可能是机器内存的大小或者网络带宽。

既然CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了,因为如果使用多线程的话会更复杂,同时需要引入上下文切换、加锁等等,会带来额外的性能消耗。

而随着近些年互联网的不断发展,大家对于缓存的性能要求也越来越高了,因此redis也开始在逐渐往多线程方向发展。

最近的6.0版本就对核心流程引入了多线程,主要用于解决redis在网络I/O上的性能瓶颈。而对于核心的命令执行阶段,目前还是单线程的。

redis为什么使用单进程、单线程也很快

1、基于内存的操作

2、使用了I/O多路复用模型,select、epoll等,基于reactor模式开发了自己的网络事件处理器

3、单线程可以避免不必要的上下文切换和竞争条件,减少了这方面的性能消耗。

4、以上这三点是redis性能高的主要原因,其他的还有一些小优化,例如:对数据结构进行了优化,简单动态字符串、压缩列表等。

项目中使用的redis版本

这个问题是我在面试某大厂真实碰到过的,所以大家平时在使用中间件和框架时可以留意下自使用的版本。

下图是从redis官方github截的图,包含了redis2.2之后的所有版本,目前常用的应该是:3.2.*、4.0.*、5.0.*。

redis在项目中的使用场景

缓存、分布式锁、排行榜(zset)、计数(incrby)、消息队列(stream)、地理位置(geo)、访客统计(hyperloglog)等。

redis常见的数据结构

常见的5种:

String:字符串,最基础的数据类型。

List:列表。

Hash:哈希对象。

Set:集合。

SortedSet:有序集合,Set的基础上加了个分值。

高级的4种:

HyperLogLog:通常用于基数统计。使用少量固定大小的内存,来统计集合中唯一元素的数量。统计结果不是精确值,而是一个带有0.81%标准差(standarderror)的近似值。所以,HyperLogLog适用于一些对于统计结果精确度要求不是特别高的场景,例如网站的UV统计。

Geo:redis3.2版本的新特性。可以将用户给定的地理位置信息储存起来,并对这些信息进行操作:获取2个位置的距离、根据给定地理位置坐标获取指定范围内的地理位置集合。

Bitmap:位图。

Stream:主要用于消息队列,类似于kafka,可以认为是pub/sub的改进版。提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。

Redis的字符串(SDS)和C语言的字符串区别

C字符串

SDS

获取字符串长度的复杂度为O(N)

获取字符串长度的复杂度为O(1)

API是不安全的,可能会造成缓冲区溢出

API是安全的,不会造成缓冲区溢出

修改字符串长度N次必然需要执行N次内存重分配

修改字符串长度N次最多需要执行N次内存重分配

只能保存文本数据

可以保存文本数据或者二进制数据

可以使用所有的string.h库中的函数

可以使用一部分string.h库中的函数

SortedSet底层数据结构

SortedSet(有序集合)当前有两种编码:ziplist、skiplist

ziplist:使用压缩列表实现,当保存的元素长度都小于64字节,同时数量小于时,使用该编码方式,否则会使用skiplist。这两个参数可以通过zset-max-ziplist-entries、zset-max-ziplist-value来自定义修改。

skiplist:zset实现,一个zset同时包含一个字典(dict)和一个跳跃表(zskiplist)

SortedSet为什么同时使用字典和跳跃表?

主要是为了性能。

单独使用字典:在执行范围型操作,比如zrank、zrange,字典需要进行排序,至少需要O(NlogN)的时间复杂度及额外O(N)的内存空间。

单独使用跳跃表:根据成员查找分值操作的复杂度从O(1)上升为O(logN)。

SortedSet为什么使用跳跃表,而不是红黑树?

1)跳表的性能和红黑树差不多。

2)跳表更容易实现和调试。

网上有同学说是因为作者不会红黑树,我觉得挺有可能的。

Hash对象底层结构

Hash对象当前有两种编码:ziplist、hashtable

ziplist:使用压缩列表实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的节点推入到压缩列表的表尾,然后再将保存了值的节点推入到压缩列表表尾。

因此:1)保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;2)先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加的会被放在表尾方向。

hashtable:使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值来保存,跟java中的HashMap类似。

Hash对象的扩容流程

hash对象在扩容时使用了一种叫“渐进式rehash”的方式,步骤如下:

1、计算新表size、掩码,为新表ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。

2、将rehash索引计数器变量rehashidx的值设置为0,表示rehash正式开始。

3、在rehash进行期间,每次对字典执行添加、删除、査找、更新操作时,程序除了执行指定的操作以外,还会触发额外的rehash操作,在源码中的_dictRehashStep方法。

_dictRehashStep:从名字也可以看出来,大意是rehash一步,也就是rehash一个索引位置。

该方法会从ht[0]表的rehashidx索引位置上开始向后查找,找到第一个不为空的索引位置,将该索引位置的所有节点rehash到ht[1],当本次rehash工作完成之后,将ht[0]的rehashidx位置清空,同时将rehashidx属性的值加一。

4、将rehash分摊到每个操作上确实是非常妙的方式,但是万一此时服务器比较空闲,一直没有什么操作,难道redis要一直持有两个哈希表吗?

答案当然不是的。我们知道,redis除了文件事件外,还有时间事件,redis会定期触发时间事件,这些时间事件用于执行一些后台操作,其中就包含rehash操作:当redis发现有字典正在进行rehash操作时,会花费1毫秒的时间,一起帮忙进行rehash。

5、随着操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],此时rehash流程完成,会执行最后的清理工作:释放ht[0]的空间、将ht[0]指向ht[1]、重置ht[1]、重置rehashidx的值为-1。

相关源码在dict.c,核心方法是:dictExpand、dictRehashMilliseconds、dictRehash、dictFind、

渐进式rehash的优点

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。

在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除、査找、更新等操作会在两个哈希表上进行。例如,要在字典里面査找一个键的话,程序会先在ht[0]里面进行査找,如果没找到的话,就会继续到ht[1]里面进行査找,诸如此类。

另外,在渐进式rehash执行期间,新增的键值对会被直接保存到ht[1],ht[0]不再进行任何添加操作,这样就保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

rehash流程在数据量大的时候会有什么问题吗

1、扩容期开始时,会先给ht[1]申请空间,所以在整个扩容期间,会同时存在ht[0]和ht[1],会占用额外的空间。

2、扩容期间同时存在ht[0]和ht[1],查找、删除、更新等操作有概率需要操作两张表,耗时会增加。

3、redis在内存使用接近maxmemory并且有设置驱逐策略的情况下,出现rehash会使得内存占用超过maxmemory,触发驱逐淘汰操作,导致master/slave均有有大量的key被驱逐淘汰,从而出现saster/slave主从不一致。

Redis的事件处理器

redis基于reactor模式开发了自己的网络事件处理器,由4个部分组成:套接字、I/O多路复用程序、文件事件分派器(dispatcher)、以及事件处理器。

套接字:socket连接,也就是客户端连接。当一个套接字准备好执行连接、写入、读取、关闭等操作时,就会产生一个相应的文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。

I/O多路复用程序:提供select、epoll、evport、kqueue的实现,会根据当前系统自动选择最佳的方式。负责监听多个套接字,当套接字产生事件时,会向文件事件分派器传送那些产生了事件的套接字。

当多个文件事件并发出现时,I/O多路复用程序会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字:当上一个套接字产生的事件被处理完毕之后,才会继续传送下一个套接字。

文件事件分派器:接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。

事件处理器:事件处理器就是一个个函数,定义了某个事件发生时,服务器应该执行的动作。例如:建立连接、命令查询、命令写入、连接关闭等等。

Redis删除过期键的策略(缓存失效策略、数据过期策略)

定时删除:在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。对内存最友好,对CPU时间最不友好。

惰性删除:放任键过期不管,但是每次获取键时,都检査键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。对CPU时间最优化,对内存最不友好。

定期删除:每隔一段时间,默认ms,程序就对数据库进行一次检査,删除里面的过期键。至于要删除多少过期键,以及要检査多少个数据库,则由算法决定。前两种策略的折中,对CPU时间和内存的友好程度较平衡。

Redis使用惰性删除和定期删除。

Redis的内存驱逐(淘汰)策略

当redis的内存空间(maxmemory参数配置)已经用满时,redis将根据配置的驱逐策略(maxmemory-policy参数配置),进行相应的动作。

当前redis的淘汰策略有以下8种。

noeviction:默认策略,不淘汰任何key,直接返回错误

allkeys-lru:在所有的key中,使用LRU算法淘汰部分key

allkeys-lfu:在所有的key中,使用LFU算法淘汰部分key

allkeys-random:在所有的key中,随机淘汰部分key

volatile-lru:在设置了过期时间的key中,使用LRU算法淘汰部分key

volatile-lfu:在设置了过期时间的key中,使用LFU算法淘汰部分key

volatile-random:在设置了过期时间的key中,随机淘汰部分key

volatile-ttl:在设置了过期时间的key中,挑选TTL(timetolive,剩余时间)短的key淘汰

最后

我不能保证所写的每个地方都是对的,但是至少能保证所写每一句话、每一行代码都经过了认真的推敲、仔细的斟酌。

如果你觉得本文写的还不错,对你有帮助,请通过让我知道,支持我写出更好的文章,这对我很重要。如果有什么问题和建议,也欢迎一起交流探讨。

推荐阅读

两年Java开发工作经验面试总结

4年Java经验面试总结、心得体会

面试必问的Spring,你懂了吗?

如何写一份让HR眼前一亮的简历(附模板)

字节、美团、快手核心部门面试总结(真题解析)

面试阿里,HashMap这一篇就够了

面试必问的线程池,你懂了吗?

BATJTMD面试必问的MySQL,你懂了吗?

如何准备好一场大厂面试

跳槽,如何选择一家公司

MySQL8.0MVCC核心源码解析

囧辉

点赞支持我写出更好的文章

1
查看完整版本: 面试必问的Redis数据结构和基础概念