00 | 开篇词 | 这样学Redis,才能技高一筹
1、思考
为了保证数据的可靠性,Redis 需要在磁盘上读写 AOF 和 RDB,但在高并发场景里,这就会直接带来两个新问题:一个是写 AOF 和RDB 会造成 Redis 性能抖动,另一个是 Redis 集群数据同步和实例恢复时,读 RDB 比较慢,限制了同步和恢复速度。
其实,一个可行的解决方案就是使用非易失内存 NVM,因为它既能保证高速的读写,又能快速持久化数据。
2、长尾延迟
Redis 处理了 100 个请求,99 个请求的响应时间都是 1s,而有一个请求的响应时间是 100s。那么,如果看平均延迟,这 100 个请求的平均延迟是 1.99s,但是对于这个响应时间是 100s 的请求而言,它对应的用户体验将是非常糟糕的。这一个请求就是长尾延迟。
3、redis知识图谱:“两大维度”就是指系统维度和应用维度,“三大主线”也就是指高性能、高可靠和高可扩展(可以简称为“三高”)。
4、三大主线
高性能主线,包括线程模型、数据结构、持久化、网络框架;
高可靠主线,包括主从复制、哨兵机制;
高可扩展主线,包括数据分片、负载均衡。
5、Redis 的问题画像图
举个例子,如果你遇到了 Redis 的响应变慢问题,对照着这张图(“问题 --> 主线 --> 技术点”),你就可以发现,这个问题和 Redis 的性能主线相关,而性能主线又和数据结构、异步机制、RDB、AOF 重写相关。找到了影响的因素,解决起来也就很容易了。
6、Redis在 CPU 使用、内存组织、存储持久化和网络通信这四大方面的设计非常经典
CPU 使用上的“坑”,例如数据结构的复杂度、跨 CPU 核的访问;
内存使用上的“坑”,例如主从同步和 AOF 的内存竞争;
存储持久化上的“坑”,例如在 SSD 上做快照的性能抖动;
网络通信上的“坑”,例如多实例时的异常网络丢包。
01 | 基本架构:一个键值数据库包含什么?
1、SimpleKV看似简单,实际上却是我们理解 Redis 经常被用于缓存、秒杀、分布式锁等场景的重要基础。
2、它支持的 value 类型,Redis 能够在实际业务场景中得到广泛的应用,就是得益于支持多样化类型的 value。
例如,Memcached支持的 value 类型仅为 String 类型,而 Redis 支持的 value 类型包括了 String、哈希表、列表、集合等。
3、SimpleKV 需要支持的 3 种基本操作,即 PUT、GET 和 DELETE。
需要注意的是,有些键值数据库的新写 / 更新操作叫 SET。新写入和更新虽然是用一个操作接口,但在实际执行时,会根据 key 是否存在而执行相应的新写或更新流程。
PUT:新写入或更新一个 key-value 对;
GET:根据一个 key 读取相应的 value 值;
DELETE:根据一个 key 删除整个 key-value 对。
SCAN :根据一段 key 的范围返回相应的 value值。
因此,PUT/GET/DELETE/SCAN 是一个键值数据库的基本操作集合。
另:需要判断某个用户是否存在。如果将该用户的 ID 作为 key,那么,可以增加 EXISTS 操作接口,用于判断某个 key 是否存在。
4、考虑一个非常重要的设计问题:键值对保存在内存还是外存?
1)、保存在内存的好处是读写很快,毕竟内存的访问速度一般都在百 ns 级别。但是,潜在的风险是一旦掉电,所有的数据都会丢失。
2)、保存在外存,虽然可以避免数据丢失,但是受限于磁盘的慢速读写(通常在几 ms 级别),键值数据库的整体性能会被拉低。
因此,如何进行设计选择,我们通常需要考虑键值数据库的主要应用场景。
Memcached 和 Redis 都是属于内存键值数据库。
5、大体来说,一个键值数据库包括了访问框架、索引模块、操作模块和存储模块四部分
6、采用什么访问模式?
一种是通过函数库调用的方式供外部应用使用;另一种是通过网络框架以 Socket 通信的形式对外提供键值对操作
7、如何定位键值对的位置?
SimpleKV需要查找所要操作的键值对是否存在,这依赖于键值数据库的索引模块。索引的作用是让键值数据库根据 key 找到相应 value 的存储位置,进而执行操作。
注意:Memcached 和 Redis 采用哈希表作为 key-value 索引,而 RocksDB 则采用跳表作为内存中 key-value 的索引
一般而言,内存键值数据库(例如 Redis)采用哈希表作为索引,很大一部分原因在于,其键值数据基本都是保存在内存中的,而内存的高性能随机访问特性可以很好地与哈希表O(1) 的操作复杂度相匹配。
8、不同操作的具体逻辑是怎样的?
对于 GET/SCAN 操作而言,此时根据 value 的存储位置返回 value 值即可;
对于 PUT 一个新的键值对数据而言,SimpleKV 需要为该键值对分配内存空间;
对于 DELETE 操作,SimpleKV 需要删除键值对,并释放相应的内存空间,这个过程由分配器完成。
9、如何实现重启后快速提供服务?
SimpleKV 采用了常用的内存分配器 glibc 的 malloc 和 free,因此,SimpleKV 并不需要特别考虑内存空间的管理问题。
10、小结
从 SimpleKV 演进到 Redis,有以下几个重要变化:
1)、Redis 主要通过网络框架进行访问,而不再是动态库了,这也使得 Redis 可以作为一个基础性的网络服务进行访问,扩大了 Redis 的应用范围。
2)、Redis 数据模型中的 value 类型很丰富,因此也带来了更多的操作接口,例如面向列表的 LPUSH/LPOP,面向集合的 SADD/SREM 等。在下节课,我将和你聊聊这些 value模型背后的数据结构和操作效率,以及它们对 Redis 性能的影响。
3)、Redis 的持久化模块能支持两种方式:日志(AOF)和快照(RDB),这两种持久化方式具有不同的优劣势,影响到 Redis 的访问性能和可靠性。
4)、SimpleKV 是个简单的单机键值数据库,但是,Redis 支持高可靠集群和高可扩展集群,因此,Redis 中包含了相应的集群功能支撑模块。
02 | 数据结构:快速的Redis有哪些慢操作?
1、它接收到一个键值对操作后,能以微秒级别的速度找到数据,并快速完成操作。
数据库这么多,为啥 Redis 能有这么突出的表现呢?
一方面,这是因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。
另一方面,这要归功于它的数据结构。这是因为,键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,所以高效的数据结构是 Redis 快速处理数据的基础。
2、数据结构的底层实现
底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表、整数数组。
String 类型的底层实现只有一种数据结构,也就是简单动态字符串。而 List、Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构。通常情况下,我们会把这四种类型称为集合类型,它们的特点是一个键对应了一个集合的数据。
3、键和值用什么结构组织?
Redis 使用了一个哈希表O(1)来保存所有键值对。
哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。
也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。
哈希桶中的 entry 元素中保存了*key和*value指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过*value指针被查找到。
全局哈希表这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。
好处是:用O(1) 的时间复杂度来快速查找到键值对
突然变慢:Redis中存在大量数据后,会突然变慢,潜在问题就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。
4、为什么哈希表操作变慢了?
1)、解决hash冲突:链式哈希,同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。
2)、所以,Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐
增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个
桶中的冲突。那具体怎么做呢?
3)、其实,为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希
表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空
间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
a. 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
b. 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
c. 释放哈希表 1 的空间。
4)、到此,我们就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。
但是第二次拷贝数据数据量大,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求,此时Redis采用渐进性rehash
渐进式rehash5、集合数据操作效率
首先,与集合的底层数据结构有关,其次,操作效率和这些操作本身的执行特点有关,比如读第一个比读所有快
6、有哪些底层数据结构?
1)、hash表上面已经写过了,操作复杂度是O(1)
2)、整数数组和双向链表也很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是 O(N),操作效率比较低
3)、压缩列表
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
压缩列表在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。
4)、跳表 O(logN)
有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位
跳表快速查找过程7、各个数据结构的操作时间复杂度
8、不同操作的复杂度
集合类型的操作类型很多,有读写单个集合元素的,例如 HGET、HSET,也有操作多个元素的,例如 SADD,还有对整个集合进行遍历操作的,例如 SMEMBERS。这么多操作,它们的复杂度也各不相同。而复杂度的高低又是我们选择集合类型的重要依据。下面四句口诀:
单元素操作是基础;
范围操作非常耗时;
统计操作通常高效;
例外情况只有几个。
1)、单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。
2)、范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。
3)、统计操作,是指集合类型对集合中所有元素个数的记录,操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计。
4)、例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。
9、小结
集合类型的范围操作,因为要遍历底层数据结构,复杂度通常是 O(N)。这里,我的建议是:用其他命令来替代,例如可以用 SCAN 来代替,避免在 Redis 内部产生费时的全集合遍历操作。
因地制宜地使用 List 类型。
10、每课一问:整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么 Redis 还会把它们作为底层数据结构呢?
1、内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
2、数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。
Redis的List底层使用压缩列表本质上是将所有元素紧挨着存储,所以分配的是一块连续的内存空间,虽然数据结构本身没有时间复杂度的优势,但是这样节省空间而且也能避免一些内存碎片。
03 | 高性能IO模型:为什么单线程Redis能那么快?
1、Redis 是单线程,主要是指 Redis 的网络 IO和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。
但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。--------redis不只是单线程
2、多线程开销--吞吐量问题
增加线程数,吞吐量反而降低-多线程编程模式面临的共享资源的并发访问控制问题-----为什么redis要单线程
3、单线程 Redis 为什么那么快?
一方面采用高效的数据结构,另一方面采用IO多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率
4、基本io模型的阻塞点在哪?
单线程依此执行下面操作:但是accept(客户端建立连接)、send(向 socket 中写回数据)、recv(从 socket 中读取请求)会存在阻塞,导致其他客户端无法连接
redis基本io模型5、基于多路复用的高性能 I/O 模型
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个IO 流的效果。
多个 FD 就是刚才所说的多个套接Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
基于多路复用的Redis高性能IO模型为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。
select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件,然后这些事件被放入时间处理器,然后redis单线程对这个时间处理器不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升Redis 的响应性能。
这就像病人去医院瞧病。在医生实际诊断前,每个病人(等同于请求)都需要先分诊、测体温、登记等。如果这些工作都由医生来完成,医生的工作效率就会很低。所以,医院都设置了分诊台,分诊台会一直处理这些诊断前的工作(类似于 Linux 内核监听请求),然后再转交给医生做实际诊断。这样即使一个医生(相当于 Redis 单线程),效率也能提升。
6、小结
重点学习了 Redis 线程的三个问题:“Redis 真的只有单线程吗?”“为什么用单线程?”“单线程为什么这么快?”
Redis 单线程是指它对网络 IO 和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的 Redis 也能获得高性能,跟多路复用的 IO 模型密切相关,因为这避免了 accept() 和 send()/recv() 潜在的网络 IO 操作阻塞点。
accept():和客户端建立连接
send():向socket写会数据
recv():从socket中读取请求
网友评论