一、面试官:说说看Redis底层数据类型有哪些?Redis的5种基本数据类型是用哪几种底层数据类型组成的?
Redis的底层数据类型主要包括以下几种,它们用于实现和支撑Redis提供的五种主要数据类型(String、Hash、List、Set、Zset):
1. 简单动态字符串(SDS,Simple Dynamic String)
用途:主要用于存储String类型的值。
特点SDS是Redis自己构建的一种字符串数据结构,相比C语言的传统字符串(以空字符'\0'结尾),SDS具备自动扩展、长度缓存、二进制安全等优点。SDS通过结构体来记录字符串的长度和已分配空间大小等信息,从而提高了字符串操作的性能。
2. 压缩列表(ZipList)
用途用于存储Hash、List、Zset等类型的数据,当这些类型的数据量较少且元素较小时。
特点压缩列表是一组连续的内存块,能够节省空间。它内部包含多个entry节点,每个节点包含前一个节点的长度、节点内容等信息。当数据量增多或元素变大时,压缩列表可能会转换为其他数据结构(如哈希表、双向链表等)。
3. 哈希表(Hashtable)
用途主要用于存储Hash、Set、Zset等类型的数据,当这些类型的数据量较多或元素较大时。
特点哈希表内部维护了一个数组结构,通过计算key的哈希值来确定元素在数组中的位置。哈希表支持快速的查找、插入和删除操作。
4. 双向链表(LinkedList)及快速列表(QuickList)
用途双向链表用于存储List类型的数据,在Redis 3.2之前,List的底层实现是双向链表或压缩列表。Redis 3.2之后引入了快速列表(QuickList),它是双向链表和压缩列表的结合体,用于优化List类型的性能。
特点双向链表中的每个节点持有对前一个和后一个节点的引用,支持在两端进行操作。快速列表则通过多个压缩列表的组合来实现,既保留了压缩列表的空间优势,又具备了双向链表的操作灵活性。
5. 整数集合(IntSet)
用途用于存储Set类型的数据,当Set中的所有元素都是整数且数量较少时。
特点整数集合内部使用数组来存储元素,并根据元素的类型(如16位整数、32位整数、64位整数)来选择不同的编码方式。整数集合在添加新元素时,如果超出了当前数组的容量或类型范围,会进行扩容或升级。
6. 跳表(SkipList)
用途主要用于存储Zset类型的数据,以实现有序性。
特点跳表是一种具有层次结构的链表,每一层都是一个有序的链表。通过多层链表的组合,跳表能够在O(log N)时间内完成查找、插入和删除操作。同时,跳表还使用哈希表来存储成员和分数的对应关系,以提供快速的成员查找。
Redis的5种基本数据类型(String、List、Set、Hash、Zset)是由多种底层数据结构组成的,以满足不同场景下的数据存储和操作需求。
以下是这些基本数据类型与底层数据结构之间的对应关系:
(1) String(字符串)
底层数据结构:简单动态字符串(SDS)
(2) List(列表)
底层数据结构:双向链表、压缩列表(ZipList)
说明:当列表元素较少时,Redis使用压缩列表来节省内存。当元素较多时,则使用双向链表来支持快速的插入和删除操作。
(3) Set(集合)
底层数据结构:哈希表(Hash Table)、整数集合(IntSet)
说明:当集合中的元素都是整数且数量较少时,Redis使用整数集合来优化内存占用。当元素数量较多或包含非整数元素时,则使用哈希表来实现快速的添加、删除和查询操作。
(4) Hash(散列)
底层数据结构:压缩列表(ZipList)、哈希表(Hash Table)
说明:当哈希对象保存的键值对较少且键和值的字符串长度都小于64字节时,采用压缩列表作为底层实现以节省内存。当键值对较多或键和值的字符串长度较长时,则使用哈希表来实现快速的插入、查找和删除操作。
(5) Zset(有序集合)
底层数据结构:跳跃表(SkipList)、哈希表(Hash Table)
说明:跳跃表是一种有序链表的数据结构,可以提供快速的插入、删除和查找操作。通过使用跳跃表和哈希表的组合,有序集合在保持有序性的同时,还能快速地根据分数进行范围查找和排名计算。哈希表用于存储成员和分数的对应关系。
二、面试官:能否具体说说Redis的哈希表如何实现,以及如何扩容?
1. 基本结构
Redis中的哈希表由dict结构体表示,该结构体内部嵌套了dictht(哈希表)对象。dictht结构体包含以下关键字段:
- table:一个指针数组,每个元素都是一个dictEntry对象,用于存储键值对。
- size:哈希表的大小,即table数组的长度。在Redis中,哈希表的大小总是2的n次方,这有助于优化哈希冲突的处理。
- sizemask:掩码值,用于计算索引值。它总是等于size-1,通过位运算可以快速得到哈希值在数组中的索引位置。
- used:哈希表中已使用的节点数,即存储的键值对数量。
2. 键值对存储
每个dictEntry对象包含一个键值对以及指向下一个dictEntry的指针,形成链表结构。当发生哈希冲突时,新的键值对会被添加到冲突位置的链表末尾。这种设计使得Redis能够高效地处理哈希冲突,而无需进行复杂的重哈希操作。
当创建一个哈希对象时,可以得到如下简图(部分属性被省略):
3. 哈希函数
Redis使用MurmurHash2算法作为哈希函数。该算法是一种非加密哈希函数,以其高效和低碰撞率而闻名。通过MurmurHash2算法,Redis可以将任意长度的键映射为固定长度的哈希值,从而确定键在哈希表中的位置。
4. 负载因子与rehash
负载因子是衡量哈希表使用程度的指标,计算公式为已使用节点数 / 哈希表大小。当负载因子超过预设的阈值(默认为0.75)时,Redis会触发rehash操作,以扩展哈希表的大小并降低冲突概率。
Rehash操作涉及以下步骤:
(1) 分配一个新的哈希表,其大小是当前哈希表大小的2倍(或根据配置的其他倍数)。
(2) 遍历当前哈希表中的所有键值对,并根据新的哈希表大小重新计算哈希值,然后将键值对插入到新的哈希表中。
(3) 替换旧的哈希表为新的哈希表,完成rehash操作。
值得注意的是,Redis采用渐进式rehash策略来避免一次性处理大量数据导致的性能问题。在渐进式rehash期间,每次对哈希表进行增删改查操作时,都会顺带将一部分数据从旧表迁移到新表,直到迁移完成。
下面是rehash的具体过程:
- 当Redis的哈希表的负载因子超过阈值时,系统会让字典同时持有ht[0]和ht[1]两个哈希表。
- Redis会设置一个变量rehashidx来记录当前rehash的进度。rehashidx的初始值为0,表示从ht[0]的起始位置0开始迁移。
- 在rehash期间,每次对字典执行增删改查操作时会顺带将ht[0]哈希表在rehashindex位置上的所有键值对rehash到ht[1],当rehash工作完成以后,rehashindex的值+1。
- 随着字典操作的不断执行,最终会在某一时间段上ht[0]的所有键值对都会被rehash到ht[1],这时将rehashindex的值设置为-1,表示rehash操作结束。
渐进式rehash采用的是一种分而治之的方式,将rehash的操作分摊在每一个的访问中,避免集中式rehash而带来的庞大计算量。
需要注意的是在渐进式rehash的过程,如果有增删改查操作时,如果index大于rehashindex,访问ht[0],否则访问ht[1]。
渐进式rehash的优势在于它能够在不影响主线程服务请求的情况下逐渐完成哈希表的扩容或缩容,极大地降低了对系统性能的影响。然而,它也会带来一些额外的内存空间开销,因为在rehash过程中需要同时维护新旧两个哈希表。
三、面试官:Redis为什么这么快呢?Redis单机QPS能达到多少?
Redis之所以速度非常快,主要归因于以下几个关键因素:
(1) 基于内存的数据存储:Redis将数据存储在内存中,这大大减少了磁盘I/O操作的开销。相比传统数据库需要将数据从磁盘读取和写入磁盘,Redis可以非常快速地读取和写入数据,从而实现了极高的操作速率。
(2) 高效的数据结构:Redis支持多种数据结构,如字符串、哈希、列表、集合和有序集合等。这些数据结构都经过了精心设计和优化,使得数据存储和访问的时间复杂度降到最低。例如,Redis使用简单动态字符串(SDS)来处理字符串,相比C语言中的传统字符串处理方式,SDS在获取字符串长度、修改字符串以及内存分配等方面都更加高效。
(3) 合理的数据编码:Redis能够根据数据的类型和大小自动选择最优的编码方式。例如,对于字符串类型的数据,Redis会根据字符串的长度和内容的数字性选择int编码或raw编码。这种合理的编码选择使得Redis在处理不同类型和大小的数据时都能保持高性能。
(4) 单线程模型:Redis采用单线程模型来处理客户端的请求。这种模型避免了多线程之间的上下文切换和锁竞争等开销,从而提高了处理请求的速度。同时,Redis通过I/O多路复用技术来同时处理多个客户端的连接和请求,进一步提高了并发性能。
(5) 异步非阻塞I/O:Redis使用异步非阻塞I/O模型来处理网络请求。这意味着Redis可以在等待I/O操作完成时继续执行其他任务,从而提高了整体的吞吐量和响应速度。
Redis单机的QPS(每秒查询率)性能取决于多个因素,包括Redis的版本、硬件配置、操作系统、网络状况以及业务操作的复杂性等。一般来说,Redis单机版可以支持上万到几万的QPS。然而,要达到10万以上的QPS,单机版Redis可能会面临较大的压力,这时通常需要考虑使用Redis集群或其他分布式架构来分担负载。
可以通过 redis-benchmark 命令进行基准测试:
redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 10000
- -h:指定 Redis 服务器的地址,默认是 127.0.0.1。
- -p:指定 Redis 服务器的端口,默认是 6379。
- -c:并发连接数,即同时有多少个客户端在进行测试。
- -n:请求总数,即测试过程中总共要执行多少个请求。
四、面试官:你刚刚提到Redis使用单线程模型,Redis真的是一个单线程应用吗?如果是请说说看为什么使用单线程模型?
从核心操作的角度看,Redis在执行命令时确实使用单个线程进行操作,包括接收客户端请求、解析请求、数据读写等操作,以及返回结果给客户端,这些过程都是由一个主线程来完成的。这也是Redis被称为单线程数据库的原因。
然而,从整体功能和实现的角度看,Redis并不是严格意义上的单线程。在Redis 6.0之前的版本中,虽然大部分操作是由主线程完成的,但也有一些后台线程或子进程在处理任务,如清理脏数据、生成快照、AOF重写等。这些后台任务的存在是为了避免阻塞主线程,提高Redis的整体性能。
在Redis 6.0及以后的版本中,Redis引入了多线程模型来处理网络I/O的任务。这个多线程模型只用来处理网络数据的读写和协议解析,而执行读写命令的仍然是单线程。这种设计是为了充分利用服务器CPU的多核资源,提高Redis的网络I/O性能。
因此,可以说Redis在执行命令时采用单线程模型,但从整体实现和功能角度来看,它并不是完全的单线程。
Redis通过结合单线程和多线程的优势,以及利用内存和非阻塞I/O技术,实现了高性能和高效率。
在执行命令时采用单线程模型的原因如下:
1. 避免过多的上下文切换开销
多线程调度过程中必然需要在 CPU 之间切换线程上下文 context,而上下文的切换又涉及程序计数器、堆栈指针和程序状态字等一系列的寄存器置换、程序堆栈重置甚至是 CPU 高速缓存、TLB 快表的汰换,如果是进程内的多线程切换还好一些,因为单一进程内多线程共享进程地址空间,因此线程上下文比之进程上下文要小得多,如果是跨进程调度,则需要切换掉整个进程地址空间。
如果是单线程则可以规避进程内频繁的线程切换开销,因为程序始终运行在进程中单个线程内,没有多线程切换的场景。
2.避免同步机制的开销
如果 Redis 选择多线程模型,又因为 Redis 是一个数据库,那么势必涉及到底层数据同步的问题,则必然会引入某些同步机制,比如锁,而我们知道 Redis 不仅仅提供了简单的 key-value 数据结构,还有 list、set 和 hash 等等其他丰富的数据结构,而不同的数据结构对同步访问的加锁粒度又不尽相同,可能会导致在操作数据过程中带来很多加锁解锁的开销,增加程序复杂度的同时还会降低性能。
3. 简单可维护
Redis 的作者对 Redis 的设计和代码的初衷就是简洁可维护,而引入多线程必然会导致代码的复杂度上升和可维护性下降。
首先多线程的引入会使得程序不再保持代码逻辑上的串行性,代码执行的顺序将变成不可预测的,稍不注意就会导致程序出现各种并发编程的问题;其次,多线程模式也使得程序调试更加复杂和麻烦。
如果 Redis 使用多线程模式,那么所有的底层数据结构都必须实现成线程安全的,这无疑又使得 Redis 的实现变得更加复杂。
总而言之,Redis 在执行命令这种主场景下选择单线程可以说是多方博弈之后的一种权衡:在保证足够的性能表现之下,使用单线程保持代码的简单和可维护性。
五、面试官:能不能详细说说看Redis 6.0为什么引入了多线程?Redis的哪些地方用到了多线程?Redis多线程的实现机制是怎样的?
Redis 最初选择单线程网络模型的理由是:CPU 通常不会成为性能瓶颈,瓶颈往往是内存和网络,因此单线程足够了。现在 Redis 又要引入多线程是因为 Redis 的网络 I/O 瓶颈已经越来越明显了。
随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,要提升 Redis 的性能有两个方向:
- 优化网络 I/O 模块
- 提高机器内存读写的速度
后者依赖于硬件的发展,暂时无解。所以只能从前者下手,网络 I/O 的优化又可以分为两个方向:
- 零拷贝技术或者 DPDK 技术
- 利用多核优势
零拷贝技术有其局限性,无法完全适配 Redis 这一类复杂的网络 I/O 场景。而 DPDK 技术通过旁路网卡 I/O 绕过内核协议栈的方式又太过于复杂以及需要内核甚至是硬件的支持。
因此,利用多核优势的多线程模型成为了优化网络 I/O 性价比最高的方案。
在 Redis 6.0 中,多线程主要用来处理网络 IO 操作,命令解析和执行仍然是单线程完成,这样既可以发挥多核 CPU 的优势,又能避免锁和上下文切换带来的性能损耗。
图片
接下来再说说看Redis的多线程实现机制:
(1) 主线程负责命令执行:
Redis的主线程仍然负责处理客户端命令的执行,包括数据的读写操作。
(2) 多线程处理网络I/O:
- 在多线程I/O模型中,客户端请求的读取以及响应的写入等网络I/O操作被分发到多个工作线程中进行处理。
- 这些工作线程只负责网络I/O的读写和协议解析,不负责命令的具体执行。
(3) 任务分发机制:
- Redis使用全局读队列(clients_pending_read)和全局写队列(clients_pending_write)来存储待处理的网络I/O任务。
- 主线程负责将任务从全局队列分发到每个线程对应的队列中(io_threads_list)。
- 分发任务时,主线程采用轮询(Round Robin)的方式,以确保任务能够均匀分配到各个线程。
(4) 命令执行流程:
- 当客户端发送请求时,主线程负责接收请求并放入全局读队列。
- 主线程将任务分发到各个线程对应的队列中,并设置相应的标记。
- 子线程轮询检查自己的队列是否有任务,如果有则处理网络I/O读写和协议解析。
- 解析完成后,子线程将解析结果返回给主线程。
- 主线程根据解析结果执行相应的命令,并将结果放入全局写队列。
- 主线程再将写任务分发到各个线程对应的队列中,子线程负责将结果写回给客户端。
在Redis 6.0及以后的版本中,多线程默认是禁用的。要启用多线程,需要在redis.conf配置文件中设置io-threads-do-reads yes,并指定线程数(io-threads)。如果不设置线程数,多线程将不会生效。
关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了。
实际上如果开启多线程,至少要4核的机器,且Redis实例已经占用相当大的CPU耗时的时候才建议采用,否则使用多线程没有意义。所以估计80%的公司业务在不开启多线程的情况下也能正常支撑。
综上所述,Redis多线程的实现机制是通过将网络I/O操作分发到多个工作线程中进行处理,而命令的执行仍然由单线程完成。这种设计既充分利用了多核CPU的性能,又避免了多线程切换和共享资源竞争带来的开销。面试官:说说看Redis底层数据类型有哪些?Redis的5种基本数据类型是用哪几种底层数据类型组成的?
Redis的底层数据类型主要包括以下几种,它们用于实现和支撑Redis提供的五种主要数据类型(String、Hash、List、Set、Zset):
一、简单动态字符串(SDS,Simple Dynamic String)
用途:主要用于存储String类型的值。
特点SDS是Redis自己构建的一种字符串数据结构,相比C语言的传统字符串(以空字符'\0'结尾),SDS具备自动扩展、长度缓存、二进制安全等优点。SDS通过结构体来记录字符串的长度和已分配空间大小等信息,从而提高了字符串操作的性能。
二、压缩列表(ZipList)
用途用于存储Hash、List、Zset等类型的数据,当这些类型的数据量较少且元素较小时。
特点压缩列表是一组连续的内存块,能够节省空间。它内部包含多个entry节点,每个节点包含前一个节点的长度、节点内容等信息。当数据量增多或元素变大时,压缩列表可能会转换为其他数据结构(如哈希表、双向链表等)。
三、哈希表(Hashtable)
用途主要用于存储Hash、Set、Zset等类型的数据,当这些类型的数据量较多或元素较大时。
特点哈希表内部维护了一个数组结构,通过计算key的哈希值来确定元素在数组中的位置。哈希表支持快速的查找、插入和删除操作。
四、双向链表(LinkedList)及快速列表(QuickList)
用途双向链表用于存储List类型的数据,在Redis 3.2之前,List的底层实现是双向链表或压缩列表。Redis 3.2之后引入了快速列表(QuickList),它是双向链表和压缩列表的结合体,用于优化List类型的性能。
特点双向链表中的每个节点持有对前一个和后一个节点的引用,支持在两端进行操作。快速列表则通过多个压缩列表的组合来实现,既保留了压缩列表的空间优势,又具备了双向链表的操作灵活性。
五、整数集合(IntSet)
用途用于存储Set类型的数据,当Set中的所有元素都是整数且数量较少时。
特点整数集合内部使用数组来存储元素,并根据元素的类型(如16位整数、32位整数、64位整数)来选择不同的编码方式。整数集合在添加新元素时,如果超出了当前数组的容量或类型范围,会进行扩容或升级。
六、跳表(SkipList)
用途主要用于存储Zset类型的数据,以实现有序性。
特点跳表是一种具有层次结构的链表,每一层都是一个有序的链表。通过多层链表的组合,跳表能够在O(log N)时间内完成查找、插入和删除操作。同时,跳表还使用哈希表来存储成员和分数的对应关系,以提供快速的成员查找。
Redis的5种基本数据类型(String、List、Set、Hash、Zset)是由多种底层数据结构组成的,以满足不同场景下的数据存储和操作需求。
以下是这些基本数据类型与底层数据结构之间的对应关系:
String(字符串)
底层数据结构:简单动态字符串(SDS)
List(列表)
底层数据结构:双向链表、压缩列表(ZipList)
说明:当列表元素较少时,Redis使用压缩列表来节省内存。当元素较多时,则使用双向链表来支持快速的插入和删除操作。
Set(集合)
底层数据结构:哈希表(Hash Table)、整数集合(IntSet)
说明:当集合中的元素都是整数且数量较少时,Redis使用整数集合来优化内存占用。当元素数量较多或包含非整数元素时,则使用哈希表来实现快速的添加、删除和查询操作。
Hash(散列)
底层数据结构:压缩列表(ZipList)、哈希表(Hash Table)
说明:当哈希对象保存的键值对较少且键和值的字符串长度都小于64字节时,采用压缩列表作为底层实现以节省内存。当键值对较多或键和值的字符串长度较长时,则使用哈希表来实现快速的插入、查找和删除操作。
Zset(有序集合)
底层数据结构:跳跃表(SkipList)、哈希表(Hash Table)
说明:跳跃表是一种有序链表的数据结构,可以提供快速的插入、删除和查找操作。通过使用跳跃表和哈希表的组合,有序集合在保持有序性的同时,还能快速地根据分数进行范围查找和排名计算。哈希表用于存储成员和分数的对应关系。
面试官:能否具体说说Redis的哈希表如何实现,以及如何扩容?
一、基本结构
Redis中的哈希表由dict结构体表示,该结构体内部嵌套了dictht(哈希表)对象。dictht结构体包含以下关键字段:
table一个指针数组,每个元素都是一个dictEntry对象,用于存储键值对。
size哈希表的大小,即table数组的长度。在Redis中,哈希表的大小总是2的n次方,这有助于优化哈希冲突的处理。
sizemask掩码值,用于计算索引值。它总是等于size-1,通过位运算可以快速得到哈希值在数组中的索引位置。
used哈希表中已使用的节点数,即存储的键值对数量。
二、键值对存储
每个dictEntry对象包含一个键值对以及指向下一个dictEntry的指针,形成链表结构。当发生哈希冲突时,新的键值对会被添加到冲突位置的链表末尾。这种设计使得Redis能够高效地处理哈希冲突,而无需进行复杂的重哈希操作。
当创建一个哈希对象时,可以得到如下简图(部分属性被省略):
三、哈希函数
Redis使用MurmurHash2算法作为哈希函数。该算法是一种非加密哈希函数,以其高效和低碰撞率而闻名。通过MurmurHash2算法,Redis可以将任意长度的键映射为固定长度的哈希值,从而确定键在哈希表中的位置。
四、负载因子与rehash
负载因子是衡量哈希表使用程度的指标,计算公式为已使用节点数 / 哈希表大小。当负载因子超过预设的阈值(默认为0.75)时,Redis会触发rehash操作,以扩展哈希表的大小并降低冲突概率。
Rehash操作涉及以下步骤:
分配一个新的哈希表,其大小是当前哈希表大小的2倍(或根据配置的其他倍数)。
遍历当前哈希表中的所有键值对,并根据新的哈希表大小重新计算哈希值,然后将键值对插入到新的哈希表中。
替换旧的哈希表为新的哈希表,完成rehash操作。
值得注意的是,Redis采用渐进式rehash策略来避免一次性处理大量数据导致的性能问题。在渐进式rehash期间,每次对哈希表进行增删改查操作时,都会顺带将一部分数据从旧表迁移到新表,直到迁移完成。
下面是rehash的具体过程:
1. 当Redis的哈希表的负载因子超过阈值时,系统会让字典同时持有ht[0]和ht[1]两个哈希表。
2. Redis会设置一个变量rehashidx来记录当前rehash的进度。rehashidx的初始值为0,表示从ht[0]的起始位置0开始迁移。
3. 在rehash期间,每次对字典执行增删改查操作时会顺带将ht[0]哈希表在rehashindex位置上的所有键值对rehash到ht[1],当rehash工作完成以后,rehashindex的值+1。
4. 随着字典操作的不断执行,最终会在某一时间段上ht[0]的所有键值对都会被rehash到ht[1],这时将rehashindex的值设置为-1,表示rehash操作结束。
渐进式rehash采用的是一种分而治之的方式,将rehash的操作分摊在每一个的访问中,避免集中式rehash而带来的庞大计算量。
需要注意的是在渐进式rehash的过程,如果有增删改查操作时,如果index大于rehashindex,访问ht[0],否则访问ht[1]。
渐进式rehash的优势在于它能够在不影响主线程服务请求的情况下逐渐完成哈希表的扩容或缩容,极大地降低了对系统性能的影响。然而,它也会带来一些额外的内存空间开销,因为在rehash过程中需要同时维护新旧两个哈希表。
面试官:Redis为什么这么快呢?Redis单机QPS能达到多少?
Redis之所以速度非常快,主要归因于以下几个关键因素:
基于内存的数据存储Redis将数据存储在内存中,这大大减少了磁盘I/O操作的开销。相比传统数据库需要将数据从磁盘读取和写入磁盘,Redis可以非常快速地读取和写入数据,从而实现了极高的操作速率。
高效的数据结构Redis支持多种数据结构,如字符串、哈希、列表、集合和有序集合等。这些数据结构都经过了精心设计和优化,使得数据存储和访问的时间复杂度降到最低。例如,Redis使用简单动态字符串(SDS)来处理字符串,相比C语言中的传统字符串处理方式,SDS在获取字符串长度、修改字符串以及内存分配等方面都更加高效。
合理的数据编码Redis能够根据数据的类型和大小自动选择最优的编码方式。例如,对于字符串类型的数据,Redis会根据字符串的长度和内容的数字性选择int编码或raw编码。这种合理的编码选择使得Redis在处理不同类型和大小的数据时都能保持高性能。
单线程模型Redis采用单线程模型来处理客户端的请求。这种模型避免了多线程之间的上下文切换和锁竞争等开销,从而提高了处理请求的速度。同时,Redis通过I/O多路复用技术来同时处理多个客户端的连接和请求,进一步提高了并发性能。
异步非阻塞I/ORedis使用异步非阻塞I/O模型来处理网络请求。这意味着Redis可以在等待I/O操作完成时继续执行其他任务,从而提高了整体的吞吐量和响应速度。
Redis单机的QPS(每秒查询率)性能取决于多个因素,包括Redis的版本、硬件配置、操作系统、网络状况以及业务操作的复杂性等。一般来说,Redis单机版可以支持上万到几万的QPS。然而,要达到10万以上的QPS,单机版Redis可能会面临较大的压力,这时通常需要考虑使用Redis集群或其他分布式架构来分担负载。
可以通过 redis-benchmark 命令进行基准测试:
redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 10000
-h:指定 Redis 服务器的地址,默认是 127.0.0.1。
-p:指定 Redis 服务器的端口,默认是 6379。
-c:并发连接数,即同时有多少个客户端在进行测试。
-n:请求总数,即测试过程中总共要执行多少个请求。
面试官:你刚刚提到Redis使用单线程模型,Redis真的是一个单线程应用吗?如果是请说说看为什么使用单线程模型?
从核心操作的角度看,Redis在执行命令时确实使用单个线程进行操作,包括接收客户端请求、解析请求、数据读写等操作,以及返回结果给客户端,这些过程都是由一个主线程来完成的。这也是Redis被称为单线程数据库的原因。
然而,从整体功能和实现的角度看,Redis并不是严格意义上的单线程。在Redis 6.0之前的版本中,虽然大部分操作是由主线程完成的,但也有一些后台线程或子进程在处理任务,如清理脏数据、生成快照、AOF重写等。这些后台任务的存在是为了避免阻塞主线程,提高Redis的整体性能。
在Redis 6.0及以后的版本中,Redis引入了多线程模型来处理网络I/O的任务。这个多线程模型只用来处理网络数据的读写和协议解析,而执行读写命令的仍然是单线程。这种设计是为了充分利用服务器CPU的多核资源,提高Redis的网络I/O性能。
因此,可以说Redis在执行命令时采用单线程模型,但从整体实现和功能角度来看,它并不是完全的单线程。
Redis通过结合单线程和多线程的优势,以及利用内存和非阻塞I/O技术,实现了高性能和高效率。
在执行命令时采用单线程模型的原因如下:
1. 避免过多的上下文切换开销
多线程调度过程中必然需要在 CPU 之间切换线程上下文 context,而上下文的切换又涉及程序计数器、堆栈指针和程序状态字等一系列的寄存器置换、程序堆栈重置甚至是 CPU 高速缓存、TLB 快表的汰换,如果是进程内的多线程切换还好一些,因为单一进程内多线程共享进程地址空间,因此线程上下文比之进程上下文要小得多,如果是跨进程调度,则需要切换掉整个进程地址空间。
如果是单线程则可以规避进程内频繁的线程切换开销,因为程序始终运行在进程中单个线程内,没有多线程切换的场景。
2.避免同步机制的开销
如果 Redis 选择多线程模型,又因为 Redis 是一个数据库,那么势必涉及到底层数据同步的问题,则必然会引入某些同步机制,比如锁,而我们知道 Redis 不仅仅提供了简单的 key-value 数据结构,还有 list、set 和 hash 等等其他丰富的数据结构,而不同的数据结构对同步访问的加锁粒度又不尽相同,可能会导致在操作数据过程中带来很多加锁解锁的开销,增加程序复杂度的同时还会降低性能。
3. 简单可维护
Redis 的作者对 Redis 的设计和代码的初衷就是简洁可维护,而引入多线程必然会导致代码的复杂度上升和可维护性下降。
首先多线程的引入会使得程序不再保持代码逻辑上的串行性,代码执行的顺序将变成不可预测的,稍不注意就会导致程序出现各种并发编程的问题;其次,多线程模式也使得程序调试更加复杂和麻烦。
如果 Redis 使用多线程模式,那么所有的底层数据结构都必须实现成线程安全的,这无疑又使得 Redis 的实现变得更加复杂。
总而言之,Redis 在执行命令这种主场景下选择单线程可以说是多方博弈之后的一种权衡:在保证足够的性能表现之下,使用单线程保持代码的简单和可维护性。
面试官:能不能详细说说看Redis 6.0为什么引入了多线程?Redis的哪些地方用到了多线程?Redis多线程的实现机制是怎样的?
Redis 最初选择单线程网络模型的理由是:CPU 通常不会成为性能瓶颈,瓶颈往往是内存和网络,因此单线程足够了。现在 Redis 又要引入多线程是因为 Redis 的网络 I/O 瓶颈已经越来越明显了。
随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,要提升 Redis 的性能有两个方向:
优化网络 I/O 模块
提高机器内存读写的速度
后者依赖于硬件的发展,暂时无解。所以只能从前者下手,网络 I/O 的优化又可以分为两个方向:
零拷贝技术或者 DPDK 技术
利用多核优势
零拷贝技术有其局限性,无法完全适配 Redis 这一类复杂的网络 I/O 场景。而 DPDK 技术通过旁路网卡 I/O 绕过内核协议栈的方式又太过于复杂以及需要内核甚至是硬件的支持。
因此,利用多核优势的多线程模型成为了优化网络 I/O 性价比最高的方案。
在 Redis 6.0 中,多线程主要用来处理网络 IO 操作,命令解析和执行仍然是单线程完成,这样既可以发挥多核 CPU 的优势,又能避免锁和上下文切换带来的性能损耗。
接下来再说说看Redis的多线程实现机制:
主线程负责命令执行:
Redis的主线程仍然负责处理客户端命令的执行,包括数据的读写操作。
多线程处理网络I/O:
在多线程I/O模型中,客户端请求的读取以及响应的写入等网络I/O操作被分发到多个工作线程中进行处理。
这些工作线程只负责网络I/O的读写和协议解析,不负责命令的具体执行。
任务分发机制:
Redis使用全局读队列(clients_pending_read)和全局写队列(clients_pending_write)来存储待处理的网络I/O任务。
主线程负责将任务从全局队列分发到每个线程对应的队列中(io_threads_list)。
分发任务时,主线程采用轮询(Round Robin)的方式,以确保任务能够均匀分配到各个线程。
命令执行流程:
当客户端发送请求时,主线程负责接收请求并放入全局读队列。
主线程将任务分发到各个线程对应的队列中,并设置相应的标记。
子线程轮询检查自己的队列是否有任务,如果有则处理网络I/O读写和协议解析。
解析完成后,子线程将解析结果返回给主线程。
主线程根据解析结果执行相应的命令,并将结果放入全局写队列。
主线程再将写任务分发到各个线程对应的队列中,子线程负责将结果写回给客户端。
在Redis 6.0及以后的版本中,多线程默认是禁用的。要启用多线程,需要在redis.conf配置文件中设置io-threads-do-reads yes,并指定线程数(io-threads)。如果不设置线程数,多线程将不会生效。
关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了。
实际上如果开启多线程,至少要4核的机器,且Redis实例已经占用相当大的CPU耗时的时候才建议采用,否则使用多线程没有意义。所以估计80%的公司业务在不开启多线程的情况下也能正常支撑。
综上所述,Redis多线程的实现机制是通过将网络I/O操作分发到多个工作线程中进行处理,而命令的执行仍然由单线程完成。这种设计既充分利用了多核CPU的性能,又避免了多线程切换和共享资源竞争带来的开销。