Redis - 数据结构和持久化机制
前言
Redis
这块的复习,看的是蒋德钧老师的相关文章,主要是想Redis
实战相关的一些理论知识和解决方案。
Redis
是一种典型的键值数据库,即Key-Value
形式的一种存储关系。
Redis
是一种内存数据库,数据都保存在内存上。
一. Redis 底层数据结构
Redis
中常见的数据类型有5大类:
String
:字符串。List
:列表。Hash
:哈希。Set
:集合。Sorted Set
:有序集合。
Redis
中底层的数据结构一共有6大类(实际上也就是Value
的存储形式):
- 简单动态字符串。
- 双向链表。
- 压缩列表。
- 哈希表。
- 跳表。
- 整数数组。
两者的映射关系图如下:
除了String
类型,其他的List、Hash、Sorted Set、Set
类型统称为集合类型,一个键对应一个集合的数据。
1.1 键值之间的组织结构
Redis
中使用一个哈希表来保存所有的键值对,也因此叫做全局哈希表。
- 哈希表实际上就是一个数组,由多个哈希桶组成,数组元素就是一个哈希桶。
- 每个哈希桶又保存了键值对数据。
- 键值对数据并不是值本身,而是指向值的指针。 这里无论是
key
还是value
都是存的指针。
如图:
由于Key - Value
之间的存储关系是一个哈希表。因此我们可以用O(1)
的时间复杂度来查找到键值对。但是,当Redis
中写入的数据量越来越多的时候,操作有可能会突然变慢了,是因为哈希表结构存在着哈希冲突问题以及 rehash
操作带来的操作阻塞。
1.2 哈希冲突和 rehash
我们知道,哈希表中每个哈希桶都会存储若干个键值对(entry
),而这些键值对存储于哪一个哈希桶中,由Key
的哈希值来决定。那么哈希冲突也就是两个 key
的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。
每个哈希桶中元素(entry
)之间的组织方式又是什么呢?每个entry
之间会通过 next
指针相连,成一个链表。就是哈希冲突链。目的是为了在发生哈希冲突的时候,可以通过哈希冲突链进行逐一的元素查找操作。如图:
虽然哈希冲突链用于解决哈希冲突,但是我们上文也提到了一个字眼,会根据指针进行逐一的查找
,也就是O(N)
的一个时间复杂度。因此倘若哈希冲突越严重,即同一个哈希桶中的元素越多,那么对应元素的查找时间也就越久。
而这样的操作对于Redis
来说是不可接受的,因为Redis
就是追求快,因此Redis
此时会对哈希表做rehash
操作。也就是增加现有的哈希桶数量,让entry
元素能够在更多地哈希桶之间分散保存,从而减少单个哈希桶中的元素数量。
Redis
准备了两个全局哈希表,简称H1
和H2
。平时插入的元素假设都默认使用H1
,这时候H2
并没有被分配空间。 当数据到达一定程度,阈值一般是当前哈希表总容量的0.75倍
,Redis
开始执行rehash
。
rehash
的一个大概流程:
H2
分配更大的空间,是H1
容量的两倍。- 将
H1
中的数据映射并拷贝给H2
中。 - 释放掉
H1
的空间。
下一次rehash
以此类推,H1
变两倍… 还需要注意的一点是,在数据的拷贝过程中,如果一次性拷贝所有的数据,会造成Redis
线程的阻塞,无法服务其他的请求。因此采用了渐进式rehash
。
1.2.1 渐进式 rehash
渐进式rehash
也就是在拷贝数据阶段下,Redis
每处理一个请求,就会从H1
的某个索引位置开始,将该索引对应的哈希桶上的所有entry
拷贝到H2
中,等下一个请求的时候,则拷贝下一个自然顺序的所有entry
。如图:
在渐进式rehash
的过程中,会同时使用H1
和H2
两个哈希表,新添加到字典的键值对一律会被保存到H2
里面,而H1
则不再进行任何添加操作,这一措施保证了H1
包含的键值对数量会只减不增,并随着rehash
操作的执行而最终变成空表。
一句话总结就是:将一次性大量拷贝的过程拆分为多次处理请求的过程中。
最后,我们知道哈希表在查找元素方面的时间复杂度是O(1)
,而数组和压缩列表这类数据结构则在O(N)
,那么为什么Redis
还将其作为底层的数据结构呢?原因有两点:
- 内存利用率高:数组和压缩列表的数据结构比较紧凑,比链表的占用空间要更少。而
Redis
作为内存数据库,数据都保存在内存上,需要提高内存的利用率。 - 数组对
CPU
高速缓存支持更好:集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU
高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。
1.3 Redis 单线程
为什么Redis
使用单线程去处理呢?首先来说下多线程的优缺点:
- 优点:在合理的资源分配情况下,可以增加系统中同一时间内处理请求的个数,即提高吞吐率。
- 缺点:同时多线程对共享资源的并发访问控制,需要有额外的机制进行保证,例如锁。而这个额外的机制,就会带来额外的开销。 有时候控制不好反而会让效率减低。
因此为了减少多线程带来的额外开销和多线程同时访问共享资源的并发问题,Redis
直接采用了单线程模式。
注意:
- 这里的单线程指的是
Redis
的网络IO
和键值对读写是由一个线程来完成的。 - 实际上
Redis
的其他功能,例如持久化、异步删除、集群数据的同步等操作是由额外的线程执行的。
那么为什么Redis
单线程还这么快呢?从上文我们可以得到两个原因:
Redis
是一个内存型数据库,大部分操作在内存上完成。- 高效的数据结构,哈希表保存键值对。跳表加快查询。
其实还有一个原因就是多路复用机制 IO模型复习传送门。可以让其在网路IO
操作中能够并发的处理大量的客户端请求,实现高吞吐率。
在 Redis
只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis
线程处理,这就实现了一个 Redis
线程处理多个 IO
流的效果。
而对于Redis
的IO
模型而言,为了能够在请求到达的时候能够及时通知Redis
线程,Redis
运用了基于事件的回调机制:针对不同的事件,调用对应的处理函数。 如图:
总的来说其流程很简单:
- 客户端有着不同的请求,而这些请求都作为一个个事件被放入到事件队列中。
Redis
单线程则对该事件队列不断进行处理。
再做个小总结,Redis
单线程快,快在内存操作、哈希和跳表等数据结构、多路复用IO
机制,但是Redis
单线程处理IO请求也有它的性能瓶颈,主要包括两个方面:
- 由于网络
IO
和键值对读写是由一个线程来完成的,因此一旦上一个请求阻塞了,后面的请求都要等待前一个耗时请求处理完成,才能够自己做处理。 - 并发量非常大的时候,单线程读写客户端
IO
存在性能瓶颈,虽然采用IO
多路复用机制,但是读写客户端数据依旧是同步IO
,只能单线程依次读取客户端的数据,无法利用到CPU
多核。
耗时操作可能有:
- 操作超大字符串
Key
,即bigKey
:bigKey
的写入和删除都需要消耗更多的时间,即在分配内存和释放内存上耗费的时间更久。 - 使用耗时命令,比如范围查询,时间复杂度就是
O(N)
,N
越大,越耗时。 - 大量
Key
集中过期:因为Redis
的过期机制是在主线程中执行的,而大量的Key
过期会导致大把时间耗费在Key
的删除上。 - 淘汰策略:同理,淘汰策略也是在主线程上执行的。
AOF
刷盘开启always
机制:每次写入都需要把这个操作刷到磁盘,写磁盘的速度远比写内存慢,会拖慢Redis
的性能;- 主从全量同步生成
RDB
:虽然采用fork
子进程生成数据快照,但fork
这一瞬间也是会阻塞整个线程的,实例越大,阻塞时间越久。
1.3.1 Redis 6.0 多线程
这里只做介绍和了解:Redis
在6.0版本引入了多线程,原因如下:
Redis
的瓶颈不在CPU
,而在内存和网络,内存不够可以增加内存或通过数据结构等进行优化。Redis
的网络IO
的读写占用了部分CPU
的时间,如果可以把网络处理改成多线程的方式,性能会有很大提升。(注意:针对客户端的读写是并行的,每个命令的真正操作依旧是单线程的)
引入后好处:
- 充分利用服务器的多核资源。
- 多线程分摊
Redis
同步IO
读写负荷,要么都同时读,要么都同时写,没有同时读写的情况。
1.4 总结
5种数据类型,6种数据结构。(左侧为数据类型,右侧为数据结构组成)
String
:简单动态字符串。List
:双向链表+压缩列表。Hash
:压缩列表+哈希表。Set
:哈希表+整数数组。Sorted Set
:跳表+压缩列表。
Redis
用一个全局的哈希表保存着所有的键值对:
- 一个哈希表本质上是一个数组,数组的每个元素是一个哈希桶。
- 哈希桶中的元素组成一个哈希冲突链。即不同的
Key
计算出的hash
值相等 - 元素存储的时候,根据
Key
的hash
值来决定放到哪个哈希桶。 - 若发生哈希冲突。则在哈希冲突链上逐一查找。
Redis
通过渐进rehash
的方式,避免哈希冲突太严重(目的)。利用两个全局哈希表来进行数据的渐进拷贝和扩容。
Redis
采用单线程模型。Redis
的网络 IO
和键值对读写是由一个线程来完成的。其快的原因主要有两点:
Redis
是一个内存型数据库,大部分操作在内存上完成。- 高效的数据结构,哈希表保存键值对。跳表加快查询。
二. Redis 持久化机制
Redis
的持久化主要有两大机制:
AOF
日志:记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据。RDB
快照:能够在指定的时间间隔内对你的数据进行快照存储。
2.1 AOF 日志如何实现快速恢复
我们知道,Mysql
中有redo log
,都是先写日志,再将数据写入磁盘中。即WAL(Write Ahead Log)
。这个日志也叫重做日志缓冲,主要是用来实现数据恢复的。而Redis
也有个有相同作用的日志:AOF
日志。全称为Append Only File
。不过,和Mysql
不同的是,AOF
机制是先将数据写入内存,再记录日志。
如图:
AOF
日志主要记录着Redis
接收到的每一条指令,以文本形式保存。当Redis
宕机时,可以根据AOF
日志来恢复内存中的数据。 举个例子:
set title Hello
那么上述命令对应的AOF
日志内容就是:
# 表示该命令由3个部分组成,其中每个部分都以$+数字开头
*3
# 代表3个字节,后面的同理
$3
set
$5
title
$6
Hello
AOF
的优点:
- 为了避免额外的检查开销,
Redis
在AOF
做日志记录的时候,并不会先对命令做语法检查。而是直接记录执行成功的命令。 - 由于后写日志,因此并不会阻塞当前的写操作。
AOF
的缺点:
- 若执行完一个命令,但在记录
AOF
日志之前就发生了宕机,那么这个命令和对应的数据就有丢失的风险。 - 写
AOF
日志时的主线程阻塞问题。
2.1.1 AOF 写回策略
为了解决上述的AOF
日志缺点,AOF
机制提供了三个选项用来控制AOF
日志的写到磁盘的时机:
appendfsync always
appendfsync everysec
appendfsync no
always
:同步写回。每个写命令执行完,立马同步地将日志写回磁盘。everysec
:每秒写回。每个写命令执行完,只是先把日志写到AOF
文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘。no
:操作系统控制。每个写命令执行完,同样先把日志写到AOF
文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
三种写回策略的优劣势:
策略 | 写回的时机 | 优点 | 缺点 | 选择 | 阻塞问题 |
---|---|---|---|---|---|
always | 同步写回,每执行一个命令就写。 | 高可靠,数据基本不会丢失 | 每写一个命令就落盘,性能影响较大 | 若追求高可靠性保证,选择它 | 同步操作是在主进程的主线程中进行的 |
everysec | 每秒写回。 | 性能折中 | 若发生宕机,会丢失1秒内的数据 | 折中 | 同步操作是通过后台I/O线程进行的 |
no | 操作系统控制写回 | 性能好 | 若发生宕机,丢失的数据较多 | 若追求高性能,就选择它 | 同步操作的控制权交由操作系统,不阻塞主线程 |
最后,既然AOF
是一种日志的机制,那么不可避免的性能问题就来了:
AOF
日志的内容越来越多怎么办?毕竟存储空间有限。- 若文件太大,后面追加命令记录的时候,效率也会变低。
- 若发生宕机,难道要对
AOF
里面的记录都一条条的执行吗?效率太低了。
上面归根到底就是:日志文件太大了怎么办? 通过AOF
重写机制来解决。
2.1.2 AOF 重写机制
AOF
重写机制指的是对于过大的AOF
文件进行重写,即压缩AOF
文件的大小。 检查当前键值数据库中的键值对,记录键值对的最终状态,从而实现对某个键值对重复操作后产生的多条操作记录压缩成一条的效果。进而实现压缩AOF
文件的大小。
举个例子:
set title name1;
set title name2;
set title name3;
那么AOF
重写后,对于Key
为title
的这个键值对,只会记录最终的结果,即set title name3;
AOF
重写的触发条件(同时满足):
auto-aof-rewrite-min-size
: 表示运行AOF
重写时文件的最小大小,默认为64MB
。auto-aof-rewrite-percentage
:重写的一个阈值,公式在下面。
2.1.3 AOF 阻塞问题
首先,根据上文我们得知,写AOF
日志的时候,有三种写回机制,总的来说对应两种情况:
always
:由主线程来写,会阻塞。everysec
和no
:一个是IO
线程,一个是系统控制,归于子进程来执行,不会阻塞主线程。
而AOF
重写过程和写AOF
日志过程又不同,重写过程是由后台子进程bgrewriteaof
来完成的。(注意:AOF
重写和写AOF
日志是两个东西,不要搞混了)
AOF
重写过程总结为:一个拷贝,两处日志。
一个拷贝:每次执行重写的时候,主线程就 fork
出一个后台 bgrewriteaof
线程,简称子进程。子进程会拷贝父进程的页表,即虚实映射关系,从而共享访问父进程的内存数据了。
两处日志:
- 第一处
日志A
:当前正在使用的AOF
日志:在AOF
重写阶段,倘若有新的写操作,那么Redis
就讲这个操作写到日志A
的缓冲区。这样一来即使宕机,该AOF
日志的操作依旧是完整的,可以用于恢复。 - 第二处
日志B
:新的AOF
重写日志。倘若有新的操作也会被写到该新重写日志B
的缓冲区
中(注意这里不是写入日志,而是写入缓冲区)。这样一来,日志B
也不会丢失最新的操作,重写完成后,就可以用新的AOF
文件代替旧文件了。
AOF
工作原理:
Redis
执行fork()
,现在同时拥有父进程和子进程bgrewriteaof
。- 子进程开始将新
AOF
文件的内容写入到临时文件。 - 对于所有新执行的写入命令,父进程一边将它们累积到一个内存缓存中,一边将这些改动追加到现有
AOF
文件的末尾。这样样即使在重写的中途发生停机,现有的AOF
文件也还是安全的。 - 当子进程完成重写工作时,它给父进程发送一个信号,父进程在接收到信号之后,将内存缓存中的所有数据追加到新
AOF
文件的末尾。 - 最后
Redis
用新文件替换旧文件,之后所有命令都会直接追加到新AOF
文件的末尾。
流程图大概如下:
总的来说就是:
- 用子进程拷贝内存页表,从而实现父子进程的数据共享。再进行日志的重写操作。
- 重写阶段用两个
AOF
日志做记录,重写完成后,用新创建的AOF
日志替代老的。
问题1:为什么AOF
重写需要用到两个AOF
日志,不能复用AOF
日志本身吗?
回答:
- 父子进程写同一个文件必然会产生竞争问题,控制竞争就意味着会影响父进程的性能。
- 若
AOF
重写过程中失败了,那么原本的AOF
文件相当于被污染了,无法做恢复使用。所以Redis AOF
重写一个新文件,重写失败的话,直接删除这个文件就好了,不会对原先的AOF
文件产生影响。等重写完成之后,直接替换旧文件即可。
问题2:AOF
日志重写的时候,虽然有个子进程,其执行不会阻塞主线程,但是重写过程就一定不会发生阻塞吗?
回答:AOF
重写过程中父进程进行写操作的时候可能会发生阻塞。
从两个角度来考虑:fork
角度:
- 首先主线程
fork
子进程的时候,一定是阻塞主线程的。fork
采用操作系统提供的写实复制(Copy On Write)
机制,拷贝进程必要的数据结构,包括内存页表。在拷贝完成之前都是阻塞的。 - 因此,阻塞的时间取决于拷贝的内存大小,实例越大,内存页表越大,
fork
时间也就越久。 - 拷贝完成后,子进程与父进程指向相同的内存地址空间,父子进程共享一块数据,但是并没有申请与父进程相同的内存大小。
- 而写实复制的意思就是在写操作发生的时候,才会真正拷贝内存真正的数据。
第二点:主进程的写操作角度:
- 若父进程操作的是一个已经存在的
key
,那么这个时候父进程就会真正拷贝这个key
对应的内存数据,申请新的内存空间(以页为单位,默认4K
),父子进程内存数据开始分离。 - 倘若父进程此时操作的是一个
bigkey
,根据文章上面的内容可以得知,申请大块内存的耗时会边长,从而增加阻塞风险。 - 若操作系统开启了内存大页机制(
Huge Page
,页面大小2M
),那么父进程申请内存时阻塞的概率将会大大提高(因为申请大块内存空间,会很耗时,从而阻塞)。
2.2 RDB 如何实现快速恢复
到这里,我们知道Redis
可以通过AOF
日志来实现数据的恢复,本质就是执行AOF
里面记录的命令。虽然在命令行很多的情况下,AOF
有着重写机制可以压缩日志。但是还是得一条条去执行对应的命令,在操作种类繁多的情况下,Redis
的恢复速度还是比较慢的。因此又有一种内存快照的方式,所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。即RDB
,来实现数据的恢复。
和AOF
相比,RDB
记录的是某一个时刻的全量数据,并不是一条条具体的操作命令了,因此在数据恢复的时候,可以把RDB
文件读入内存中,直接恢复整个数据状态。不过做RDB
快照,也有几个问题需要去思考:
- 快照的范围。
- 快照过程中,数据是否允许被增删改,期间
Redis
是否会被阻塞。
针对第一个问题:快照执行的是全量快照。
针对第二个问题,Redis
提供了两个命令来生成RDB
文件:
save
:在主线程中执行,会导致阻塞;bgsave
(默认方式):创建一个子进程,专门用于写入RDB
文件,避免了主线程的阻塞。
那么快照期间能够修改数据吗?答案是可以的。从 2.1 章节中我们得知AOF
重写机制有个fork
的过程,子进程和父进程共享内存数据。那么RDB
快照的生成也是同理(使用bgsave
命令)。
当然,也可以通过配置来设置RDB功能:
# 意思是,如果在60s内,至少有1000个键值对的修改,就会触发bgsave
save 60 1000
bgsave
子进程是由主线程 fork
生成的,可以共享主线程的所有内存数据。bgsave
子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB
文件。同样地,Redis
借助写时复制技术,在执行快照的同时,正常处理写操作。
那么主子进程之间就是相互独立的。在主进程开始修改某个数据的时候,大概原理如下:
- 在
RDB
快照过程中,假设主进程修改了一块数据,键值对M
。那么此时这块数据就会被复制一份,生成对应的副本N
。 - 那么之后主进程主要在副本
N
上进行数据的操作和修改。 - 同时
bgsave
子进程可以将原来的键值对M
写入到RDB
文件中。
如图:
那么在了解完RDB
的作用以及 RDB
可以不阻塞主进程并且不影响读写操作之后,我们可以关注于RDB
功能的使用时机。虽然 bgsave
执行时不阻塞主线程,但是快照执行的时机却是一个难题。
- 快照间隔过长会丢失数据。
- 快照时间过短会加大磁盘写入压力。
- 频繁
fork
子进程 ,fork
的过程会导致主进程阻塞。
那么就是说,RDB
快照的频率太高,就会有额外的开销,太低又可能造成数据的丢失。那咋办呢?
Redis4.0
中提出了一个混合使用 AOF
日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF
日志记录这期间的所有命令操作。 可以通过以下命令开启:
aof-use-rdb-preamble yes
2.2.1 使用RDB案例分析
题目的搬运(主要这一块有大佬讲解的太好了):
一个 2 核 CPU
、4GB
内存、500GB
磁盘的云主机运行 Redis
,Redis
数据库的数据量大小差不多是 2GB
,我们使用了 RDB
做持久化保证。同时Redis
主要以写操作为主,读写比例为2:8
。那么此时做RDB
持久化有什么风险?
内存资源风险:
- 因为
RDB
是由fork
子进程做持久化的,而本篇文章提到好多次写时复制这么个概念。那么在持久化的过程中,写时复制就会重新分配整个实例80%的内存副本(读写比例为2:8
)。也就是2GB * 0.8 = 1.6GB
。那么此时系统一共就4GB
,接近饱和了。 - 如果此时父进程又有大量新
key
写入,而Redis
中的数据又是保存到内存中的,所以机器的内存很容易被消耗殆尽。
而这里又可以考虑是否开启Swap
机制:
- 开启
Swap
机制,那么Redis
会有一部分数据被换到磁盘上,当Redis
访问这部分在磁盘上的数据时,性能会急剧下降,已经达不到高性能的标准。 - 没有开启
Swap
机制,会直接触发OOM
,父子进程会面临被系统kill
掉的风险。
CPU资源风险:
- 子进程在做生成
RDB
快照的过程会消耗大量的CPU
资源。 - 虽然
Redis
处理处理请求是单线程的,但Redis Server
还有其他线程在后台工作,例如AOF
每秒刷盘等这些操作。即其他工作线程还需要占用CPU
。 - 由于机器只有2核
CPU
,这也就意味着父进程占用了超过一半的CPU
资源,此时子进程做RDB
持久化,可能会产生CPU
竞争,导致的结果就是父进程处理请求延迟增大,子进程生成RDB
快照的时间也会变长,整个Redis Server
性能下降。
2.2.2 题外话 - 什么是Swap
swap
空间是硬盘上的一块区域。虚拟内存是由可访问的物理内存和swap space
组成,也即swap space
是虚拟内存的一部分。swap
存储那些暂时不活跃的内存页面。当操作系统决定要给活跃的进程分配物理内存空间并且可利用的物理内存不足时会用到swap space。
说白了,就是把一块磁盘空间或者一个本地文件,当做内存来使用(重点)。 因此即使服务器的内存不足,也可以运行大内存的应用程序。