Redis核心技术基础(一)
一、数据结构
1,数据结构
Redis表现突出的原因: 1、在内存中进行操作 2、高效的数据结构(降低复杂度)
Redis的存储接口主要有:String、List、Hash、Set和Sorted Set(Redis6.0之前)。底层结构一共有6种:简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。
2,K-V的存储
Redis使用哈希表来保存所有的键值对。一个哈希表其实就是一个数组,数组的每个元素称为一个哈希桶。一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
如图,哈希桶中的 entry 元素中保存了*key和*value指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过*value指针被查找到。
因为哈希表保存了所有的键值对,所以也称之为全局哈希表。哈希表可以用O(1)的时间复杂度快速查找到键值对——只需要计算建的哈希值即可定位到哈希桶的位置,然后访问相应的entry元素。
3,哈希表操作变慢
a>哈希冲突
当往哈希表中写入很多数据时,哈希冲突是不可避免的。解决方式:Redis采用链式哈希,同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。
这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。
b>渐进式rehash
Redis对哈希表做rehash操作就是增加现有的哈希桶数量,让逐渐增多的entry元素能在更多的桶之间分散保存,减少单桶的冲突数量,提升查询效率。
Redis默认使用两个全局哈希表(哈希表1和哈希表2)处理rehash,具体步骤:a.给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;b.把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;c.释放哈希表 1 的空间。
渐进式rehash:但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,所以为了处理这个问题就采用了渐进式rehash方式。
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
4,集合数据操作效率
a>底层数据结构
集合类型的底层数据结构主要有5种:整数数组、双向链表、哈希表、压缩列表和跳表。
压缩列表:
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段zlbytes、zltail和zllen,分别表示:列表长度、列尾的偏移量和entry个数;在表尾还有zlend表示列表结束
跳表:
有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:
数据结构时间复杂度:
b>操作复杂度
- 单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。例如,Hash 类型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等。这些操作的复杂度由集合采用的数据结构决定,例如,HGET、HSET 和 HDEL 是对哈希表做操作,所以它们的复杂度都是 O(1);Set 类型用哈希表作为底层数据结构时,它的 SADD、SREM、SRANDMEMBER 复杂度也是 O(1)。
- 范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,比如 List 类型的 LRANGE 和 ZSet 类型的 ZRANGE。这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。
- 统计操作,是指集合类型对集合中所有元素个数的记录,例如 LLEN 和 SCARD。这类操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。
- 例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。
二、高性能IO模型
1,Redis为什么是单线程?
对于一个多线程的系统来说,在有合理的资源分配情况下,可以增加系统中处理请求操作的资源实体,进而提升系统能够同时处理的请求数,即吞吐率。但是在实际使用中,刚开始增加线程数时,系统吞吐率会增加,但是,再进一步增加线程时,系统吞吐率就增长迟缓了,有时甚至还会出现下降的情况。
原因在于系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要额外的机制进行保证,这样就会带来额外的开销。以Redis为例,Redis中的List数据类型,有出队(LPOP)和入队(LPUSH)操作,假设采用多线程设计,现在有两个线程 A 和 B,线程 A 对一个 List 做 LPUSH 操作,并对队列长度加 1。同时,线程 B 对该 List 执行 LPOP 操作,并对队列长度减 1。为了保证队列长度的正确性,Redis 需要让线程 A 和 B 的 LPUSH 和 LPOP 串行执行,这样一来,Redis 可以无误地记录它们对 List 长度的修改。这就是多线程编程模式面临的共享资源的并发访问控制问题。
2,单线程Redis为什么那么快?
Redis单线程模型能达到每秒数十万的处理能力取决于:1.Redis的大部分操作在内存上完成,并采用了高效的数据结构;2.Redis采用了多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐率。
a>基本IO模型与阻塞点
以Get为例,网络全流程如下图,其中bind/listen、accept、recv、parse和send属于网络IO处理,而get属于键值数据操作。
有潜在的阻塞点,分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。由于socket 网络模型本身支持非阻塞模式,可以有效规避这个问题。
b>非阻塞模式
Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上。socket()方法会返回主动套接字,然后调用listen()方法,将主动套接字转化为监听套接字,最后调用accept()方法接收到达的客户端连接,并返回已连接套接字。
针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。
c>基于多路复用的高性能I/O模型
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
四、AOF日志
1,AOF日志是如何实现的
Redis采用写后日志:1,避免记录错误命令;2,在命令执行后记录日志,不会阻塞当前的写操作。
为何不做写前日志(Write Ahead Log,WAL)?为了避免额外的检查开销,Redis在向AOF里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis在使用日志恢复数据时,就可能出错。而写后日志是先让系统执行命令,只有命令能执行成功,才会记录到日志中,否则,系统就会直接向客户端报错。
潜在风险:1.刚执行完一个命令,还没来得及记日志就宕机了,会有命令和数据的丢失;2.AOF虽然避免了当前命令的阻塞,但可能会给下个操作带来风险,AOF日志是在主线程中执行的,如果写入磁盘时,磁盘压力大,会导致写盘很慢,进而导致后续操作阻塞。
2,写回策略
- Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘
- Everysec,每秒写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘
- No,操作系统控制的写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回
AOF文件越来越大,会出现“性能问题”:1.文件系统本身对文件大小有限制,无法保存过大的文件;2.如果文件太大,之后再往里面追加命令记录的话,效率会变低;3.如果发生宕机,AOF中记录的命令要一个个重新执行,过程非常缓慢,会影响Redis的正常使用。
3,AOF重写机制
AOF重写机制就是在重写时,Redis根据数据库的现状创建一个新的AOF文件。重写机制具有“多变一”功能,也就是旧日志文件中的多条命令,在重写后的新日志中变成一条命令。
AOF重写会阻塞吗?与AOF日志由主线程写回不同,重写过程是由后台子进程bgrewriteaof来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。“一个拷贝,两处日志”。
“一个拷贝”:每次执行重写,主线程fork出后台的bgrewriteaof子进程,将主线程的内存拷贝一份给bgrewriteaof子进程,子进程在不影响主线程的情况下,逐一把拷贝的数据写成操作并记入重写日志。
“两处日志”:第一处日志指正在使用的AOF日志,Redis会把这个操作写到它的缓冲区。第二处日志是指新的AOF重写日志,这个操作也会被写到重写日志的缓冲区,等到拷贝数据的所有操作记录重写完成后,重写日志记录这些最新操作也会写入新的AOF文件,以保证数据库最新状态的记录,此时则可以用新的AOF文件替代旧文件。
每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。
4,何时触发AOF重写?
有两个配置项在控制AOF重写的触发时机:
- auto-aof-rewrite-min-size: 表示运行AOF重写时文件的最小大小,默认为64MB
- auto-aof-rewrite-percentage: 这个值的计算方法是:当前AOF文件大小和上一次重写后AOF文件大小的差值,再除以上一次重写后AOF文件大小。也就是当前AOF文件比上一次重写后AOF文件的增量大小,和上一次重写后AOF文件大小的比值。
AOF文件大小同时超出上面这两个配置项时,会触发AOF重写。
五、内存快照
1,定义
所谓内存快照,就是指内存中的数据在某一时刻的状态记录。
对于Redis来说,它实现类似照片记录效果的方式,就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这种快照文件就称为RDB文件,Redis DataBase。
问题:1.快照如何取景(给哪些数据做快照);2.在按快门时,不能乱动(快照是数据如何修改)
2,给哪些内存数据做快照?
Redis的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中。
Redis提供了两个命令来生成RDB文件,分别是save和bgsave
- save:在主线程中执行,会导致阻塞
- bgsave:创建一个子线程,专门用于写入RDB文件,避免了主线程的阻塞,这也是Redis RDB文件生成的默认配置
3,快照时数据能否修改?
bgsave能避免阻塞。但是避免阻塞和正常处理写操作并不是一回事。执行bgsave时,主线程的确不会阻塞,可以正常接收请求,但是为了保证快照的完整性,它只能处理读操作,因为不能修改正在执行快照的数据。
Redis借助操作系统提供的写时复制技术(Copy-On-Write,COW),保证在执行快照的同时处理写操作。
如图,主线程fork出bgsave的子线程后,此时希望修改键值对C为键值对C',那么这块数据会被复制一份,生成该数据的副本(键值对C')。然后主线程在数据副本上进行修改。同时,bgsave子进程可以继续把原来的数据(键值对C)写入RDB文件。
4,快照的时间间隔
如下图,我们可以在T0时刻做一次快照,然后又在T0+t时刻做一次快照,在这期间,数据块5和9被修改了。如果在t这段时间内,机器宕机了,那么只能按照T0时刻的快照进行恢复。数据块5和9的修改由于没有快照记录无法恢复
所以要想尽可能恢复数据,t值就要尽可能小。但是如果t值特别小比如为每秒一次快照又会出现什么问题呢?
虽然bgsave执行时不阻塞主线程,但是如果频繁地执行全量快照,也会带来两方面的开销:
- 频繁将全量数据写入磁盘,会给磁盘带来很大的压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环
- bgsave子进程需要通过fork操作从主线程创建出来。虽然,子进程在创建后不会阻塞主线程,但是fork这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁fork出bgsave子进程,这就会频繁阻塞主线程了(所以在Redis中如果有一个bgsave在运行,就不会再启动第二个bgsave子进程)。
5,增量快照
在第一次做完全量快照后,T1和T2时刻如果再做快照,我们只需要将被修改的数据写入快照文件即可。但是记住那些数据被修改,需要使用额外的元数据信息来记录,这会带来额外的空间开销问题。
如果有1w个被修改的键值对,我们就需要有1w条额外的记录。引入的额外空间开销比较大,这对于内存资源宝贵的Redis来说,有些得不偿失。
Redis 4.0提出了混合使用AOF日志和内存快照的方法:内存快照以一定的频率执行,在两次快照之间使用AOF日志记录这期间的所有命令。这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。
如下图,T1和T2时刻的修改,用AOF日志记录,等到第二次做全量快照时,就可以清空AOF日志,因为此时的修改都已经记录到快照中了,恢复时就不在用日志了
6,AOF和RDB的选择问题
- 数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择;
- 如果允许分钟级别的数据丢失,可以只使用 RDB;
- 如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。