Redis是C-S架构
Redis 使用 dictht 结构体表示哈希表,不过,在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了两个哈希表(ht[2])。??
Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash。
之所以定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。
可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。?
要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。
接下来,看看 Redis 是如何实现的 rehash 的。
在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。
随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:
这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。?
渐进式 rehash 步骤如下:
这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。
在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。
比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。
另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。
介绍了 rehash 那么多,还没说什么时情况下会触发 rehash 操作呢?
rehash 的触发条件跟 负载因子(load factor)有关系。
负载因子可以通过下面这个公式计算:
?
触发 rehash 操作的条件,主要有三个:
- 我们都知道Redis中保存的Key是字符串,Value往往是字符串或者字符串的集合,可见字符串是Redis中最常用的一种数据结构。
Redis String类型的底层的数据结构实现主要是SDS(简单动态字符串),不过Redis并没有直接使用C语言中的字符串,因为SDS相比C语言的原生字符串:
- SDS不仅可以保存文本数据,还可以保存图片、音视频等这样的二进制数据,而C语言的原生字符串只能保存文本数据。
- SDS获取字符串长度的时间复杂度是O(1),因为C语言的字符串并不记录自身长度,所以获取长度的时间复杂度为O(n),而SDS结构里用len属性记录了字符串长度,所以时间复杂度为O(1)。
- 不会发生缓冲区溢出 - SDS之所以叫动态字符串,是因为它具备动态扩容的能力:Redis的SDS API是安全的,拼接字符串不会造成缓冲区溢出,因为SDS在拼接字符串之前会检查SDS空间是否满足要求,如果空间不够会自动扩容,会申请新的内存空间,所以不会导致缓冲区溢出的问题。?
所以Redis构建了一种全新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。?
List类型的底层数据结构是由双向链表或ZipList压缩列表实现的:
- 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用ZipList压缩列表作为 List 类型的底层数据结构;
- 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 QuickList 实现了,替代了双向链表和压缩列表。?
Hash类型的底层数据结构是由ZipList压缩列表或哈希表实现的:?
- 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用ZipList压缩列表作为 Hash 类型的底层数据结构;
- 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
在 Redis 7.0 中,ZipList压缩列表数据结构已经废弃了,交由 listpack 紧凑列表数据结构来实现了。
Set 类型的底层数据结构是由哈希表或整数集合IntSet实现的:
- 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用IntSet????整数集合作为 Set 类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
Zset / SortedSet有序集合类型的底层数据结构是由ZipList压缩列表或SkipList跳表实现的:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表ZipList作为 Zset 类型的底层数据结构,来代替HashTable和SkipList;
- 如果有序集合的元素不满足上面的条件,Redis 会使用SkipList跳表作为 Zset 类型的底层数据结构;
由于ZipList压缩列表存在连锁更新问题,因此在 Redis 7.0 中压缩列表数据结构已经废弃了,交由 Listpack紧凑列表 数据结构来实现了。
先来看看「链表节点」结构的样子:
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;
有前置节点和后置节点,可以看的出,这个是一个双向链表:
因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。?
- 压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,从而有效的利用CPU缓存,节省内存开销。?
但是,压缩列表的缺陷也是有的:
- 不能保存过多的元素,否则查询效率就会降低,如果我们要查找定位第一个元素和最后一个元素,复杂度是 O(1) - 压缩列表可以在任意一端进行压入 / 弹出操作,而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。;
- 新增或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配,当新插入或删除的元素较大时,甚至可能引发「连锁更新」的问题,导致每个元素的空间都要重新分配,这会直接影响到压缩列表的访问性能。
因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。
压缩列表结构设计:
- 压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。
- 压缩列表紧凑型的内存布局能节省内存开销。
Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。
Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。
可能很多人会奇怪,为什么我开头说 Zset 对象的底层数据结构是「压缩列表」或者「跳表」,而没有说哈希表呢?
Zset 对象在使用跳表作为数据结构的时候,是使用由「哈希表+跳表」组成的 struct zset,但是我们讨论的时候,都会说跳表是 Zset 对象的底层数据结构,而不会提及哈希表,是因为 struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。
接下来,详细的说下跳表。
传统链表在查找元素的时候,因为需要逐一查找(因为链表它的指针跨度是1,也就是说每一个节点它的指针永远指向的是下一个节点,所以需要逐一查找,依次遍历),所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。?
SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。??
?