Redis 的底层数据结构(整数集合)

当一个集合中只包含整数,而且元素的个数不是不少的话,redis 会用整数集合做为底层存储,它的一个优势就是能够节省不少内存,虽然字典结构的效率很高,可是它的实现结构相对复杂而且会分配较多的内存空间。java

而咱们的整数集合(intset)能够作到使用较少的内存空间却达到和字典同样效率的实现,但也是前提的,集合中只能包含整型数据而且数量不能太多。整数集合最多能存多少个元素在 redis 中也是有体现的。git

OBJ_SET_MAX_INTSET_ENTRIES 512程序员

也就是超过 512 个元素,或者向集合中添加了字符串或其余数据结构,redis 会将整数集合向字典结构进行转换。github

1、基本的数据结构

intset 的结构定义很简单,有如下成员构成:redis

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents [];
} intset;
复制代码

encoding 记录当前 intset 使用编码,有三个取值:数组

#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
复制代码

length 记录整数集合中目前存储了多少个元素,contents 记录咱们实际的数据集合,虽然咱们看到结构体中给数组元素的类型定死成 int8_t,但实际上这个 int8_t 定义的毫无心义,由于这里的处理方式很是规的数组操做,content 字段虽然被定义成指向一个 int8_t 类型数据的指针,但实际上 redis 不管是读取数组元素仍是新增元素进去都依赖 encoding 和 length 两个字段直接操做的内存。bash

基本数据结构仍是很是的简单的,下面咱们来看看它的一些核心方法。微信

2、核心 API 实现

一、初始化一个 intsetmarkdown

intset *intsetNew(void) {
    intset *is = zmalloc(sizeof(intset));
    is->encoding = intrev32ifbe(INTSET_ENC_INT16);
    is->length = 0;
    return is;
}
复制代码

可见,默认的 inset 配置是使用 INTSET_ENC_INT16 做为数据存储大小,而且不会为 content 数组初始化。常规的数组须要先预先肯定数组长度,而后分配内存,继而经过 contents[x] 能够访问数组中任一元素。数据结构

可是,inset 这里是很是规式操做数组,encoding 字段定义了数组中每一个元素实际类型,lenth 字段定义了数组中实际的元素个数,那么 contents[x] 是失效的,这种方式只会按照 int8_t 进行内存偏移,这种方式是拿不到正确的数据的,因此 redis 中经过 memcpy 按照 encoding 字段的值暴力直接偏移地址操做内存读取数据。

因此,这也是为何 intset 初始化时不初始化 content 数组的缘由所在,由于没有必要。而每当新增一个元素的时候都会去动态扩容原数组的长度以盛放下新插入进来的元素,扩容不会扩容不少,恰好一个新元素所占用的内存便可。具体的细节,咱们接着看。

二、添加新元素

intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
    //计算获得新插入的元素的编码
    uint8_t valenc = _intsetValueEncoding(value);
    uint32_t pos;
    if (success) *success = 1;
    //若是大于 intset 目前存储元素的编码大小
    if (valenc > intrev32ifbe(is->encoding)) {
        //触发 intset 升级
        return intsetUpgradeAndAdd(is,value);
    } else {
        //二分搜索当前元素,若是元素已经存在会直接返回
        //若是没找到元素,pos 的值就是该元素的位置索引
        if (intsetSearch(is,value,&pos)) {
            if (success) *success = 0;
            return is;
        }
        //resize 集合,扩容一个元素的内存空间
        is = intsetResize(is,intrev32ifbe(is->length)+1);
        //移动 pos 后面的元素,以插入咱们的新元素
        if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
    }
    //赋值
    _intsetSet(is,pos,value);
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
    return is;
}
复制代码

由此,咱们应该知道为何 intset 内的数据是有序且无重复的了,二分查找 O(logN),可是 intset 插入一个元素却不是 O(logN),由于有些状况会触发升级操做,或者极端状况下,会移动全部元素,时间复杂度达到 O(N)。

三、升级

咱们先看示意图的变化,而后再分析源码,假设原 intset 使用 16 位的编码存储数据,先来了一个 32 位的数据,触发了咱们的编码升级。

原 intset 结构以下:

image

新 intset 结构会扩容成这样:

image

虽然数据占用的内存已经分配好了,可是还须要作的是迁移每一个元素占用的比特位。 作法是这样的,假设咱们的新元素是 int_32 类型的数值 65536,那么首先咱们会将这个 65536 放到[128-159]比特位区间,而后将 78 放到[96-127]比特位区间,并向前以此类推,最后咱们会获得升级完成以后 intset。

下面咱们看 redis 中代码的实现:

static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
    //intset目前的编码
    uint8_t curenc = intrev32ifbe(is->encoding);
    //intset即将扩展到的编码
    uint8_t newenc = _intsetValueEncoding(value);
    int length = intrev32ifbe(is->length);
    int prepend = value < 0 ? 1 : 0;

    //根据新的元素内存大小从新分配 intset 内存大小
    is->encoding = intrev32ifbe(newenc);
    is = intsetResize(is,intrev32ifbe(is->length)+1);
    //这个地方我先标记一下 @1,下面详细分析
    //整体上你能够理解,就是咱们上图画的那样,从原集合的最后一个元素
    //开始扩大它占用的比特位
    while(length--)
        _intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));

    //将新元素放进 intset 中
    if (prepend)
        _intsetSet(is,0,value);
    else
        _intsetSet(is,intrev32ifbe(is->length),value);
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
    return is;
}
复制代码

别的再也不解释,我重点解释一下我作标记的 @1,这个循环实际上是这个方法的核心点,它完成了将旧元素扩充比特位这么一个操做。

首先明确的一点是,升级操做只有两种状况会触发,一种是新插入一个较大的数值,另外一种是新插入一个负很大的值,这两种状况都会致使类型不够存储,须要扩大数据位。

_intsetGetEncoded 这个方法能够根据给定了 length,也就是元素在数组中的下标取出旧数组中对应的元素,很显然,这里是从后往前倒着来的。

由于咱们的 intsetResize 方法已经完成了扩容内存的操做,也就是说新元素的内存已经分配完毕,那么 _intsetSet 方法就会将 _intsetGetEncoded 取出的元素从新的向数组中赋值。循环结束时,就是全部元素从新归位的时候,最后再将新元素赋值进入数组最后的位置。

但其实细心的同窗会发现,_intsetSet 方法在传下标索引的时候实际传的是 length+prepend,这其实就是咱们说,若是 value 是小于零的,length+prepend 最终会致使全部的旧元素日后挪了一个偏移量,而后新的元素会被赋值的索引为零的位置。也就是说,若是新插入的数值是负数,它会被头插进数组的第一个位置。

核心的几个 API 咱们都已经介绍了,其余的一些 API 你能够自行参阅源码,相信对你不难。

总结一下,整数集合(intset)使用了很是简洁的数据结构,能够更少的占用内存存储一些整数,但终究是基于数组的,也就避免不了不能存储大量数据的缺点。整体来讲,插入一个元素,最好状况 O(logN),最坏的状况是 O(N),摊还时间复杂度为 O(N),查找一个元素,根据索引下标时间复杂度在 O(1)。当 intset 中的元素超过 512 个,或者向其中添加了字符串,redis 会将 intset 转换成字典。

一样的,若是以为我写的对你有点帮助的话,顺手点一波关注吧,也欢迎加做者微信深刻探讨,咱们下一讲,压缩列表,尽请关注。


关注公众不迷路,一个爱分享的程序员。 公众号回复「1024」加做者微信一块儿探讨学习! 每篇文章用到的全部案例代码素材都会上传我我的 github github.com/SingleYam/o… 欢迎来踩!

YangAM 公众号
相关文章
相关标签/搜索