Bootstrap

Redis - 数据结构和持久化机制

前言

Redis这块的复习,看的是蒋德钧老师的相关文章,主要是想Redis实战相关的一些理论知识和解决方案。

Redis是一种典型的键值数据库,即Key-Value形式的一种存储关系。

Redis是一种内存数据库,数据都保存在内存上。

一. Redis 底层数据结构

Redis中常见的数据类型有5大类:

  1. String:字符串。
  2. List:列表。
  3. Hash:哈希。
  4. Set:集合。
  5. Sorted Set:有序集合。

Redis中底层的数据结构一共有6大类(实际上也就是Value的存储形式):

  1. 简单动态字符串。
  2. 双向链表。
  3. 压缩列表。
  4. 哈希表。
  5. 跳表。
  6. 整数数组。

两者的映射关系图如下:
在这里插入图片描述
除了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准备了两个全局哈希表,简称H1H2平时插入的元素假设都默认使用H1,这时候H2并没有被分配空间。 当数据到达一定程度,阈值一般是当前哈希表总容量的0.75倍Redis开始执行rehash

rehash的一个大概流程:

  1. H2分配更大的空间,是H1容量的两倍。
  2. H1中的数据映射并拷贝给H2中。
  3. 释放掉H1的空间。

下一次rehash以此类推,H1变两倍… 还需要注意的一点是,在数据的拷贝过程中,如果一次性拷贝所有的数据,会造成Redis线程的阻塞,无法服务其他的请求因此采用了渐进式rehash

1.2.1 渐进式 rehash

渐进式rehash也就是在拷贝数据阶段下,Redis每处理一个请求,就会从H1的某个索引位置开始,将该索引对应的哈希桶上的所有entry拷贝到H2中,等下一个请求的时候,则拷贝下一个自然顺序的所有entry。如图:
在这里插入图片描述

在渐进式rehash的过程中,会同时使用H1H2两个哈希表,新添加到字典的键值对一律会被保存到H2里面,而H1则不再进行任何添加操作,这一措施保证了H1包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

一句话总结就是:将一次性大量拷贝的过程拆分为多次处理请求的过程中。

最后,我们知道哈希表在查找元素方面的时间复杂度是O(1),而数组和压缩列表这类数据结构则在O(N),那么为什么Redis还将其作为底层的数据结构呢?原因有两点:

  1. 内存利用率高:数组和压缩列表的数据结构比较紧凑,比链表的占用空间要更少。而Redis作为内存数据库,数据都保存在内存上,需要提高内存的利用率。
  2. 数组对CPU高速缓存支持更好:集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。

1.3 Redis 单线程

为什么Redis使用单线程去处理呢?首先来说下多线程的优缺点:

  1. 优点:在合理的资源分配情况下,可以增加系统中同一时间内处理请求的个数,即提高吞吐率。
  2. 缺点:同时多线程对共享资源的并发访问控制,需要有额外的机制进行保证,例如锁。而这个额外的机制,就会带来额外的开销。 有时候控制不好反而会让效率减低。

因此为了减少多线程带来的额外开销和多线程同时访问共享资源的并发问题,Redis直接采用了单线程模式。

注意:

  • 这里的单线程指的是 Redis 的网络 IO 和键值对读写是由一个线程来完成的。
  • 实际上Redis的其他功能,例如持久化、异步删除、集群数据的同步等操作是由额外的线程执行的。

那么为什么Redis单线程还这么快呢?从上文我们可以得到两个原因:

  1. Redis是一个内存型数据库,大部分操作在内存上完成。
  2. 高效的数据结构,哈希表保存键值对。跳表加快查询。

其实还有一个原因就是多路复用机制 IO模型复习传送门。可以让其在网路IO操作中能够并发的处理大量的客户端请求,实现高吞吐率。

Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

而对于RedisIO模型而言,为了能够在请求到达的时候能够及时通知Redis线程,Redis运用了基于事件的回调机制:针对不同的事件,调用对应的处理函数。 如图:
在这里插入图片描述
总的来说其流程很简单:

  1. 客户端有着不同的请求,而这些请求都作为一个个事件被放入到事件队列中。
  2. Redis单线程则对该事件队列不断进行处理。

再做个小总结,Redis单线程快,快在内存操作哈希和跳表等数据结构多路复用IO机制,但是Redis单线程处理IO请求也有它的性能瓶颈,主要包括两个方面:

  1. 由于网络 IO 和键值对读写是由一个线程来完成的,因此一旦上一个请求阻塞了,后面的请求都要等待前一个耗时请求处理完成,才能够自己做处理。
  2. 并发量非常大的时候,单线程读写客户端IO存在性能瓶颈,虽然采用IO多路复用机制,但是读写客户端数据依旧是同步IO,只能单线程依次读取客户端的数据,无法利用到CPU多核。

耗时操作可能有:

  • 操作超大字符串Key,即bigKeybigKey的写入和删除都需要消耗更多的时间,即在分配内存和释放内存上耗费的时间更久。
  • 使用耗时命令,比如范围查询,时间复杂度就是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 的时间,如果可以把网络处理改成多线程的方式,性能会有很大提升。(注意:针对客户端的读写是并行的,每个命令的真正操作依旧是单线程的

引入后好处:

  1. 充分利用服务器的多核资源。
  2. 多线程分摊 Redis 同步 IO 读写负荷,要么都同时读,要么都同时写,没有同时读写的情况。

1.4 总结

5种数据类型,6种数据结构。(左侧为数据类型,右侧为数据结构组成)

  • String:简单动态字符串。
  • List:双向链表+压缩列表。
  • Hash:压缩列表+哈希表。
  • Set:哈希表+整数数组。
  • Sorted Set:跳表+压缩列表。

Redis用一个全局的哈希表保存着所有的键值对:

  1. 一个哈希表本质上是一个数组,数组的每个元素是一个哈希桶。
  2. 哈希桶中的元素组成一个哈希冲突链。即不同的Key计算出的hash值相等
  3. 元素存储的时候,根据Keyhash值来决定放到哪个哈希桶。
  4. 若发生哈希冲突。则在哈希冲突链上逐一查找。

Redis通过渐进rehash的方式,避免哈希冲突太严重(目的)。利用两个全局哈希表来进行数据的渐进拷贝和扩容。


Redis采用单线程模型Redis 的网络 IO 和键值对读写是由一个线程来完成的。其快的原因主要有两点:

  1. Redis是一个内存型数据库,大部分操作在内存上完成。
  2. 高效的数据结构,哈希表保存键值对。跳表加快查询。

二. 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的优点:

  1. 为了避免额外的检查开销RedisAOF做日志记录的时候,并不会先对命令做语法检查。而是直接记录执行成功的命令。
  2. 由于后写日志,因此并不会阻塞当前的写操作。

AOF的缺点:

  1. 若执行完一个命令,但在记录AOF日志之前就发生了宕机,那么这个命令和对应的数据就有丢失的风险。
  2. 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重写后,对于Keytitle的这个键值对,只会记录最终的结果,即set title name3;

AOF重写的触发条件(同时满足):

  • auto-aof-rewrite-min-size: 表示运行AOF重写时文件的最小大小,默认为64MB
  • auto-aof-rewrite-percentage:重写的一个阈值,公式在下面。
    在这里插入图片描述

2.1.3 AOF 阻塞问题

首先,根据上文我们得知,写AOF日志的时候,有三种写回机制,总的来说对应两种情况:

  • always:由主线程来写,会阻塞。
  • everysecno:一个是IO线程,一个是系统控制,归于子进程来执行,不会阻塞主线程。

AOF重写过程和写AOF日志过程又不同,重写过程是由后台子进程bgrewriteaof来完成的。(注意:AOF重写和写AOF日志是两个东西,不要搞混了)

AOF重写过程总结为:一个拷贝,两处日志。

一个拷贝:每次执行重写的时候,主线程就 fork 出一个后台 bgrewriteaof 线程,简称子进程。子进程会拷贝父进程的页表,即虚实映射关系,从而共享访问父进程的内存数据了。


两处日志:

  • 第一处日志A:当前正在使用的AOF日志:在AOF重写阶段,倘若有新的写操作,那么Redis就讲这个操作写到日志A的缓冲区。这样一来即使宕机,该 AOF 日志的操作依旧是完整的,可以用于恢复。
  • 第二处日志B:新的AOF重写日志。倘若有新的操作也会被写到该新重写日志B缓冲区中(注意这里不是写入日志,而是写入缓冲区)。这样一来,日志B也不会丢失最新的操作,重写完成后,就可以用新的AOF文件代替旧文件了。

AOF工作原理:

  1. Redis 执行 fork() ,现在同时拥有父进程和子进程 bgrewriteaof
  2. 子进程开始将新 AOF 文件的内容写入到临时文件。
  3. 对于所有新执行的写入命令,父进程一边将它们累积到一个内存缓存中,一边将这些改动追加到现有 AOF 文件的末尾。这样样即使在重写的中途发生停机,现有的 AOF 文件也还是安全的。
  4. 当子进程完成重写工作时,它给父进程发送一个信号,父进程在接收到信号之后,将内存缓存中的所有数据追加到新 AOF 文件的末尾。
  5. 最后 Redis 用新文件替换旧文件,之后所有命令都会直接追加到新 AOF 文件的末尾。

流程图大概如下:
在这里插入图片描述

总的来说就是:

  1. 用子进程拷贝内存页表,从而实现父子进程的数据共享。再进行日志的重写操作。
  2. 重写阶段用两个AOF日志做记录,重写完成后,用新创建的AOF日志替代老的。

问题1:为什么AOF重写需要用到两个AOF日志,不能复用AOF日志本身吗?

回答:

  • 父子进程写同一个文件必然会产生竞争问题,控制竞争就意味着会影响父进程的性能。
  • AOF重写过程中失败了,那么原本的AOF文件相当于被污染了,无法做恢复使用。所以Redis AOF重写一个新文件,重写失败的话,直接删除这个文件就好了,不会对原先的AOF文件产生影响。等重写完成之后,直接替换旧文件即可。

问题2:AOF日志重写的时候,虽然有个子进程,其执行不会阻塞主线程,但是重写过程就一定不会发生阻塞吗?

回答:AOF重写过程中父进程进行写操作的时候可能会发生阻塞。

从两个角度来考虑:fork 角度:

  1. 首先主线程fork子进程的时候,一定是阻塞主线程的。fork 采用操作系统提供的写实复制(Copy On Write)机制,拷贝进程必要的数据结构,包括内存页表。在拷贝完成之前都是阻塞的。
  2. 因此,阻塞的时间取决于拷贝的内存大小,实例越大,内存页表越大,fork 时间也就越久。
  3. 拷贝完成后,子进程与父进程指向相同的内存地址空间,父子进程共享一块数据,但是并没有申请与父进程相同的内存大小。
  4. 而写实复制的意思就是在写操作发生的时候,才会真正拷贝内存真正的数据。

第二点:主进程的写操作角度:

  1. 若父进程操作的是一个已经存在的key那么这个时候父进程就会真正拷贝这个key对应的内存数据,申请新的内存空间(以页为单位,默认4K),父子进程内存数据开始分离。
  2. 倘若父进程此时操作的是一个bigkey,根据文章上面的内容可以得知,申请大块内存的耗时会边长,从而增加阻塞风险。
  3. 若操作系统开启了内存大页机制(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借助写时复制技术,在执行快照的同时,正常处理写操作。

那么主子进程之间就是相互独立的。在主进程开始修改某个数据的时候,大概原理如下:

  1. RDB快照过程中,假设主进程修改了一块数据,键值对M。那么此时这块数据就会被复制一份,生成对应的副本N
  2. 那么之后主进程主要在副本N上进行数据的操作和修改。
  3. 同时bgsave子进程可以将原来的键值对M写入到RDB文件中。

如图:
在这里插入图片描述

那么在了解完RDB的作用以及 RDB可以不阻塞主进程并且不影响读写操作之后,我们可以关注于RDB功能的使用时机。虽然 bgsave 执行时不阻塞主线程,但是快照执行的时机却是一个难题。

  1. 快照间隔过长会丢失数据。
  2. 快照时间过短会加大磁盘写入压力。
  3. 频繁fork子进程 ,fork的过程会导致主进程阻塞。

那么就是说,RDB快照的频率太高,就会有额外的开销,太低又可能造成数据的丢失。那咋办呢?

Redis4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。 可以通过以下命令开启:

aof-use-rdb-preamble yes

2.2.1 使用RDB案例分析

题目的搬运(主要这一块有大佬讲解的太好了):

一个 2 核 CPU4GB 内存、500GB 磁盘的云主机运行 RedisRedis 数据库的数据量大小差不多是 2GB,我们使用了 RDB 做持久化保证。同时Redis主要以写操作为主,读写比例为2:8。那么此时做RDB持久化有什么风险?

内存资源风险:

  1. 因为RDB是由fork子进程做持久化的,而本篇文章提到好多次写时复制这么个概念。那么在持久化的过程中,写时复制就会重新分配整个实例80%的内存副本(读写比例为2:8)。也就是2GB * 0.8 = 1.6GB。那么此时系统一共就4GB,接近饱和了。
  2. 如果此时父进程又有大量新key写入,Redis中的数据又是保存到内存中的,所以机器的内存很容易被消耗殆尽。

而这里又可以考虑是否开启Swap机制:

  • 开启Swap机制,那么Redis会有一部分数据被换到磁盘上,当Redis访问这部分在磁盘上的数据时,性能会急剧下降,已经达不到高性能的标准。
  • 没有开启Swap机制,会直接触发OOM,父子进程会面临被系统kill掉的风险。

CPU资源风险:

  1. 子进程在做生成RDB快照的过程会消耗大量的CPU资源。
  2. 虽然Redis处理处理请求是单线程的,但Redis Server还有其他线程在后台工作,例如AOF每秒刷盘等这些操作。即其他工作线程还需要占用CPU
  3. 由于机器只有2核CPU,这也就意味着父进程占用了超过一半的CPU资源,此时子进程做RDB持久化,可能会产生CPU竞争,导致的结果就是父进程处理请求延迟增大,子进程生成RDB快照的时间也会变长,整个Redis Server性能下降。

2.2.2 题外话 - 什么是Swap

swap 空间是硬盘上的一块区域。虚拟内存是由可访问的物理内存和swap space组成,也即swap space是虚拟内存的一部分。swap存储那些暂时不活跃的内存页面。当操作系统决定要给活跃的进程分配物理内存空间并且可利用的物理内存不足时会用到swap space。

说白了,就是把一块磁盘空间或者一个本地文件,当做内存来使用(重点)。 因此即使服务器的内存不足,也可以运行大内存的应用程序。

;