作为键值数据库,Redis 的应用非常广泛,如果你是后端工程师,我猜你出去面试,八成都会被问到与它相关的性能问题。比如说,为了保证数据的可靠性,Redis 需要在磁盘上读写 AOF 和 RDB,但在高并发场景里,这就会直接带来两个新问题:一个是写 AOF 和 RDB 会造成 Redis 性能抖动,另一个是 Redis 集群数据同步和实例恢复时,读 RDB 比较慢,限制了同步和恢复速度。
那这个问题有没有好的解决方法呢?其实,一个可行的解决方案就是使用非易失内存 NVM,因为它既能保证高速的读写,又能快速持久化数据。
同样是使用 Redis,但是不同公司的“玩法”却不太一样,比如说,有做缓存的,有做数据库的,也有用做分布式锁的。不过,他们遇见的“坑”,总体来说集中在四个方面:
- CPU 使用上的“坑”,例如数据结构的复杂度、跨 CPU 核的访问;
- 内存使用上的“坑”,例如主从同步和 AOF 的内存竞争;
- 存储持久化上的“坑”,例如在 SSD 上做快照的性能抖动;
- 网络通信上的“坑”,例如多实例时的异常网络丢包。
学习Redis,系统观其实是至关重要的。从某种程度上说,在解决问题时,拥有了系统观,就意味着你能有依据、有章法地定位和解决问题。
1.Redis全景图
Redis 知识全景图都包括什么呢?简单来说,就是“两大维度,三大主线”。
“两大维度”就是指系统维度和应用维度,“三大主线”也就是指高性能、高可靠和高扩展。
1.1系统维度
首先,从系统维度上说,你需要了解 Redis 的各项关键技术的设计原理,这些能够为你判断和推理问题打下坚实的基础,而且,你还能从中掌握一些优雅的系统设计规范,例如 run-to-complete 模型、epoll 网络模型,这些可以应用到你后续的系统开发实践中。
别看技术点是零碎的,其实你完全可以按照这三大主线,给它们分下类,就像图片中展示的那样,具体如下:
- 高性能主线,包括线程模型、数据结构、持久化、网络框架;
- 高可靠主线,包括主从复制、哨兵机制;
- 高可扩展主线,包括数据分片、负载均衡。
这样,你就有了一个结构化的知识体系。当你遇见这些问题时,就可以按图索骥,快速找到影响这些问题的关键因素。
1.2应用维度
其次,在应用维度上,我建议你按照两种方式学习: “应用场景驱动”和“典型案例驱动”,一个是“面”的梳理,一个是“点”的掌握。
我们知道,缓存和集群是 Redis 的两大广泛的应用场景。在这些场景中,本身就具有一条显式的技术链。比如说,提到缓存场景,你肯定会想到缓存机制、缓存替换、缓存异常等一连串的问题。
不过,并不是所有的东西都适合采用这种方式,比如说 Redis 丰富的数据模型,就导致它有很多零碎的应用场景,很多很杂。而且,还有一些问题隐藏得比较深,只有特定的业务场景下(比如亿级访问压力场景)才会出现,并不是普遍现象,所以,我们也比较难于梳理出结构化的体系。
这个时候,你就可以用“典型案例驱动”的方式学习了。我们可以重点解读一些对Redis的“三高”特性影响较大的使用案例。如多家大厂在万亿级访问量和万亿级数据量的情况下对 Redis 的深度优化,解读这些优化实践,非常有助于你透彻地理解 Redis。而且,你还可以梳理一些方法论,做成 Checklist,就像是一个个锦囊,之后当你遇到问题的时候,就可以随时拿出自己的“锦囊妙计”解决问题了。
有一个非常好用的技巧,可以将自己遇到的、看到的Redis各大典型问题绘制一张Redis的问题画像图,这样遇到问题后,可以快速定位到相应的技术点上。
2.Redis的数据结构
Redis之所以这么快,一方面是因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。另一方面,要归功于他的数据结构,因为键值对是按一定的数据结构来组织的,操作键值对就是对数据结构进行增删改查操作,所以高效的数据结构是Redis快速处理数据的基础。
我们知道Redis有String(字符串)、List(列表)、Hash(哈希表)、Set(集合)和Sorted Set(有序集合)。而这里,我们所说的数据结构,是要去看看他们的底层实现。
简单来说,底层数据结构一共有6中,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组,他们和数据类型的对应关系如下所示:
- String:简单动态字符串
- List:双向链表、压缩列表
- Hash:哈希表、压缩列表
- Set:哈希表、整数数组
- Sorted Set:跳表、压缩列表
先来看下键和值之间是用什么做组织的。
2.1 键和值用什么结构?
为了实现从键到值的快速访问,Redis使用了一个全局哈希表来保存所有的键值对。哈希表其实就是一个数组,数组中的元素为哈希桶,每个哈希桶保存了键值对数据。
哈希桶中的entry元素中保存了 *key
和*value
指针,分别指向了实际的键和值。这样一来不管值是String还是集合类型,都可以通过*value
指针被查到。
全局哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。
但是,如果你只是了解了哈希表的 O(1) 复杂度和快速查找特性,那么,当你往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险点,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。
2.2 为什么哈希表操作变慢了?
难免会有一些 key 的哈希值对应到了同一个哈希桶中。Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。
但是,这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。
所以,Redis 会对哈希表做 rehash 操作,也就是增加哈希桶的数量,让entry元素能在更多的桶之间分散保存,减少哈希冲突。
Redis为了使rehash操作更加高效,Redis默认使用了两个全局哈希表:哈希表1和哈希表2。一开始,刚插入数据的时候,默认使用哈希表1。随着数据的增多,Redis开始rehash,过程分为散步:
- 给哈希表2分配更大的框架,一般是哈希表1的2倍。
- 把哈希表1中的数据重新映射到哈希表2中。
- 释放哈希表1的框架。
到此,我们就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。
上面过程的第二步因为设计大量的数据拷贝,如果一次性的全部前一晚,会造成Redis线程阻塞。为了避免这个问题,Redis采用了渐进式rehash。
所谓渐进式rehash其实就是在第二步拷贝数据时,Redis仍正常处理客户端请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有entries拷贝到哈希表2中;等待下一个请求时,再顺带拷贝哈希表1中的下一个索引位置的entries。如下图所示:
这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
好了,至此,你应该能理解 Redis 的键和值是怎么通过哈希表组织的了。对于String来说,哈希表的O(1)操作复杂度也就是他的复杂度了。
2.3集合数据操作效率
一个集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。
集合数据操作效率,首先与底层结构有关。例如,使用哈希表实现的集合,要比链表实现的集合访问效率更高。其次,操作效率和这些操作本身的执行特点有关,如读写一个元素的操作要比读写所有元素的效率要高。
前面我们已经知道,Redis底层的数据结构有5种:双向链表、哈希表、压缩列表、跳表、整数数组。
- 哈希表的操作复杂度是O(1)。
- 双向链表的操作也很常见,通过链表的指针逐个访问元素,操作复杂度是O(N),操作效率较低。
- 整数数组是通过数组下标逐个访问元素,操作复杂度也是O(N)。
- 压缩列表和跳表我们来分析下。
2.3.1 压缩列表
压缩列表实际上类似于一个数组,数组中的每个元素都对应保存一个数据。压缩列表在表头有三个字段 zlbytes
、zltail
和zlen
,分别表示长度、列表尾偏移量和列表中entry的个数;压缩列表在表尾还有一个zlend
,表示列表结束。
如果我们查找第一个和最后一个元素,可以通过表头的三个字段直接定位,复杂度是O(1)。而查找其他元素的复杂度则是O(N)。
2.3.2 跳表
跳表就是在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位。
如下图所示,我们在查找33这个元素,只能从头开始遍历链表,查找6次,此时复杂度是O(N)。
为了提供效率,增加了一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。如,从前两个元素抽取元素1作为一级索引,从第三、四个元素中抽取元素11作为一级索引。此时,我们只需要4次查找就能定位到元素33了。
若想在快一点,可以再增加二级索引:从一级索引中再抽取部分元素作为2级索引。例如,从一级索引中抽取1、27、100作为二级索引,二级索引指向一级索引。这样,我们只需3次查找,就能定位到元素33了。
跳表的查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法,它的操作复杂度是O(log N)。
2.4 不同操作的复杂度
集合类型的操作有很多,有读写单个集合元素的,如HGET、HSET,也有操作多个元素的,如SADD,还有对整个集合进行遍历操作的,如 SMEMBERS。这么多操作的复杂度也各不相同。而复杂度的高低又是我们选择集合类型的重要依据。
记住“四句口诀”:
- 单元素操作是基础;
- 范围操作非常耗时;
- 统计操作通常高效;
- 例外情况只有几个。
单元素操作是指每一种集合类型对单个数据实现增删改查。如Hash类型的HGET、HSET和HDEL,Set类型的SADD、SREM、SRANDMEMBER等。这些操作的复杂度由集合采用的底层数据结构决定。例如HGET、HSET和HDEL是对哈希表做操作,所以它们的复杂度是O(1);Set类型用哈希表作为底层数据结构是,他的SADD、SREM、SRANDMEMBER的复杂度也是O(1)。
有个地方需要注意下,集合类型支持同时对多个元素进行增删改查,如Hash类型的HMGET和HMSET,Set类型的SADD也支持同时增加多个元素。此时,这些操作的复杂度是由单个元素的操作复杂度和元素个数决定的。例如,HMSET增加M个元素是,复杂度就从O(1)编程O(M)了。
范围操作是指集合中的遍历操作,可以返回集合中的所有数据,比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,比如 List 类型的 LRANGE 和 ZSet 类型的 ZRANGE。这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。
不过 Redis 从 2.8 版本开始提供了 SCAN 系列操作(HSCAN、SSCAN、ZSCAN),这类操作实现了渐进式遍历,每次只返回有限的数据量。这样一来,相比对 HGETALL HE、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞。
统计操作是指集合类型对集合中所有元素个数的记录,如 LLEN 和 SCARD。这类操作的复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数,因此可以高效的完成统计。
例外情况是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于List类型的 LPOP 、RPOP、LPUSH、RPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1)。
3.Redis 的高性能IO
这里要说明下,Redis的“单线程”主要指 Redis 的网络IO 和键值对读写是由一个线程完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,如持久化、异步删除、集群数据同步等,其实是由额外的线程执行。
所以,严格来说 Redis 并不是单线程,但是我们一般把 Redis 称为单线程高性能,这样显得“酷”点。
“Redis为什么用单线程?为什么单线程能这么快?”,带着这个问题,我们进行接下来的学习。
3.1 Redis 为什么用单线程?
想要更好的理解 Redis 使用单线程,我们就要先了解多线程的开销。
3.1.1 多线程的开销
我们听过“使用多线程,可以增加系统吞吐率”。的确,对于一个多线程的系统来说,在有合理的资源分配的情况下,可以提升系统能够同时处理的请求数,及吞吐率。但是,请注意,通常情况下,采用多线程后,如果没有良好的系统设计,实际得到的结果不是我们期望的。刚开始增加线程数时,系统吞吐率会增加,但是再进一步增加线程时系统吞吐率就增长迟缓了,有时甚至还会出现下降的情况。
出现这种情况的原因是系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要额外的机制进行保证,这个额外的机制会带来额外的开销。
比如 Redis 中的 List 数据类型中的出队(LPOP)和入队(LPUSH)。假设 Redis 采用多线程设计,如下图所示,现有两个线程 A 和 B,线程 A 对 List 做 LPUSH,并对长度加 1。同时线程 B 对 List 执行 LPOP 操作,并对队列长度减 1。Redis 为了保证队列长度的正确性,需要让线程 A 和线程 B 的 LPUSH 和 LPOP 串行执行,这样一来, Redis 可以无误地记录它们对 List 长度的修改。否则,可能会得到错误的长度。这就是多线程面临的共享资源的并发控制问题。
并发控制一直是多线程开发中的一个难点,如果没有精细的设计就会出现,即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,吞吐率并没随着线程的增加而增加。
Redis 为了避免这些问题,直接采用单线程模式。
3.2 单线程 Redis 为什么那么快?
一方面 Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如跳表和哈希表,这是它实现高性能的一个重要原因。另一方面,就是 Redis 采用了多路复用机制,在其网络IO操作中能并发处理大量的客户端请求,实现搞吞吐率。
3.2.1 基于多路复用的高性能 I/O 模型
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到偶读 select/epoll 机制。简单来说,在 Redis 只允许单线程情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这样就实现了一个 Redis 线程处理多个 IO 流的效果。
下图就是基于多路复用的 Redis IO 模型。图中的多个 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接的套接字上。正因为如此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
为了在请求到达时能通知到 Redis 线程, select/poll 提供了基于事件的回调机制,即针对不同事件的发送,调用相应的处理函数。
回调机制的工作原理,select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。这些事件会被放进一个队列,Redis 单线程对该事件不断进行处理。这样一来,Redis 无需一直轮询是否有请求发生,这就避免造成 CPU 资源浪费。同时, Redis 在对事件队列中的事件进行处理时就会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
以连接请求和读数据请求为例,具体解释一下:这两个请求分别对应 Accept 事件和 Read 事件,Redis 分别对这两个事件注册 accept 和 get 回调。当 linux 内核监听到有连接请求或读数据请求时,就会触发 Accept 事件和 Read 事件,此时内核就会调用 Redis 相应的 accept 和 get 回调函数进行处理。