-
Redis 字符串是以字节数组形式存在的,是一个带长度信息的字节数组;
-
Redis 字符串长度不能超过 512M 字节;
-
Redis 的字符串有两种存储方式,在长度特别短时,使用 emb 形式存储 (embeded),当长度超过 44 时,使用 raw 形式存储。具体表现于 redisObject 对象的 encoding 属性;
-
Redis 扩容时,字符串长度小于 1M 之前,加倍扩容。当超度超过 1M 之后,每次只扩容 1M;
[](()二、hash
=========================================================================
[](()2.1 字典
dict 是 Redis 服务器中出现最为频繁的复合型数据结构,比如
-
hash 结构的数据会用到字典;
-
整个 Redis 数据库的所有 key 和 value 也组成了一个全局字典;
-
带过期时间的 key 集合也是一个字典;
-
zset 集合中存储 value 和 score 值的映射关系也是通过 dict 结构实现的;
[](()2.2 dict 内部结构
dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。但是在 dict 扩容缩容时,需要分配新的 hashtable,然后进行渐进式搬迁,这时候两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后,旧的 hashtable 被删除,新的 hashtable 取而代之。
struct dict {
…
dictht ht[2];
}
[](()2.2.1 hashtable
hashtable 的结构和 Java 的 HashMap 几乎是一样的,都是通过分桶的方式解决 hash 冲突。第一维是数组,第二维是链表。数组中存储的是第二维链表的第一个元素的指针。
struct dictEntry {
void* key;
void* val;
dictEntry* next; // 链接下一个 entry
}
struct dictht {
dictEntry** table; // 二维
long size; // 第一维数组的长度
long used; // hash 表中的元素个数
…
}
其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表节点
。可以看出,dictEntry是一个数组,这很好理解,因为一个哈希表里要包含多个哈希表节点。而dict里包含2个dictht,多出的哈希表用于rehash
。当哈希表保存的键值对数量过多或过少时,需要对哈希表的大小进行扩展或收缩操作,在Redis中,扩展和收缩哈希表是通过rehash实现的。
[](()2.2.2 渐进式rehash
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
long index;
dictEntry *entry;
dictht *ht;
// 这里进行小步搬迁
if (dictIsRehashing(d)) _dictRehashStep(d);
if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
return NULL;
// 如果字典处于搬迁过程中,要将新的元素挂接到新的数组下面
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
entry = zmalloc(sizeof(*entry));
entry->next = ht->table[index];
ht->table[index] = entry;
ht->used++;
/* Set the hash entry fields. */
dictSetKey(d, entry, key);
return entry;
}
为了避免rehash对服务器性能造成影响,rehash操作不是一次性地完成的,而是分多次、渐进式地完成的。渐进式rehash的详细过程如下:
-
为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;
-
在字典中的索引计数器
rehashidx
设置为0,表示rehash操作正式开始; -
在rehash期间,每次对字典执行添加、删除、修改、查找操作时,程序除了执行指定的操作外,还会顺带将
ht[0]中位于rehashidx上的所有键值对迁移到ht[1]中,再将rehashidx的值加1
; -
随着字典不断被访问,最终在某个时刻,ht[0]上的所有键值对都被迁移到ht[1]上,此时程序将
rehashidx属性值设置为-1
,标识rehash操作完成。
rehash期间,字典同时持有两个哈希表,此时的访问将按照如下原则处理:
-
新添加的键值对,一律被保存到ht[1]中;
-
删除、修改、查找等其他操作,会在两个哈希表上进行,即程序先尝试去ht[0]中访问要操作的数据,若不存在则到ht[1]中访问,再对访问到的数据做相应的处理。
[](()2.2.3 扩容操作
当满足以下任何一个条件时,程序会自动开始对哈希表执行扩展操作:
-
服务器目前
没有执行bgsave或bgrewriteof命令
,并且哈希表的负载因子大于等于1
; -
服务器目前
正在执行bgsave或bgrewriteof命令
,并且哈希表的负载因子大于等于5
。
当元素个数和容量达到 1:1,则开始扩容(如正在执行 bgsave、bgaof 则不执行,如达到 1:5 则强制执行)
哪些指令会触发扩容:增删改。
扩容时新增、查询、修改、删除操作该如何定向?
-
新增:当新增元素时会将元素迁移到新的 hashtable 中;
-
查询、删除、修改:先查询 ht[0] ,若存在则迁移这个 key 所在的桶并返回元素,若不存在则到 ht[1] 中查找元素。
[](()2.2.4 缩容操作
int htNeedsResize(dict *dict) {
long long size, used;
size = dictSlots(dict);
used = dictSize(dict);
return (size > DICT_HT_INITIAL_SIZE &&
(used*100/size < HASHTABLE_MIN_FILL));
}
当 hash 表因为元素的逐渐删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的 10%。缩容不会考虑 Redis 是否正在做 bgsave
。
[](()2.3 总结
-
Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表(ziplist)进行存储,压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有冗余空隙;
-
当 set 集合容纳的元素都是整数并且元素个数较小时,Redis 会使用 intset 来存储结合元素;
[](()三、set
========================================================================
Redis 里面 set 的结构底层实现也是字典,只不过所有的 value 都是 NULL,其它的特性和字典一模一样
。
[](()四、zset
=========================================================================
[](()4.1 ziplist
Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表 (ziplist)
进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。
输出的 encoding 字段是 ziplist,这表示内部采用压缩列表结构进行存储。
struct ziplist {
int32 zlbytes; // 整个压缩列表占用字节数
int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; // 元素个数
T[] entries; // 元素内容列表,挨个挨个紧凑存储
int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
压缩列表的结构如下图所示:
压缩列表为了支持双向遍历,所以才会有 ztail_offset
这个字段,用来快速定位到最后一个元素,然后倒着遍历。
entry 块结构:
struct entry {
int prevlen; // 前一个 entry 的字节长度
int encoding; // 元素类型编码
optional byte[] content; // 元素内容
}
prevlen 字段表示前一个 entry 的字节长度
,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于 254(0xFE) 时,使用一个字节表示;如果达到或超出 254(0xFE) 那就使用 5 个字节来表示。第一个字节是 0xFE(254),剩余四个字节表示字符串长度。你可能会觉得用 5 个字节来表示字符串长度,是不是太浪费了。我们可以算一下,当字符串长度比较长的时候,其实 5 个字节也只占用了不到(5/(254+5))<2%
的空间。
encoding
字段存储了元素内容的编码类型信息,ziplist 通过这个字段来决定后面的 content 内容的形式。
Redis 通过这个字段的前缀位来识别具体存储的数据形式。下面我们来看看 Redis 是如何根据encoding的前缀位来区分内容的:
[](()4.1.1 增加元素
因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多。
[](()4.2 skiplist
对于 zset ,在 redis.conf 文件中,其中有两个配置,会触发其内部编码 ziplist 《一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》无偿开源 威信搜索公众号【编程进阶路】 和 skiplist 之间的变化。
上图中配置的含义是,当有序集合的元素个数
小于 zset-max-ziplist-entries 配置的值
,且每个元素的值的长度
都小于zset-max-ziplist-value配置的值时
,zset 的内部编码是 ziplist,反之则用 skiplist。
此时有序集合的元素个数是 2,内部编码采用的是 ziplist 的结构。
大致的储存就是这样:
[](()4.2.1 ziplist 到 skiplist 的变化
先验证 zset-max-ziplist-value 配置,往 memberscore 元素中塞入一个长度大于 64的字节
(zset-max-ziplist-value 默认配置)的值:
此时的内部编码采用的是 skiplist。
那么接下来,让我们步入正题。
[](()4.2.2 什么是 skiplist?
跳表是一种可以用来代替平衡树的数据结构,跳表使用概率平衡而不是严格的平衡,因此,与平衡树相比,跳表中插入和删除的算法要简单得多,并且速度要快得多。
跳表的查找复杂度为平均O(logN),最坏O(N),效率堪比红黑树,却远比红黑树实现简单。跳跃表是在链表的基础上,通过增加索引来提高查找效率的。
有序链表插入、删除的复杂度为O(1),而查找的复杂度为O(N)。例:若要查找值为50的元素,需要从第1个元素依次向后比较,共需比较5次才行,如下图:(head节点已省略)
head -> 10 -> 20 -> 30 -> 40
跳表是从有序链表中选取部分节点,组成一个新链表,并以此作为原始链表的一级索引。再从一级索引中选取部分节点,组成一个新链表,并以此作为原始链表的二级索引。以此类推,可以有多级索引,如下图:
这样做有什么好处呢?当结构稍微一变,变成了上图的样子之后,查询路径40就是:
head -> 10 -> 30 -> 40
这个情况下我们找到指定的元素,不会超过 ( n / 2 ) + 1
个节点。
我们再举个例子,找到50这个元素,到底是怎么找到的。我给大家重新画个示意图:
**“多了”一个向下的指针。**所以,查询 50 的路径是这样的,红色虚线箭头指示的方向:
按照这个往上抽节点的思想,假设我们抽到第四层:
我们查询 60 的时候,只需要经过 2 次。第一步就直接跳过了 50 之前的所有元素。
[](()4.3 IntSet 小整数集合
当 set 集合容纳的元素都是整数并且元素个数较小时,Redis 会使用intset
来存储结合元素。intset 是紧凑的数组结构,同时支持 16 位、32 位和 64 位整数。
struct intset {
int32 encoding; // 决定整数位宽是 16 位、32 位还是 64 位
int32 length; // 元素个数
int contents; // 整数数组,可以是 16 位、32 位和 64 位
}
注意观察 debug object 的输出字段 encoding 的值,当 set里面放进去了非整数值时
,存储形式立即从intset 转变成了 hash
结构。
[](()五、list
=========================================================================
Redis 早期版本存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表 linkedlist,也就是元素少时用 ziplist,元素多时用 linkedlist。
// 链表的节点
struct listNode {