聊一聊redis底层的数据结构 + 设计跳表

本文详细介绍了 Redis 的基础数据结构,包括简单动态字符串(SDS)、链表、字典(哈希表)和跳表。SDS 是对 C 字符数组的封装,支持快速获取长度和二进制安全操作。链表广泛应用于列表对象和其他内部结构。字典基于哈希表实现,支持渐进式 rehash,有效处理扩容问题。跳表则提供近似 O(logN) 的查找效率,避免了链表的线性查找。这些数据结构的设计优化了 Redis 的性能和内存管理。
摘要由CSDN通过智能技术生成

基础数据结构

简单动态字符串SDS

redis底层的字符串是对C字符数组的封装,它是一个结构体,其中包含三个成员:
【1】字符串的长度
【2】底层的字符数组char[]
【3】记录字符数组未使用的字节数
底层的字符数组是基于预分配和延迟释放的,因此字符串长度不等于底层字符数组的容量

由于保存了len字段,获取redis的字符串长度只需要O(1)的时间复杂度,而且从字符数组中读取内容的时候,并不是读取到空字符’\0’就直接返回了,而是读到len为止,这保证了二进制存入数组中是安全的,不会出现将某个值误认为结束标志。
另一方面,redis字符串由于本质上是对C的字符数组的封装,因此可以兼容部分C语言操作字符数组的API(前提是二进制安全的,不能判断一个’\0’就直接返回,不兼容的方法redis提供新的实现)

基于空间预分配和延迟释放策略,可以避免内存分配函数被频繁调用,对于内存分配操作底层不可避免涉及系统调用,而redis是一个内存数据库,频繁的系统调用将极大的影响性能。

链表

链表结构在redis底层的应用十分广泛,如作为列表对象的实现、在服务器保存多个客户端的状态、在客户端构建输出缓冲区结构等。
链表结构底层离不开链表节点,链表结构本身其中维护的主要是一个头指针和一个尾指针(是否是哨兵节点取决于具体的实现)以及操作数据结构的函数。而数据保存在其中的链表节点上。

字典

redis的字典基于哈希表实现,哈希表中存放的是哈希表节点。
哈希表结构底层就是哈希表节点的数组(可以类比hashMap底层的Object[] elements),而哈希表节点就是一个键值对(entry)类型。每个哈希表节点还保存了next指针用于解决哈希冲突。

redis的字典基于哈希表实现指的是redis的字典也是一个结构体,其中包含哈希表成员(哈希表、哈希表节点都是redis实现的结构体),类似class dict { HashMap map[] = new HashMap[2]; },相当于在哈希表的外层又封装了一层,不过这层提供了rehash的实现和相关的变量支持

而字典结构的组成:
【1】操作数据结构的函数指针、及其对应的可选参数
【2】哈希表数组,大小为2,另一个可以用于扩容(主要使用的还是其中一个)
【3】rehash索引,当rehash=-1表示没有在扩容

redis字典的扩容是分多次的、渐进式的,每次扩大为原来的二倍(可以类别java hashMap的扩容)
其中一个出现rehash事件,则另一个哈希表重新分配内存,并将rehash索引置为0表示开始进行rehash。这个rehash是一个持续的过程,直到源哈希表的节点全部移动到目标哈希表rehash工作才算完毕。(redis会定时触发时间事件,也会推进rehash)

rehash期间,每次对字典执行增删改查操作都会顺带执行rehash操作,rehashIndex索引指向的是当前正在执行rehash操作的节点(键值对在entry数组中的索引),每完成一个索引就增加。(从rehashIndex索引位置往后找到下一个不为空的位置,进行rehash)

rehash期间,删除、更新和查找操作可能需要查找两张哈希表,插入操作一律插入新的数组,最终原数组会变成一张空表并释放内存,rehash索引重置为-1。

渐进式rehash避免了集中式rehash带来的巨大计算量,但是也存在一些问题:
【1】整个扩容期间,一直会存在两张哈希表,占用一定空间
【2】增删改一般需要查询两张表,效率比较低
【3】redis使用内存临近最大内存时(并设置了驱逐策略的情况下),执行rehash可能使得内存占用超过最大内存,从而触发驱逐操作,使得主从服务器出现不一致的情况。

跳表

跳表可以支持平均logN,最坏N的复杂度(退化为链表)的查找。跳表本身可以看作一个**“支持一定程度随机访问的链表”**。甚至可以把跳表看作一个更高层次的链表

链表无法支持随机访问,而现在我们在链表上层加上了类似索引的东西,每次我们对总的查找范围进行二分(前提跳表是有序的),不断缩小范围,最终达到目标节点最近的(能够通过索引达到)的节点。

跳表由节点组成,而它仅需要维护头结点和尾结点及跳表长度(表头节点不计算在内),除此之外还需要维护一个maxLevel成员,保存层数最大节点的层数

对于每一个节点,维护用于排序的分值score、成员对象obj、指向前置节点的回退指针、以及一个level数组成员。level中包含两个成员:前进指针指向后面的某一个节点、跨度值span保存这两个节点之间的距离,指向null的前进指针跨度为0。跨度是一个相对的量,它可以通过累加计算一个节点的绝对位置(目标节点在跳表中的排位)

每次创建一个新的跳跃表节点时,程序都会根据幂次定理随机生成一个介于1和32之间的值,作为level数组的大小,这个大小就是高度。

同一跳表中,保存的成员对象必须是唯一的,但是多个节点保存的分值可以重复,跳表按照分值进行排序,分值相同按照对象字典序排序。

整数集合

整数集合不会出现重复元素,而且是有序的,是redis用于保存整数值的集合抽象数据结构,可以保存16位、32位和64位的整数值,底层数组是什么类型取决于最大的数是什么类型。如果一开始插入一个16位可以装下的数,那么数组此时就是int16_t的,如果某个放入的元素只能使用64位表示,那么数组就会升级为int64_t的。

升级:重新分配内存空间、数组中的元素转型,高位补零、将新元素放入数组中。引发升级的元素要么大于所有现有的元素,要么小于所有现有的元素

升级操作可以实现一个懒类型转换的效果,并不一开始就申请一个能够容纳64位元素的连续数组,而是当有一个64位元素将放入的时候才进行升级。
整数集合不支持降级操作。

压缩列表

当一个列表键只包含少量列表项,并且都是小的整数值或者短字符串,那么它便适合使用压缩列表进行实现,因此压缩列表的一个特点就是节约内存

列表列表由一系列特殊编码的连续内存块组成。压缩列表的组成:整个列表占用的字节数、表尾节点到其实地址的偏移量(快速拿到表尾节点)、节点数量、各个节点、用于标识末尾的标志符号。
其中,节点由三个部分组成:前一个节点的长度、节点编码、节点值

如果前一个节点小于254字节,preLen占1字节,否则占用5字节,这5字节中的第一个表示这是一个5字节长度的preLen属性,后4字节存储前一个节点的实际长度。通过这种存储方式,可以实现从表尾到表头的遍历。

preLen属性本身也算作节点的长度,因此添加、删除一个元素,都可能使得后面的元素指向连锁更新。(连续多次内存扩展或调整),不过并不多见,最坏复杂度是N^2

对象

redis为数据结构抽象出了一种对象系统,如集合对象,既可以通过哈希表实现也可以通过线性表实现,类似Java的接口与实现类之间的多态关系
同时redis基于引用计数的方式回收不被使用的对象。
redis的对象带有标识访问时间记录的属性,在内存不够用的时候将会优先回收空转时间过长的对象。
当在redis中创建一个键值对的时候,至少会创建两个对象,每个对象由一个结构体标识RedisObject,type成员指明它是一个什么类型的对象,encoding指明它底层是基于实现的,而ptr指针指向底层的数据结构。

redis的命令可以分为两种,一种可以在任意类型的键执行如del 、 type 、 expire等。另一种只能对特定类型的键执行如set、hset等。其中是基于类型(type)的多态,而后者是基于编码(encoding)的多态

对象的引用计数属性还可以实现对象共享,而redis仅共享保存整数值的字符串对象(0-9999一万个),因为这只需要O(1)复杂度的验证。

字符串对象

redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一个场景下的效率。
redis字符串对象的编码可以是int、raw和embstr 。
【1】可以使用long放下的整数值会以int编码保存
【2】可以用long double保存的浮点数、超过long范围的整数、字符串将被以raw或者embstr保存。其中,embstr是一种保存短字符串的优化编码格式,使用一次内存分配函数分配一块连续的空间,而raw使用两次内存分配函数分别创建RedisObject和sds字符串结构。embstr是只读的,一旦涉及修改操作就会升级为raw编码

如果对int编码执行append操作,则会先转换为raw然后执行字符串追加操作

列表对象

列表对象可以通过压缩列表链表结构实现。
如果列表对象保存的所有字符串长度都比较小(小于64字节),并且元素总数不多(小于512个),那么使用压缩列表编码,否则升级为链表。

哈希对象

基于压缩列表或者字典/哈希表实现。
如果是压缩列表实现,键和值会被连续推入压缩列表表尾,紧挨在一起。
当哈希对象的键值对的键和值长度较小(都小于64字节),且数量不多(键值对entry小于512)则使用压缩列表,否则基于字典实现。

集合对象

集合对象基于整数集合或者字典/哈希表实现。集合对象很常用,尤其是它的交、并、差集等操作。
当集合对象的所有元素都是整数,且元素数量不多(不超过512个)的时候,使用整数集合编码。

有序集合对象

有序集合通过压缩列表或者跳表进行实现。使用压缩列表实现时,每个集合元素对应两个紧邻的压缩列表节点,分别代表成员与分值。按照分值的大小进行排序。
当有序集合保存的元素不多(少于128个),且长度不长(小于64字节)时使用压缩列表

严格来说,有序集合是通过跳表和字典共同实现的,因为快表只能维护元素的顺序,但是无法快速根据对象obj拿到它的分数score,引入字典,可以将根据成员查找分值的世界复杂度从O(logN)优化到O(1)。

设计:跳表

力扣1206:设计跳表

    static class Entry {
        Entry[] next;
        int val;
        int count;
        int level;

        public Entry(int val, int level) {
            this.val = val;
            this.count = 1;
            this.level = level;
            this.next = new Entry[level];
        }
    }

每个跳表节点有一个前进数组的成员,val保存对应的值,level保存前进指针的数量(前进指针数组的对象),count用于保存元素的数量(重复元素使用count进行逻辑存储,没有重复元素count等于1)。其中level值是通过随机数生成的
其中层数越高,能够跳动的跨度越大,例如第一层的前进指针总是执行直接相邻的节点1->2->3->4->5->null,而更高层的跳表指针可能是1->3->5->null,如果我们想要查找6,如果按照最底层查找就相当于退化为了链表,而如果自高向低进行查找,查找效率就会大大提升,如果想要查找4,只需要先和3比较,再和5比较,最后确定范围是(3,5),于是在指针3的下一层进行更小范围的跳跃,最终达到4,跳表的层数越高,跳跃的范围越大。

因此,由于高层节点的存在,不再需要逐一比较每个节点,需要比较的节点大概只有原来的一半。跳表的本质就是就是多层链表,每一层链表的节点个数大致都是下一层节点个数的一半,但是这种关系很容易被插入删除等更改结构的操作打破,为了避免这个问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数。
由于每一层的层数都是随机处理的,因此新插入一个节点不会影响其他节点的层数,插入操作只需要修改插入节点前后的指针,这降低了插入和修改维护的复杂度。

跳表和平衡树具有相似的查询效率(查找当个键,时间复杂度都为logN),但是比平衡树更容易实现,而且维护开销更小。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。

从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势

    private static final int MAX_LEVEL = 64;

    private Entry head;
    private Random random;

跳表结构维护一个头结点,和一个用于生成随机数的对象。其中头结点就是一个哨兵节点

    public Skiplist() {
        this.head = new Entry(Integer.MIN_VALUE, MAX_LEVEL);
        this.random = new Random();
    }

随机层数

redis的大致实现:
一个节点最少有一层指针,它具有下一层指针的概率是p(redis中取1/4),以下函数的逻辑就是,循环每轮执行的概率都是1/4,1/4的概率增加一层,最大增加到max_level(可以根据数据规模取,一般取2的整数倍)。假设产生了4层,那么这个概率就是pow(1/4,3)

    private int randomLevel() {
        int level = 1;
        while(random.nextInt(100) < 25) {
            level++;
            if(level == MAX_LEVEL) {
                break;
            }
        }
        return level;
    }

search

我们从头结点开始搜索,而且从最高层开始搜索(这里的实现没有维护maxLevel而是每次从最高层开始向下搜索,有优化空间),如果在某一层发现存在不为空的指针,就横向跳跃直到当前指针cur的相邻指针cur.next指向的对象大于等于目标对象target。每当内层循环退出时,当前层已经成功划定了一个范围cur以及cur.next[i]所在节点,二者之间可能存在一其他节点(层数小于二者),然后 i - - 层数下降,重复以上过程、
最终cur下降到第1层即i=0,这时cur.next[0].val可能大于等于target,cur.next[0]也可能为空。做一个后处理即可
其中外循环是常量级别的,内循环相当于做二分操作,总体时间复杂度在logN 。

    public boolean search(int target) {
        Entry cur = head;
        //从最高层往下搜索,和相邻指向的元素最比较
        for(int i = MAX_LEVEL - 1; i >= 0; i--) {//纵向
            while(cur.next[i] != null && cur.next[i].val < target) {//横向
                cur = cur.next[i];
            }
        }
        //此时next可能>=target或者为null
        cur = cur.next[0];
        if(cur == null) {
            return false;
        }
        return cur.val == target;
    }

add

    private Entry newEntry(int num) {
        int level = randomLevel();
        return new Entry(num, level);
    }

插入一个节点(值为num),我们依然从头结点开始搜索,同时我们还需要一个专门用于映射指针的辅助节点数组,例如我们需要在54367中的3和6直接插入一个9(数字均代表层数),插入的实现,需要将5 4 3的指针指向9,同时9这个节点还需要继承他们指向 3 6 7的指针(想象成一堵墙立在中间)。

每当内循环退出,说明当前层cur < num且cur.next[i] >= num,使用辅助节点数组prev在第i层保存cur(相当于cur和cur.next[i]全部拿到了)。从上到下依次对prev进行填充,最终prev被初始化完毕

prev数组是用于实现节点接替的,如果只有一层链表我们直接遍历到目标节点,完成接替并返回即可,但是现在是多层链表,因此需要使用一个临时数组保存接替节点,最后再依次接替

cur最终会走到第一层,其中cur.val和cur.next[i].val将num夹住,如果cur.next[0]等于num则直接逻辑添加并返回。
否则,创建新节点,然后借助prev数组依次实现指针变换操作。

    public void add(int num) {
        Entry[] prev = new Entry[MAX_LEVEL];//prev将小于num的level保存起来
        Entry cur = head;
        //prev的每一个元素,记录了“preV元素”对应的引用
        for(int i = MAX_LEVEL - 1; i >= 0; i--) {
            while(cur.next[i] != null && cur.next[i].val < num) {
                cur = cur.next[i];
            }
            //当前cur<num且cur.next[i]>=num
            prev[i] = cur;
        }
        //已存在的元素则只需要逻辑上添加
        if(cur.next[0] != null && cur.next[0].val == num) {
            cur.next[0].count += 1;
            return;
        }

        Entry newEntry = newEntry(num);
        int newLevel = newEntry.level;
        //prev-newEntry-prevNext 
        for(int i = newLevel - 1; i >= 0; i--) {
            newEntry.next[i] = prev[i].next[i];
            prev[i].next[i] = newEntry;
        }
    }

erase

删除操作和添加操作实现思路基本一致,也是通过辅助数组prev记录哪些指针需要修改。最后的后处理将两个指针关系重新连接即可。

    public boolean erase(int num) {
        Entry[] prev = new Entry[MAX_LEVEL];
        Entry cur = head;
        for(int i = MAX_LEVEL - 1; i >= 0; i--) {
            while(cur.next[i] != null && cur.next[i].val < num) {
                cur = cur.next[i];
            }
            //此时满足 cur > num >= cur.next[i]
            prev[i] = cur;
        }
        cur = cur.next[0];
        //从来没有添加过该值
        if(cur == null || cur.val != num) {
            return false;
        }
        //存在多个该值
        if(cur.count > 1) {
            cur.count -= 1;
        } else {
            //移除该值对应结构
            int level = cur.level;
            //prev - beDeleted - prevNext
            for(int i = level - 1; i >= 0; i--) {
                prev[i].next[i] = cur.next[i];
            }
        }
        return true;
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值