面试题一:Redis为什么执行这么快?
Redis运行比较快主要原因有以下几种:
- 纯内存操作:Redis将所有数据存储在内存中,这意味着对数据的读写操作直接在内存中运行,而内存的访问速度远远高于磁盘。这种设计使得Redis能够已接近硬件极限的速度处理数据读写
- 单线程模型:Redis使用单线程模型来处理客户端请求。这可能听起来效率不高,但是实际上,这种设计避免了多线程频繁切换和过度竞争带来的性能开销。Redis每个请求的执行时间都是很短的,因此单线程下,也能处理大量的并发请求
- I/O多路复用:Redis使用了I/O多路复用技术,可以在单线程的环境下同时监听多个客户端连接,只有当有网络事件(如用户发送一个请求)发生的时候才会进行实际的I/O操作。这样有效的利用了CPU资源,减少了无谓的等待
- 高效数据结构:Redis提供了多种高效的数据结构,如哈希表、有序集合等。这些数据结构的实现都经过了优化,使得Redis在处理这些数据结构的操作是非常高效的
面试题二:Redis是单线程执行还是多线程执行?它有线程安全问题吗?为什么吗?
Redis版本在6.0之前都是使用的单线程运行的。所有的客户端的请求处理、命令执行以及数据读写操作都是在一个主线程中完成得。这种设计目的就是为了防止多线程环境下的锁竞争和上下文切换所带来的性能开销,这样保证在高并发场景下的性能
Redis版本在6.0中,开始引入了多线程的支持,但是这仅限于网络I/O层面,即在网络请求阶段使用工作线程进行处理,对于指令的执行过程,仍然是在主线程来处理,所以不会存在多个线程通知执行操作指令的情况
关于线程安全问题,从Redis服务层面俩看,Redis Server本身就是一个线程安全按的K-V数据库,也就是说在Redis Server上面执行的指令,不需要任何同步机制,不会存在线程安全问题
面试题三:在实际工作中,使用Redis实现了哪些业务场景?
Redis在实际工作中广泛应用于多种业务场景,以下是一些常见的例子:
缓存:Redis作为Key-Value形态的内存数据库,最先会被想到的应用场景就是作为数据缓存。Redis提供了键过期功能,也提供了键淘汰策略,所以Redis用在缓存的场合非常多
排行榜:很多网站都有排行榜应用,如京东的月度销量榜单、商品按时间上新排行榜等。Redis提供的有序集合(zset)数据类
分布式会话:在集群模式下,一般会搭建以Redis等内存等内存数据库为中心的session服务,session不再由容器管理,而是由session服务及内存数据库管理
分布式锁:在高并发的情景,可以利用Redis的setnx功能来编写分布式锁
面试题四:Redis常用数据类型有哪些?
Redis中,常见的数据类型有如下几种:
- 字符串(String):最简单的数据类型,可以包含任意数据,如文本、二进制数据等。常见的使用场景是存储Session信息、存储缓存信息、存储整数信息,可以使用incr实现整数+1,使用decr实现整数-1
- 列表(List):有序的字符串元素集合,支持双端进行插入和删除操作,可以用作队列或栈
- 哈希(Hash):用于存储对象,类似于关联数组。每个哈希可以包含字段和与之相关联的值。常见使用场景是存储Session信息、存储商品的购物车,购物车非常适用于哈希字典表示,使用人员唯一编号作为字典的key,value值可以存储商品的id和数量等信息、存储详情页等信息
- 集合(Set):一个无序并唯一的键值集合。它常见的使用场景是是仙女关注功能,比如关注我的人和我关注的人,使用集合存储,可以保证人员不重复
- 有序集合(Sorted Set):使用zset表示,相当于Set集合类型多了一个排序属性score(分值)。。它常见的使用场景是可以用来存储排名信息,关注列表功能,这样就可以根据关注实现排序展示
面试题五:存储Session信息你会使用哪种数据类型?为什么?
在实际工作中,小型的项目会使用Redis存储Session信息,但是不同业务场景存储Session信息的类型也是不同的,具体来说:
存储数据简单(不涉及局部更新):使用String类型粗怒触Session,这样做的优缺点如下:
优点:
存取操作简单直观,只需要单个键执行操作即可
多余小型Session,存储开销相对于较小
缺点:
如果Session数据复杂或者需要频繁更新其中的部分字段,则每次更新都需要重新序列化整个Session对象
不利于查询Session内特定字段值
存储数据复杂(涉及局部更新):如果Session数据结构复杂且需要频繁更新或查询其中个别字段,通常建议使用哈希表存储Session。每个Session视为一个独立的哈希表,Session ID作为key,Sesion内各个字段作为field-value对存储在哈希表中。示例:HSET session:123 userId 123 username user1,这样做的优缺点如下:
优点:
可以方便地进行字段级别的读写操作,例如 HGET session:23 userd 和 HSET session:123 lastAccessTime now
更新部分字段时无需修改整个Session内容
缺点:
相对于简单的字符串存储,哈希表占用的空间可能更大,尤其时当Session数据包含多个值的时候
小结: 如果 Session 数据结构复杂且需要频繁更新或查询其中的个别字段,通常建议使用哈希表来存储 Session;而在 Session 数据较为简单、不涉及局部更新的情况下,使用字符串存储也是可行的选择
面试题六:有序集合底层是如何实现的?
在Redis7之前,有序集合使用的是ziplist(压缩列表)+skiplist(跳跃表),当数据列表元素小于128个,并且所有元素成员的长度都小于64字节时,使用压缩列表存储,否则使用调表存储
在Redis之后,有序集合使用listPack(紧凑列表)+skiplist(跳跃表)
面试题七:什么是跳表?为什么使用跳表?
skiplist是一种以空间换时间的数据结构。由于链表无法进行二分查找,因此借鉴数据库索引的思想,提取出链表中的关键姐点(索引),现在关键节点上查找,在进入下层链表查找提取多层关键节点,就形成了跳表。但是由于索引要占据一定的空间,所以索引添加的越多,占用的空间越多。
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高O(N)
从这个例子里,我们看出,加来一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。时间复杂度从原来的O(n)到O(logn),是一空间换时间的解决方法
面试题八:说一下跳表的查询流程?
跳表的查询流程如下:
- 起始搜索:查询操作从跳表的顶层开始,跳表的顶层包含一个或多个节点,从最顶层的头节点开始,将当前节点设置为头节点
- 检查下一个节点:检查当前节点的下一个节点,如果节点的分值小于目标分值,则向右移动检查下一个节点,重复此步骤,直到找到一个大于目标值的节点,或者最后一个节点
- 逐层探索:如果当前下一个结点的值大于目标值,或者最后一个节点,则将当前指针向下层进行搜索,重复上述步骤
- 终止并返回:在查找的过程中,如果找到了和目标分支相同的值,或者遍历完所有层级仍然未找到对应的节点,则说明要查找的元素不存在于跳表中,则终止查找并返回查询到的内容或NULL值
面试题九:说一下跳表的添加流程?为什么要有“随机层数”这个概念?
跳表的添加流程主要是包括以下步骤:
查找插入位置:首先,我们需要找到新元素应该插入的位置。这个过程与跳表查找操作类似,我们从最高层所以一年开始,逐层向下查找直到找到最后一个位置,使得该位置前面的元素小于新元素,后面的元素大于新元素
生成随机层数:在确定新元素插入位置后,我们需要决定新元素在跳表中的层数。这个层数是通过一个随机函数生成的。每个节点肯定都有第一层指针(每个节点都在第一层链表中)。如果一个节点有第i层指针(即节点已经在第一层到第i层链表中),那么他又第(i+1)层指针的
插入新元素:根据生成的随机层数,我们在相应的层中插入新元素。对于每一层,我们都需要更新相应的前驱和后继指针,使得它们指向新插入的元素
更新跳表的最大层数:如果新插入的元素的层数大于跳表的当前最大层数,我们需要更新跳表的最大层数
关于“随机层数”的概念,其主要目的是为了保持跳表的平衡性。如果我们固定每个元素的层数,那么在某些情况下,跳表可能会退化成普通的链表,从而导致查找效率降低。通过随机选择每个元素的层数,我们可以确保跳表的高度大致为log(n),从而保证查找、插入和删除操作的时间复杂度为O(log n)
给定如上跳表,假设要插入节点2。首先需要判断节点2是否已经存在,若存在则返回false
。否则,随机生成待插入节点的层数
/*** 生成随机层数[0,maxLevel)* 生成的值越大,概率越小** @return*/
private int randomLevel() {int level = 0;while (Math.random() < PROBABILITY && level < maxLevel - 1) {++level;}return level;
}
这里的PROBABILITY =0.5。上面算法的意思是返回1的概率是1/2,返回2的概率是1/4,返回3的概率是1/8,依次类推。看成一个分布的话,第0层包含所有节点,第1层含有1/2个节点,第2层含有1/4 个节点…
注意这里有一个最大层数maxLevel ,也可以不设置最大层数。通过这种随机生成层数的方式使得实现起来简单。假设我们生成的层数是3
在1和3之间插入节点2,层数是3,也就是节点2跳跃到了第3层
public boolean add(E e) {if (contains(e)) {return false;}int level = randomLevel();if (level > curLevel) {curLevel = level;}Node newNode = new Node(e);Node current = head;//插入方向由上到下while (level >= 0) {//找到比e小的最大节点current = findNext(e, current, level);//将newNode插入到current后面//newNode的next指针指向该节点的后继newNode.forwards.add(0, current.next(level));//该节点的next指向newNodecurrent.forwards.set(level, newNode);level--;//每层都要插入}size++;return true;
}
我们通过一个例子来模拟,由于实现了直观的打印算法。假设我们要插入1, 6, 9, 3, 5, 7, 4, 8
过程如下:
add: 1
Level 0: 1add: 6
Level 0: 1 6add: 9
Level 2: 9
Level 1: 9
Level 0: 1 6 9add: 3
Level 2: 3 9
Level 1: 3 9
Level 0: 1 3 6 9add: 5
Level 2: 3 9
Level 1: 3 5 9
Level 0: 1 3 5 6 9add: 7
Level 2: 3 9
Level 1: 3 5 9
Level 0: 1 3 5 6 7 9add: 4
Level 2: 3 9
Level 1: 3 5 9
Level 0: 1 3 4 5 6 7 9add: 8
Level 2: 3 9
Level 1: 3 5 9
Level 0: 1 3 4 5 6 7 8 9
面试题十:使用Redis如何实现分布式锁?
实现分布式锁
使用Redis实现分布式锁可以通过setnx(set if not exists)命令实现,但当我们使用setnx创建键值成功时,则表中加锁成功,否则代码加锁失败,实现示例如下:
127.0.0.1:6379> setnx lock true
(integer) 1#创建锁成功
#逻辑业务处理..
当我们重复加锁时,只有第一次会加锁成功
127.8..1:6379> setnx lock true # 第一次加锁
(integer) 1
127.8.8.1:6379> setnx lock true # 第二次加锁
(integer) 0
从上述命令可以看出,我们可以看执行结果返回是不是1,就可以看出是否加锁成功
释放分布式锁
127.0.0.1:6379> de1 lock
(integer) 1 #释放锁
然而,如果使用 setnx ock true 实现分布式锁会存在死锁问题,以为 setnx 如未设置过期时间,锁忘记删了或加锁线程宕机都会导致死锁,也就是分布式锁一直被占用的情况
解决死锁问题
死锁问题可以通过设置超时时间来解决,如果超过了超时时间,分布锁会自动释放,这样就不会存在死锁问题了也就是 setnx和 expire 配合使用,在 Redis 2.6.12 版本之后,新增了一个强大的功能,我们可以使用一个原子操作也就是一条命令来执行 setnx 和expire 操作了,实现示例如下:
127.0.0.1:6379> set lock true ex 3 nx
OK #创建锁成功
127...1:6379> set lock true ex 3 nx
(ni1) #在锁被占用的时候,企图获取锁失败
其中ex为设置超时时间, nx 为元素非空判断,用来判断是否能正常使用锁的。
因此,我们在 Redis 中实现分布式锁最直接的方案就是使用 set key value ex timeout nx 的方式来实现。