我是变态的面试官---欢迎来造火箭

79 阅读16分钟

最近发现了一种超级神奇的学习方式。为什么这样说呢,就是我发现自己在学新技能的时候,前面的技能老师会忘记,然后我就开始纳闷儿,为什么会忘得这么快。后来我尝试着用问答的方式去边理解边学习,然后感觉还不错,除了对于这个技术有了自己的看法,还能将以前别的知识进行迁移,总之,效果不错。

Redis篇

基础数据结构篇

Q1:

知道Redis的字典吧,问你个问题,字典的底层数据结构知道怎么实现的吗?Java的HashMap不是采用的是数组加链表加红黑树,为什么这里的哈希表解决键冲突的时候用的是拉链法,而没有用到红黑树呢?

A:

字典的底层数据结构就是哈希表,而且使用的是两个哈希表,第一个哈希表用于存放数据,第二个哈希表用户扩容(Rehash),字典的结构是typehtrehashIndexprivatedata

而关于HashMap中的键冲突问题,首先是采用拉链法,在java 1.8中,如果链表的长度超过了8,那么链表将转换为红黑树。(桶的数量必须大于64,小于64的时候只会扩容)发生hash碰撞时,java 1.7 会在链表的头部插入,而java 1.8会在链表的尾部插入。而且可以看到转换红黑树的时机是超过一定大小才会去转换,因为红黑树是一种自平衡二叉搜索树,它可以保证在最坏情况下,查找、插入和删除操作的时间复杂度为O(log n)。也就是说在规模不断增加的情况下,红黑树是比较不错的选择,但是如果规模比较小的话,就没必要采取红黑树了。

另外,Redis的哈希表会让键值对处于一个合理的区间(执行BGSAVE指令时,哈希表的负载因子大于等于1,未执行BGSAVE时,负载因子大于等于5执行扩展;哈希表的负载因子小于0.1自动执行收缩===》执行BGSAVE命令时,Redis需要创建当前进程的子进程(写时复制优化子进程的使用效率),因此提高扩展所需要的负载因子,尽可能避免执行扩展,最大限度节约内存),因此会自动进行Rehash去扩展与收缩,查询性能消耗并不大,因此采取红黑树反而增加了性能耗费。因此,并没有采用红黑树来实现哈希表的冲突扩展。

Q2:

我们都知道Redis里的跳表是一种平衡树的不错的替代数据结构,你可以讲一下跳表的结构,然后大概讲一下level中的前进指针的跨度是怎样生成的吧?

A:

跳表是使用类似于双向链表的结构,可以通过多个zskipListNode直接组成,也可以添加zSkipList表头后形成。首先就是同样的headerTail头尾指针(其中header指向的是一个表头),然后不一样的地方在于增加了levellength两个属性,一个是结点的个数,另外一个是最高的层数。然后结点的结构呢,也有几个属性(score, object, level[], backward)其中level数组中是不同级别,不同级别中分别对应不同的前进指针和跨度。

这里说的跨度就是前进指针的移动距离,它的生成规则也比较简单,假设最开始的时候,跳表是空的,这个时候新添加一个跳表节点,首先会随机生成一个1~32之间的数作为这个节点的层高度,然后从表头出发,用前进指针指向表尾,期间经过的节点进行连接,比如说有两个节点,都有L5,但是中间隔了两个点(高度均小于5),那么遍历后的结果就是第一个节点的第5层的前进指针的跨度是2.。跨度除了用来进行遍历,还可以用来计算目标结点的排位

Q3:

我们都知道Redis里面除了有链表,字典,跳表,还有压缩列表,请你简单介绍一下压缩列表的结构,然后解释一下压缩列表中的连锁更新问题。

A:

之所以叫压缩列表是因为没有指针参与的原因,结构大概是zlbytes、 zltail、zllen、entrys、zlend这几个构成,然后zlbytes是整个压缩列表的总字节数. 其中的entrys里面记录着所有的内容,但是因为没有指针,所以就在每个entry上添加了一个pre_entry用来记录上一个entry项的长度,用于遍历。

而且这里的pre_entry不是固定大小,如果前一个项大于254字节,它就会变成5个字节,其中第一个字节为0xEF,剩下4个字节则用于保存长度,这样就会导致一个问题,比如新增加一个大于254的节点,但是表头的pre_entry是1个字节,然后将其进行扩容,这样就会导致后面的每个项都需要移位,另外,如果这个表项修改完之后变成了大于254字节,就会后面的同样需要更新,依此类推,就会导致连锁更新问题。但是看书上给出的答案就发现连锁更新不会造成很严重的性能问题:因为多个连续的临界表项才会可能被引发,对于少量节点的更新并不会影响性能

Q4:

我们都知道Redis对象可以实现一种类型不同底层实现,我们现在来看字符串对象。你可以简单地说一下它的几种底层实现方案?然后讲一下为什么embstr和raw两种实现方案里要以字符串39个字节为界限或者说3版本往后为什么要使用44个字节为界限?

A:

在Redis里,字符串是一个对象,对象是由一个RedisObject结构表示,这个结构中和保存数据有关的三个属性分别是type,encoding,ptr,其中type确定对象的类型(比如字符串,集合,哈希,集合,有序集合),而encoding对应每种类型的不同底层实现方式(比如字符串对象对应三种实现方式,分别为int,embstr,raw)。

字符串的三种底层实现方案分别对应不同的应用场景,当value的值是数字时,会将指向底层实现的ptr指针变换为long类型,然后将数字直接存放进去,同时将encoding改为int;当value是一个字符串,且大小低于44个字节时(3版本用的是39个字节),采用类似压缩列表的做法,将SDS直接跟在redisObject后面,而不是通过指针指向,同时将编码改为embstr;当这个字符串大于44个字节时(3版本以前用的是39字节),使用SDS实现,并且由指针指向SDS的地址,将编码改为raw。

使用embstr的好处在于分配内存只需要一次,因为使用的是连续的内存,同样释放内存也只需要一次。

而使用39个字节作为分类标准的原因是因为计算机分配内存时通常以8为界限,其中8,16,32,64为分配单位,而最开始的RedisObject的头已经占据16个字节,而SDS对象的头又会占据8个字节(两个unsigned int会占据8个字节),因此留给字符串的空间只有40个字节,又因为字符串的最后一个字符默认为\0,因此剩下39个字符。而新版本以44为界限的原因是,每个sds都有一个sdshdr,里面的len和free记录了这个sds的长度和空闲空间,但是这样的处理十分粗糙,使用的unsigned int可以表示很大的范围,但是对于很短的sds有很多的空间被浪费了(两个unsigned int 8个字节)。新版本将原来的sdshdr改成了sdshdr8,sdshdr16,sdshdr32,sdshdr64,里面的unsigned int 变成了uint8_t,uint16_t(还加了一个char flags)这样更加优化小sds的内存使用;因此最小的字符串对象头仅仅占用3个字节(len,alloc都是1个字节,还有一个char字符flag占用一个字节) ,因此节约了5个字节的空间,流出来的就是44个字节。

Q5:

我们都知道Redis中有一个数据结构是列表,它的实现方式很特殊,在3.2版本之前使用的是ziplist或者linkedlist而在3.2之后则实现了统一的quicklist,你可以讲一下为什么要这么做吗?

A:

Redis的列表对象,在3.2版本之前使用的是ziplist+linkedlist实现,其中在列表对象元素比较少或者元素长度比较小的时候使用ziplist来进行存储,这样可以很方便的进行存取在一段连续的内存上,不容易产生内存碎片,内存利用率高,但是插入和删除操作需要频繁的申请和释放内存, 同时会发生内存拷贝,数据量大时内存拷贝开销较大;而这时就可以采用linkedlist来进行存取(规定单个元素超过64字节或者列表元素数量超过512个就进行转换),采用双端链表进行,虽然每个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片,但是插入,删除节点复杂度很低。

而在3.2版本之后,使用一种新的编码形式,quicklist,结合linkedlist与ziplist来实现,即实现了空间和时间的折中方案:第一层结构用双向链表实现,每一个node都有前后指针,list则拥有一个head和tail,然后每一个node则是由一个ziplist构成,这样就保证了可以一直利用ziplist的优势,并且避免ziplist过长引起的较大拷贝开销

Q5:

我们都知道在Java里有HashMap和HashSet,那Redis里面是有没有相关的数据结构呢?如果有,请你尝试着介绍一下

A:

Java里面的hashMap和HashSet具有相似的结构,不一样的地方只是在于HashSet将元素作为Key,而Value作为null存进Hash Map中,而在Redis里,哈希对象使用的是ziplist或者hashtable(dict)来实现的(ziplist只适用于较短元素以及较少数量元素的存取,超出一定限制性能就会有所下降),而集合对象则是使用的是intset或者hashtable实现(intset同样只适用于纯数字的存取,如果有字符串对象存入就需要采用hashtable)。

两个对象在使用hashtable来实现的时候与Java中实现HashSet和HashMap非常的相似,同样是相同数据结构,不过集合中的Value统统为null。

Q6:

如果我现在想用Redis实现一个TOP排行榜,我该怎样实现这个操作呢?然后可以为我详细介绍一下这个数据结构(它的底层是怎样实现的)

A:

排行榜可以通过Redis中的zset来实现,可以根据实时的Score来进行排序得到TOP排行榜。而Redis中的zset具有两种不同的实现方案,首先便是ziplist,采用的是类似于哈希对象的存取方式,一个entry项中,前一个是key,然后跟着的是score;但是类似于其他对象的ziplist实现方案,这种实现会受到大小的限制,即单个元素的长度小于64,元素数量小于128;另外一种是采取skiplist(可以根据Score由小到大进行排序,另外还可以存储对象,即分值对应的对象)编码实现,这种实现方案不仅采用了skiplist,而且使用了hash来进行优化。

而关于Redis为什么采用了SkipList还要使用Hash来进行优化,我的考虑在于skiplist与hash的优缺点比较,其中skiplist的优点在于:实现简单,数据天然有序 ,但是其查询和插入时间复杂度均为O(logn),而Hash的查询,插入时间复杂度均为O(1),但是其并不适合查询操作,因此,可以结合两者的特性,让更擅长的人做更擅长的事。因此采用了hash和skiplist共同实现。

Q7:

我们都知道Redis使用的是C语言来编写的,但是C语言并不支持垃圾回收机制,你能讲一下Redis的内存回收机制是怎样实现的吗?

A:

因为Redis中存取数据的都是对象,所以开发者在RedisObject里预留了一个字段叫refcount,类似于Java的引用计数法判断垃圾。在创建对象时,将其初始化为1,当遇到被程序使用时,会新增1,不被使用时会减小1,而当减小为0时,则进行回收,其生命周期相较于Java的垃圾回收机制简单不少,主要分为“创建对象”、“操作对象”、“释放对象”。另外,鉴于Redis的内存比较宝贵,因此还引入了另外一种机制(共享内存),类似于JVM中的StringPool,用于节省内存,防止创建过多的相同数值的字符串

MySQL篇

基础架构:

Q&A:

Q1:

MySQL了解吧,你可以讲一下它的架构吗?然后你刚刚提到的几个阶段里,我举个例子哈,如果有一个表T,他没有字段ttt,然后现在去select ttt from t是肯定会报错的,但是可不可以告诉我为啥会报错?然后会在那个阶段报错?

A:

MySQL的架构从一个查询的流程来说会比较清晰,我这里用一个查询语句来解释它的结构吧。首先是建立连接,这里会用到连接器,首先会校验用户名和密码是否正确,然后查询权限,为后面的查询做准备。这个过程是由连接器进行的,另外,如果长时间无操作,就会自动断开数据库的连接(默认是8小时),一般采用的是长连接,但是会导致内存占用较高(因为执行过程使用的内存是连接对象里面的),所以需要定时重置连接;连接之后会对SQL语句进行检查,看是否有相同的SQL语句已经执行过,其执行的结果会以key-value的形式进行存储,如果缓存命中的话,直接返回结果即可,但是因为缓存命中率较低,通常不使用缓存,MySQL8还将缓存去掉了;接下来就正式进入执行阶段了,首先是分析器,即词法语法分析,通过建立语法树进行检查(字段不在表中同样会被检查到);然后是优化器,因为分析器会生成一个解析树给优化器,优化器会根据这个解析器来进行优化,比如使用怎样的索引,多表关联的时候表的顺序等等;最后就是执行器,开始执行的时候肯定是要判断是否有权限,然后再进入执行,打开这个表的数据,调用执行引擎提供的接口执行即可。总结来说就是,连接器--》查询缓存---》分析器--》优化器--》执行器--》存储引擎。

报错的原因就是分析器会建立一个解析树,也就是说类似于编译原理的语义分析,然后发现没有该字段就会报错。会在分析阶段报错。

Q2:

既然你说到查询语句的执行流程,那就说一下一条更新语句的执行流程吧?

A:

类似于查询语句,首先是进行连接器检查权限与连接,然后清空查询缓存(这个表上的缓存),然后进入分析器,进行分析,得知这是一个更新语句,然后进入优化器,确定使用的索引,最终进入执行器进行执行(找到具体的一行进行更新),但是不一样的点在于更新语句再执行阶段还涉及到了两个日志:redolog(重做日志)、binlog(归档日志)

执行器首先找到存储引擎的数据行;然后执行器将这一行的数据进行更新,再调用接口将这个新数据写入;存储引擎将这行数据更新至内存中,同时将这个更新记录同步至redolog中,此时redolog处于prepare阶段,然后告知执行器完成,随时可以提交事务;执行器生成这个操作的binlog,并把binlog写入磁盘;执行器调用存储引擎的事务提交接口,将redolog改为提交状态,更新完成。

另外一点,就是关于为什么要先更新redolog至prepare,然后再更新binlog,最后再提交rodolog(两阶段提交)。因为如果不这样的做的话就会导致数据库的状态与用它的日志回复出来的并不一致,比如如果不采用两阶段提交的话,先完全写完redolog再写binlog,如果在写binlog的时候crash就会导致binlog的最后一条记录失效,而redolog却记录了这个操作,所以出现了不一致;如果先写完binlog,再写redolog,就会出现binlog中多出一个事务,与原来的库也不一致。

Q3:

你提到使用MySQL的binlog日志进行备份,那现在有一个问题,什么场景下一日备份会比一周备份和一月备份效果更好呢?

A:

binlog是全量备份日志,因此一般一天一备份对应的总量更小,因此恢复的时间也更短,即RTO(回复目标时间)。因此,如果需要较短时间内恢复数据,采用一天一备份是比较好的选择。