给一张表的一列,sql语句随机取出10条数据插入我指定范围内的数,SQL需要怎么写?

1.键值数据库的基本架构

不同键值数据库支持的key类型一般差异不大,而value类型则有较大差别。我们在对键值数据库进行选型时,一个重要的考虑因素是它支持的value类型。例如,Memcached支持的value类型仅为String类型,而Redis支持的value类型包括了String、哈希表、列表、集合等。Redis能够在实际业务场景中得到广泛的应用,就是得益于支持多样化类型的value。

在实际的业务场景中,我们经常会碰到这种情况:查询一个用户在一段时间内的访问记录。这种操作在键值数据库中属于SCAN操作,即根据一段key的范围返回相应的value值。因此,PUT/GET/DELETE/SCAN是一个键值数据库的基本操作集合。

大体来说,一个键值数据库包括了访问框架、索引模块、操作模块和存储模块四部分。

访问模式通常有两种:一种是通过函数库调用的方式供外部应用使用,比如libsimplekv.so,就是以动态链接库的形式链接到我们自己的程序中,提供键值存储功能;另一种是通过网络框架以Socket通信的形式对外提供键值对操作,这种形式可以提供广泛的键值存储服务。

实际的键值数据库也基本采用上述两种方式,例如,RocksDB以动态链接库的形式使用,而Memcached和Redis则是通过网络框架访问。

键值数据库网络框架接收到网络包,并按照相应的协议进行解析之后,就可以知道,客户端想写入一个键值对,并开始实际的写入流程。此时,我们会遇到一个系统设计上的问题,简单来说,就是网络连接的处理、网络请求的解析,以及数据存取的处理,是用一个线程、多个线程,还是多个进程来交互处理呢?该如何进行设计和取舍呢?我们一般把这个问题称为I/O模型设计。不同的I/O模型对键值数据库的性能和可扩展性会有不同的影响。

当SimpleKV解析了客户端发来的请求,知道了要进行的键值对操作,此时,SimpleKV需要查找所要操作的键值对是否存在,这依赖于键值数据库的索引模块。索引的作用是让键值数据库根据key找到相应value的存储位置,进而执行操作。

索引的类型有很多,常见的有哈希表、B+树、字典树等。不同的索引结构在性能、空间消耗、并发控制等方面具有不同的特征。如果你看过其他键值数据库,就会发现,不同键值数据库采用的索引并不相同,例如,Memcached和Redis采用哈希表作为key-value索引,而RocksDB则采用跳表作为内存中key-value的索引。

一般而言,内存键值数据库(例如Redis)采用哈希表作为索引,很大一部分原因在于,其键值数据基本都是保存在内存中的,而内存的高性能随机访问特性可以很好地与哈希表O(1)的操作复杂度相匹配。

Redis采用一些常见的高效索引结构作为某些value类型的底层数据结构,这一技术路线为Redis实现高性能访问提供了良好的支撑。

SimpleKV采用了常用的内存分配器glibc的malloc和free,因此,SimpleKV并不需要特别考虑内存空间的管理问题。但是,键值数据库的键值对通常大小不一,glibc的分配器在处理随机的大小内存块分配时,表现并不好。一旦保存的键值对数据规模过大,就可能会造成较严重的内存碎片问题。

因此,分配器是键值数据库中的一个关键因素。对于以内存存储为主的Redis而言,这点尤为重要。Redis的内存分配器提供了多种选择,分配效率也不一样。

从SimpleKV演进到Redis,有以下几个重要变化:

  1. Redis主要通过网络框架进行访问,而不再是动态库了,这也使得Redis可以作为一个基础性的网络服务进行访问,扩大了Redis的应用范围。
  2. Redis数据模型中的value类型很丰富,因此也带来了更多的操作接口,例如面向列表的LPUSH/LPOP,面向集合的SADD/SREM等。在下节课,我将和你聊聊这些value模型背后的数据结构和操作效率,以及它们对Redis性能的影响。
  3. Redis的持久化模块能支持两种方式:日志(AOF)和快照(RDB),这两种持久化方式具有不同的优劣势,影响到Redis的访问性能和可靠性。
  4. SimpleKV是个简单的单机键值数据库,但是,Redis支持高可靠集群和高可扩展集群,因此,Redis中包含了相应的集群功能支撑模块。

简单来说,底层数据结构一共有6种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。它们和数据类型的对应关系如下图所示:

可以看到,String类型的底层实现只有一种数据结构,也就是简单动态字符串。而List、Hash、Set和Sorted Set这四种数据类型,都有两种底层实现结构。通常情况下,我们会把这四种类型称为集合类型,它们的特点是一个键对应了一个集合的数据

一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

其实,哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这也就是说,不管值是String,还是集合类型,哈希桶中的元素都是指向它们的指针。

哈希桶中的entry元素中保存了key和value指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过*value指针被查找到。

因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。哈希表的最大好处很明显,就是让我们可以用O(1)的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的entry元素。

如果你只是了解了哈希表的O(1)复杂度和快速查找特性,那么,当你往Redis中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险点,那就是哈希表的冲突问题和rehash可能带来的操作阻塞

哈希冲突:当你往哈希表中写入更多数据时,哈希冲突是不可避免的问题。这里的哈希冲突,也就是指,两个key的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。

Redis解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。

所以,Redis会对哈希表做rehash操作。rehash也就是增加现有的哈希桶数量,让逐渐增多的entry元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。

其实,为了使rehash操作更高效,Redis默认使用了两个全局哈希表:哈希表1和哈希表2。一开始,当你刚插入数据时,默认使用哈希表1,此时的哈希表2并没有被分配空间。随着数据逐步增多,Redis开始执行rehash,这个过程分为三步:

  1. 给哈希表2分配更大的空间,例如是当前哈希表1大小的两倍;
  2. 把哈希表1中的数据重新映射并拷贝到哈希表2中;

到此,我们就可以从哈希表1切换到哈希表2,用增大的哈希表2保存更多数据,而原来的哈希表1留作下一次rehash扩容备用。

这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表1中的数据都迁移完,会造成Redis线程阻塞,无法服务其他请求。此时,Redis就无法快速访问数据了。

简单来说就是在第二步拷贝数据时,Redis仍然正常处理客户端请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有entries拷贝到哈希表2中;等处理下一个请求时,再顺带拷贝哈希表1中的下一个索引位置的entries。

这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

对于String类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的O(1)操作复杂度也就是它的复杂度了。

一个集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。

集合的操作效率,首先,与集合的底层数据结构有关。例如,使用哈希表实现的集合,要比使用链表实现的集合访问效率更高。其次,操作效率和这些操作本身的执行特点有关,比如读写一个元素的操作要比读写所有元素的效率高。

集合类型的底层数据结构主要有5种:整数数组、双向链表、哈希表、压缩列表和跳表。

整数数组和双向链表也很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是O(N),操作效率比较低。

压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段zlbytes、zltail和zllen,分别表示列表长度、列表尾的偏移量和列表中的entry个数;压缩列表在表尾还有一个zlend,表示列表结束。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是O(N)了。

有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:

为了提高查找速度,我们来增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。例如,从前两个元素中抽取元素1作为一级索引,从第三、四个元素中抽取元素11作为一级索引。此时,我们只需要4次查找就能定位到元素33了。

如果我们还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。例如,从一级索引中抽取1、27、100作为二级索引,二级索引指向一级索引。这样,我们只需要3次查找,就能定位到元素33了。

可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是O(logN)。

按照查找的时间复杂度给这些数据结构分类:

第一,单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。例如,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),比较耗时,我们应该尽量避免。

不过,Redis从2.8版本开始提供了SCAN系列操作(包括HSCAN,SSCAN和ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于HGETALL、SMEMBERS这类操作来说,就避免了一次性返回所有元素而导致的Redis阻塞。

第三,统计操作,是指集合类型对集合中所有元素个数的记录,例如LLEN和SCARD。这类操作复杂度只有O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。

第四,例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于List类型的LPOP、RPOP、LPUSH、RPUSH这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有O(1),可以实现快速操作。

Redis之所以能快速操作键值对,一方面是因为O(1)复杂度的哈希表被广泛使用,包括String、Hash和Set,它们的操作复杂度基本由哈希表决定,另一方面,Sorted Set也采用了O(logN)复杂度的跳表。不过,集合类型的范围操作,因为要遍历底层数据结构,复杂度通常是O(N)。这里,我的建议是:用其他命令来替代,例如可以用SCAN来代替,避免在Redis内部产生费时的全集合遍历操作。

当然,我们不能忘了复杂度较高的List类型,它的两种底层实现结构:双向链表和压缩列表的操作复杂度都是O(N)。因此,我的建议是:因地制宜地使用List类型。例如,既然它的POP/PUSH效率很高,那么就将它主要用于FIFO队列场景,而不是作为一个可以随机读写的集合。

Redis的List底层使用压缩列表本质上是将所有元素紧挨着存储,所以分配的是一块连续的内存空间,虽然数据结构本身没有时间复杂度的优势,但是这样节省空间而且也能避免一些内存碎片。


Redis的网络IO和键值对读写是由一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程。但Redis的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

Redis采用单线程的原因

多线程编程模式面临的共享资源的并发访问控制问题。

一个关键的瓶颈在于,系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。

并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。

而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis直接采用了单线程模式。

单线程Redis为什么那么快?

一方面,Redis的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是Redis采用了多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐率。

基于多路复用的高性能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的响应性能。

即使你的应用场景中部署了不同的操作系统,多路复用机制也是适用的。因为这个机制的实现有很多种,既有基于Linux系统下的select和epoll实现,也有基于FreeBSD的kqueue实现,以及基于Solaris的evport实现,这样,你可以根据Redis实际运行的操作系统,选择相应的多路复用实现。

总结:Redis单线程是指它对网络IO和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的Redis也能获得高性能,跟多路复用的IO模型密切相关,因为这避免了accept()和send()/recv()潜在的网络IO操作阻塞点。

Redis单线程处理IO请求性能瓶颈主要包括2个方面:

1.任意一个请求在server中一旦发生耗时,都会影响整个server的性能,也就是说后面的请求都要等前面这个耗时请求处理完成,自己才能被处理到。耗时的操作包括以下几种:

a. 操作bigkey:写入一个bigkey在分配内存时需要消耗更多的时间,同样,删除bigkey释放内存同样会产生耗时;

c. 大量key集中过期:Redis的过期机制也是在主线程中执行的,大量key集中过期会导致处理一个请求时,耗时都在删除过期key,耗时变长;

d. 淘汰策略:淘汰策略也是在主线程执行的,当内存超过Redis内存上限后,每次写入都需要淘汰一些key,也会造成耗时变长;

e. AOF刷盘开启always机制:每次写入都需要把这个操作刷到磁盘,写磁盘的速度远比写内存慢,会拖慢Redis的性能;

f. 主从全量同步生成RDB:虽然采用fork子进程生成数据快照,但fork这一瞬间也是会阻塞整个线程的,实例越大,阻塞时间越久;

2.并发量非常大时,单线程读写客户端IO数据存在性能瓶颈,虽然采用IO多路复用机制,但是读写客户端数据依旧是同步IO,只能单线程依次读取客户端的数据,无法利用到CPU多核。

针对问题1,一方面需要业务人员去规避,一方面Redis在4.0推出了lazy-free机制,把bigkey释放内存的耗时操作放在了异步线程中执行,降低对主线程的影响。

针对问题2,Redis在6.0推出了多线程,可以在高并发场景下利用CPU多核多线程读写客户端数据,进一步提升server性能,当然,只是针对客户端的读写是并行的,每个命令的真正操作依旧是单线程的。


说到日志,我们比较熟悉的是数据库的写前日志(Write Ahead Log, WAL),也就是说,在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复。不过,AOF日志正好相反,它是写后日志,“写后”的意思是Redis是先执行命令,把数据写入内存,然后才记录日志。

传统数据库的日志,例如redo log(重做日志),记录的是修改后的数据,而AOF里记录的是Redis收到的每一条命令,这些命令是以文本形式保存的。

写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。

AOF也有两个潜在的风险:

首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时Redis是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果Redis是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。

其次,AOF虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。

其实,对于这个问题,AOF机制给我们提供了三个选择,也就是AOF配置项appendfsync的三个可选值。

  1. Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
  2. Everysec,每秒写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
  3. No,操作系统控制的写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

想要获得高性能,就选择No策略;如果想要得到高可靠性保证,就选择Always策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择Everysec策略。

但是,按照系统的性能需求选定了写回策略,并不是“高枕无忧”了。毕竟,AOF是以文件的形式在记录接收到的所有写命令。随着接收的写命令越来越多,AOF文件会越来越大。这也就意味着,我们一定要小心AOF文件过大带来的性能问题。

这里的“性能问题”,主要在于以下三个方面:一是,文件系统本身对文件大小有限制,无法保存过大的文件;二是,如果文件太大,之后再往里面追加命令记录的话,效率也会变低;三是,如果发生宕机,AOF中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到Redis的正常使用。

所以,我们就要采取一定的控制手段,这个时候,AOF重写机制就登场了。

简单来说,AOF重写机制就是在重写时,Redis根据数据库的现状创建一个新的AOF文件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。比如说,当读取了键值对“testkey”: “testvalue”之后,重写机制会记录set testkey testvalue这条命令。这样,当需要恢复时,可以重新执行该命令,实现“testkey”:

为什么重写机制可以把日志文件变小呢? 实际上,重写机制具有“多变一”功能。所谓的“多变一”,也就是说,旧日志文件中的多条命令,在重写后的新日志中变成了一条命令。

我们知道,AOF文件是以追加的方式,逐一记录接收到的写命令的。当一个键值对被多条写命令反复修改时,AOF文件会记录相应的多条命令。但是,在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。

和AOF日志由主线程写回不同,重写过程是由后台线程bgrewriteaof来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。

我把重写的过程总结为“一个拷贝,两处日志”。

“一个拷贝”就是指,每次执行重写时,主线程fork出后台的bgrewriteaof子进程。此时,fork会把主线程的内存拷贝一份给bgrewriteaof子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。

“两处日志”又是什么呢?

因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第一处日志就是指正在使用的AOF日志,Redis会把这个操作写到它的缓冲区。这样一来,即使宕机了,这个AOF日志的操作仍然是齐全的,可以用于恢复。

而第二处日志,就是指新的AOF重写日志。这个操作也会被写到重写日志的缓冲区。这样,重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日志记录的这些最新操作也会写入新的AOF文件,以保证数据库最新状态的记录。此时,我们就可以用新的AOF文件替代旧文件了。

总结来说,每次AOF重写时,Redis会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为Redis采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。

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


所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。这就类似于照片,当你给朋友拍照时,一张照片就能把朋友一瞬间的形象完全记下来。

对Redis来说,它实现类似照片记录效果的方式,就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这个快照文件就称为RDB文件,其中,RDB就是Redis DataBase的缩写。

和AOF相比,RDB记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我们可以直接把RDB文件读入内存,很快地完成恢复。

Redis的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中。

save:在主线程中执行,会导致阻塞;

bgsave:创建一个子进程,专门用于写入RDB文件,避免了主线程的阻塞,这也是Redis RDB文件生成的默认配置。

好了,这个时候,我们就可以通过bgsave命令来执行全量快照,这既提供了数据的可靠性保证,也避免了对Redis的性能影响。

在给别人拍照时,一旦对方动了,那么这张照片就拍糊了,我们就需要重拍,所以我们当然希望对方保持不动。对于内存快照而言,我们也不希望数据“动”。

为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。

简单来说,bgsave子进程是由主线程fork生成的,可以共享主线程的所有内存数据。bgsave子进程运行后,开始读取主线程的内存数据,并把它们写入RDB文件。

此时,如果主线程对这些数据也都是读操作(例如图中的键值对A),那么,主线程和bgsave子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对C),那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave子进程会把这个副本数据写入RDB文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。

到这里,我们就解决了对“哪些数据做快照”以及“做快照时数据能否修改”这两大问题:Redis会使用bgsave对当前内存中的所有数据做快照,这个操作是子进程在后台完成的,这就允许主线程同时可以修改数据。

频繁执行全量快照带来的开销

一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。

另一方面,bgsave子进程需要通过fork操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁fork出bgsave子进程,这就会频繁阻塞主线程了。

此时,我们可以做增量快照,所谓增量快照,就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。

在第一次做完全量快照后,T1和T2时刻如果再做快照,我们只需要将被修改的数据写入快照文件就行。但是,这么做的前提是,我们需要记住哪些数据被修改了。你可不要小瞧这个“记住”功能,它需要我们使用额外的元数据信息去记录哪些数据被修改了,这会带来额外的空间开销问题。

虽然跟AOF相比,快照的恢复速度快,但是,快照的频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销。

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

这样一来,快照不用很频繁地执行,这就避免了频繁fork对主线程的影响。而且,AOF日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。

关于AOF和RDB的选择问题的三点建议:

  1. 数据不能丢失时,内存快照和AOF的混合使用是一个很好的选择;
  2. 如果允许分钟级别的数据丢失,可以只使用RDB;
  3. 如果只用AOF,优先使用everysec的配置选项,因为它在可靠性和性能之间取了一个平衡。

6.主从库同步实现数据一致

读操作:主库、从库都可以接收;
写操作:首先到主库执行,然后,主库将写操作同步给从库。

当我们启动多个Redis实例的时候,它们相互之间就可以通过replicaof(Redis 5.0之前使用slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。

第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。

具体来说,从库给主库发送psync命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync命令包含了主库的runID和复制进度offset两个参数。

runID,是每个Redis实例启动时都会自动生成的一个随机ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的runID,所以将runID设为“?”。
offset,此时设为-1,表示第一次复制。

主库收到psync命令后,会用FULLRESYNC响应命令带上两个参数:主库runID和主库目前的复制进度offset,返回给从库。从库收到响应后,会记录下这两个参数。

这里有个地方需要注意,FULLRESYNC响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的RDB文件。

在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的RDB文件中。为了保证主从库的数据一致性,主库会在内存中用专门的replication buffer,记录RDB文件生成后收到的所有写操作。

最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成RDB文件发送后,就会把此时replication buffer中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

主从级联模式分担全量复制时的主库压力

一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成RDB文件和传输RDB文件。

如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于fork子进程生成RDB文件,进行数据全量同步。fork这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输RDB文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。那么,有没有好的解决方法可以分担主库压力呢?

我们可以通过“主-从-从”模式将主库生成RDB和传输RDB的压力,以级联的方式分散到从库上。

简单来说,我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库。然后,我们可以再选择一些从库(例如三分之一的从库),在这些从库上执行如下命令,让它们和刚才所选的从库,建立起主从关系。

这样一来,这些从库就会知道,在进行同步时,不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这就可以减轻主库上的压力,如下图所示:

一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

在Redis 2.8之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。

从Redis 2.8开始,网络断了之后,主从库会采用增量复制的方式继续同步。听名字大概就可以猜到它和全量复制的不同:全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。

当主从库断连后,主库会把断连期间收到的写操作命令,写入replication buffer,同时也会把这些操作命令也写入repl_backlog_buffer这个缓冲区。

repl_backlog_buffer是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

因为repl_backlog_buffer是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。

因此,我们要想办法避免这一情况,一般而言,我们可以调整repl_backlog_size这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即repl_backlog_size = 缓冲空间大小 *


7.哨兵机制:主库挂了,如何不间断服务

哨兵其实就是一个运行在特殊模式下的Redis进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。

监控是指哨兵进程在运行时,周期性地给所有的主从库发送PING命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的PING命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的PING命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。

这个流程首先是执行哨兵的第二个任务,选主。主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。

然后,哨兵会执行最后一个任务:通知。在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行replicaof命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

在这三个任务中,通知任务相对来说比较简单,哨兵只需要把新主库信息发给从库和客户端,让它们和新主库建立连接就行,并不涉及决策的逻辑。但是,在监控和选主这两个任务中,哨兵需要做出两个决策:

在监控任务中,哨兵需要判断主库是否处于下线状态;
在选主任务中,哨兵也要决定选择哪个从库实例作为主库。

哨兵进程会使用PING命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对PING命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。

哨兵机制通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一个客观事实了。这个判断原则就是:少数服从多数。同时,这会进一步触发哨兵开始主从切换流程。

一般来说,我把哨兵选择新主库的过程称为“筛选+打分”。简单来说,我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库。

在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。

具体怎么判断呢?你使用配置项down-after-milliseconds * 10。其中,down-after-milliseconds是我们认定主从库断连的最大连接超时时间。如果在down-after-milliseconds毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了10次,就说明这个从库的网络状况不好,不适合作为新主库。

接下来就要给剩余的从库打分了。我们可以分别按照三个规则依次进行三轮打分,这三个规则分别是从库优先级、从库复制进度以及从库ID号。只要在某一轮中,有从库得分最高,那么它就是主库了,选主过程到此结束。如果没有出现得分最高的从库,那么就继续进行下一轮。

用户可以通过slave-priority配置项,给不同的从库设置不同优先级。

主从库同步时有个命令传播的过程。在这个过程中,主库会用master_repl_offset记录当前的最新写操作在repl_backlog_buffer中的位置,而从库会用slave_repl_offset这个值记录当前的复制进度。

每个实例都会有一个ID,这个ID就类似于这里的从库的编号。目前,Redis在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID号最小的从库得分最高,会被选为新主库。

我们再回顾下这个流程。首先,哨兵会按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库,然后,依次按照优先级、复制进度、ID号大小再对剩余的从库进行打分,只要有得分最高的从库出现,就把它选为新主库。


08-哨兵集群:哨兵挂了,主从库还能切换吗?

基于pub/sub机制的哨兵集群组成

哨兵实例之间可以相互发现,要归功于Redis提供的pub/sub机制,也就是发布/订阅机制。
哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的IP地址和端口。
除了哨兵实例,我们自己编写的应用程序也可以通过Redis进行消息的发布和订阅。所以,为了区分不同应用的消息,Redis会以频道的形式,对这些消息进行分门别类的管理。所谓的频道,实际上就是消息的类别。当消息类别相同时,它们就属于同一个频道。反之,就属于不同的频道。只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换。

在主从集群中,主库上有一个名为“sentinel:hello”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。

哨兵除了彼此之间建立起连接形成集群外,还需要和从库建立连接。这是因为,在哨兵的监控任务中,它需要对主从库都进行心跳判断,而且在主从库切换完成后,它还需要通知从库,让它们和新主库进行同步。

那么,哨兵是如何知道从库的IP地址和端口的呢?

这是由哨兵向主库发送INFO命令来完成的。就像下图所示,哨兵2给主库发送INFO命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵1和3可以通过相同的方法和从库建立连接。

基于pub/sub机制的客户端事件通知

从本质上说,哨兵就是一个运行在特定模式下的Redis实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供pub/sub机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。

频道有这么多,一下子全部学习容易丢失重点。为了减轻你的学习压力,我把重要的频道汇总在了一起,涉及几个关键事件,包括主库下线判断、新主库选定、从库重新配置。

知道了这些频道之后,你就可以让客户端从哨兵这里订阅消息了。具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息。

有了pub/sub机制,哨兵和哨兵之间、哨兵和从库之间、哨兵和客户端之间就都能建立起连接了,再加上我们上节课介绍主库下线判断和选主依据,哨兵集群的监控、选主和通知三个任务就基本可以正常工作了。

由哪个哨兵执行主从切换?

确定由哪个哨兵执行主从切换的过程,和主库“客观下线”的判断过程类似,也是一个“投票仲裁”的过程。

一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。这个所需的赞成票数是通过哨兵配置文件中的quorum配置项设定的。例如,现在有5个哨兵,quorum配置的是3,那么,一个哨兵需要3张赞成票,就可以标记主库为“客观下线”了。这3张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader选举”。因为最终执行主从切换的哨兵称为Leader,投票过程就是确定Leader。
在投票过程中,任何一个想成为Leader的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的quorum值。以3个哨兵为例,假设此时的quorum设置为2,那么,任何一个想成为Leader的哨兵只要拿到2张赞成票,就可以了。

需要注意的是,如果哨兵集群只有2个实例,此时,一个哨兵要想成为Leader,必须获得2票,而不是1票。所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。因此,通常我们至少会配置3个哨兵实例。这一点很重要,你在实际应用时可不能忽略了。

支持哨兵集群的这些关键机制:
基于pub/sub机制的哨兵集群组成过程;
基于INFO命令的从库列表,这可以帮助哨兵和从库建立连接;
基于哨兵自身的pub/sub功能,这实现了客户端和哨兵之间的事件通知。


切片集群,也叫分片集群,就是指启动多个Redis实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。

纵向扩展:升级单个Redis实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的CPU。就像下图中,原来的实例内存是8GB,硬盘是50GB,纵向扩展后,内存增加到24GB,磁盘增加到150GB。

横向扩展:横向增加当前Redis实例的个数,就像下图中,原来使用1个8GB内存、50GB磁盘的实例,现在使用三个相同配置的实例。

首先,纵向扩展的好处是,实施起来简单、直接。不过,这个方案也面临两个潜在的问题。
第一个问题是,当使用RDB对数据进行持久化时,如果数据量增加,需要的内存也会增加,主线程fork子进程时就可能会阻塞(比如刚刚的例子中的情况)。不过,如果你不要求持久化保存Redis数据,那么,纵向扩展会是一个不错的选择。

不过,这时,你还要面对第二个问题:纵向扩展会受到硬件和成本的限制。这很容易理解,毕竟,把内存从32GB扩展到64GB还算容易,但是,要想扩充到1TB,就会面临硬件容量和成本上的限制了。

与纵向扩展相比,横向扩展是一个扩展性更好的方案。这是因为,要想保存更多的数据,采用这种方案的话,只用增加Redis的实例个数就行了,不用担心单个实例的硬件和成本限制。在面向百万、千万级别的用户规模时,横向扩展的Redis切片集群会是一个非常好的选择。

数据切片和实例的对应分布关系

Redis Cluster方案采用哈希槽(Hash Slot,接下来我会直接称之为Slot),来处理数据和实例之间的映射关系。在Redis Cluster方案中,一个切片集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key,被映射到一个哈希槽中。

具体的映射过程分为两大步:首先根据键值对的key,按照CRC16算法计算一个16 bit的值;然后,再用这个16bit值对16384取模,得到0~16383范围内的模数,每个模数代表一个相应编号的哈希槽。关于CRC16算法,不是这节课的重点,你简单看下链接中的资料就可以了。

我们在部署Redis Cluster方案时,可以使用cluster create命令创建集群,此时,Redis会自动把这些槽平均分布在集群实例上。例如,如果集群中有N个实例,那么,每个实例上的槽个数为16384/N个。

当然, 我们也可以使用cluster meet命令手动建立实例间的连接,形成集群,再使用cluster addslots命令,指定每个实例上的哈希槽个数。

在手动分配哈希槽时,需要把16384个槽都分配完,否则Redis集群无法正常工作。

在定位键值对数据时,它所处的哈希槽是可以通过计算得到的,这个计算可以在客户端发送请求时来执行。但是,要进一步定位到实例,还需要知道哈希槽分布在哪个实例上。

一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。

那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?这是因为,Redis实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

在集群中,实例有新增或删除,Redis需要重新分配哈希槽;
为了负载均衡,Redis需要把哈希槽在所有实例上重新分布一遍。

Redis Cluster方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

MOVED重定向命令的使用方法

可以看到,由于负载均衡,Slot 2中的数据已经从实例2迁移到了实例3,但是,客户端缓存仍然记录着“Slot 2在实例2”的信息,所以会给实例2发送命令。实例2给客户端返回一条MOVED命令,把Slot 2的最新位置(也就是在实例3上),返回给客户端,客户端就会再次向实例3发送请求,同时还会更新本地缓存,把Slot 2与实例的对应关系更新过来。

ASK重定向命令得使用方法

在下图中,Slot 2正在从实例2往实例3迁移,key1和key2已经迁移过去,key3和key4还在实例2。客户端向实例2请求key2后,就会收到实例2返回的ASK命令。

ASK命令表示两层含义:第一,表明Slot数据还在迁移中;第二,ASK命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例3发送ASKING命令,然后再发送操作命令。

和MOVED命令不同,ASK命令并不会更新客户端缓存的哈希槽分配信息。所以,在上图中,如果客户端再次请求Slot 2中的数据,它还是会给实例2发送请求。这也就是说,ASK命令的作用只是让客户端能给新实例发送一次请求,而不像MOVED命令那样,会更改本地缓存,让后续所有命令都发往新实例。


问题1:AOF重写过程中有没有其他潜在的阻塞风险?

风险一:Redis主线程fork创建bgrewriteaof子进程时,内核需要创建用于管理子进程的相关数据结构,这些数据结构在操作系统中通常叫作进程控制块(Process Control Block,简称为PCB)。内核要把主线程的PCB内容拷贝给子进程。这个创建和拷贝过程由内核执行,是会阻塞主线程的。而且,在拷贝过程中,子进程要拷贝父进程的页表,这个过程的耗时和Redis实例的内存大小有关。如果Redis实例内存大,页表就会大,fork执行时间就会长,这就会给主线程带来阻塞风险。

风险二:bgrewriteaof子进程会和主线程共享内存。当主线程收到新写或修改的操作时,主线程会申请新的内存空间,用来保存新写或修改的数据,如果操作的是bigkey,也就是数据量大的集合类型数据,那么,主线程会因为申请大空间而面临阻塞风险。因为操作系统在分配内存空间时,有查找和锁的开销,这就会导致阻塞。

问题2:AOF 重写为什么不共享使用 AOF 本身的日志?

如果都用AOF日志的话,主线程要写,bgrewriteaof子进程也要写,这两者会竞争文件系统的锁,这就会对Redis主线程的性能造成影响。

问题3:为什么主从库间的复制不使用 AOF?

1 RDB文件是二进制文件,无论是要把RDB写入磁盘,还是要通过网络传输RDB,IO效率都比记录和传输AOF的高。
2 在从库端进行恢复时,用RDB的恢复效率要高于用AOF。

问题4:为什么Redis不直接用一个表,把键值对和实例的对应关系记录下来?

如果使用表记录键值对和实例的对应关系,一旦键值对和实例的对应关系发生了变化(例如实例有增减或者数据重新分布),就要修改表。如果是单线程操作表,那么所有操作都要串行执行,性能慢;如果是多线程操作表,就涉及到加锁开销。此外,如果数据量非常大,使用表记录键值对和实例的对应关系,需要的额外存储空间也会增加。

基于哈希槽计算时,虽然也要记录哈希槽和实例的对应关系,但是哈希槽的个数要比键值对的个数少很多,无论是修改哈希槽和实例的对应关系,还是使用额外空间存储哈希槽和实例的对应关系,都比直接记录键值对和实例的关系的开销小得多。

Redis会使用装载因子(load factor)来判断是否需要做rehash。装载因子的计算方式是,哈希表中所有entry的个数除以哈希表的哈希桶个数。Redis会根据装载因子的两种情况,来触发rehash操作:

1 装载因子≥1,同时,哈希表被允许进行rehash;

在第一种情况下,如果装载因子等于1,同时我们假设,所有键值对是平均分布在哈希表的各个桶中的,那么,此时,哈希表可以不用链式哈希,因为一个哈希桶正好保存了一个键值对。

但是,如果此时再有新的数据写入,哈希表就要使用链式哈希了,这会对查询性能产生影响。在进行RDB生成和AOF重写时,哈希表的rehash是被禁止的,这是为了避免对RDB和AOF重写造成影响。如果此时,Redis没有在生成RDB和重写AOF,那么,就可以进行rehash。否则的话,再有数据写入时,哈希表就要开始使用查询较慢的链式哈希了。

在第二种情况下,也就是装载因子大于等于5时,就表明当前保存的数据量已经远远大于哈希桶的个数,哈希桶里会有大量的链式哈希存在,性能会受到严重影响,此时,就立马开始做rehash。

刚刚说的是触发rehash的情况,如果装载因子小于1,或者装载因子大于1但是小于5,同时哈希表暂时不被允许进行rehash(例如,实例正在生成RDB或者重写AOF),此时,哈希表是不会进行rehash操作的。

问题6:采用渐进式hash时,如果实例暂时没有收到新请求,是不是就不做rehash了?

其实不是的。Redis会执行定时任务,定时任务中就包含了rehash操作。所谓的定时任务,就是按照一定频率(例如每100ms/次)执行的任务。

在rehash被触发后,即使没有收到新请求,Redis也会定时执行一次rehash操作,而且,每次执行时长不会超过1ms,以免对其他任务造成影响。

问题7:写时复制的底层实现机制

对Redis来说,主线程fork出bgsave子进程后,bgsave子进程实际是复制了主线程的页表。这些页表中,就保存了在执行bgsave命令时,主线程的所有数据块在内存中的物理地址。这样一来,bgsave子进程生成RDB时,就可以根据页表读取这些数据,再写入磁盘中。如果此时,主线程接收到了新写或修改操作,那么,主线程会使用写时复制机制。具体来说,写时复制就是指,主线程在有写操作时,才会把这个新写或修改后的数据写入到一个新的物理地址中,并修改自己的页表映射。

我来借助下图中的例子,具体展示一下写时复制的底层机制。

bgsave子进程复制主线程的页表以后,假如主线程需要修改虚页7里的数据,那么,主线程就需要新分配一个物理页(假设是物理页53),然后把修改后的虚页7里的数据写到物理页53上,而虚页7里原来的数据仍然保存在物理页33上。这个时候,虚页7到物理页33的映射关系,仍然保留在bgsave子进程中。所以,bgsave子进程可以无误地把虚页7的原始数据写入RDB文件。

总的来说,replication buffer是主从库在进行全量复制时,主库上用于和从库连接的客户端的buffer,而repl_backlog_buffer是为了支持从库增量复制,主库上用于持续保存写操作的一块专用buffer。

Redis主从库在进行复制时,当主库要把全量复制期间的写操作命令发给从库时,主库会先创建一个客户端,用来连接从库,然后通过这个客户端,把写操作命令发给从库。在内存中,主库上的客户端就会对应一个buffer,这个buffer就被称为replication buffer。Redis通过client_buffer配置项来控制这个buffer的大小。主库会给每个从库建立一个客户端,所以replication buffer不是共享的,而是每个从库都有一个对应的客户端。

repl_backlog_buffer是一块专用buffer,在Redis服务器启动后,开始一直接收写操作命令,这是所有从库共享的。主库和从库会各自记录自己的复制进度,所以,不同的从库在进行恢复时,会把自己的复制进度(slave_repl_offset)发给主库,主库就可以和它独立同步。

1.以下叙述中正确的是 。
A.串是一种特殊的线性表 B.串的长度必须大于零
C.串中无素只能是字母 D.空串就是空白串
2.空串与空格串是相同的,这种说法____。
3.串是一中特殊的线性表,其特殊性体现在____。
A. 可以顺序存储 B. 数据元素是一个字符
C. 可以链接存储 D. 数据元素可以是多个字符
4.设有两个串p和q,求q在p中首次出现的位置的运算称作____。
6.设串的长度为n,则它的子串个数为 。
4.2 填空题(将正确的答案填在相应的空中)
1.串的两种最基本的存储方式是____。
2.两个串相等的充分必要条件是____。
3.空串是____,其长度等于____。
4.空格串是____,其长度等于____。
1.串是由有限个字符构成的连续序列,串长度为串中字符的个数,子串是主串中
符构成的有限序列。 ()
2.子串定位函数的时间复杂度在最坏情况下为O(n*m),因此子串定位函数没有实际使用的价值。 ()
3.KMP算法的最大特点是指主串的指针不需要回溯。 ()
4.设模式串的长度为m,目标串的长度为n;当n≈m且处理只匹配一次的模式时,朴素的匹配(即子串定位函数)算法所花的时间代价也可能会更为节省。 ()
5.如果一个串中的所有字符均在另一串中出现,则说前者是后者的子串。 ()
1.编写算法,从串s 中删除所有和串 t相同的子串。
3.写一个递归算法来实现字符串逆序存储,要求不另设存储空间。
1.顺序存储方式和链接存储方式
2.两个串的长度相等且对应位置的字符相同
4.由一个或多个空格字符组成的串、其包含的空格个数

1. 由于二叉树中每个结点的度最大为2,所以二叉树是一种特殊的树,这种说法____。
2. 假定在一棵二叉树中,双分支结点数为15,单分支结点数为30个,则叶子结点数为 个。 A.15 B.16 C.17 D.47
3. 按照二叉树的定义,具有3个结点的不同形状的二叉树有____种。
4. 按照二叉树的定义,具有3个不同数据结点的不同的二叉树有____种。
5. 深度为5的二叉树至多有____个结点。
6. 设高度为h的二叉树上只有度为0和度为2的结点,则此类二叉树中所包含的结点数至少为_ ___。
7. 对一个满二叉树,m个树叶,n个结点,深度为h,则____ 。
8. 任何一棵二叉树的叶结点在先序、中序和后序遍历序列中的相对次序____。
A.不发生改变 B.发生改变 C.不能确定 D.以上都不对
10. 二叉树的前序遍历序列中,任意一个结点均处在其子女结点的前面,这种说法____。 A. 正确 B. 错误
11. 某二叉树的前序遍历结点访问顺序是abdgcefh,中序遍历的结点访问顺序是dgbaechf,则其后序遍历的结点访问顺序是____。
12. 在一非空二叉树的中序遍历序列中,根结点的右边____。
A. 只有右子树上的所有结点 B. 只有右子树上的部分结点
C. 只有左子树上的部分结点 D. 只有左子树上的所有结点
13. 如图6.1所示二叉树的中序遍历序列是____。

14. 一棵二叉树如图6.2所示,其中序遍历的序列为__ __。
15.设a,b为一棵二叉树上的两个结点,在中序遍历时,a在b前的条件是 。
A.a在b的右方 B.a在b的左方
C.a是b的祖先 D.a是b的子孙
17. 实现任意二叉树的后序遍历的非递归算法而不使用栈结构,最佳方案是二叉树采用____存储结构。
A. 二叉链表 B. 广义表存储结构 C. 三叉链表 D. 顺序存储结构
18. 如图6.3所示的4棵二叉树,____不是完全二叉树。

19. 如图6.4所示的4棵二叉树,____是平衡二叉树。

20. 在线索化二叉树中,t所指结点没有左子树的充要条件是____。
21. 二叉树按某种顺序线索化后,任一结点均有指向其前驱和后续的线索,这种说法____。 A. 正确 B. 错误
22. 二叉树为二叉排序树的充分必要条件是其任一结点的值均大于其左孩子的值、小于其右孩子的值。这种说法____。 A. 正确 B. 错误
23. 具有五层结点的二叉平衡树至少有____个结点。
24. 树的基本遍历策略可分为先根遍历和后根遍历;二叉树的基本遍历策略可分为先序遍历、中序遍历和后序遍历。这里,我们把由树转化得到的二叉树叫做这棵数对应的二叉树。结论____是正确的。
A.树的先根遍历序列与其对应的二叉树的先序遍历序列相同
B.树的后根遍历序列与其对应的二叉树的后序遍历序列相同
C.树的先根遍历序列与其对应的二叉树的中序遍历序列相同
25. 树最适合用来表示____。
A. 有序数据元素 B. 无序数据元素
C. 元素之间具有分支层次关系的数据 D. 元素之间无联系的数据
6.2 填空题(将正确的答案填在相应的空中)
1. 有一棵树如图6.5所示,回答下面的问题:
⑴ 这棵树的根结点是____;
⑵ 这棵树的叶子结点是____;
⑷ 这棵树的度是____;
⑸ 这棵树的深度是____;
⑹ 结点k3的子女是____;
⑺ 结点k3的父结点是____;

2. 指出树和二叉树的三个主要差别____、____、____。
3. 从概念上讲,树与二叉树是两种不同的数据结构,将树转化为二叉树的基本目的是___ _。

4. 一棵二叉树的结点数据采用顺序存储结构,存储于数组t中,如图6.6所示,则该二叉树的链接表示形式为__ __。
5. 深度为k的完全二叉树至少有____个结点。至多有____个结点,若按自上而下,从左到右次序给结点编号(从1开始),则编号最小的叶子结点的编号是____。
6. 在一棵二叉树中,度为零的结点的个数为n 0,度为2的结点的个数为 n 2,则有n0=____。
7. 一棵二叉树的第i(i≥1)层最多有____个结点;一棵有n(n>0)个结点的满二叉树共有____个叶子和____个非终端结点。
8. 结点最少的树为____,结点最少的二叉树为____。
9. 现有按中序遍历二叉树的结果为abc,问有____种不同形态的二叉树可以得到这一遍历结果,这些二叉树分别是____。
10. 由如图6.7所示的二叉树,回答以下问题:
⑴ 其中序遍历序列为____;
⑵ 其前序遍历序列为____;
⑶ 其后序遍历序列为____;

1. 根据二叉树的定义,具有三个结点的二叉树有5种不同的形态,请将它们分别画出。
3. 由如图6.7所示的二叉树,回答以下问题:
(1)画出该二叉树的中序线索二叉树;
(2)画出该二叉树的后序线索二叉树;
(3)画出该二叉树对应的森林。
4. 已知一棵树如图6.8所示,转化为一棵二叉树,表示为____。

5. 以数据集{4,5,6,7,10,12,18}为结点权值,画出构造Huffman树的每一步图示,计算其带权路径长度为。
6. 一棵含有N个结点的k叉树,可能达到的最大深度和最小深度各为多少?
7. 证明:一棵满k叉树上的叶子结点数n 和非叶子结点数n 之间满足以下关系:
1. 编写按层次顺序(同一层自左至右)遍历二叉树的算法。
2.试编写算法,对一棵二叉树,统计叶子的个数。
3.试编写算法,对一棵二叉树根结点不变,将左、右子树进行交换,树中每个结点的左、右子树进行交换。
使用0-7的二进制表示形式是另一种编码方案。对于上述实例,比较两种方案的优缺点。
8. 试编写算法,对一棵以孩子-兄弟链表表示的树统计叶子的个数。假设一棵 二叉树的先序序列为EBADCFHGIKJ和中序序列为ABCDEFGHIJK。请画出该树。
2. 树的结点个数至少为1(不同教材规定不同),而二叉树的结点个数可以为0;
树中结点的最大度数没有限制,而二叉树结点的最大度数为2;
树的结点无左、右之分,而二叉树的结点有左、右之分;
3. 树可采用孩子-兄弟链表(二叉链表)做存储结构,目的并利用二叉树的已有算法解决树的有关问题。
8. 只有一个结点的树;空的二叉树
2. 二叉树如图6.12所示。

3. 中序线索二叉树如图6.13(左)所示;后序线索二叉树如图6.13(右)所示;
该二叉树转换后的的森林如图6.14所示。

4. 图6.8的树转化为一棵二叉树如下,图6.15:

5. 画出构造Huffman树如图6.16所示,计算其带权路径长度为 。

6. 一棵含有N个结点的k叉树,可能达到的最大深度 h=N-k+1 ,

11.已知一有向图的邻接表存储结构如图7.2所示。

⑴ 根据有向图的深度优先遍历算法,从顶点v1出发,所得到的顶点序列是____。
⑵ 根据有向图的宽度优先遍历算法,从顶点v1出发,所得到的顶点序列是____。
12.采用邻接表存储的图的深度优先遍历算法类似于二叉树的____。
A. 先序遍历 B. 中序遍历 C. 后序遍历 D. 按层遍历
13.采用邻接表存储的图的宽度优先遍历算法类似于二叉树的____。
A. 先序遍历 B. 中序遍历 C. 后序遍历 D. 按层遍历
14.判定一个有向图是否存在回路除了可以利用拓扑排序方法外,还可以利用____。
A. 求关键路径的方法 B. 求最短路径的Dijkstra方法
C. 宽度优先遍历算法 D. 深度优先遍历算法
15.关键路径是事件结点网络中 。
A.从源点到汇点的最长路径 B.从源点到汇点的最短路径
C.最长的回路 D.最短的回路
16.下面不正确的说法是 。
(1)在AOE网中,减小一个关键活动上的权值后,整个工期也就相应减小;
(2)AOE网工程工期为关键活动上的权之和;
(3)在关键路径上的活动都是关键活动,而关键活动也必在关键路径上。
17.用DFS遍历一个无环有向图,并在DFS算法退栈返回时打印出相应的顶点,则输出的顶点序列是 。
A.逆拓朴有序的 B.拓朴有序的 C.无序的
18.在图7.3所示的拓朴排列的结果序列为 。

19.一个有n个顶点的无向连通图,它所包含的连通分量个数为 。
20.对于一个有向图,若一个顶点的入度为k1,、出度为k2,则对应邻接表中该顶点单链表中的结点数为 。
21.对于一个有向图,若一个顶点的入度为k1,、出度为k2,则对应逆邻接表中该顶点单链表中的结点数为 。
7.2 填空题(将正确的答案填在相应饿空中)
1.n个顶点的连通图至少____条边。
2.在无权图G的邻接矩阵A中,若(vi,vj)或<vi,vj>属于图G的边集合,则对应元素A[i][j]等于____,否则等于____。
4.已知图G的邻接表如图7.4所示,其从顶点v1出发的深度有限搜索序列为____,其从顶点v1出发的宽度优先搜索序列为____。

图7.4 图G的邻接表
5.已知一个有向图的邻接矩阵表示,计算第i个结点的入度的方法是____。
6.已知一个图的邻接矩阵表示,删除所有从第i个结点出发的边的方法是____。
7.如果含n个顶点的图形成一个环,则它有 棵生成树。
8.一个非连通无向图,共有28条边,则该图至少有 个顶点。
9.遍历图的过程实质上是 。BFS遍历图的时间复杂度为 ,DFS遍历图的时间复杂度为 ,两者不同之处在于 ,反映在数据结构上的差别是 。
10.一个图的 表示法是唯一的,而 表示法是不唯一的。
11.有向图中的结点前驱后继关系的特征是 。
12.若无向图G的顶点度数最小值大于等于 时,G至少有一条回路。
13.根据图的存储结构进行某种次序的遍历,得到的顶点序列是 的。
1.已知如图7.5所示的有向图,请给出该图的:
(1)每个顶点的入/出度;

2.请用克鲁斯卡尔和普里姆两种算法分别为图7.6、图7.7构造最小生成树:

3.试列出图7.8中全部的拓扑排序序列。

4.请用图示说明图7.9从顶点a到其余各顶点之间的最短路径。

5.已知AOE网有9个结点:V1,V2,V3,V4,V5,V6,V7,V8,V9,其邻接矩阵如下:
(2)计算完成整个计划需要的时间。
(3)求出该AOE网的关键路径。
∝ ∝ ∝ ∝ ∝ ∝ ∝ ∝ ∝

(2)完成整个计划需要18天。

1.顺序查找法适合于存储结构为____的线性表。
A. 散列存储 B. 顺序存储或链接存储
C. 压缩存储 D. 索引存储
2.对线性表进行二分查找时,要求线性表必须____。
A. 以顺序方式存储 B. 以链接方式存储
C. 以顺序方式存储,且结点按关键字有序排序
D. 以链接方式存储,且结点按关键字有序排序
3.采用顺序查找方法查找长度为n的线性表时,每个元素的平均查找长度为____.
4.采用二分查找方法查找长度为n的线性表时,每个元素的平均查找长度为____。
5.二分查找和二叉排序树的时间性能____。
6.有一个有序表为{1,3,9,12,32,41,45,62,75,77,82,95,100},当二分查找值82为的结点时,____次比较后查找成功。
如用二次探测再散列处理冲突,关键字为49的结点的地址是____。
8.有一个长度为12的有序表,按二分查找法对该表进行查找,在表内各元素等概率情况下查找成功所需的平均比较次数为____。
9.对于静态表的顺序查找法,若在表头设置岗哨,则正确的查找方式为 。
A.从第0个元素往后查找该数据元素
B.从第1个元素往后查找该数据元素
C.从第n个元素往开始前查找该数据元素
10.解决散列法中出现的冲突问题常采用的方法是 。
A.数字分析法、除余法、平方取中法
B.数字分析法、除余法、线性探测法
C.数字分析法、线性探测法、多重散列法
D.线性探测法、多重散列法、链地址法
11.采用线性探测法解决冲突问题,所产生的一系列后继散列地址 。
A.必须大于等于原散列地址
B.必须小于等于原散列地址
C.可以大于或小于但不能等于原散列地址
D.地址大小没有具体限制
12.对于查找表的查找过程中,若被查找的数据元素不存在,则把该数据元素插入到集合中。这种方式主要适合于 。
A.静态查找表 B.动态查找表
C.静态查找表与动态查找表 D两种表都不适合
13.散列表的平均查找长度 。
A.与处理冲突方法有关而与表的长度无关
B.与处理冲突方法无关而与表的长度有关
C.与处理冲突方法有关而与表的长度有关
D.与处理冲突方法无关而与表的长度无关
8.2 填空题(将正确的答案填在相应的空中)
1.顺序查找法的平均查找长度为____;折半查找法的平均查找长度为____;哈希表查找法采用链接法处理冲突时的平均查找长度为____。
2.在各种查找方法中,平均查找长度与结点个数n无关的查找方法是____。
3.折半查找的存储结构仅限于____,且是____。
4. 假设在有序线性表A[1..20]上进行折半查找,则比较一次查找成功的结点数为____,则比较二次查找成功的结点数为____,则比较三次查找成功的结点数为____,则比较四次查找成功的结点数为____,则比较五次查找成功的结点数为____,平均查找长度为____。
5. 对于长度为n的线性表,若进行顺序查找,则时间复杂度为____;若采用折半法查找,则时间复杂度为____;
6.已知有序表为(12,18,24,35,47,50,62,83,90,115,134),当用折半查找90时,需进行 次查找可确定成功;查找47时,需进行 次查找成功;查找100时,需进行 次查找才能确定不成功。
7.二叉排序树的查找长度不仅与 有关,也与二叉排序树的 有关。
8.一个无序序列可以通过构造一棵 树而变成一个有序树,构造树的过程即为对无序序列进行排序的过程。
9.平衡二叉排序树上任一结点的平衡因子只可能是 、 或 。
10. 法构造的哈希函数肯定不会发生冲突。
12.在散列存储中,装填因子 的值越大,则____; 的值越小,则____。
1. 画出对长度为10的有序表进行折半查找的判定树,并求其等概率时查找成功的平均查找长度。
2.含九个叶子结点的3阶B-树中至少有多少个非叶子结点?含10个叶子结点的3阶B-树中至多有多少个非叶子结点?
3.试从空树开始,画出按以下次序向2-3树即3阶B-树中插入关键码的建树过程:20,30,50,52,60,68,70.如果此后删除50和68,画出每一步执行后2-3树的状态。
4. 选取哈稀函数H(k)=(3k)MOD 11。用开放定址法处理冲突,di=i((7k)MOD 10+1)(I=1,2,3,…).试在0-10的散列地址空间中对关键字序列(22,41,53,46,30,13,01,67)造哈希表,并求等概率情况下查找成功时的平均查找长度。
5. 已知一组关键字{49,38,65,97,76,13,27,44,82,35,50},画出由此生成的二叉排序树,注意边插入边平衡。

3. 顺序存储结构、有序的
(依题意,构造一棵有序二叉树,共12个结点,第一层1个结点,第二层2个结点,第三层4个结点,第四层5个结点,则:ASL=(1*1+2*2+3*4+4*5)/12=37/12)
7.结点个数n、生成过程
12.存取元素时发生冲突的可能性就越大、存取元素时发生冲突的可能性就越小

1. 在所有排序方法中,关键字比较的次数与记录的初始排列次序无关的是____。
A. 希尔排序 B. 起泡排序 C. 插入排序 D. 选择排序
2. 设有1000个无序的元素,希望用最快的速度挑选出其中前10个最大的元素,最好选用____排序法。
3. 在待排序的元素序列基本有序的前提下,效率最高的排序方法是____。
A. 插入排序 B. 选择排序

我要回帖

更多关于 sql语句随机取出10条数据 的文章

 

随机推荐