1、底层数据类型查看
OBJECT ENCODING key
该命令是用来显示那五大数据类型的底层数据结构。
比如对于 string 数据类型:
我们可以看到实现string数据类型的数据结构有 embstr 以及 int。
再比如 list 数据类型:
2、简单动态字符串SDS
SDS定义
free:还剩多少空间
len:字符串长度
buf:存放的字符数组
SDS空间预分配
为减少修改字符串带来的内存重分配次数,sds采用了“一次管够”的策略:
- 若修改之后sds长度小于1MB,则多分配现有len长度的空间
- 若修改之后sds长度大于等于1MB,则扩充除了满足修改之后的长度外,额外多1MB空间
SDS惰性空间释放
为避免缩短字符串时候的内存重分配操作,sds在数据减少时,并不立刻释放空间。
SDS与C 字符串比较
相对于 C 语言对于字符串的定义,SDS多出了 len 属性以及 free 属性。为什么不使用C语言字符串实现,而是使用 SDS呢?这样实现有什么好处?
①、常数复杂度获取字符串长度
由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。通过 strlen key 命令可以获取 key 的字符串长度。
②、杜绝缓冲区溢出
我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。
③、减少修改字符串的内存重新分配次数
C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。
而对于SDS,由于len属性和free属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:
1、空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。
2、惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)
④、二进制安全
因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。
⑤、兼容部分 C 字符串函数
虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数。
字符串对象的底层实现有三种可能:int, embstr,raw
如果一个字符串对象,保存的值是一个整数值,并且这个整数值在 long 的范围内,那么 redis 用整数值来保存这个信息,并且将字符串编码设置为 int
如果字符串对象保存的是一个字符串, 并且长度大于 32 个字节,它就会使用前面讲过的SDS(简单动态字符串)
数据结构来保存这个字符串值,并且将字符串对象的编码设置为raw
如果字符串对象保存的是一个字符串, 但是长度小于 32 个字节,它就会使用embstr
来保存了,embstr
编码不是一个数据结构,而是对 SDS 的一个小优化,当使用 SDS 的时候,程序需要调用两次内存分配,来给 字符串对象 和 SDS 各自分配一块空间,而embstr
只需要一次内存分配,因为他需要的空间很少,所以采用 连续的空间保存,即将 SDS 的值和 字符串对象的值放在一块连续的内存空间上。这样能在短字符串的时候提高一些效率。
编码 | 使用条件 |
int | 可以用 long 保存的整数 |
embstr | 字符串长度小于 32 字节(或者浮点数转换后满足) |
raw | 长度大于 32 的字符串 |
3、列表(ziplist、linkedlist、quicklist)
在 Redis 3.2 版本之前,列表对象底层由 压缩列表和双向链表配合实现,当元素数量较少的时候,使用压缩列表,当元素数量增多,就开始使用普通的双向链表保存数据。
但是这种实现方式不够好,双向链表中的每个节点,都需要保存前后指针,这个内存的使用量 对于 Redis 这个内存数据库来说极其不友好。
因此在 3.2 之后的版本,作者新实现了一个数据结构,叫做 quicklist. 所有列表的底层实现都是这个数据结构了。它的底层实现基本上就是将 双向链表和压缩列表进行了结合,用双向的指针将压缩列表进行连接,这样不仅避免了压缩列表存储大量元素的性能压力,同时避免了双向链表连接指针占用空间过多的问题。
ziplist结构定义
此数据结构是为了节约内存而开发的。和各种语言的数组类似,它是由连续的内存块组成的,这样一来,由于内存是连续的,就减少了很多内存碎片和指针的内存占用,进而节约了内存。
struct ziplist<T>{
// 整个压缩列表占用字节数
int32 zlbytes;
// 最后一个节点到压缩列表起始位置的偏移量,可以用来快速的定位到压缩列表中的最后一个元素
int32 zltail_offset;
// 压缩列表包含的元素个数
int16 zllength;
// 元素内容列表,用数组存储,内存上紧挨着
T[] entries;
// 压缩列表的结束标志位,值永远为 0xFF.
int8 zlend;
}
然后文中的entry
的结构是这样的:
元素遍历
先找到列表尾部元素:
然后再根据ziplist节点元素中的previous_entry_length
属性,来逐个遍历:
元素新增
在列表键的内容比较少时,列表键会使用压缩列表,那么压缩列表为什么不能用于大的列表键呢?
ziplist 是连续存储的数据结构,内存是没有冗余的(前面的文章讲过的 SDS 中就有冗余空间), 也就是说,每一次新增节点,都需要进行内存申请,然后将如果当前内存连续块够用,那么将新节点添加,如果申请到的是另外一块连续内存空间,那么需要将所有的内容拷贝到新的地址。
也就是说,每一次新增节点,都需要内存分配,可能还需要进行内存拷贝。当 ziplist 中存储的值太多,内存拷贝将是一个很大的消耗。
也是因此,Redis 只在一些数据量小的场景下使用 ziplist
级联更新
再次看看entry
元素的结构,有一个previous_entry_length
字段,他的长度要么都是1个字节,要么都是5个字节:
- 前一节点的长度小于254字节,则
previous_entry_length
长度为1字节 - 前一节点的长度大于254字节,则
previous_entry_length
长度为5字节
假设现在存在一组压缩列表,长度都在250字节至253字节之间,突然新增一新节点new
,
长度大于等于254字节,会出现:
程序需要不断的对压缩列表进行空间重分配工作,直到结束。
除了增加操作,删除操作也有可能带来“连锁更新”。
请看下图,ziplist中所有entry节点的长度都在250字节至253字节之间,big节点长度大于254字节,small节点小于254字节。
级联更新的时间复杂度很差,但是其实不用怕,因为级联更新造成 Redis 性能压力的概率极其低。
quicklist结构定义
struct ziplist_compressed{
int32 size;
byte[] compressed_data;
}
struct quicklistNode {
quicklistNode* prev;
quicklistNode* next;
// 指向压缩列表
ziplist* zi;
// ziplist 的字节总数
int32 size;
// ziplist 的元素总数
int 16 count;
// 存储形式,是原生的字节数组,还是 LZF 压缩存储
int2 encoding;
}
struct quicklist{
// 头结点
quicklistNode* head;
// 尾节点
quicklistNode* tail;
// 元素总数
long count;
// ziplist 节点的个数
int nodes;
// LZF 算法压缩深度
int compressDepth;
}
从结构定义中可以看到,quicklist 的定义和 链表的很像,本质上也是一个双端的链表,只是把普通的节点换成了 quicklistNode, 在这个节点中,保存的不是一个简单的值,而是一个 ziplist。
纯粹的使用 Linkedlist, 也就是普通链表来存储数据有两个弊端:
- 每个节点都有自己的前后指针,指针所占用的内存有点多,太浪费了。
- 每个节点单独的进行内存分配,当节点过多,造成的内存碎片太多了。影响内存管理的效率。
因此,定义了 quicklist, 将 linkedlist 和 ziplist 结合起来,形成一个,将多个 ziplist 通过前后指针互相连接起来的结构,可以在一定程度上缓解上面说到的两个问题。
为了进一步节约内存,Reids 还可以对 ziplist 进行压缩存储,应用 LZF 算法压缩。
quicklist中每个ziplist大小
既然 quicklist 本质上是将 ziplist 连接起来,那么每个 ziplist 存放多少的元素,就成为了一个问题。
太小的话起不到应有的作用,极致小的话(为 1 个元素), 快速列表就退化成了普通的链表。
太大的话性能太差,极致大的话(整个快速列表只用一个 ziplist), 快速列表就退化成了 ziplist.
quicklist 内部默认定义的单个 ziplist 的大小为 8k 字节
. 超过这个大小,就会重新分配一个 ziplist 了。这个长度可以由参数list-max-ziplist-size
来控制。
4、字典dict(hashtable)
dict结构定义
typedef struct dict{
// 类型特定函数
dictType *type;
// 私有数据
void *private;
// 哈希表
dictht ht[2];
// rehash 索引,当当前的字典不在 rehash 时,值为-1
int trehashidx;
}
- type 和 private
这两个属性是为了实现字典多态而设置的,当字典中存放着不同类型的值,对应的一些复制,比较函数也不一样,这两个属性配合起来可以实现多态的方法调用。
- ht[2]
这是一个长度为 2 的 dictht
结构的数组(dictht
就是哈希表)。有且只有俩元素ht[0]和ht[1];其中,ht[0]存放的是redis中使用的哈希表,而ht[1]和rehashidx和哈希表的rehash
有关。
- trehashidx
这是一个辅助变量,用于记录 rehash 过程的进度,以及是否正在进行 rehash 等信息。
字典这个数据结构,本质上是对 hashtable的一个简单封装,因此字典的实现细节主要就来到了 哈希表上。
哈希表定义:
typedef struct dictht{
// 哈希表的数组
dictEntry **table;
// 哈希表的大小
unsigned long size;
// 哈希表的大小的掩码,用于计算索引值,总是等于 size-1
unsigned long sizemasky;
// 哈希表中已有的节点数量
unsigned long used;
}
哈希表中节点定义:
typedef struct dictEntry{
// 键
void *key;
// 值
union {
void *val;
uint64_tu64;
int64_ts64;
}v;
// 指向下一个节点的指针
struct dictEntry *next;
} dictEntry;
上图是一个没有处在 rehash 状态下的字典。可以看到,字典持有两张哈希表ht[0]和ht[1],其中一个的值为 null, 另外一个哈希表的 size=4, 其中两个位置上已经存放了具体的键值对,而且没有发生 hash 冲突。
节点增加
新建key的过程:
哈希冲突情况:
Redis 的哈希表处理 Hash 冲突的方式和 Java 中的 HashMap 一样,选择了分桶的方式,也就是常说的链地址法。Hash 表有两维,第一维度是个数组,第二维度是个链表,当发生了 Hash 冲突的时候,将冲突的节点使用链表连接起来,放在同一个桶内。
由于第二维度是链表,我们都知道链表的查找效率相比于数组的查找效率是比较差的。那么如果 hash 冲突比较严重,导致单个链表过长,那么此时 hash 表的查询效率就会急速下降。
扩容和缩容
当哈希表过于拥挤,查找效率就会下降,当 hash 表过于稀疏,对内存就有点太浪费了,此时就需要进行相应的扩容与缩容操作。
想要进行扩容缩容,那么就需要描述当前 hash 表的一个填充程度,总不能靠感觉。这就有了 负载因子
这个概念。
负载因子
是用来描述哈希表当前被填充的程度。计算公式是:负载因子=哈希表以保存节点数量 / 哈希表的大小
.
在 Redis 的实现里,扩容缩容有三条规则:
- 当 Redis 没有进行 BGSAVE 相关操作,且
负载因子>1
的时候进行扩容。 - 当
负载因子>5
的时候,强行进行扩容。 - 当
负载因子<0.1
的时候,进行缩容。
根据程序当前是否在进行 BGSAVE 相关操作,扩容需要的负载因子条件不相同。
这是因为在进行 BGSAVE 操作时,存在子进程,操作系统会使用 写时复制 (Copy On Write) 来优化子进程的效率。Redis 尽量避免在存在子进程的时候进行扩容,尽量的节省内存。
扩容和缩容的数量
扩容:第一个大于等于ht[0].used * 2
的2^n
(2的n次方幂)。
缩容:第一个大于等于ht[0].used
的2^n
(2的n次方幂)。
哈希表的扩容都是2倍增长的,最小是4。
扩容步骤:
缩容步骤:
渐进式rehash
在 Java 的 HashMap 中,实现方式是 新建一个哈希表,一次性的将当前所有节点 rehash 完成,之后释放掉原有的 hash 表,而持有新的表。
而 Redis 不是,Redis 使用了一种名为渐进式 hash 的方式来满足自己的性能需求。
rehash 需要重新定位所有的元素,这是一个 O(N) 效率的问题,当对数据量很大的字典进行这一操作的时候,比较耗时。
对于单线程的 Redis 来说,表示很难接受这样的延时,因此 Redis 选择使用一点一点搬的策略。
Redis 实现了渐进式 hash. 过程如下:
- 假如当前数据在 ht[0] 中,那么首先为 ht[1] 分配足够的空间。
- 在字典中维护一个变量,rehashindex = 0. 用来指示当前 rehash 的进度。
- 在 rehash 期间,每次对字典进行增删改查操作,在完成实际操作之后,都会进行 一次 rehash 操作,将 ht[0] 在
rehashindex
位置上的值 rehash 到 ht[1] 上。将 rehashindex 递增一位。 - 随着不断的执行,原来的 ht[0] 上的数值总会全部 rehash 完成,此时结束 rehash 过程。 将 rehashindex 置为-1。
在上面的过程中有两个问题没有提到:
1、假如这个服务器很空余呢?中间几小时都没有请求进来,那么同时保持两个 table, 岂不是很浪费内存?
解决办法是:在 redis 的定时函数里,也加入帮助 rehash 的操作,这样子如果服务器空闲,就会比较快的完成 rehash.
2、在保持两个 table 期间,该哈希表怎么对外提供服务呢?
解决办法:对于添加操作,直接添加到 ht[1] 上,因此这样才能保证 ht[0] 的数量只会减少不会增加,才能保证 rehash 过程可以完结。而删除,修改,查询等操作会在 ht[0] 上进行,如果得不到结果,会去 ht[1] 再执行一遍。
渐进式 hash 带来的好处是显而易见的,他采用了分而治之的思想,将 rehash 操作分散到每一个对该哈希表的操作上以及定时函数上,避免了集中式 rehash 带来的性能压力。
与此同时,渐进式 hash 也带来了一个问题,那就是 在 rehash 的时间内,需要保存两个 hash 表,对内存的占用稍大,而且如果在 redis 服务器本来内存满了的时候,突然进行 rehash 会造成大量的 key 被抛弃。
扩容rehash示例:
5、整数集合intset
intset结构定义
typedef struct intset{
// 编码方法,指定当前存储的是 16 位,32 位,还是 64 位的整数
int32 encoding;
// 集合中的元素数量
int32 length;
// 保存元素的数组
int<T> contents;
}
- encoding 属性有三种取值,分别代表当前整数集合存储方式是用 16 位整数数组,32 位整数数组或者 64 位整数数组。
- length 属性保存了当前整数集合中有多少个整数。
- contents 是一个数组,具体是多少位整数的数组,取决 encoding 的值。
这是一个保存了 5 个整数的 intset 的结构图。因为存储的数字都很小,所以 encoding 的值是 16 位的整数。
升级降级
在 C 语言里,整数也是有很多种的。每当一个整数被添加到整数集合时,都需要先去判断 这个整数是否大于 当前编码方式 所能容放的 最大整数
, 如果大于,就需要对当前的整数集合进行升级。
升级是指什么呢?假如当前的整数集合中只有一个数字 2. 那么我们用 16 位的整数的数组就可以放下。
当此时进来一个大于 32767(16 位整数的最大值) 的整数,我们就需要将当前的整数数组升级成一个 32 位整数的数组,同时,要将原来的所有整数转换成新的编码。
对于 64 位的升级类似于上面这样。
分级的好处:
- 用能容纳数字的最小编码进行存储,可以有效的节约内存。
- 整数集合封装了对三种整数之间的转换,使用我们不用考虑类型错误,可以不断的向整数集合内添加整数。提升了操作的灵活性。
与升级相对应的,当大的数字被删除之后,整数集合不会进行降级。
6、跳跃表skiplist
skiplist结构定义
#define ZSKIPLIST_MAXLEVEL 32 //最大层数
#define ZSKIPLIST_P 0.25 //P
typedef struct zskiplistNode {
// member 对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
//每一层中的前进指针
struct zskiplistNode *forward;
//这个层跨越的节点数量,x.level[i].span 表示节点x在第i层到其下一个节点需跳过的节点数。注:两个相邻节点span为1
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
// 头节点,尾节点
struct zskiplistNode *header, *tail;
//节点总数
unsigned long length;
//表中层数最大的节点的层数
int level;
} zskiplis
查找元素
查找过程如上图蓝色箭头过程,会从最顶层链表的头节点开始遍历。以升序跳表为例,如果当前节点的下一个节点包含的值比目标元素值小,则继续向右查找。如果下一个节点的值比目标值大,就转到当前层的下一层去查找。重复向右和向下的操作,直到找到与目标值相等的元素为止。
插入元素
Redis 中,新添加一个节点时,会给该节点随机一个索引层数(如下随机算法),而且概率是 25%. 之后将该节点的各层索引与左右的索引相链接。
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
注意概率p值由ZSKIPLIST_P
常量定义,值为0.25,即被插入到高层的概率为1/4。
插入流程
- 按照前面讲过的查找流程,找到合适的插入第1层。注意zset允许分数score相同,这时会根据节点数据obj的字典序来排序。
- 调用zslRandomLevel()方法,随机出要插入的节点的层数。
- 调用zslCreateNode()方法,根据层数level、分数score和数据obj创建出新节点。
- 每层遍历,修改新节点以及其前后节点的前向指针forward和跳跃长度span,也要更新最底层的后向指针backward。
跳表层数上限为啥是32?
根据前面的随机算法当level[0]有2的64次方个节点时,才能达到32层,因此层数上限是32完全够用了。
7、Redis五种数据类型的实现
redis对象
redis中并没有直接使用以上所说的各种数据结构来实现键值数据库,而是基于一种对象,对象底层再间接的引用上文所说的具体的数据结构。
字符串
其中:embstr和raw都是由SDS动态字符串构成的。唯一区别是:raw是分配内存的时候,redisobject和 sds 各分配一块内存,而embstr是redisobject和raw在一块儿内存中。
列表
hash
set
zset
参考列表
https://i6448038.github.io/2019/12/01/redis-data-struct/
https://segmentfault.com/u/doto_5cf7722c57196/activities
https://developer.aliyun.com/article/617666