简介:
在维基百科的描述中,Redis(远程字典服务器)是一个开源的内存存储系统,用作分布式内存键值数据库、缓存和消息代理,具有可选的持久性。Redis 将所有数据保存在内存中,这使其具备低延迟的读取和写入特性,特别适用于需要缓存的用例。然而,当被问及 Redis 为何快时,若仅回答 “因为数据都在内存中,读取时无需像普通数据库那样经过磁盘的慢 I/O 操作”,这样的解释是不够全面的。今天,我们将从不同角度深入探讨 Redis 快的原因。
数据结构
Redis 不仅有在接口层常见的 string、hash、list、set、sorted set 等数据结构,还包括一些不太常用的特殊数据结构,如 HyperLogLog、Bitmap、Geo。实际上,这些数据结构可以看作是全局哈希表的值对象,它们各自还有对应的底层数据结构。常见的底层数据结构共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。其与数据类型的对应关系如下图所示:
常见的五种数据结构大家都比较熟悉可以看上图就可以找到与底层数据结构的对应关系。我们来看看特殊的数据结构对应的底层数据结构:
- HyperLogLog:
底层采用一种概率算法实现,具体来说是基于调和平均数的基数估计算法,称为 HyperLogLog 算法。它使用少量的固定内存来统计非常大的集合的基数,内存占用通常只有 12k 字节左右,无论集合中有多少元素
- Bitmap:
底层实际上是一个字符串数据类型,通过对字符串的位操作来实现位图的功能。可以把字符串看作是一个由二进制位组成的数组,每个位对应一个特定的状态或标志。
- Geo(Geospatial Index):
底层使用有序集合(Sorted Set)来实现。将地理位置的经纬度编码为一个有序集合中的元素,元素的分数(score)是根据经纬度计算出来的地理位置编码值。这样可以利用有序集合的特性进行高效的地理位置查询操作。
介绍完了这些键对象的数据的数据结构,我们再来看看6种常见的底层数据结构它们不同的时间复杂度是怎么样的。
在简单动态字符串、双向链表、压缩列表、哈希表、跳表,整数数组,这六种数据结构中,双向链表,哈希表,整数数组,跳表,这这些数据结构大家都比较熟悉,这里介绍一下 简单动态字符串和压缩链表。
- 简单动态字符串
SDS的定义
struct sdshdr {
// 记录buf 数组中已使用字节的数量
// 等于SDS所保存字符串的长度
unsigned int len;
//记录buf 数组中未使用宇节的数量
unsigned int free;
// 字节数组,用于保存字符串
char buf[];
};
相较于 C 中普通的字符串,SDS 有其独特之处
- 压缩链表
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。
下面是各种数据结构的时间复杂度表格
全局哈希表
Redis 并没有直接使用这些数据结构来实现键值对数据库,而是基于(简单动态字符串、双向链表、压缩列表、哈希表、跳表,整数数组)这些数据结构创建 了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种我们前面所介绍的数据结构,为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对就是上面所说的全局哈希表。Redis的全局哈希表是通过哈希桶(链表)来解决哈希冲突,其示意图如下。
可以看到Redis的全局哈希表和我们常见的哈希表其实是一样的。但是有所不同的是Redis的entry是由键对象和值对象构成的。
可以看到,Redis 的全局哈希表与常见哈希表类似,但不同之处在于 Redis 的 entry 是由键对象和值对象构成的。对于 Redis 数据库保存的键值对来说,键总是 REDIS_STRING 字符串对象,而值可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象中的一种。并且每种值对象又对应不同的底层数据结构,例如 list 类型的值对象可以是压缩链表或者双向链表。
渐进式哈希
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(loadfactor )维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行 rehash (重新散列)操作来完成,Redis的rehash不同于普通的哈希表的rehash一次性的将整个哈希表进行rehash,而是采用了一种循序渐进的方式进行rehash,以扩容为例我们来看看redis是如何执行的。
为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 ht[0] 和哈希表 ht[1]。一开始,当你刚插入数据时,默认使用哈希表 h[0],此时的哈希表 ht[1]并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
- 给哈希表 ht[1] 分配更大的空间,例如是当前哈希表 ht[0] 大小的两倍;
- 把哈希表 ht[0] 中的数据重新映射并拷贝到哈希表 ht[1] 中;
- 释放哈希表 ht[0] 的空间。
到此,我们就可以从哈希表 ht[0] 切换到哈希表 ht[1],用增大的哈希表 ht[1]保存更多数据,而原来的哈希表 ht[0] 留作下一次 rehash 扩容备用。
这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表 ht[0] 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。redis用了一个巧妙的方法将第二步耗时操作变为一个循序渐进的过程。以下是redis的rehash步骤
- 为ht [1]分配空间,让字典同时持有ht [0]和ht [2]两个哈希表
- 在字典中维持一个索引计数器变量 rehashidx, 并 将 它 的 值 设 置 为 0 , 表 示 rehash 工作正式开始。
- 在rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除 了执行指定的操作以外,还会顺带将ht [0]哈希表在rehashidx索引上的所有键值对rehash到 h t [1 ], 当rehash 工作 完 成 之 后 , 程 序 将rehashidx属 性 的 值 增 一
- 随着字典操作的不断执行,最终在某个时间点上,ht [0〕的所有键值对都会被 rehash 至 h t [1 ], 这 时 程 序 将rehashidx 属 性 的 值 故 为 - 1 , 表 示 rehash操 作 已 完 成
以下是rehash的示意图
到此redis的数据结构我们就说完了,正因为以上的这些数据结构的存在使得redis在存储数据的时候可以选择合适的数据结构来保证redis的性能。
线程模型
单线程的redis为什么这么’快‘,Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。所以,严格来说,Redis 并不是单线程
接下来我们看看我们所说的这个单线程都干了哪些活,其实可以将这个单线程干的活分为两部分,一部分是网络io一部分是键值对的读写操作,关于键值对的读写这部分工作其实比较快,一方面是因为Redis的数据都在内存中,另一方面就是Redis各种数据结构的支持使得键值对的读写操作很快,这里就不做过多的介绍了,我们重点来看一下网络IO这一部分。
Redis 基于Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器, Redis服务器通过套接字与客户端服务器 进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其 他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来 完成一系列网络IO。
- 文件事件处理器使用I/ O多路复用(multiplexing )程序来同时监听多个套接字,并 根据套接字目前执行的任务来为套接字关联不同的事件处理器
- 当被监 听 的 套 接 字 准 备 好 执 行 连 接 应 答 (accept)、 读 取 ( read )、 写 人(writ)、 关 闭 (close )等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就 会调用套接字之前关联好的事件处理器来处理这些事件
虽然文件事件处理器以单线程方式运行,但通过使用I O多路复用程序来监听多个套接 字 , 文件 事 件 处 理 器 既 实 现 了高 性 能 的 网 络 通 信 模 型 , 又 可 以 很 好 地 与 Redis服 务 器 中其 他 同样以单线程方式运行的模块进行对接,这保持了Redis 内部单线程设计的简单性
下图展示了文件事件处理器的四个组成部分,它们分别是套接字、1/ O多路复用程 序、文件事件分派器(dispatcher ),以及事件处理器。
事件处理器 文件事件是对套接字操作的抽象,每当 一个套接字准备好执行连接应答命令请求处理器 写 人 、 读 取 、 关 闭 等 操 作 时 , 就会产生 一个文件事件。因为 一个服务器命令回复处理器 通常会连接多个套接字,所以多个文件事连接应答处理器 件有可能会并发地出现
IO多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。尽管多个文件事件可能会并发地出现,但IO多路复用程序总是会将所有产 生事件的套接字都放到一个臥列里面,然后通过这个队列,以有序(sequentially)、同步 (synchronously)、每次 一个套接字的方式向文件事件分派器传送套接字。当上 一个套接字产 生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),1/O多路复用 程序才会继续向文件事件分派器传送下一个套接字,如图12-2 所示
文件事件分派器接收1/ O多路复用程序传来的套接字,并根据套接字产生的事件的类 型,调用相应的事件处理器。 服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是 一个个函数, 它们定义了某个事件发生时,服务器应该执行的动作。
IO多路复用的实现
Redis的IO多路复用程序的所有功能都是通过包装常见的select、epoll、evport和kqueue这些IO多路复用函数库来实现的,每个IO多路复用函数库在Redis源码中都对应一个单独的文件,比如ae_select.c、ae_epoll.c、ae_kqueue.c,诸如此类。因为Redis为每个IO多路复用函数库都实现了相同的API,所以IO多路复用程序的底层实现是可以互换的
Redis在IO多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的IO多路复用函数库来作为Redis的IO多路复用程序的底层实现:
现在,我们知道了,Redis 单线程是指它对网络 IO 和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的 Redis 也能获得高性能,跟多路复用的 IO 模型密切相关,因为这避免了 accept() 和 send()/recv() 潜在的网络 IO 操作阻塞点。
持久化:
Redis 的持久化有 AOF 和 RDB 两种方式。
AOF(Append Only File)
我们要知道AOF也是在redis的主线程中执行的,所以也直接影响Redis ’快不快‘,我们来看redis是如何实现aof日志的,说到日志,我们比较熟悉的是数据库的写前日志(Write Ahead Log, WAL),也就是说,在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复。不过,AOF 日志正好相反,它是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志。
那 AOF 为什么要先执行命令再记日志呢?要回答这个问题,我们要先知道 AOF 里记录了什么内容。传统数据库的日志,例如 redo log(重做日志),记录的是修改后的数据,而 AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。我们以 Redis 收到“set testkey testvalue”命令后记录的日志为例,看看 AOF 日志的内容。其中,“*3”表示当前命令有三个部分,每部分都是由“3 set”表示这部分有 3 个字节,也就是“set”命令。
为了避免额外的检查开销,Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。
除此之外,AOF 还有一个好处:它是在命令执行后才记录日志,所以不会阻塞当前的写操作。
AOF 的潜在风险及应对策略
不过,AOF 也有两个潜在的风险。首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。其次,AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了
这两个风险都是和 AOF 写回磁盘的时机相关的。这也就意味着,如果我们能够控制一个写命令执行完后 AOF 日志写回磁盘的时机,这两个风险就解除了。
aof的写回模式有三种
- Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
- Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
- No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
总结一下就是:想要获得高性能,就选择 No 策略;如果想要得到高可靠性保证,就选择 Always 策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择 Everysec 策略。
RDB(Redis database)
提到rdb大家都会联想到内存快照。所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。这就类似于照片,当你给朋友拍照时,一张照片就能把朋友一瞬间的形象完全记下来。对 Redis 来说,它实现类似照片记录效果的方式,就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这个快照文件就称为 RDB 文件,其中,RDB 就是 Redis DataBase 的缩写。
和 AOF 相比,RDB 记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我们可以直接把 RDB 文件读入内存,很快地完成恢复。
听起来好像很不错,但内存快照也并不是最优选项。为什么这么说呢?我们还要考虑两个关键问题:
- 对哪些数据做快照?这关系到快照的执行效率问题;
- 做快照时,数据还能被增删改吗?这关系到 Redis 是否被阻塞,能否同时正常处理请求。
对哪些数据做快照
Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中。
当你给一个人拍照时,只用协调一个人就够了,但是,拍 100 人的大合影,却需要协调 100 个人的位置、状态,等等,这当然会更费时费力。同样,给内存的全量数据做快照,把它们全部写入磁盘也会花费很多时间。而且,全量数据越多,RDB 文件就越大,往磁盘上写数据的时间开销就越大。
对于 Redis 而言,它的单线程模型就决定了,我们要尽量避免所有会阻塞主线程的操作,所以,针对任何操作,我们都会提一个灵魂之问:“它会阻塞主线程吗?”RDB 文件的生成是否会阻塞主线程,这就关系到是否会降低 Redis 的性能。
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。
- save:在主线程中执行,会导致阻塞;
- bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。
我们可以通过 bgsave 命令来执行全量快照,这既提供了数据的可靠性保证,也避免了对 Redis 的性能影响。
快照时数据能修改吗?
在给别人拍照时,一旦对方动了,那么这张照片就拍糊了,我们就需要重拍,所以我们当然希望对方保持不动。对于内存快照而言,我们也不希望数据“动”。举个例子。我们在时刻 t 给内存做快照,假设内存数据量是 4GB,磁盘的写入带宽是 0.2GB/s,简单来说,至少需要 20s(4/0.2 = 20)才能做完。如果在时刻 t+5s 时,一个还没有被写入磁盘的内存数据 A,被修改成了 A’,那么就会破坏快照的完整性,因为 A’不是时刻 t 时的状态。因此,和拍照类似,我们在做快照时也不希望数据“动”,也就是不能被修改。但是,如果快照执行期间数据不能被修改,是会有潜在问题的。对于刚刚的例子来说,在做快照的 20s 时间里,如果这 4GB 的数据都不能被修改,Redis 就不能处理对这些数据的写操作,那无疑就会给业务服务造成巨大的影响。
为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。
到这里,我们就解决了对“哪些数据做快照”以及“做快照时数据能否修改”这两大问题:Redis 会使用 bgsave 对当前内存中的所有数据做快照,这个操作是子进程在后台完成的,这就允许主线程同时可以修改数据。
通过数据结构、线程模型和持久化这三个角度的分析,我们就能更全面地理解 Redis 为何如此之快。下次再有人问起 Redis 为什么快时,相信我们能给出更深入、准确的回答
参考资料
《Redis设计与实现》
《Redis核心技术与实战》
Redis 3.0.0 源码
维基百科