Redis详解

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的存储模块

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中包含了相应的集群功能支撑模块。

2.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中;
  3. 释放哈希表1的空间。

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

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

渐进式rehash

简单来说就是在第二步拷贝数据时,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底层使用压缩列表本质上是将所有元素紧挨着存储,所以分配的是一块连续的内存空间,虽然数据结构本身没有时间复杂度的优势,但是这样节省空间而且也能避免一些内存碎片。

相关推荐
緣起緣落2 小时前
Linux(CentOS 7) 部署 redis 集群
linux·运维·服务器·redis·centos·集成学习
Kagol1 天前
macOS 和 Windows 操作系统下如何安装和启动 MySQL / Redis 数据库
redis·后端·mysql
hzulwy1 天前
Redis常用的数据结构及其使用场景
数据库·redis
ashane13141 天前
Redis 哨兵集群(Sentinel)与 Cluster 集群对比
redis
Y第五个季节1 天前
Redis - HyperLogLog
数据库·redis·缓存
Justice link1 天前
企业级NoSql数据库Redis集群
数据库·redis·缓存
爱的叹息1 天前
Spring Boot 集成Redis 的Lua脚本详解
spring boot·redis·lua
morris1312 天前
【redis】redis实现分布式锁
数据库·redis·缓存·分布式锁
爱的叹息2 天前
spring boot集成reids的 RedisTemplate 序列化器详细对比(官方及非官方)
redis
weitinting2 天前
Ali linux 通过yum安装redis
linux·redis