原文首更地址,阅读效果更佳!
Redis原理 - 数据结构的底层实现 | CoderMast编程桅杆https://www.codermast.com/database/redis/redis-datastruct-underlying-implementation.html
Redis 中保存的 Key 是字符串,Value 往往是字符串或者字符串的集合。可见字符串是 Redis 中最常见的一种数据结构。
Redis 是使用 C 语言来编写的,C 语言中也有字符串,但是 Redis 中并没有直接使用 C 语言的字符串,这是因为 C 语言中字符串存在着很多的问题:
因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。
\0
结尾。为了解决以上问题,Redis 自己构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称为SDS
SDS 在 Redis 中的实现在 /src/sds.h
、/src/sds.c
文件中,具体的核心实现如下:
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
标识符对应信息
标识信息 | 对应值 |
---|---|
SDS_TYPE_5 | 0 |
SDS_TYPE_8 | 1 |
SDS_TYPE_16 | 2 |
SDS_TYPE_32 | 3 |
SDS_TYPE_64 | 4 |
例如,一个包含字符串name
的sds结构如下:
SDS 之所以叫做动态字符串,是因为其具备动态扩容的能力,例如一个内容为 “hi” 的 SDS
假如我们要给 SDS 追加一段字符串 “,Amy” ,这里因为空间不够,需要申请新的内存空间:
优点
Redis的字符串表示为 SDS ,而不是 C 字符串(以\0结尾的char*), 它是 Redis 底层所使用的字符串表示,它被用在几乎所有的 Redis 模块中。可以看如下对比:
一般来说,SDS 除了保存数据库中的字符串值以外,SDS 还可以作为缓冲区(buffer):包括 AOF 模块中的AOF缓冲区以及客户端状态中的输入缓冲区。
IntSet 是 Redis 中 Set 集合类型的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。
当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。
为了方便查找,Redis 会将 IntSet 中所有的整数按照升序依次保存在 contents 数组中,结构图如下
现在数组中每个数字都保存在 int16_t 的范围内,因此采用的编码方式为 INTSET_ENC_INT16,每部分占用的字节大小为:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
其中的 encoding 包含三种模式,表示存储的整数大小不同:
/* Note that these encodings are ordered, so:
* INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64. */
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
当在一个 int8 类型的整数集合中添加一个 int16 类型的数据元素,那么整个整数集合中的元素都会升级为 int16 类型,内存不够时还会进行扩容。具体的步骤如下:
底层实现
代码详情
代码详情
代码详情
思考
在添加数据时会进行扩容操作,那么在删除数据时会进行缩容操作吗?那么如果删除掉刚加入的int16类型时,会不会做一个降级操作呢?
答案:不会。主要还是减少开销的权衡。
IntSet 可以看做是特殊的整数数组,具备一些特点:
Dict Dict 由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)
哈希算法
Redis 计算哈希值和索引值方法如下:
使用字典设置的哈希函数,计算键 key 的哈希值 hash = dict->type->hashFunction(key);
使用哈希表的sizemask属性和第一步得到的哈希值,计算索引值 index = hash & dict->ht[x].sizemask;
哈希冲突
哈希冲突(Hash Collision)是指在使用哈希表存储数据时,两个或多个不同的键(Key)被哈希函数映射到同一个位置的情况。这种情况会导致数据的存储和查找变得复杂,因此需要采取一些措施来解决哈希冲突。
Dict 中解决哈希冲的方法是 链地址法。
其他办法
除了链地址法解决哈希冲突以外,还可以使用开放地址法、在哈希法、建立公共溢出区等方法解决。
typedef struct dictht{
// entry 数组
// 数组中保存的是指向 entry 的指针
dictEntry **table;
// 哈希大小
unsigned long size;
// 哈希表大小的掩码,总等于 size - 1
unsigned long sizemask;
// entry 个数
unsigned long used;
} dictht;
typedef struct dictEntry {
void *key; // 键
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // 值
// 下一个 Entry 的指针
struct dictEntry *next;
} dictEntry;
当我们向 Dict 添加键值对时,Redis 首先根据 key 计算出 hash 值(h),然后利用 h & sizemask 来计算元素应该存储到数组中的哪个索引位置。
typedef struct dict{
// dict 类型,内置不同的 hash 函数
dictType *type;
// 私有数组,在做特殊 hash 运算时使用
void *privdata;
// 一个Dict包含两个哈希表,其中一个是当前数据,另一个一般是空,rehash时使用
dictht ht[2];
// rehash 的进度,-1 表示未进行
long rehashidx;
// rehash是否暂停,1则暂停,0则继续]
int16_t pauserehash;
}dict;
当哈希表保存的键值对太多或者太少时,就要通过 rehash(重新散列)来对哈希表进行相应的扩展或者收缩。
扩容
Dict 中的 HashTable 就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict在每次新增键值对时都会检查负载因子,满足以下两种情况时会触发哈希表扩容:
负载因子
负载因子 = 哈希表已保存节点数量 / 哈希表大小。
收缩
Dict 除了扩容以外,每次删除元素时,也会对负载因子做检查,当 LocalFactor < 0.1 时,会做哈希收缩。
扩容收缩的具体步骤如下:
如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used*2n
的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
重新利用哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
所有键值对都迁徙完毕后,释放原哈希表的内存空间。
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的 size 和 sizemask 变化,而 key 的查询与 sizemask 有关。因此必须对哈希表中的每一个 key 重新计算索引,插入新的哈希表,这个过程称为 rehash。具体的步骤如下。
dict.ht[O].used + 1
的 2n2 ^ n2ndict.ht[O].used
的2n2 ^ n2n (不得小于4)dictht
,并赋值给dict.ht[1]
dict.rehashidx=0
,标示开始rehash
dict.ht[O]
中的每一个dictEntry
都rehash
到dict.ht[1]
dict.ht[1]
赋值给dict.ht[O]
,给 dict.ht[1]
初始化为空哈希表,释放原来的dict.ht[O]
的内存Dict的 rehash 并不是一次性完成的,如果 Dict 中包含数百万的 entry ,要在一次 rehash 完成,极有可能导致主线程阻塞。因此 Dict 的 rehash 是分多次、渐进式的完成,因此称为渐进式 rehash。
计算新hash表的realesize,值取决于当前要做的是扩容还是收缩:
dict.ht[O].used + 1
的 2n2 ^ n2ndict.ht[O].used
的2n2 ^ n2n (不得小于4)按照新的realeSize申请内存空间,创建dictht
,并赋值给dict.ht[1]
设置dict.rehashidx=0
,标示开始rehash
每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx
是否大于-1,如果是则将dict.ht[0].table[rehashid]
的entry
链表rehash到dictht[1]
,并且将rehashidx++
。直至dict.ht[0]
的所有数据都rehash到dict.ht[1]
将 dict.ht[1]
赋值给dict.ht[O]
,给 dict.ht[1]
初始化为空哈希表,释放原来的dict.ht[O]
的内存
将rehashidx赋值为-1,代表rehash结束
在rehash过程中,新增操作,则直接写入ht[1]
,查询、修改和删除则会在dict,ht[0]
和dict.ht[1]
依次查找并执行。这样可以确保ht[0]
的数据只减不增,随着rehash最终为空
什么叫渐进式 rehash?
也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成 Redis 一段时间内不能进行别的操作。所以 Redis 采用渐进式 rehash,这样在进行渐进式 rehash 期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行增加操作,一定是在新的哈希表上进行的。
可以简单的理解为慢慢的将旧的哈希表,慢慢迁移到新的哈希表中。
Dict的结构
类似java的HashTable,底层是数组加链表来解决哈希冲突
Dict包含两个哈希表,ht[0]
平常用,ht[1]
用来rehash
Dict的伸缩
used + 1
的2n2 ^ n2nused
的2n2 ^ n2nht[0]
只减不增,新增操作只在ht[1]
执行,其它操作在两个哈希表ZipList 可以看做一种特殊的双端链表,由一系列特殊编码的连续内存块组成。可以在任意一端压入弹出操作,并且该操作的时间复杂度为 O(1)。
ZipList 中的Entry 并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用 16 个字节,浪费内存,而是采用了如下的结构:
为什么ZipList特别省内存
理解了 ZipList 的 Entry 结构,就很容易理解 ZipList 为什么节省内存。
ZipListEntry 中的 Encoding 编码分为字符串和整数两种类型:
字符串:Encoding 是以 "00"、"01"、"10" 开头,则 content 为字符串类型
|00pppppp|
:此时encoding长度为1个字节,该字节的后六位表示entry中存储的string长度,因为是6位,所以entry中存储的string长度不能超过63;|01pppppp|qqqqqqqq|
此时encoding长度为两个字节;此时encoding的后14位用来存储string长度,长度不能超过16383;|10000000|qqqqqqqq|rrrrrrrr|ssssssss|ttttttt|
此时encoding长度为5个字节,后面的4个字节用来表示encoding中存储的字符串长度,长度不能超过2^32 - 1;整数:Encoding 是以 "11" 开头,则 content 为整数类型,且 encoding 固定只占用 1 个字节。
11000000
:int16_t (2 bytes)11010000
:int32_t (4 bytes)11100000
:int64_t (8 bytes)11110000
:24位有符号整数 (3 bytes)11111110
:8 位有符号整数 (1 bytes)1111xxxx
:直接在 xxxx 位置保存数值,范围从 0001~1101 减 1 后结果为实际值11111111
: zlend
ZipListEntry 节点中保存前一个节点的大小长度,前一个节点长度小于254字节,则使用一个字节保存这个长度,如果大于等于254字节,则使用 5 个字节来保存这个长度。那么当一个节点数据发生变化时,恰好从 254 字节以下变到 254 字节以上,那么 previous_entry_length 属性从1个字节变为5个字节,由于 ZipList 中 Entry 节点是连续存在的,则需要将后续的所有节点进行移动。如果后续空间不足,还需要申请新的空间等问题。
ZipList 这种特殊情况下产生的连续多次空间扩展操作称之为 连锁更新 。新增删除都可能导致连锁更新的发生。ZipList 也不预留内存空间, 并且在移除结点后, 也是立即缩容, 这代表每次写操作都会进行内存分配操作.
思考
为了缓解这个问题,我们必须限制 ZipList 的长度和 Entry 大小。
我们可以创建多个 ZipList 来分片存储数据。
Redis3.2版本引入了新的数据结构 QuickList ,它是一个双端链表,只不过链表中的每个节点都是一个 ZipList 。
QuickList 这个结构是 Redis3.2 版本后新加的, 之前的版本是 list(即 LinkedList), 用于 String 数据类型中。
QuickList 是一种以 ZipList 为结点的双端链表结构。 从宏观上看,QuickList是一个双向链表,从微观上看,QuickList 的每一个节点都是一个 ZipList。
QuickList示意图
代码详情
代码详情
代码详情
代码详情
代码详情
代码详情
限制
为了避免 QuickList 中的每一个 ZipList 中 Entry 过多,Redis 提供了一个配置项:list-max-ziplist-size 来限制。
config get list-max-ziplist-size
命令查看。压缩
除了控制 ZipList 的大小,QuickList 还可以对节点的 ZipList 做压缩。通过配置项 list-compress-depth 来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数:
config list-compress-depth
命令查看对于于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。比如查找12,需要7次查找。为了解决这个问题,我们可以给链表增加多级的索引指针,方便我们快速找到想要的节点。
SkipList (跳表)首先是链表,但是与传统的链表相比有些差异:
几级指针代表一次横跨几个节点。
SkipList内存结构
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;