前言
最近看了相关的Redis设计核心相关的书籍,对Redis有了一些小的认识,然后自己也做一些产出加深映象,我会从几个方面去总结Redis设计的核心内容:Redis底层数据结构总结、Redis高性能由哪些基础支撑、Redis应用场景、那些有趣的功能。
概述
本篇主要内容是Redis底层数据结构总结。Redis供用户直接使用的数据结构有String、List、Set、Zset、Hash等结构,而这些结构下层又基于一些数据结构,这些数据结构被设计的非常优美,来提供了Redis高效的性能、低内存占用、多样的功能等,他们有Dict、RedisObject、SDS、ZipList、QuickList、Listpak、Skiplist,我将从是什么、为什么使用、设计思想、特点、实现结构几个方面去总结。
目录
前言
概述
Dict
是什么
为什么使用
设计思想
特点
实现结构
RedisObject
是什么
为什么使用
设计思想
特点
实现结构
SDS
是什么
为什么使用SDS
设计思想
特点
实现结构
使用emb的好处
Ziplist
是什么
为什么使用
设计思想
特点
实现结构
为什么previous_entry_length要小于254?
Dict
是什么
Dict是一种查找结构,用来解决查询问题,是一种哈希散列表结构,采用拉链法解决哈希冲突,类似于JDK中HashTable但其特点是渐进式扩容,将单次指令触发扩容的时间分散到多次指令渐进扩容,避免了单次响应时间的剧烈波动,符合其响应效率的特点。
为什么使用
一般查找结构有常用的两种结构:1.各种平衡树,内部对数据排序,时间复杂度趋近于O(logn),但是需要维护其平衡结构,较耗费性能并且实现复杂,可执行标识查找和范围查询;2.哈希散列表结构,不对元素排序,通过hash()定位元素在数组上的位置,利用了数组的索引O(1)查找特点,快速定位元素,其实现相对简单但只提供标识查询,时间复杂度为O(1)。
设计思想
Dict是字典结构,采用了散列表结构,利用数组索引定位O(1)的特点来实现快速操作,但区别于JDK的散列表实现,Dict使用数组存储元素,拉链法解决hash冲突,并且最突出的是使用渐进式扩容。HashMap扩容是一次性同步扩容,既单次add指令导致元素数量达到临界点,触发同步全量扩容,导致单次的响应时间剧烈波动。而Dict是将一次指令的同步扩容分散到多次指令上,每次执行指令时都会触发一部分扩容,避免了单次响应时间的剧烈波动,这也是体现其设计核心--响应效率优先。
特点
- 响应效率->采用散列表结构,其操作时间复杂度为O(1)。
- 响应效率->渐进式扩容。非一次性同步扩容,Dict采用了渐进式扩容,将扩容的时间分散到每次操作上,避免单次请求的响应时间剧烈波动。
- 存储效率-->Dict的key使用SDSHDR5结构存储,降低内存占用率和内存碎片率。
- 补充:Dict的渐进式扩容内部有两种策略,上面讲的是惰性扩容,只有执行指令时才会触发扩容,但如果某个key不执行时,就会导致扩容一致未完成,所以Redis还提供了定时扩容,即每隔一段时间自动执行未完成的扩容操作,使用Dict存储整个database时就采用了这两种策略。
实现结构
这张图来源于http://zhangtielei.com/posts/blog-redis-dict.html。
这是Redis使用Dict存储Database的结构图。
数据结构:dictEntry --> dictht --> dict
其中dict内部维护了ht[2],ht[0]用来存储数据,ht[1]用于扩容,rehashidx记录当前扩容位置,为-1则未执行扩容
dictht维护了存储数组的信息,以及数组长度、元素个数
dictEntry是真正存储元素的结构,记录了key、value、next下一个元素。
更多关于该数据结构内容请从我的Redis专栏中获取。
RedisObject
是什么
Redis的最外层的数据结构是一个Dict结构,Key使用SDS存储,而Value因为其功能的不同所以底层数据结构也不同,RedisObject被用来设计包装Value结构,为多种数据结构提供统一的表现方式。
为什么使用
因为Redis为了节省内存,Redis对value的结构根据应用场景提供了多种实现。每种数据结构大部分都有2种数据结构来实现,这样就需要一个类型来包住百变的value,Redis设计了RedisObject来封装value,并且还可以提供全局value所需的基础字段。
设计思想
RedisObject(roj)是封装了value对象,可以通过roj获取到value的基础信息,如数据结构、编码类型、lru、value引用等,让底层数据结构无需关联全局字段,顶层数据结构信息和底层数据结构解耦,底层数据结构只需要关注于如何高效的存储数据,而顶层数据结构关注于全局功能,如数据的基础信息、lru驱逐时所需的lru最后访问时间、refcount等。
特点
- 1.为多种数据类型提供一种统一的表示方式。
- 2.解耦。将Redis顶层数据结构与底层存储数据结构解耦,底层数据结构只需要关系如何更高效的存储数据,而底层结构用来实现全局功能,如LRU驱逐策略、recount引用失效等,便于后续扩展功能。
- 3.提供全局基础字段,来维护其基础功能。
- 4.支持对象共享和引用计数。当对象被共享的时候,只占用一份内存拷贝,进一步节省内存。
- 5.允许同一类型的数据采用不同的内部表示,从而在某些情况下尽量节省内存。
- 6.占用内存少。roj只占用16byte,一个最小的字符串只需要19个byte。
实现结构
typedef struct redisObject {
unsigned type:4; 代表value的数据类型,如OBJ_STRING, OBJ_LIST, OBJ_SET, OBJ_ZSET, OBJ_HASH,分别代表5种数据类型。
unsigned encoding:4; 对象的内部的编码方式,对于同一个type可能会存在不同值,可能的取值有10种,如:emb、raw、int、ht、ziplist、skiplist、linkedlist等
unsigned lru:LRU_BITS;用于LRU或LFU驱逐策略
int refcount; 引用计数,当refcount为0时会释放roj对象。它允许robj对象在某些情况下被共享
void *ptr; 数据指针。指向真正的数据
} robj;
roj所表示的就是Redis对外暴露的第一层面的数据结构:string, list, hash, set, sorted set,而每一种数据结构的底层实现所对应的是哪个(或哪些)第二层面的数据结构(dict, sds, ziplist, quicklist, skiplist, 等),则通过不同的encoding来区分。
更多关于该数据结构内容请从我的Redis专栏中获取。
SDS
是什么
Redis的字符串是可以修改的,底层实现是SDS数据结构-底层使用字节数组存储元素,可以存储任意类型的数组,Redis为了节省内存空间,将SDS分为5种数据结构和内存使用压缩编码方式来节省内存空间。并且提供冗余扩容来避免每次修改时都触发内存重分配.
为什么使用SDS
Redis是用C写的,为什么不直接采用C语言提供的字符串呢?C语言的字符串结构使用\0字符结尾的字符数组实现,它不能用来存储任意的二进制数据,并且获取元素长度时需要遍历获取。而SDS使用字节数组实现,并且兼容C语言字符串,常数复杂度获取字符串长度,可存储任意数据、高效利用内存。
设计思想
Redis的底层数据结构核心有三个,而SDS主要专注于两点:1.提高存储效率 2.提高响应效率。比如根据字符串长度提供多种SDS结构:节省内存、使用紧凑数组存储:节省内存,降低内存碎片率、提供多种编码提高效率。
冗余的设计思想是:使N次append操作最多导致N次扩容操作,避免频繁的空间分配和数据拷贝工作。
特点
- 0.内部使用字节数组紧凑存储元素
- 1.可动态扩展内存。sds表示的字符串内容可以修改和追加,
- 2.二进制安全。使用字节数组存储数据,可存储任意类型的数据
- 3.与传统C语言字符串类型兼容。
- 4.SDS结构支持位操作
- 5.内部根据字符串长度提供多种数据结构,目的是节省内存空间
- 6.压缩编码,节省内存空间
- 7.SDS有三种编码格式: emb 、raw、 int(用于存储整形)
- 8.冗余设计,避免每次操作都执行扩容操作。
- 9.结构不下沉。reids的内存回收策略如下:当SDS中存储的字符串长度变少时,结构不会改变
- 10.最大支持存储512M的value。
实现结构
struct SDS<T> {
T capacity; // 数组容量,提供冗余空间而避免频繁的数组复制工作,比如append操作,根据SDS的Header结构类型分为多个长度类型。
T len; // 数组长度,表示数组实际长度,根据SDS的Header结构类型分为多个长度类型。
byte flags; // 固定一个字节,其中的最低3个bit用来表示header的类型。header的类型共有5种。
byte[] content; // 数组内容,未满部分用\0填空(\0是ASCLL码,真实代表NULL)
}
所以知道为什么上面说一个字符串最少需要19个字节,因为roj需要16字节,而capacity+len+flags最少需要3个字节,所以最少需要19字节存储一个字符串。capacity是用来存储数组容量,初始化时capacity与len相等,进行append操作时会对数组冗余扩容--double和1M的选择,而flags是标识SDS结构的,SDS内部为了节省内存有5种结构,SDSHDR5、SDSHDR8、SDSHDR16、SDSHDR32、SDSHDR64,每种结构的内部结构有部分区别(capacity和len的值范围不同)。
除了这些Redis为String还设计多种编码类型,int、emb、raw,其中int是存储整形数据,emb是存储等于小于44字符的字符串,raw是大于44字符的字符串,emb和raw内部效率不同。
使用emb的好处
- 1.使用emb只需要一次malloc分配,而raw需要两次,相对的释放内存也是两次。
- 2.emb的内容是连续的,更好的利用缓存优势顺序读取。
- 3.Redis并未提供对emb的修改,emb是只读模式,对emb的修改实际上是先转为raw然后在修改的。
- 4.emb只需要一次定位即可获取元素,因为roj和emb内存连续。而raw内存不连续需要两次定位,也降低了执行效率
更多关于该数据结构内容请从我的Redis专栏中获取。
Ziplist
是什么
ziplist是一个经过特殊编码的双向链表,底层使用字节数组实现,可以存储字符串和整形。其中hash和zset非标准结构底层都直接使用了ziplist,list间接使用了ziplist。最大存储2^32-1个字节。其核心思想是提升存储效率,内部数据使用了压缩编码+紧凑字节数组(代表内存连续),并且根据元素长度使用合适的变长编码。
为什么使用
传统的双向链表每个元素都需要维护多个引用指针,占用大量内存空间,比如存储的数据可能比维护元素所需的基础结构占用空间还小;传统双向链表每个元素都独立申请一块内存,造成了内存碎片,不利于内存管理。ziplist是一个紧凑的字节数组,内存是连续的,且无需维护内部指针,并且也提供双向遍历以及首尾操作O(1)时间复杂度。
设计思想
ziplist充分体现了Redis对于存储效率的追求。ziplist将数据存放在前后连续的字节数组内,并且元素之间紧凑存储,另外,ziplist为了节省内存,对值的存储采用了变长编码方式,既大值用大空间存储小值用小空间存储,但是这种结构并不擅长做修改操作。一旦数据发生改动,就会引发内存realloc,可能导致内存拷贝。最后还对部分数据结构做了压缩编码,使一个属性能存储多个数据。
特点
- 1.使用字节数组紧凑存储,内存连续、内存碎片率低
- 2.存储效率高。ziplist使用压缩编码、根据数据类型选择合适的编码结构、当数据在0-12之间时直接存储在encoding上,内置了9种结构
- 3.提供双向遍历、首尾操作时间复杂度为O(1)
- 4.无需维护内部指针,根据指针偏移量来获取元素
- 5.通过encoding可以获取数据类型和占用长度
- 6.元素与元素有相关性,所以存在级联影响
- 7.因为内部对数据经过了压缩编码,所以每次使用时都需要先解码再使用
- 8.只适合存储少量元素或小字节元素,因为每一次操作都会触发realloc,可能导致内存空间重分配和数据拷贝工作
- 9.对中间数组更改时,后向数据有可能都跟着变动,比如插入元素时后向元素都需要向后移动,比如级联更新。
- 10.因为len占用2个字节,所以最大能表示2^16-2个元素,当大于这个临界点时需要通过遍历方式获取元素数量
- 11.因为zlbytes占用4个字节,则元素最多存储2^32-1个字节。
实现结构
struct ziplist<T> {
int32 zlbytes; //表示整个压缩列表占用内存空间,占4个字节,因此压缩列表最长(2^32)-1字节;
可以对压缩列表进行内存重分配或计算zlend位置
int32 zltail_offset; //压缩列表尾元素距离首地址的偏移量,用于快速定位尾部元素,占4个字节。zlbytes+zltail_offset就是尾元素的地址。从而实现了快速的lrpop lrpush
int16 zllength; // 压缩列表的元素个数,占两个字节;那么当压缩列表的元素数目超过(2^16)-1怎么
处理呢?此时通过zllen字段无法获得压缩列表的元素数目,必须遍历整个压缩列表才能获取到元素数目;一般
不会出现,会提前转为标准结构。
entryX //元素内容的引用,该类型可以为字节数组(字符串)或者整数,内部数组采用紧凑存储结构。
int8 zlend; // 标志压缩列表的结束位,占一个字节,值恒为 0xFF(255),这是为什么pre_e_l最多
表示254,因为这里使用了255标示。
}
struct entry {
int<var> previous_entry_length; // 前一个 entry 的字节占用空间,占1个或者5个字节,为了实
现反向遍历,这个字段使元素之间有了联系,可能导致极联更新。
int<var> encoding; // 元素类型编码,即content字段存储的数据类型(整数或者字节数组)和元素
长度,为了节约内存,encoding字段同样是可变长度,表示了9种长度范围的字符串或整数。当元素极小时会
直接存在encoding中。
optional byte[] content; // 存储元素内容,值的类型和长度由encoding决定
}
previous_entry_length :是一个变长整数如果前一个entry小于254个字节时,那么pre_e_l占一个字节,如果大于254个字节时占用5个字节表示,5个字节时其中属性的第一字节会被设置为 0xFE(十进制值 254), 而之后的四个字节则用于保存前一节点的长度,其实是用0XFE来标识pre_e_l占用5个字节,实际用4个字节存储元素,代表前一个字节空间最大为2^32-1个字节,默认是64字节转为标准结构。
encoding 同样是可变长度,决定了content的类型、最大存储长度、占用空间。Redis通过这个字段的前缀位来识别元素的类型(00,01,10代表数组,11代表整数。),后缀位来识别元素的存储长度.encoding总共有9种类型,其中3种字符串类型,最大存储2^32-1字节元素,6种整形类型,最多8字节的整数
为什么previous_entry_length要小于254?
因为1个字节最大表示数值为255,而255标示已被lend结束符占用,在ziplist的很多操作中会读取第一个字节,判断是否为255来代表ziplist已达到结尾,所以previous_entry_length最大能使用254,而previous_entry_length小于254时能代表该长度时前一个元素的占用空间,当等于时则代表previous_entry_length占用5个字节。所以ziplist在读取元素第一个字节可以得出previous_entry_length的占用空间。