基础架构、数据结构与 IO 模型
参考:
# 1. 基础架构:一个键值数据库包含什么?
我们先建立起系统观,再深入理解和优化 Redis。今天,先构造一个简单的 KV 数据库,我们只需要关注整体架构和核心模块,并对其进行剖析。
我把这个简单的键值数据库称为 SimpleKV,它只是一个具有关键组件的 KV 数据库架构。
这里的 SimpleKV 与 GitHub 上同名的项目并不是一个东西。
开始构造 SimpleKV 时,首先就要考虑里面可以存什么样的数据,对数据可以做什么样的操作,也就是数据模型和操作接口。
对于Redis来说,它到底能做什么,不能做什么呢?只有先搞懂它的数据模型和操作接口,我们才能真正把“这块好钢用在刀刃上”。
# 1.1 可以存哪些数据?
对于键值数据库而言,基本的数据模型是 key-value 模型:
- key 是 String 类型
- value 是基本数据类型
对于实际生产环境中的键值数据库来说,value 类型可以是一些复杂类型。
不同键值数据库支持的key类型一般差异不大,而value类型则有较大差别。我们在对键值数据库进行选型时,一个重要的考虑因素是它支持的value类型。例如,Memcached 支持的 value 类型仅为 String 类型,而 Redis 支持的 value 类型包括了 String、哈希表、列表、集合等。Redis能够在实际业务场景中得到广泛的应用,就是得益于支持多样化类型的 value。
不同 value 类型的实现存在各种复杂度之间的差异,因此用于满足不同业务的需求。明白这些原理才能去选择。
# 1.2 可以对数据做什么操作?
SimpleKV 是一个简单的键值数据库,因此,基本操作无外乎增删改查:
- PUT:新写入或更新一个 key-value 对;
- GET:根据一个 key 读取相应的 value 值;
- DELETE:根据一个 key 删除整个 key-value 对。
需要注意的是, 有些键值数据库的新写/更新操作叫 SET。新写入和更新虽然是用一个操作接口,但在实际执行时,会根据 key 是否存在而执行相应的新写或更新流程。
在实际的业务场景中,我们经常会碰到这种情况:查询一个用户在一段时间内的访问记录。这种操作在键值数据库中属于 SCAN 操作,即根据一段key的范围返回相应的value值。因此,PUT/GET/DELETE/SCAN是一个键值数据库的基本操作集合。
此外,实际业务场景通常还有更加丰富的需求,比如 EXISTS 接口。
现在,数据模型和操作接口我们就构造完成了,这是我们的基础工作。接下来更进一步,需要考虑一个非常重要的设计问题: 键值对保存在内存还是外存?
- 保存在内存的好处是读写很快,潜在风险是一旦掉电,所有的数据都会丢失。
- 保存在外存虽可以避免数据丢失,但受限于磁盘性能,整体数据库的性能就会被拉低。
因此, 如何进行设计选择,我们通常需要考虑键值数据库的主要应用场景。比如,缓存场景下的数据需要能快速访问但允许丢失,那么,用于此场景的键值数据库通常采用内存保存键值数据。Memcached 和 Redis 都是属于内存键值数据库。
为了和Redis保持一致,我们的 SimpleKV 就采用内存保存键值数据。接下来,我们来了解下 SimpleKV 的基本组件。大体来说,一个键值数据库包括了访问框架、索引模块、操作模块和存储模块四部分:
# 1.3 采用什么访问模式?
访问模式通常有两种:
- 一种是通过函数库调用的方式供外部应用使用。比如上图的 libsimplekv.so
- 一种是通过网络框架以 Socket 通信的形式对外提供 KV 操作
实际的键值数据库也基本采用上述两种方式,例如,RocksDB 以动态链接库的形式使用,而 Memcached 和 Redis 则是通过网络框架访问。
通过网络框架提供键值存储服务,一方面扩大了键值数据库的受用面,但另一方面,也给键值数据库的性能、运行模型提供了不同的设计选择,带来了一些潜在的问题。
KV 数据库通过接收网络数据包,并按照协议进行解析得到操作,然后执行。此时,我们会遇到一个系统设计上的问题,简单来说,就是网络连接的处理、网络请求的解析,以及数据存取的处理,是用一个线程、多个线程,还是多个进程来交互处理呢?该如何进行设计和取舍呢?我们一般把这个问题称为 I/O模型设计。不同的I/O模型对键值数据库的性能和可扩展性会有不同的影响:
- 如果使用单线程,那一个线程既要处理网络连接、解析请求,又要完成数据存取,一旦某一步操作发生阻塞,整个线程就会阻塞住,这就降低了系统响应速度。
- 如果使用多线程,那不同线程间如果需要访问共享资源,那又会产生线程竞争,也会影响系统效率。
因此我们需要进行精心的设计来面对这个两难的问题。
也许你听说过 Redis 是单线程,那 Redis 是如何做到“单线程、高性能”的呢?
# 1.4 如何定位键值对的位置?
SimpleKV 查找某个 KV 对的操作依赖于 KV 数据库的索引模型。索引的作用是让键值数据库根据key找到相应value的存储位置,进而执行操作。
索引的类型有很多,常见的有哈希表、B+树、字典树等。不同的索引结构在性能、空间消耗、并发控制等方面具有不同的特征。Memcached 和 Redis 采用哈希表作为 key-value 索引,而 RocksDB 则采用跳表作为内存中 key-value 的索引。
SimpleKV的索引根据 key 找到 value 的存储位置即可。但是,和 SimpleKV 不同,对 Redis 而言,它的 value 支持多种类型,当我们通过索引找到一个 key 所对应的 value 后,仍然需要从 value 的复杂结构(例如集合和列表)中进一步找到我们实际需要的数据,这个操作的效率本身就依赖于它们的实现结构。
Redis 采用一些常见的高效索引结构作为某些 value 类型的底层数据结构,这一技术路线为 Redis 实现高性能访问提供了良好的支撑。
# 1.5 不同操作的具体逻辑是怎样的?
SimpleKV 找到数据的存储位置后,对于不同的操作,其进一步执行的操作逻辑也有所差异。
SimpleKV 的操作模块就实现了不同操作的具体逻辑:
- GET/SCAN:立刻返回当前位置的 value 即可;
- PUT:为新 KV pair 分配内存空间;
- DELETE:删除 KV pair,并释放相应内存空间,这个过程由分配器完成。
对于 PUT 或 DELETE 操作,涉及到了分配和释放内存,这就需要 SimpleKV 数据库的存储模块了。
# 1.6 如何实现重启后快速提供服务?
SimpleKV 采用了常用的内存分配器 glibc 的 malloc 和 free,因此,SimpleKV 并不需要特别考虑内存空间的管理问题。
但是,KV 数据库的 KV pair 通常大小不一,glibc 的分配器在处理随机的大小内存块分配时表现并不好。一旦保存的键值对数据规模过大,就可能会造成较严重的内存碎片问题。
因此,分配器是 KV 数据库的一个关键因素。Redis 的内存分配器提供了多种选择,分配效率也不一样,后面会对此展开讲述。
SimpleKV 虽然依赖于内存保存数据,提供快速访问,但是,我也希望 SimpleKV 重启后能快速重新提供服务,所以,我在 SimpleKV 的存储模块中增加了持久化功能。不过,鉴于磁盘管理要比内存管理复杂,SimpleKV 就直接采用了文件形式,将键值数据通过调用本地文件系统的操作接口保存在磁盘上。
此时,SimpleKV 只需要考虑何时将内存中的键值数据保存到文件中:
- 一种方式是,每次 KV pair 的修改都进行落盘操作。虽然这更可靠,但性能会受到很大影响。
- 一种方式是,周期性地把内存中的 KV pair 同步到文件中。但这面临数据丢失的风险。
Redis 也提供了持久化功能。不过,为了适应不同的业务场景,Redis 为持久化提供了诸多的执行机制和优化改进。后面会对 Redis 的持久化机制的设计进行讨论。
# 1.7 小结
在了解了这个 SimpleKV 的基本组件后,再学习 Redis 这个丰富版的 SimpleKV 时就会轻松很多。为了支持更加丰富的业务场景,Redis对这些组件或者功能进行了扩展,或者说是进行了精细优化,从而满足了功能和性能等方面的要求。
从 SimpleKV 到 Redis,主要有以下重要变化:
- Redis 主要通过网络框架进行访问,而不再是动态库了。
- value 的类型更加丰富,因此可支持的操作也更加丰富。
- Redis的持久化模块能支持两种方式:日志(AOF)和快照(RDB)。
- SimpleKV 是个简单的单机键值数据库,但是,Redis 支持高可靠集群和高可扩展集群,因此,Redis 中包含了相应的集群功能支撑模块。
# 2. 数据结构:快速的Redis有哪些慢操作?
Redis 的“快”有一个重要表现:它接收到一个键值对操作后,能以微秒级别的速度找到数据,并快速完成操作。
数据库这么多,为啥 Redis 能有这么突出的表现呢:
- 一方面,操作在内存中完成
- 另一方面,归功于它的数据结构,高效的数据结构是 Redis 快速处理数据的基础。
这里说的数据结构指的是数据库中 kv pair 的底层实现方式
简单来说,底层数据结构一共有 6 种:简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。它们和数据类型的对应关系如下图所示:
可以看到,String 类型的底层实现只有一种数据结构,也就是简单动态字符串。而 List、Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构。通常情况下,我们会把这四种类型称为集合类型,它们的特点是一个键对应了一个集合的数据。
看到这里,其实有些问题已经值得我们去考虑了:
- 这些数据结构都是值的底层实现,键和值本身之间用什么结构组织?
- 为什么集合类型有那么多的底层结构,它们都是怎么组织数据的,都很快吗?
- 什么是简单动态字符串,和常用的字符串是一回事吗?
接下来,我就和你聊聊前两个问题。这样,你不仅可以知道 Redis“快”的基本原理,还可以借此理解 Redis 中有哪些潜在的“慢操作”,最大化 Redis 的性能优势。而关于简单动态字符串,会在后面的课程继续讨论。
# 2.1 key 和 value 用什么结构组织?
为了实现从 key 到 value 的快速访问,Redis 使用了一个哈希表来保存所有键值对。
一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。哈希桶中的元素保存的不是值本身,而是一个 pointer,这样不管 value 是 String 还是 collection 类型,哈希桶中的元素都是指向它们的 pointer。
如下图所示,哈希桶中的 entry 元素中保存了 key 和 value 的 pointer:
因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。使用 hash table 可以在 O(1) 时间复杂度下访问到相应的 entry 元素。
但是,随着往 Redis 写入大量数据后,会发现操作有时候突然变慢了,因为有一个潜在的风险点:哈希表的冲突问题和 rehash 可能带来的操作阻塞。
# 2.2 为什么哈希表操作变慢了——哈希冲突
当数据增多,哈希冲突是不可避免的问题。
Redis 解决哈希冲突的方式,就是链式哈希:同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。
但随着哈希冲突越来越多,一条哈希冲突链的逐 entry 查找会降低性能。所以,Redis 会对哈希表做 rehash 操作:增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
rehash 具体怎么做呢?:
为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
- 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
- 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
- 释放哈希表 1 的空间。
到此,我们就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。
这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。为了避免这个问题,Redis 采用了渐进式 rehash:
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
到这里,你就能理解 Redis 的 key 和 value 是怎么通过哈希表来组织的了。
- 对于 value 为 String 类型的来说,找到 entry 就可以直接 CRUD 了,操作复杂度 O(1)。
- 对于 value 为 collection 类型的来说,找到 entry 后还要在集合中进一步操作。
接下来来看在 collection 类型中的操作效率又是怎样的。
# 2.3 集合数据的操作效率
和 String 类型不同,一个集合类型的 value,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。那么,集合的操作效率和哪些因素相关呢?
- 与底层的数据结构有关
- 与操作本身的执行特点有关
接下来,我们就分别聊聊集合类型的底层数据结构和操作复杂度。
# 2.3.1 有哪些底层数据结构
集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表:
- 哈希表的操作我们已经了解了;
- 整数数组和双向链表平时很常见,其操作特征是顺序读写,操作复杂度往往是 O(N);
- 压缩跳表和跳表平时接触不多,但也是 Redis 的重要数据结构,下面重点解释这部分。
压缩列表:
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段:
- zlbytes:列表长度
- zltail:列表尾的偏移量
- zllen:列表中的 entry 的个数
跳表:
有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:
如果我们要在链表中查找 33 这个元素,只能从头开始遍历链表,查找 6 次,直到找到 33 为止。此时,复杂度是 O(N),查找效率很低。
为了提高查找速度,我们来增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。例如,从前两个元素中抽取元素 1 作为一级索引,从第三、四个元素中抽取元素 11 作为一级索引。此时,我们只需要 4 次查找就能定位到元素 33 了。
如果我们还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。例如,从一级索引中抽取 1、27、100 作为二级索引,二级索引指向一级索引。这样,我们只需要 3 次查找,就能定位到元素 33 了。
可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是
好了,我们现在可以按照查找的时间复杂度给这些数据结构分下类了:
# 2.3.2 不同操作的复杂度
集合类型的操作类型很多,有读写单个集合元素的,例如 HGET、HSET,也有操作多个元素的,例如 SADD,还有对整个集合进行遍历操作的,例如 SMEMBERS。这么多操作,它们的复杂度也各不相同。而复杂度的高低又是我们选择集合类型的重要依据。
我总结了一个“四句口诀”,希望能帮助你快速记住集合常见操作的复杂度。这样你在使用过程中,就可以提前规避高复杂度操作了:
- 单元素操作是基础;
- 范围操作非常耗时;
- 统计操作通常高效;
- 例外情况只有几个。
第一,单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。例如,Hash 类型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等。这些操作的复杂度由集合采用的数据结构决定,例如,HGET、HSET 和 HDEL 是对哈希表做操作,所以它们的复杂度都是 O(1);Set 类型用哈希表作为底层数据结构时,它的 SADD、SREM、SRANDMEMBER 复杂度也是 O(1)。
这里,有个地方你需要注意一下,集合类型支持同时对多个元素进行增删改查,例如 Hash 类型的 HMGET 和 HMSET,Set 类型的 SADD 也支持同时增加多个元素。此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的。例如,HMSET 增加 M 个元素时,复杂度就从 O(1) 变成 O(M) 了。
第二,范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,比如 List 类型的 LRANGE 和 ZSet 类型的 ZRANGE。这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。
不过,Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于 HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞。
第三,统计操作,是指集合类型对集合中所有元素个数的记录,例如 LLEN 和 SCARD。这类操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。
第四,例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。
# 2.4 小结
这一大节学习了 Redis 的底层数据结构,包括 Redis 整体的用来保存每个 KV 的全局哈希表结构,也包括支持集合类型实现的双向链表、压缩列表、整数数组、哈希表和跳表这五大底层结构。
Redis 之所以能快速操作键值对,一方面是因为 O(1) 复杂度的哈希表被广泛使用,包括 String、Hash 和 Set,它们的操作复杂度基本由哈希表决定,另一方面,Sorted Set 也采用了 O(logN) 复杂度的跳表。不过,集合类型的范围操作,因为要遍历底层数据结构,复杂度通常是 O(N)。这里,我的建议是:用其他命令来替代,例如可以用 SCAN 来代替,避免在 Redis 内部产生费时的全集合遍历操作。
当然,我们不能忘了复杂度较高的 List 类型,它的两种底层实现结构:双向链表和压缩列表的操作复杂度都是 O(N)。因此,我的建议是:因地制宜地使用 List 类型。例如,既然它的 POP/PUSH 效率很高,那么就将它主要用于 FIFO 队列场景,而不是作为一个可以随机读写的集合。
Redis 数据类型丰富,最好的办法就是掌握原理,从原理上推断不同操作的复杂度,从而快速合理地做出选择。
# 3. 高性能 IO 模型:为什么单线程还能这么快?
这一大节探讨:为什么单线程的 Redis 能那么快?
首先要厘清一个事实,我们通常说的 Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
所以,严格来说,Redis 并不是单线程,但是我们一般把 Redis 称为单线程高性能,这样显得“酷”些。因此接下来我们也称 Redis 为单线程模式。
要弄明白这节提出的问题,我们就要深入地学习下 Redis 的单线程设计机制以及多路复用机制。之后你在调优 Redis 性能时,也能更有针对性地避免会导致 Redis 单线程阻塞的操作,例如执行复杂度高的命令。
# 3.1 Redis 为什么用单线程——多线程的并发访问控制问题
要更好地理解 Redis 为什么用单线程,我们就要先了解多线程的开销。
对于一个多线程的系统来说,在有合理的资源分配的情况下,可以增加系统中处理请求操作的资源实体,进而提升系统能够同时处理的请求数,即吞吐率。但通常情况下如果没有良好的设计,实际多线程的表现可能并不好,如下图,左边是我们的期望,右边是可能的实际表现:
出现这种情况的一个关键瓶颈就是:系统通常会存在被多线程同时访问的共享资源,为保证正确性的机制会带来额外的开销。这就是多线程编程模式面临的共享资源的并发访问控制问题。
并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。
# 3.2 单线程 Redis 为什么那么快——多路复用机制
Redis 使用单线程模型却达到了每秒数十万级别的处理能力,这是为什么呢?其实,这是 Redis 多方面设计选择的一个综合结果:
- 一方面,大部分操作在内存上完成,再加上采用了高效的数据结构。
- 另一方面,Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。
首先,我们要弄明白网络操作的基本 IO 模型和潜在的阻塞点。毕竟,Redis 采用单线程进行 IO,如果线程被阻塞了,就无法进行多路复用了。
# 3.2.1 基本 IO 模型与阻塞点
一次网络交互的过程:以 Get 请求为例,SimpleKV 为了处理一个 Get 请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向 socket 中写回数据(send)。下图显示了这一过程:
由于 Redis 是单线程,而网络 IO 有潜在的阻塞点,分别是 accept() 和 recv(),从而可能导致 Redis 整个线程阻塞。幸运的是,socket 网络模型本身支持非阻塞模式。
# 3.2.2 非阻塞模式
Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用 socket 非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。接下来,我们就重点学习下它们。
在 socket 模型中,不同操作调用后会返回不同的套接字类型:
- socket() 方法会返回主动套接字;
- 然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求;
- 最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。
针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。
虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。
类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。
这样才能保证 Redis 线程,既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据。
到此,Linux 中的 IO 多路复用机制就要登场了。
# 3.2.3 基于多路复用的高性能 I/O 模型
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
下图就是基于多路复用的 Redis IO 模型。图中的多个 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。
那么,回调机制是怎么工作的呢?其实,select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。
这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
为了方便你理解,我再以连接请求和读数据请求为例,具体解释一下。这两个请求分别对应 Accept 事件和 Read 事件,Redis 分别对这两个事件注册 accept 和 get 回调函数。当 Linux 内核监听到有连接请求或读数据请求时,就会触发 Accept 事件和 Read 事件,此时,内核就会回调 Redis 相应的 accept 和 get 函数进行处理。
这就像病人去医院瞧病。在医生实际诊断前,每个病人(等同于请求)都需要先分诊、测体温、登记等。如果这些工作都由医生来完成,医生的工作效率就会很低。所以,医院都设置了分诊台,分诊台会一直处理这些诊断前的工作(类似于 Linux 内核监听请求),然后再转交给医生做实际诊断。这样即使一个医生(相当于 Redis 单线程),效率也能提升。
不过,需要注意的是,即使你的应用场景中部署了不同的操作系统,多路复用机制也是适用的。因为这个机制的实现有很多种,既有基于 Linux 系统下的 select 和 epoll 实现,也有基于 FreeBSD 的 kqueue 实现,以及基于 Solaris 的 evport 实现,这样,你可以根据 Redis 实际运行的操作系统,选择相应的多路复用实现。
# 3.3 小结
我们重点学习了 Redis 线程的三个问题:
- Redis 真的只有单线程吗?
- 为什么用单线程?
- 单线程为什么这么快?
Redis 单线程是指它对网络 IO 和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的 Redis 也能获得高性能,跟多路复用的 IO 模型密切相关,因为这避免了 accept() 和 send()/recv() 潜在的网络 IO 操作阻塞点。
另外,我也剧透下,可能你也注意到了,2020 年 5 月,Redis 6.0 的稳定版发布了,Redis 6.0 中提出了多线程模型。那么,这个多线程模型和这节课所说的 IO 模型有什么关联?会引入复杂的并发控制问题吗?会给 Redis 6.0 带来多大提升?关于这些问题,我会在后面的课程中和你具体介绍。
问题:Redis 的基本 IO 模型中还有哪些潜在的性能瓶颈?
在主线程执行操作期间,任何耗时的操作都是潜在的性能瓶颈,除了网络 IO 外,还包括 bigkey、全量返回等操作。