本文已参与「新人创作礼」活动,一起开启掘金创作之路。
Redis具有五种数据类型:String,List,Hash,Set,ZSet,底层使用了多种数据结构,同种数据类型在不同的状态也会使用不同的数据结构。关于这五种数据类型的简单介绍及基本指令可以移步:34:Redis五种数据类型及其使用 ---- 《Redis深度历险》读书笔记 - 掘金 (juejin.cn),本文主要简单介绍存储原理。
RedisObject
Redis中的值都是一个个键值对,其中键总是字符串对象,值则可以是字符串,整数,列表,集合等。Redis的值对象都通过RedisObject来表示。
typedef struct redisObject{
//表示类型:string,list,hash,set,zset
unsigned type:4;
//编码:比如字符串的编码有int编码,embstr编码,raw编码
unsigned encoding:4;
//指向底层数据结构的指针,prt是个指针变量,存放地址,指向数据存储的位置
void *ptr;
//引用计数,类似java里的引用计数
int refcount;
//记录最后一次被程序访问的时间
unsigned lru:22;
}
- type:记录着对象的类型(String,List,Hash,Set,ZSet),可以通过
type key命令来获取。 - encoding:编码方式,比如字符串的编码有int编码,embstr编码,raw编码,可以通过
object encoding key来查看。 - *ptr:对象指针,对象存放的地址,通过它可以找到对象底层的数据结构
- refcount:引用计数,因为C语言比较贴近硬件,因此redis底层用C语言实现。C语言没有自动的GC机制,Redis就采用引用计数法建立一个自己的内存回收机制。有对象引用就+1,引用断开就减1。但是众所周知,引用计数法无法解决循环引用,导致内存泄漏的问题,Redis采用淘汰策略来应对这个问题,在内存满时通过指定的淘汰策略强制淘汰目标数据。
- lru:这个字段记录着改对象最后一次被访问的时间。淘汰策略中有两种lru的策略,就是借助这个属性实现的。
内存共享
如果键不同,但是却具有相同的值,此时无需建立多个重复的对象,仅需建立一个RedisObject大家共用,然后维护相应的引用计数即可。但是内存共享仅支持int类型,这是因为判断两个对象是否相等需要额外的资源,int类型复杂度较低可以接受,String类型已经较复杂了,更别说list和hash这种了。
String 类型
String类型的底层编码有两种,分别是int,raw和embstr:
-
int编码:存储的整数值,如果值被修改为非整数值或者值的大小超过了long的范围,就会自动转换成raw类型。
-
embstr编码:用来存储短字符串,存储不大于44字节的字符串。Redis3.2之前则是存储不大于39字节的字符串,这是因为Redis3.2对RedisObject做了优化,节省了5个字节。String在Redis底层的存储形式是sdshdr:
struct sdshdr { unsigned int len;//4个字节 unsigned int free;//4个字节 char buf[];//存储字符串的字符 }; if (ptr) { memcpy(sh->buf,ptr,len); sh->buf[len] = '\0';//一个字节redis内存采用jemalloc内存分配器来分配内存的,内存的分配不是随意大小分配的,jemalloc提供了众多小类定义了可选的分配大小,如果用户申请的大小位于两个小类之间,会取较大的。而embstr就巧妙的运用额外分的这块空间,将RedisObject和sdshdr放在一起,计算RedisObject和sdshdr占用的空间,如果buf占用的空间在[8,44]之间,都是会分配64字节,因此这也是为什么界定44的原因。
embstr编码仅仅需要申请一次空间,而且RedisObject和sdshdr放在一起,这使得embstr在效率上会快很多,但是也同样带来了缺点,如果字符串改动,会导致RedisObject也要重新分配空间,这回影响性能。因此embstr编码仅允许读,如果发生修改,则直接变为raw编码。
-
raw编码:用来存储长字符串或者发生更新的字符串。raw编码申请两次内存,为RedisObject和sdshdr分配独立的空间,这使得字符串重新分配空间时就不会影响到RedisObject。但是这也导致raw编码在创建和删除时都需要多操作一次空间。sdshdr 的buf就是容器,扩容时如果容量小于1M就二倍扩容,如果大于1M就一次扩容1M。字符串的最大长度是512M。 1M和512M这两个值都是底层写死的,不可配置。
List 类型
List类型的底层是一个链表结构,他的底层编码有两种:ziplist(压缩列表)和LinkedList(双端列表),当列表元素个数小于512个,且每个元素的长度小于64字节时使用ziplist,否则使用LinkedList。可以通过list-max-ziplist-entries和list-max-ziplist-value分别设置个数和大小阈值。
Redis3.2之后list改用了quicklist结构,其实就是由ziplist组成的LinkedList。
Hash 类型
Hash类型底层编码同样是两种:ziplist和hashtable。当列表元素个数小于512个,且每个元素的长度小于64字节时使用ziplist,否则使用hashtable。在元素较少时,将数据放到一块ziplist会有更好的效率。
Set类型
Set类型底层编码同样是两种:intset和hashtable。当列表元素个数小于512个,使用intset,否则使用hashtable。
ZSet
Set类型底层编码同样是两种:ziplist和skiplist。当列表元素个数小于512个,且每个元素的长度小于64字节时使用ziplist,否则使用skiplist。
zipList
可以看到list,hash,zset底层在小数据时都用了一种叫做ziplist的结构,那么ziplist是什么呢?
ziplist是一个申请一块连续的内存块,顺序存储元素的存储结构。可以理解为一种特殊的数组,不过数组元素类型固定,这就表示每个元素占据的空间大小都是一致的,只需要知道地址起点,就可以很简单的利用固定长度计算偏移量去访问任意位置的元素。但是ziplist不同,ziplist为了节省内存大小,每个元素所占的内存大小可以不同,这就是意味着无法通过固定偏移量去访问任意一个元素了。
ziplist结构由5块组成:
- zlbytes: 一个无符号整数,表示整个ziplist的长度,单位是字节
- zltail:ziplist最后一个节点的偏移量,记录这个是用来反向遍历或者移除尾部节点
- zllen: ziplist的节点(entry)数目
- entry:节点,包含这元素及其相关信息
- zlend:标记ziplist的结尾,值为0xff(255)。
上面提到每个元素所占的内存大小可以不同,会导致无法通过固定偏移量去访问任意一个元素了。为了解决这个问题,ziplist中的节点entry由三部分组成。
- prevlength:记录上一个节点的长度,记录这个主要为了逆向遍历ziplist。
- encoding:当前节点的编码规则,用来记录当前节点的数据类型以及数据长度
- data:当前节点的值,
prevlength 为了节省内存,prevlength有两种存储形式:
- 如果上一个entry的长度小于254,则用8位直接记录上一节点的长度
- 如果上一个entry的长度大于等于254,那么前8位记录254,然后后续再申请32位来记录长度。为什么8位能记录255,这里却实254呢?是因为0xff(255)在ziplist是一个特殊的值,他代表着zlend,即ziplist的尾部标记
encoding 在redis里面也就底层数据类型也就只有字符串和数字两种类型了。因此encoding的前两位用来标记是字符串还是数字。11表示数字。00,01,10表示不同长度情况下的字符串。
整数:
对于整数来说,encoding的值的定义如下:
#define ZIP_INT_16B (0xc0 | 0<<4)//整数data,占16位(2字节)
#define ZIP_INT_32B (0xc0 | 1<<4)//整数data,占32位(4字节)
#define ZIP_INT_64B (0xc0 | 2<<4)//整数data,占64位(8字节)
#define ZIP_INT_24B (0xc0 | 3<<4)//整数data,占24位(3字节)
#define ZIP_INT_8B 0xfe //整数data,占8位(1字节)
/* 4 bit integer immediate encoding */
//整数值1~13的节点没有data,encoding的低四位用来表示data
#define ZIP_INT_IMM_MASK 0x0f
#define ZIP_INT_IMM_MIN 0xf1 /* 11110001 */
#define ZIP_INT_IMM_MAX 0xfd /* 11111101 */
可以看到对于小数值(0-12)encoding采用了和embstr一样的方式,直接用低4位表示了数值,没有额外的data部分,更长的则用后6位区分整数节点类型。但是后4位的范围可以是0000-1111,可以更多,为什么只取用0001-1101呢?而且为什么0001-1101表示的是1-13,为什么实际范围是0-12呢?看上面的encoding编码可以知道这是因为0000是特殊值,1110也是特殊值,所以范围就只取了0001-1101这一段,然后再用存储的值-1代表实际值,即0001代表0,所以实际范围是0-12。
对于字符串来说:encoding有三种形式:
- 当data长度小于等于63(2^6)字节时,直接用8位的encoding表示,高两位为00
- 当data长度小于等于16383(2^14)字节时,用16位的encoding表示,高两位为01
- 当data长度小于等于4294967296(2^32)字节时,用40位的encoding表示,前8位的高两位为10,后6位为任意值,剩下的32位表示长度
通过ziplist的结构,就能完成像数组访问一样,访问元素长度可变的连续内存中的节点。因为redis基于内存的操作,就意味着内存非常珍贵,因为ziplist的按需分配以及不用维护prev指针和next指针,就节省了很多的内存空间。缺点则是会影响访问速度,毕竟在ziplist内部的时间复杂度是O(n),而对于hash,Zset这样的可以存着中间插入的场景,还可能存在连锁更新。所谓的连锁更新就是如果有一段连续的长度接近253的节点(小于254的用8位记录prevlength),此时如果一个节点发生更新导致后一个节点的prevlength改变后刚好大于253,那么为了记录新的长度这个节点就要扩充(大于253的要用40位记录),扩充之后这个节点又大于253,就会继续影响下去。
以上两个原因就是为什么ziplist设置两个参数:节点小于指定数目,且每个节点的大小小于指定值时才使用ziplist的原因。
小对象压缩
Redis是一个非常耗费内存的数据库,因为所有的数据都放在内存里,因此Redis在内存占用上做了很多优化。对于一些小对象上,redis并不会采用标准结构存储,比如hash,元素较少时,用一维数组遍历可能比树查询更快,而且还省去了存储指针的空间。还有list,通过zplist即提高了随机访问效率,也同样省去了存储指针的空间。这种小对象压缩想法,贯穿Redis的设计始终。
这里记录一些存储边界,如果用到的话可以做个参考:
- hash-max-zipmap-entries 512 # hash 的元素个数超过 512 就必须用标准结构存储
- hash-max-zipmap-value 64 # hash 的任意元素的 key/value 的长度超过 64就必须用标准结构存储
- list-max-ziplist-entries 512 # list 的元素个数超过 512就必须用标准结构存储
- list-max-ziplist-value 64 # list 的任意元素的长度超过 64就必须用标准结构存储
- zset-max-ziplist-entries 128 # zset 的元素个数超过 128就必须用标准结构存储
- zset-max-ziplist-value 64 # zset 的任意元素的长度超过 64就必须用标准结构存储
- set-max-intset-entries 512 # set 的整数元素个数超过 512就必须用标准结构存储
根据数量不同采取不同存储策略是一个很好的节省资源的手段。比如HashMap也采用了小对象压缩策略,他有一个树化阈值,元素数量小于树化阈值时,进行扩容而不是树化。
参考资料:
Redis之压缩列表ziplist
开发成长之旅 [持续更新中...]
欢迎关注…