01 | 基本架构:一个键值数据库包含什么?

模块.png

思考角度

  • 可以存哪些数据?
    • Redis 支持的 value 类型包括了 String、哈希表、列表、集合等。
  • 可以对数据做什么操作?
    • 3 种基本操作,即 PUT、GET 和 DELETE
    • 有些键值数据库的新写 / 更新操作叫 SET。即 Insert on duplicate key
    • SCAN:根据一段 key 的范围返回相应的 value 值
    • EXIST….
  • 键值对保存在内存还是磁盘

整体设计

大体来说,一个键值数据库包括了访问框架、索引模块、操作模块和存储模块四部分(见下图)。
基础篇 (01-10讲) - 图2

采用什么访问模式?

通过网络框架以 Socket 通信的形式对外提供键值对操作
网络连接的处理、网络请求的解析,以及数据存取的处理,是用一个线程、多个线程,还是多个进程来交互处理呢?该如何进行设计和取舍呢?我们一般把这个问题称为 I/O 模型设计。不同的 I/O 模型对键值数据库的性能和可扩展性会有不同的影响。

如何定位键值对的位置?

索引的作用是让键值数据库根据 key 找到相应 value 的存储位置,进而执行操作。索引的类型有很多,常见的有哈希表、B+ 树、字典树等。
一般而言,内存键值数据库(例如 Redis)采用哈希表作为索引,很大一部分原因在于,其键值数据基本都是保存在内存中的,而内存的高性能随机访问特性可以很好地与哈希表 O(1) 的操作复杂度相匹配。
value 支持多种类型,当我们通过索引找到一个 key 所对应的 value 后,仍然需要从 value 的复杂结构(例如集合和列表)中进一步找到我们实际需要的数据,这个操作的效率本身就依赖于它们的实现结构。

Redis 采用一些常见的高效索引结构作为某些 value 类型的底层数据结构

不同操作的具体逻辑是怎样的?

  • 对于 GET/SCAN 操作而言,此时根据 value 的存储位置返回 value 值即可;
  • 对于 PUT 一个新的键值对数据而言,SimpleKV 需要为该键值对分配内存空间;
  • 对于 DELETE 操作,SimpleKV 需要删除键值对,并释放相应的内存空间,这个过程由分配器完成。

内存分配

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

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

如何实现重启后快速提供服务?

  • 一种方式是,对于每一个键值对,SimpleKV 都对其进行落盘保存,这虽然让 SimpleKV 的数据更加可靠,但是,因为每次都要写盘,SimpleKV 的性能会受到很大影响。
  • 另一种方式是,SimpleKV 只是周期性地把内存中的键值数据保存到文件中,这样可以避免频繁写盘操作的性能影响。但是,一个潜在的代价是 SimpleKV 的数据仍然有丢失的风险。

    Redis 为持久化提供了诸多的执行机制和优化改进,后面我会和你逐一介绍 Redis 在持久化机制中的关键设计考虑。

基础篇 (01-10讲) - 图3

02 | 数据结构:快速的Redis有哪些慢操作?

性能提升.png

redis快在那

  • 基于内存操作
  • io多路复用
  • 存储的数据结构

    数据结构指的不是redis的保存形式”string、hash、set、zset、list”,这些只是键值对中值的数据类型。我们说的数据结构是它们的底层。

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

键和值用什么结构组织?

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。
基础篇 (01-10讲) - 图6
当你往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险点,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞

完全可以按照hashmap思考

为什么哈希表操作变慢了?

Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。
基础篇 (01-10讲) - 图7
哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。所以,Redis 会对哈希表做 rehash 操作。
为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:

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

这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。为了避免这个问题,Redis 采用了渐进式 rehash
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
基础篇 (01-10讲) - 图8这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

渐进式 rehash 开始时,会在 redis 中维护一个 rehashidx 的变量,代表 ht[0] 的索引,初始值为0。渐进式 rehash 是“随着执行请求,顺带 rehash”,而不是 “根据当前请求的 key,顺带 rehash”。每次处理一次请求,对当前 rehashidx 对应的元素进行拷贝,拷贝完成,rehashidx++。当整个 rehash 执行结束,rehashidx 设置为 -1,此时 ht[0] 的空间就可以释放了。 在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找, 在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

集合数据操作效率

和 String 类型不同,一个集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。那么,集合的操作效率和哪些因素相关呢?

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

下面分别聊聊集合类型的底层数据结构和操作复杂度。

有哪些底层数据结构?

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

压缩列表

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

跳表

有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:
基础篇 (01-10讲) - 图10当数据量很大时,跳表的查找复杂度就是 O(logN)。
基础篇 (01-10讲) - 图11

不同操作的复杂度

集合类型的操作类型很多,有读写单个集合元素的,例如 HGET、HSET,也有操作多个元素的,例如 SADD,还有对整个集合进行遍历操作的,例如 SMEMBERS。这么多操作,它们的复杂度也各不相同。而复杂度的高低又是我们选择集合类型的重要依据。
快速记住集合常见操作的复杂度

  • 单元素操作是基础;
  • 范围操作非常耗时;
  • 统计操作通常高效;
  • 例外情况只有几个。

  • 第一,单元素操作,是指每一种集合类型对单个数据实现的增删改查操作

  • 第二,范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如 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 还会把它们作为底层数据结构呢?
    1、内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
    2、数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。

问题延伸:如果在数组上是随机访问,对CPU高速缓存还友好不?
随机数据要取决于数组的大小,如果大小刚刚符合了缓存行的64字节的话就不怎么消耗性能

数组通过下标访问数据虽然是O(1),但是由于cpu读取数据从高速缓存读,而高速缓存的容量很小。比如cpu要读取nums[0],cpu发现高速缓存没有nums[0],就会从内存把num[0]以及nums[0]附近的数据(比如还拖取了nums[1])都拖取到高速缓存中。如果接下来cpu要读取nums[1],由于nums[1]已经在告诉缓存中,那么cpu能马上拿到数据。但如果cpu想要读取nums[100],显然高速缓存中没有这个数据,然后就又要到内存中把nums[100]和他附近的数据拖到高速缓存。假设高速缓存只能存两个数字。那么因为读取nums[100],就会把原本在nums[0]的数据替换掉。如果接下来cpu又读nums[0]。。那么就又要从内存中获取。 也就是说,老师问的对cpu友好,说的并不是你理解的时间复杂度。而是对于这种从高速缓存读取数据的行为。顺便说一句,在《深入理解操作系统中》中把要读取的数据附近的数据一起拖到高速缓存的行为,叫空间局部性。

03 | 高性能IO模型:为什么单线程Redis能那么快?

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

Redis 为什么用单线程?

多线程的开销

系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。
拿 Redis 来说,在上节课中,我提到过,Redis 有 List 的数据类型,并提供出队(LPOP)和入队(LPUSH)操作。假设 Redis 采用多线程设计,如下图所示,现在有两个线程 A 和 B,线程 A 对一个 List 做 LPUSH 操作,并对队列长度加 1。同时,线程 B 对该 List 执行 LPOP 操作,并对队列长度减 1。为了保证队列长度的正确性,Redis 需要让线程 A 和 B 的 LPUSH 和 LPOP 串行执行,这样一来,Redis 可以无误地记录它们对 List 长度的修改。否则,我们可能就会得到错误的长度结果。这就是多线程编程模式面临的共享资源的并发访问控制问题

单线程 Redis 为什么那么快?

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

基本 IO 模型与阻塞点

以 Get 请求为例,SimpleKV 为了处理一个 Get 请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向 socket 中写回数据(send)。
下图显示了这一过程,其中,bind/listen、accept、recv、parse 和 send 属于网络 IO 处理,而 get 属于键值数据操作。既然 Redis 是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操作。
基础篇 (01-10讲) - 图12
但是,在这里的网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。
不过,幸运的是,socket 网络模型本身支持非阻塞模式。

非阻塞模式

Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用 socket 非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。接下来,我们就重点学习下它们。
在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。
基础篇 (01-10讲) - 图13
针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。
虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。

这里的redis线程可以不用等待,返回处理:其实是指的redis不会被阻塞在一个连接上,可以处理其他连接,当然实际是轮询的事件队列。真正的等待是交由linux的epoll机制

类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。

基于多路复用的高性能 I/O 模型

Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
下图就是基于多路复用的 Redis IO 模型。图中的多个 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
基础篇 (01-10讲) - 图14
为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。
那么,回调机制是怎么工作的呢?其实,select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。

这句话应该说的是epoll的回调机制,说的不太好理解。 所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,一旦监测到fd上有请求到达时,就会触发相应的事件,当相应的事件发生时会调用回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。

04 | AOF日志:宕机了,Redis如何避免数据丢失?

05 | 内存快照:宕机后,Redis如何实现快速恢复?

Redis持久化.png

06 | 数据同步:主从库如何实现数据一致?

07 | 哨兵机制:主库挂了,如何不间断服务?

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

主从.png

09 | 切片集群:数据增多了,是该加内存还是加实例?

切片.png

10 | 第1~9讲课后思考题答案及常见问题答疑

REDIS.png