Redis设计原理之底层数据结构(一)

Redis设计原理之底层8种数据结构(一)

本文基本参考《Redis设计与实现》第二版,主要是学习了之后想写一些笔记,防止自己忘记了.没看过的人这本书的人,或者想深入学习redis底层原理的人,建议看书本.
如果只是想看大致了解下,可以看本博客就OK了.如果想实战操作reids的可以看redis实战大全里面有很详细的实战操作.


全局图思维导图

简单动态字符串

都知道redis是通过c语言来编写的,但是c语言里面的字符串修改,存储等有诸多问题,不符合redis的设计思路,所以作者就自己定义了一种简单动态字符串简称SDS.

SDS的定义


SDS遵循了C字符串以空格字符结尾的惯例,保存空字符的1字节空间,但是不计算在SDS的len属性里面,并且为空字符串分配额外的1字节的空间,以及添加空字符到字符串末尾等操作,都是有SDS函数完成的,对开发者了来说是透明的,不用关心.

1个字符的\0,不算入字符串的len中,也不做为redis的结束符,只是为了兼容C语言而设计的.
C语言不记录自身长度,所以每次查看字符串长度都需要O(n)的时间复杂度,而SDS则为len直接可以看到,所以复杂度为O(1).

如下图:

len=5表示sds字符串的长度为5,free表示sds的存储空间还剩余5个字符.

SDS实际占用空间下面会讲解.

C语言字符串的缺点

C语言记录自身的长度,所以对于一个包含了N个字符的C字符串来说,C字符串底层实现总是一个N+1个字符长的数组.
所以C语言中字符串的增长或者缩短,都需要进行内存重新分配.

  • 如果程序执行的是增长字符串,比如a = a+“bbb”这样的操作,那么在执行这个操作之前,程序先要通过内存重新分配来扩展底层数组的空间大小,如果忘记这一步,就会–缓冲区溢出
  • 如果程序执行的是减少字符串,比如a= a.trim(), 那么在执行这个操作之后,程序需要通过内存重新分配释放字符串不再使用的那部分空间,如果忘记了这一步就会–内存泄漏

如果修改字符串不太长出现,那么每次修改都执行一次内存重新分配是OK了,但是redis这种数据库修改,删除是很常见的就不太适合了.

SDS就C语言缺点优化

内存预分配
字符串长度分配规则最后实际占用空间大小
len < 1MBfree = lensds= free+len+1= len*2+1
len >= 1MBfree = 1MBsds= free+ len+1 = 1MB +len +1

字符串长度小于1MB则分配字符串2倍的空间.如果字符串长度大于等于1MB,则内存多分配1MB的空间.

分配内存步骤:如果字符串a=“aaa1111111”(长度为10,按照上面的规则,实际占用空间为21),现在修改a=”aaa1111111bbb"多加了3个字b,那么由于free=10,所以不需要在分配内存了,直接存入就OK了.

修改字符串为增加字符串时,内存空间只有在不够用的时候才重新分配,如果足够,则一直都不需要重新分配.

惰性空间释放

上面说了内存预分配,那么当字符串减少时呢?
还是a=“aaa1111111”(实际占用21个字符,len=10,free=10,1个\0字符),现在将a中的1移除掉,a=“aaa”了,那么a现在的空间是多少呢? 答案还是(len=3,free=17,1个\0字符).
就是说redis在减少字符串的时候,并不会重新分配内存,而是将空出来的一起做为备用空间.为下一次增加提供优化.

二进制安全

C字符串必须符合某种编码,并且出了字符串的末尾之外,字符串里买呢不能包括空字符,所以C字符串只能保存文本数据,不能保存图片,音频,视频等二进制数据.

SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数据里的数据.这样你存入什么数据,读取出来之后还是什么数据.

链表

链表的应用是非常广泛的,比如Reids的发布与订阅,满查询,监视器,保存多个客户端的状态信息等功能,都是使用链表来实现的.

链表结构:

typedef struce list{
   //头节点
	listNode *head;
	//尾节点
	listNode *tail;
	//链表包含的节点数
	unsigned long len;
	//节点值复制函数
	void *(*dup)(void * ptr);
	//节点值释放函数
	void (*free)(void *ptr);
	//节点值对比函数
	int (*match)(void *ptr,void *key);
	
}

//链表结构
typedef struct listNode{
   //前节点
	struct listNode *prev;
	//下一个节点
	struct listNode *next;
	//值
	void *value;
}

链表结构:

链表总结:

  • 双端:链表有prev和next指针,所以是一个双端链表
  • 无环: 表头节点的prev指针和表尾的next都指向了null
  • 多态: 链表节点使用void * 指针来保存节点值,所有链

字典

字典又称为符号表,关联表,或者映射(map),是一种保存键值对的抽象数据结构.
在字典中的每个key都是独一无二的,程序可以在字典中根据健查找与之关联的值.

redis字典数据结构源码:

typedef struct dict{

   //类型特定函数,比如计算hash值的函数,复制函数,键对比函数等.
	dictType *type;
	//私有数据
	void *privdata;
	//哈希表
	dictht ht[2];
	//当rehash不在进行时,值为-1
	in trehashidx;
}dict

typedef struct dictht{
//哈希表数组
   dictEntry **table;
   //哈希表大小(哈希表总的空间大小)
   unsigned long size;
   //哈希表大小烟吗,用于计算索引值
   //sizemask=size-1;
   unsigned long sizemask;
   //该哈希表已有节点的数量(哈希表中的真实的节点数量)
   unsigned long used;

}dictht


typedef struct dictEntry{

//键
 void *key;
 
 union{
 		void *val;
 		uint64_tu64;
 		int64_ts64;
 }v;
 //指向下一个哈希表节点,形成链表
 struct dicEntry *next;
} dicEntry


dict.ht[2]: 一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用.

hash键算法

//根据key计算hash值.
hash = dict->type->hashFunction(key);

//使用哈希表的sizemask属性和哈希值,计算出索引值
//根据情况不同,ht[x]可以是ht[0],或者ht[1]
index = hash& dict->ht[x].sizemask;

hash键冲突

rehash,渐进式rehash

rehash扩展与收缩条件:
  • 目前没有正在执行bgsave或者bgrewriteaof命令,则负载因子大于等于1时开始扩容.
  • 目前正在执行bgsave或者bgrewriteaof命令,负载因子大于等于5时开始扩容.
  • 负载因子小于0.1时,程序自动开始收缩

计算负载因子方式:load_factor = ht[0].used / ht[0].size;
例如:对于一个大小为4的,包含4个键值对的哈希表来说,这个哈希表的负载因子为:lf = 4/4 = 1;
注意:size=4,used=4并不表示已经存储满了,也有可能全部在t[0]节点上形成了一个4个节点的链表哦.所以used=20, size=4也是有可能的.

扩容步骤

新增或者删除key时,如果达到前面说的条件,则进行扩容或者收缩.

  • 给ht[1]分配空间
    1. 扩展操作时,为字典ht[1]分配空间,空间大小为ht[0].used*2的最近最大的一个2的n次方(2的n次方幂).
    2. 收缩操作时,为字典ht[1]分配空间,空间大小为ht[0].used 最近最大的一个的2的n次方.
  • 将ht[0]中的所有键值对移动到ht1[1]上面,rehash指的是重新计算键的哈希值和索引值.
  • 当ht[0]中的键值对都移动到ht1[0]时,将ht[1]设置为ht[0],并在ht[1]新创建一个空白的哈希表.
rehash完成后,其实就是
ht[0] = ht[1],
ht[1] = new hashMap();

解说扩展空间: ht[0]的used=3, 那么3*2=6,找到6最近的最大的2的幂次方值.那就是8喽,那么扩展空间ht[1]的扩展空间就是8. used=9,那么9*2=18,找到18最近最大的2的幂次方值,那就是32了.ht[1]的扩展空间就是32.

渐进式rehash

ht[0]里面的所有键值对rehash到ht[1]里面时,并不是一次性,集中式的完成,而是分多次,渐进式的完成. 以为假如ht[0]里面保存了上千万个键值对,一次性完成,需要大量的计算量,可能会导致服务在一点时间内停止服务.为了避免rehash对服务性能造成的影响,ht[0]里面所有的键值对都是分多次渐进式的移动.

在渐进式移动期间:字典的删除,查找,更新等操作是在两个哈希表中进行的.但是新增一定是在ht[1]中进行的.
rehashidx每移动一个元素+1.

跳跃表

跳跃表是一种有序的数据结构.跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点.大部分情况下,跳跃表的效率可以和平衡树差不多.

整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素不多时,Redis就会使用整数集合做为集合键的底层实现.

redis> sadd key1 1 3 5 7 9

redis> object encoding key1
“intset”

结构

typede struct intset{
	//编码方式
	uint32_t encoding;
	
	//集合包含的元素数量
	uint32_t length;
	//元素数组
	int8_t contents[];
}intset;

contents数组是整个集合的底层实现,整数集合的每个元素都是contents数组的一个数组项,每个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项.

注意: encoding=intset_enc_int16,表示集合保存的都是int16类型的整数值,如果是intset_enc_int64,则表示集合保存的都是int64类型的整数值.

数组大小的计算:sizeof(int16_t)*length=16*4 ,如果是int64,就是64*length.

Redis中整数集合与C语言中的数组区别

因为C语言是静态类型语言,为了避免错误,通常不会将两种类型的值存放到一起.比如int16_t类型的数组只会用来保存int16类型的值,而32位类型的值只会用int32_t类型的数组来保存.
但是Redis中可以通过自动升级来完成,整个集合中可以任意的存储16,32,64位的数据.而不需要用户关心内存溢出的问题.

整数集合自动升级

redis> sadd key1 1 3 5 7 9
这里可以看出1,3,5,7,9只需要使用int16_t类型的数组来存储就OK了.但是后面我们修改了key1的值.key1所占空间大小为16*5=80

下面在key1中再添加一个值.
redis> sadd key1 1232323432423423235235

这个时候key1=[1,3,5,7,9,1232323432423423235235]之前的key1使用int16_t的数组就能够存储了,但是现在新增加的元素是64位的,之前的数组明显不能够存储64位的值,所以就会导致key1原先的数组升级,key1需要升级到int64_t的数组才能够存储.redis也就是在存储时发现不够存储了,开始升级操作.
升级后的key1空间大小为:64*6=384

升级步骤:

  • “1”元素之前存在了0-15位, “3”元素存储到了16-31位.依次类推.
  • 现在告诉我们16位变成64位了.
  • “9”元素排名第5位,“9”开始移动,从原先的(64-79)移动到(256-319).以此类推.
  • “1”元素最后占用了(0-63)的位置,“3”元素占到了(64-127)的位置.

升级移动是从右到左的,就是先从排序最大的开始移动.

为什么要升级? 如果没有升级操作,那么刚开始定空间大小如何定呢?直接上来就用64位?如果我只存储1,3,5,7,9用64位空间就太浪费了.所以redis为了节约空间,根据数据大小来定数组空间大小.

降级

不好意思,不支持降级.一旦从16升级到32,或者64就不会在降级了.

压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一.当一个列表键只包含少量列表项,并且列表项要么是小整数值,要么就是比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现.

redis> rpush key2 1 3 5 'hello' 'work'

redis> object encoding key2
"ziplist"

结构

压缩列表是Redis为了节约内存而开发的,有一系列特殊编码的连续的内存块组成的顺序型数据结构.一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值.
结构如下:

|zlbytes|zltail|zllen|entry1|entry2|…|entryN|zlend|

  • zlbytes:4个字节,记录整个压缩列表占用的内存字节数,内存重分配,或者计算zlend的位置时使用.
  • zltail:4个字节,记录压缩列表尾节点距离压缩列表的起始地址有多少字节.
  • zllen:2个字节,记录压缩列表的节点数.当节点数》65535(16_max)时表示节点的真实数量,大于这个值的时候,则需要遍历整个列表.
  • entryX:这个就是数据存储的列表节点了.
  • zlend:1个字节,用于标记压缩列表的末端.用0xFF表示

entry:是压缩列表节点,这个节点可以保存一个字节数组或者一个整数值.

  • 字符串:可以保存最大长度小于等于2的32次方-1字节的数组大小.
  • 整数值:最大64位整数

entry结构如下:
|previous_entry_length|encoding|content|

  • previous_entry_length:属性以字节为单位,记录了压缩列表中前一个节点的长度.如果前一个节点的长度小于254个字节,则previous的使用1个字节来记录,如果前一个节点大于254字节,俺么previous则使用5个字节来记录前一个节点的长度.起哄第一个字节会被设置为OxFE,而之后的四个字节用来保存前一个节点的长度.
  • encoding:记录了节点的content属性所保存数据的类型以及长度.最高位11表示content是整数编码,其他00,01,10表示content是字符串编码.
  • content:保存节点的值.节点值的类型和长度有encoding属性决定.

连锁更新

由于压缩列表是连续的一块内存,如图,现在已经存入了entry0,entry1.假如现在有一个元素要压入列表,并且为头节点.而这个头节点超过了254个字节的长度.那么这个时候原先的entry0的previous本来占一个字节点,现在得改用5个字节来记录前一个节点的长度.entry0整个的大小也从之前的8+8+11变成了40+8+11了.由于entry0的长度变了,那么entry1的preious也得跟着调值从27该为59,entry1改变了值,但是entry1本身的长度没有改变,并不会导致entry2跟着改变.

假如entry0不是占用27个字节,而是占用了253个字节,entry1也同样刚好占用了253个字节.entry2也是253个字节,entry3也是253个字节.
按照上面的逻辑再来一次:
新节点超过1个字节的大小,所以entry0的previous的从1个字节变为5个字节.entry0=253+4*8=285.
entry0的长度超过了253,导致了entry1的previous在存储285时不够用,得从1个字节改为5个字节来存储285.
entry1=258+4*8=285.那么entry2也得更着变.这个就是连锁更新了.

从上面分析说的连锁更新条件就是必须多个节点,而这些节点刚好都赶上了253,稍微一改变旧的更新的情况下才会发生连锁更行.说明这种几率特别特别小,使用者在使用过程中不必太多担心连锁反应.

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值