目录
3 ZipList (双端链表,类似于链表,但不是,没有用到指针)
4.2.1 ist-max-ziplist-size(控制每一个zipList中entry的数量)
4.2.2 list-compress-depth(对quickList中每一个ziplist的操作)
二 数据类型(String,List,set,zset,Hash)
一:数据结构
1 动态字符串(SDS)
1.1 引入,c语音字符串缺陷及redis字符串引入
引入:redis是由c语音编写的,但是Redis没有直接使用C语言中的字符串,尾部会拼接一个(/0)来当结束标记,因为C语言字符串存在很多问题:
1.获取字符串长度的需要通过运算(没有地方存储长度,所以每次都是计算出来的)
2.非二进制安全(字符串中间出现一个/0时,会导致数据不全)
3.不可修改(拼接或截取后的会新创建);
所以redis构建了一种新的字符串结构,针对上面c的缺点进行优化,动态字符串(sds);set name 'haha',会生成两个sds字符串;
1.2 redis动态字符串结构体
像java中的一个对象,有四个属性(成员变量),
len : 表示当前字符串的实际长度。
alloc : 字符串申请的长度,不包含结束标记(/0);分两种情况;
1:第一次申请,就是字符串的实际长度
2: 非第一次申请,根据变更后的长度进行设置。(这个还要细分,下面解释)
flags : 使用的类不同,0现在被舍弃了,1代表使用的长度是2的8次方,2代表的是2的16次方。
buf[] : 实际存储的数据。
1.3 动态字符串特性(动态)
字符串的每次扩容,都会涉及到内核和用户核的切换,浪费性能,所以每次扩容都会进行内存预分配,动态字符串的优点正好解决了c语音的问题。每次扩容计算出的长度+1,是为了保存(/0)结束标记,但是实际保存alloc(申请长度)时是不会加这个1的。
追加完,Amy后,下次再次追加,首先判断新的字符串长度如果还是小于1M,并且追加后的长度小于12,就不用再次进行扩容了;
2 INTSET(有序,去重集合,类似于set)
IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。结构如下:也是类似于java的对象
encoding :编码格式
length :记录存储的元素个数
contents :数据存储
数据都是有序存储的,占据的长度也都是一样的(根据编码格式确定) ,所以方便查找;
增加一个50000的数,这个不在int16的范围了,需要升级编码格式;首先判断出50000属于哪一个编码格式范围,然后开始按照新的编码格式进行升级扩容;其次,将数组中原先的数据进行倒序处理,注意,要预留50000这个数的位置;等待原数组中的数据迁移完毕后,再将50000进行处理(-50000就会放在头部);最后,去改动编码及length数据。
3 ZipList (双端链表,类似于链表,但不是,没有用到指针)
由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。查询慢,增删快,链表特征
3.1 结构体
zlpBytes:整体长度
zlTail :起始位置到最后一个entry节点位置的长度
zllen:entry节点个数
zlend:结束标识符
3.2 entry结构体
entry结构体中保存的节点长度,方便我们快速定位到节点最后或者最前的节点位置,进行增删操作;
3.3 encoding编码
ZipListEntry中的encoding编码分为字符串和整数两种:
字符串:如果encoding是以“00”、“01”或者“10”开头,则证明content是字符串
整数:如果encoding是以“11”开始,则证明content是整数,且encoding固定只占用1个字节,其中,0到12的数字可以直接在位置上存储;
3.4 连锁更新问题
ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:
如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
现在,假设我们有N个连续(这个是重点,很少)的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示;但是现在要新增或者删除(被删除节点的前一个节点的长度是255,被删除节点及其后的都是250)一个,新增一个长度大于254的,就会导致后面的所有entry对象中的previous_entry_length(前一节点长度)的字节数变成5个,连锁影响后面的所有entry对象,就是连锁更新问题;会导致操作系统的性能浪费和内存扩张的频繁请求;
4 QuickList
4.1 引入
问题1:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?
为了缓解这个问题,我们必须限制ZipList的长度和entry大小。
问题2:但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?
我们可以创建多个ZipList来分片存储数据。
问题3:数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?
Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList
4.2 参数配置
4.2.1 ist-max-ziplist-size(控制每一个zipList中entry的数量)
为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。
(一)如果值为正,则代表ZipList的允许的entry个数的最大值
(二)如果值为负,则代表ZipList的最大内存大小,
分5种情况:
-1:每个ZipList的内存占用不能超过4kb
-2:每个ZipList的内存占用不能超过8kb
-3:每个ZipList的内存占用不能超过16kb
-4:每个ZipList的内存占用不能超过32kb
-5:每个ZipList的内存占用不能超过64kb 其默认值为 -2: 可通过config get list-max-ziplist-size获取查看。
4.2.2 list-compress-depth(对quickList中每一个ziplist的操作)
除了控制ZipList的大小,QuickList还可以对节点的ZipList做压缩。通过配置项list-compress-depth来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。
这个参数是控制首尾不压缩的节点个数:
0:特殊值,代表不压缩
1:标示QuickList的首尾各有1个节点不压缩,中间节点压缩
2:标示QuickList的首尾各有2个节点不压缩,中间节点压缩; 以此类推
4.3 结构源码
*head和*tail分别指向quickList中第一个和最后一个zipList的位置,count是指此quickList中所有zipList中entry对象和数量;len是指此quickList中zipList的数量;fill就是2.1中的数量内存控制,默认是-2;cpmpress是压缩,详见2.2中的命令
*prev和*next是指quickList中,zipList是一个个的节点,用来连接zipList;每一个ziplist中sz加起来的合计就是左图中的count;encoding是左图中的fill控制的;
4.4 总结
QuickList的特点:
是一个节点为ZipList的双端链表
节点采用ZipList,解决了传统链表的内存占用问题(传统链表只采用了指针)
控制了ZipList大小,解决连续内存空间申请效率问题(命令1)
中间节点可以压缩,进一步节省了内存(命令2)。
5 SkipList(跳表)
SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
元素按照升序排列存储(顺序存储,从小到大,才能支持这种跳表)
节点可能包含多个指针,指针跨度不同。(同一个节点可以有多个层级指针)
首先数据在zipList中是按从小到大的顺序排列的;
每一个zipList必然有一级指针,这是最基本的,这种查询慢,需要一个个去便利查询;
每一个节点又可以有多极指针,但是,在查询的时候,一定是从最大的层级开始比对;
5.1 查询流程
假如现在要查询12,首先查看zipList中的level最大的层级是多少,现在是4,所以就先从4级指针开始比对,在一个节点是10,比较后,比10大;10节点这里扩展下,还有个到20的zskpiList节点,在此比较后小于20,就进入三级指针,发现是到节点15的,比这个小,就进入二级指针,发现是13,比较后,小,就进入一级指针,发现后一个节点是11,12比11大,就往后再查询一个,到12;
5.2 结构体解析
zipList还是我们最原本的数据结构,*header,*tail就是前一个和后一个节点的指针信息,length是zipList中节点的长度,也就是一共有几个节点,level是指针层级,默认是1,基础层级;
zskipList,ele节点中存储的数据,是一个动态字符串;*backward是前一个节点指针的位置信息,方便进行倒叙查询;zskipListLevel是一个数组,比如1节点,他一共有四个层级信息,span是跨度,比如三级层级中的5节点,他的下一个节点是10,跨度就是5;*forward,是下一个指针的信息,就是10,方便进行查询,增加查询效率,方便跳表查询;
5.3 SkipList的特点
跳跃表是一个双向链表,每个节点都包含score和ele值
节点按照score值排序,score值一样则按照ele字典排序
每个节点都可以包含多层指针,层数是1到32之间的随机数(包含最底下一层的话就是32层)
不同层指针到下一个节点的跨度不同,层级越高,跨度越大(越上层的跨度越大)
增删改查效率与红黑树基本一致(强啊),实现却更简单。
6 Dict(hashTable)
我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。 Dict由三部分组成,分别是:
哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)。
6.1 数据结构图
6.1.1 Dict
dict中的:*type,*privdata,是进行hash运算时的一些固定配置;
ht[],存储数据用,数据一般默认存储在ht[0]中,只有在需要进行伸缩(扩容和删除)时,才会用到ht[1];
rehashidx,默认-1,表示没有在进行伸缩操作;
pauuserehash:也是用于rehash的,表示rehash操作的;
6.1.2 dictht(DictHashTable)
**table:两个*,代表指向的是一个数组对象;
size:hash表的大小,数组的个数,不包含数组下包含的链表数据;大小是2的n次方--2,4,8....;
sizemask:总等于size-1;
used:实际使用的entry个数;
6.1.3 DictEntry
*key:键值信息;
*next:指向下一个entry对象的位置;
v:这个值信息可以是任何数据类型的数据;
6.2 扩容和删除
Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。 Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况时会触发哈希表扩容:
哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程; 哈希表的 LoadFactor > 5 (这时候会强制扩容);
过程:(只有在新增或者删除的才会进行伸缩操作)
首先判断是否正在进行rehash,进行中,就取消本次rehash,反之,判断ht[0]中的元素个数是不是0,是0,还没有新增过,初始化哈希表(默认是4),然后,用ht[0]的size个数和实际使用数(used)进行除法运算,判断扩容因子的大小,满足上面的扩容因子条件后,进行扩容;假如是新增操作,现在的数据长度是8(size),实际使用数(used)是7(通过链表的形式存储在了结构体上,java中的hashmap),现在又加了一个,used变成8,负载因子就等于一了,可以扩容了,排除执行bgsave等命令的情况;这时size+1,变成9,然后找出大于等于9的最接近的一个数(2的n次方),是16,所以size就扩容成16;
删除的时候同样也会,判断扩容因子是不是<0.1,小于,会做哈希表收缩;
判断完需要进行扩容删减后,就可以执行下面的操作了;
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。
过程是这样的: 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩: 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
设置dict.rehashidx = 0,标示开始rehash
将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1](每次新增删除的时候自操只一个元素位置上的数据:索引位置及其下属的链表数据);每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx(是否在进行rehash操作)是否大于-1,如果是,则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1](这就是渐进式rehash)
将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存(交换了位置)
将rehashidx赋值为-1,代表rehash结束。
在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空。
7 RedisObject
可以说这个机构体是将之前的几种数据结构进行的封装;
type:数据类型(string,list,set,zset,hash)
encoding:编码格式
lru:最后一次访问时间,方便进行内存回收
refcount:引用数量,引用一次就自增一;等于0时,可以被回收
*pre:指针,指向我们存储数据的空间位置,实际存数据的地方
可以看出,除了*pre之外,这个对象就已经占用了一二十个字节了,所以建议我们在使用的时候尽量少使用string,尽量多使用其他的数据类型,可以多存储一些数据,但是只用了一个指针,只用了这一二十个字节,节省空间;
编码格式:
数据类型对应的数据结构:
二 数据类型(String,List,set,zset,Hash)
2.1 String
有三种不同的编码格式:
1:其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512mb。字符串的长度大于44了;这种是需要申请两次内存地址的,一次是redisObject,一次是ptr所指向的SDS动态字符串,这两个对象是独立的内存空间地址;这种寻值时是需要浪费性能的;需要通过ptr指针才能找到真实的数据。
2:如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高。这种格式与redis底层的内存非配有关系,都是以2的N次方去获取内存存储空间的,redisObject对象加上后面的sds字符串的长度正好控制在了64之内,也就是2的6次方以内;所以这种结构就不需要ptr再去指向sds的位置空间了,直接连接在redisObject对象后面,增加使用效率。
3:如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了。
测试:
分别set name 为字符串java; Java。。。大于44为止; 123;通过下面的命令查询name的编码格式;OBJECT ENCODING name,分别得到, embstr,raw,int。
2.2 List
LinkedList :普通链表,可以从双端访问,内存占用较高,内存碎片较多
ZipList :压缩列表,可以从双端访问,内存占用低,存储上限低
QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高所以,List底层用的是QuickList数据结构;
在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码。
在3.2版本之后,Redis统一采用QuickList来实现List。
2.3 Set
Set是Redis中的单列集合,满足下列特点:
不保证有序性 保证元素唯一 求交集、并集、差集。
2.3.1 set概念
HashTable,也就是Redis中的Dict,不过Dict是双列集合(可以存键、值对),我们只在key上存储数据就行,值的位置上全部设置为null;类似于java中的hashSet,就是只用了hashMap的key;
1:为了查询效率和唯一性,set采用HT编码(Dict)。Dict中的key用来存储元素,value统一为null。
2:当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,默认是512,Set会采用IntSet(连续的内存空间,就要求数据量小了)编码,以节省内存
2.3.2 源码解析
传递一个动态字符串参数进来,如果是long类型的,就创建一个intSet编码的redisObject对象,分配内存空间,并且制定类型为set,然后指定编码格式为intset。
如果value不是long类型的,就直接创建Dict(字典)类型的redisObject,获取内存空间。然后设置编码格式为set。
我们将编码格式设置为intset,后面存储的数据不可能一致都是long类型的整数,且长度有可能超过512,这时候,就涉及到intset向Dict转换了,这个操作是在添加元素的时候进行的;
对象创建完成后,就可以进行添加操作,添加方法进去后,首先判断编码格式是不是Dict,是的话,直接执行添加操作,反之,编码是intset的,但是传入的value不是long类型的整数,这时候就需要转化成Dict了,如果传入的value是long类型的整数,就进行添加操作,添加成功后,判断intset的长度是不是超过最大限制(512)了,或者我们调整过这个最大限制,导致过了512也没有进行转化,但是,源码有一个兜底操作,最大不能大于2的30次方,超过也会进行编码转化。
2.3.3 数据结构图
1:当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set会采用IntSet编码,以节省内存。
这种结构中,redisObject对象的类型是set,但是编码是intset,ptr指向的是一个intset类型的数据。
2:为了查询效率和唯一性,set采用HT编码(Dict)。Dict中的key用来存储元素,value统一为null。长度超过自己的设置数,或者超过源码设置的最大数(2的30次方),会采用Dict编码。
这种结构中,redisObject对象的类型是set,但是编码是Ht,也就是,ptr指向的是一个Dict类型的数据。dict中分为两个ht,一个是存储数据的,另一个是进行rehash用的。
2.4 ZSet
ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和member值:
可以根据score值排序后 ,member必须唯一 ,可以根据member查询分数
2.4.1 ZSet概念,引出编码格式
因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。之前学习的哪种编码结构可以满足?
SkipList:可以排序,并且可以同时存储score和ele值(member),但是无法进行键值查询
HT(Dict):可以键值存储,并且可以根据key找value,但是无法进行排序操作,底层是hash;
两种结构都无法全部满足ZSet的需求,所以就需要联合两个结构来实现,所以,ZSet的内存占用非常庞大,需要将数据存储在两种结构中,以空间换时间;
2.4.2 ZSet类型结构
有两种使用方式,一种是由dict和skpiList组成的,这种比较浪费内存,因为使用了两种结构;两外一种是zipList,使用连续的内存空间来实现。
2.4.2.1 dict和skiplist组成的zset
zset中,包含了两部分,dict(字典)和zskipList(跳表),在整个redisObject对象中,类型为zset类型,编码为skipList,ptr分别指向了两个地址,dict和skipList两个位置,这两个位置上都存储了数据,内存的消耗是比较大的,因为别的都是用一种编码格式就解决了;所以,为了节省内存空间,还有另一种方式。
2.4.2.2 zipList实现
需要连续的内存空间,这种,排序后,再将挨着的两个entry对象,一个作为key(在前),一个作为score(在后),遍历放进对象中,浪费时间,牺牲时间保存空间。不过需要同时满足两个条件:这种会涉及到编码转化,详见源码解析
元素数量小于zset_max_ziplist_entries,默认值128
每个元素都小于zset_max_ziplist_value字节,默认值64
2.4.3 源码解析
首先,根据key判断zset是否存在,存在,就进行添加;不存在,然后在判断元素数量是不是0(禁用zipList),或者value的大小超过了元素字节限制,这种就用HT+SkipList的组合形式;反之就采用zipList这种节省内存空间的编码格式。
根据上面的判断,就知道我们需要创建何种类型的编码格式了,开始创建对应的redisObject对象,zipList的先分配一个zipList的内存空间,创建类型为zset的redisObject对象,然后指定编码格式为zipList结束;对于组合形式的,先获取内存空间,然后分别创建Dict和ZskipList的空间指针,然后指定类型为ZSet,编码格式为skipList;
添加操作在执行的时候,判断编码格式,如果是skipList的,直接执行添加操作;如果是zipList的,如果当前元素已存在,只需要更新score就行了,反之这个元素不存在,执行添加操作,判断是否元素个数是否超限制(默认会成功,所以直接是原来的长度+1去进行比对),元素的字节个数是否超限制,任何一个超了,都会执行转化操作,反之,进行添加操作。
2.5 Hash
2.5.1 hash与zset的相同与区别
Hash结构与Redis中的Zset非常类似:
都是键值存储, 都需求根据键获取值 ,键必须唯一
区别如下: zset的键是member,值是score;hash的键和值都是任意值 zset要根据score排序;hash则无需排序。
命令:hset h1 name java age 99 zadd z1 10 a 20 b
2.5.2 hash结构图及编码解析
Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可:
1:Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value;
2:当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)。
2.5.3 源码解析
方法名中**Command,这种带command的,传递的参数client,是一个数组类型的参数,里面封装了我们所执行的命令;
首先判断key对应的hash是否存在,不存在就创建一个redisObject对象返回,只有hash的这种比较特殊,它是一个方法就将判断和创建整合了,但是这个整合的方法中,还是两个主要的方法,一个lookup判断方法,和其他类型一样,另一个就是创建redisObject的方法;类型为hash,编码格式默认为ziplist。
redisObject对象创建完成后,默认使用的是zipList的编码格式,但是,不一定符合实际情况,需要根据传入的value进行解析判断;如果redisObject的编码格式不是zipList,那就不用进行编码转换(一定是之前就转化过了,变成了dict);然后根据传入的start(第一个field,也就是name),end(操作命令的长度-1)进行for循环,里面的end有等于,依次判断操作命令中的field,value的元素字节长度是否超限制,是否是sds字符串(字符串或整数),超了就转换成dict类型,这里不判断元素的个数,是因为这个方法只是预添加,不是真正的添加操作;如果元素长度和元素类型都校验后,再最后兜底判断,field和value的合计是否超1G,超了也需要转换成HT。
预添加判断完成后,执行添加操作,方法中也需要有编码格式转换的判断;通过循环传入field和value;首先,编码如果是Ht的,直接执行添加操作;反之是zipList的,先查询zipList中的第一个元素,为空,说明zipList中没有数据,是一个空的,直接执行zipList的添加操作(元素长度在预添加方法中已完成判断),然后判断zipList长度是否超限制,超了就进行编码格式转换;如果zipList的第一个元素不是空的,就判断下这个要添加的数据(field)是不是在zipList已存在,存在就执行修改操作,反之就和ziplist第一个元素是空的逻辑差不多相同,就开始执行zipList长度判断操作,添加不需要执行,因为这个field是修改操作。