您当前的位置: 首页 >  数据结构

庄小焱

暂无认证

  • 0浏览

    0关注

    805博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

Redis——底层数据结构原理

庄小焱 发布时间:2022-03-31 13:25:24 ,浏览量:0

摘要

Redis 发展到现在已经有 9 种数据类型了,其中最基础、最常用的数据类型有 5 种,它们分别是:字符串类型、列表类型、哈希表类型、集合类型、有序集合类型,而在这 5 种数据类型中最常用的是字符串类型,所以本文我们先从字符串的使用开始说起。

一、String类型 1.1 int类型

Redis中规定假如存储的是整数型值,比如set num 123这样的类型,就会使用int的存储方式进行存储,在redisObject的ptr属性中就会保存该值。

1.2 SDS类型

假如存储的字符串是一个字符串值并且长度大于32个字节就会使用SDS(simple dynamic string)方式进行存储,并且encoding设置为raw;若是字符串长度小于等于32个字节就会将encoding改为embstr来保存字符串。

Redis使用SDS作为存储字符串的类型肯定是有自己的优势,SDS与c语言的字符串相比,SDS对c语言的字符串做了自己的设计和优化,具体优势有以下几点:

  • c语言中的字符串并不会记录自己的长度,因此每次获取字符串的长度都会遍历得到,时间的复杂度是O(n),而Redis中获取字符串只要读取len的值就可,时间复杂度变为O(1)。
  • c语言中两个字符串拼接,若是没有分配足够长度的内存空间就会出现缓冲区溢出的情况;而SDS会先根据len属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以不会出现缓冲区溢出的情况。
  • SDS还提供空间预分配和惰性空间释放两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能减少连续的执行字符串增长带来内存重新分配的次数。

        当字符串被缩短的时候,SDS也不会立即回收不适用的空间,而是通过free属性将不使用的空间记录下来,等后面使用的时候再释放。

        具体的空间预分配原则是:当修改字符串后的长度len小于1MB,就会预分配和len一样长度的空间,即len=free;若是len大于1MB,free分配的空间大小就为1MB。

  • SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据);而c语言中的字符串是以空字符串作为结束符,一些图片中含有结束符,因此不是二进制安全的。
  • redis同时写重写了大量的与sds类型相关的方法,有以下4个优点:降低获取字符串长度的时间复杂度到O(1)、减少了修改字符串时的内存重分配次数、兼容c字符串的同时,提高了一些字符串工具方法的效率、二进制安全(数据写入的格式和读取的格式一致)。
1.3 embstr类型

若是字符串长度小于等于32个字节就会将encoding改为embstr来保存字符串。

1.4 字符串类型的应用场景

字符串类型的使用场景有很多,但从功能的角度来区分,大致可分为以下两种:

  • 字符串存储和操作;
  • 整数类型和浮点类型的存储和计算。
1.4.1 页面数据缓存

我们知道,一个系统最宝贵的资源就是数据库资源,随着公司业务的发展壮大,数据库的存储量也会越来越大,并且要处理的请求也越来越多,当数据量和并发量到达一定级别之后,数据库就变成了拖慢系统运行的“罪魁祸首”,为了避免这种情况的发生,我们可以把查询结果放入缓存(Redis)中,让下次同样的查询直接去缓存系统取结果,而非查询数据库,这样既减少了数据库的压力,同时也提高了程序的运行速度。介于以上这个思路,我们可以把文章详情页的数据放入缓存系统。具体的做法是先将文章详情页序列化为字符串存入缓存,再从缓存中读取到字符串,反序列化成对象,然后再赋值到页面进行显示 (当然也可以用哈希类型进行存储,这会在下一篇文章中讲到),这样我们就实现了文章详情页的缓存功能,架构流程对比图如下所示。

1.4.2 数字计算与统计

Redis 可以用来存储整数和浮点类型的数据,并且可以通过命令直接累加并存储整数信息,这样就省去了每次先要取数据、转换数据、拼加数据、再存入数据的麻烦,只需要使用一个命令就可以完成此流程,这样我们就可以使用此功能来实现访问量的统计,当有人访问时访问量 +1 就可以了。

1.4.3 共享 Session 信息

通常我们在开发后台管理系统时,会使用 Session 来保存用户的会话(登录)状态,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统此模式将不再适用。例如用户一的 Session 信息被存储在服务器一,但第二次访问时用户一被分配到服务器二,这个时候服务器并没有用户一的 Session 信息,就会出现需要重复登录的问题。分布式系统每次会把请求随机分配到不同的服务器,因此我们需要借助缓存系统对这些 Session 信息进行统一的存储和管理,这样无论请求发送到那台服务器,服务器都会去统一的缓存系统获取相关的 Session 信息,这样就解决了分布式系统下 Session 存储的问题。

分布式系统使用同一的缓存系统存储 Session 流程图:

1.5 字符串源码分析
Redis 3.2 之前 SDS 源码如下:

struct sds{
    int len; // 已占用的字节数
    int free; // 剩余可以字节数
    char buf[]; // 存储字符串的数据空间
}
Redis 3.2 优化了 SDS 的存储结构

typedef char *sds;

struct __attribute__ ((__packed__)) sdshdr5 { // 对应的字符串长度小于 1fill, sz))) {
        // 在头部节点插入元素
        quicklist->head->zl =
            ziplistPush(quicklist->head->zl, value, sz, ZIPLIST_HEAD);
        quicklistNodeUpdateSz(quicklist->head);
    } else {
        // 头部节点不能继续插入,需要新建 quicklistNode、ziplist 进行插入
        quicklistNode *node = quicklistCreateNode();
        node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
        quicklistNodeUpdateSz(node);
        // 将新建的 quicklistNode 插入到 quicklist 结构中
        _quicklistInsertNodeBefore(quicklist, quicklist->head, node);
    }
    quicklist->count++;
    quicklist->head->count++;
    return (orig_head != quicklist->head);
}

quicklistPushHead 函数的执行流程,先判断 quicklist 的 head 节点是否可以插入数据,如果可以插入则使用 ziplist 的接口进行插入,否则就新建 quicklistNode 节点进行插入。函数的入参是待插入的 quicklist,还有需要插入的值 value 以及他的大小 sz。函数的返回值为 int,0 表示没有新建 head,1 表示新建了 head。 quicklistPushHead 执行流程,如下图所示:

quicklist 元素删除分为两种情况:单一元素删除和区间元素删除,它们都位于 src/quicklist.c 文件中。

1、单一元素删除

单一元素的删除函数是 quicklistDelEntry,源码如下:

void quicklistDelEntry(quicklistIter *iter, quicklistEntry *entry) {
    quicklistNode *prev = entry->node->prev;
    quicklistNode *next = entry->node->next;
    // 删除指定位置的元素
    int deleted_node = quicklistDelIndex((quicklist *)entry->quicklist,
                                         entry->node, &entry->zi);
    //...
}

可以看出 quicklistDelEntry 函数的底层,依赖 quicklistDelIndex 函数进行元素删除。

2、区间元素删除

区间元素删除的函数是 quicklistDelRange,源码如下:

// start 表示开始删除的下标,count 表示要删除的个数
int quicklistDelRange(quicklist *quicklist, const long start,
                      const long count) {
    if (count = 0 && extent > (quicklist->count - start)) {
        // 删除的元素个数大于已有元素
        extent = quicklist->count - start;
    } else if (start < 0 && extent > (unsigned long)(-start)) {
        // 删除指定的元素个数
        extent = -start; /* c.f. LREM -29 29; just delete until end. */
    }
    //...
    // extent 为剩余需要删除的元素个数,
    while (extent) {
        // 保存下个 quicklistNode,因为本节点可能会被删除
        quicklistNode *next = node->next;
        unsigned long del;
        int delete_entire_node = 0;
        if (entry.offset == 0 && extent >= node->count) {
            // 删除整个 quicklistNode
            delete_entire_node = 1;
            del = node->count;
        } else if (entry.offset >= 0 && extent >= node->count) {
           // 删除本节点的所有元素
            del = node->count - entry.offset;
        } else if (entry.offset < 0) {
            // entry.offset extent)
                del = extent;
        } else {
            // 删除本节点部分元素
            del = extent;
        }
        D("[%ld]: asking to del: %ld because offset: %d; (ENTIRE NODE: %d), "
          "node count: %u",
          extent, del, entry.offset, delete_entire_node, node->count);
        if (delete_entire_node) {
            __quicklistDelNode(quicklist, node);
        } else {
            quicklistDecompressNodeForUse(node);
            node->zl = ziplistDeleteRange(node->zl, entry.offset, del);
            quicklistNodeUpdateSz(node);
            node->count -= del;
            quicklist->count -= del;
            quicklistDeleteIfEmpty(quicklist, node);
            if (node)
                quicklistRecompressOnly(quicklist, node);
        }
        // 剩余待删除元素的个数
        extent -= del;
        // 下个 quicklistNode
        node = next;
        // 从下个 quicklistNode 起始位置开始删除
        entry.offset = 0;
    }
    return 1;
}

从上面代码可以看出,quicklist 在区间删除时,会先找到 start 所在的 quicklistNode,计算删除的元素是否小于要删除的 count,如果不满足删除的个数,则会移动至下一个 quicklistNode 继续删除,依次循环直到删除完成为止。

quicklistDelRange 函数的返回值为 int 类型,当返回 1 时表示成功的删除了指定区间的元素,返回 0 时表示没有删除任何元素。
3.4 linklist的应用场景

列表的典型使用场景有以下两个:

  • 消息队列:列表类型可以使用 rpush 实现先进先出的功能,同时又可以使用 lpop 轻松的弹出(查询并删除)第一个元素,所以列表类型可以用来实现消息队列;
  • 文章列表:对于博客站点来说,当用户和文章都越来越多时,为了加快程序的响应速度,我们可以把用户自己的文章存入到 List 中,因为 List 是有序的结构,所以这样又可以完美的实现分页功能,从而加速了程序的响应速度。
四、set (集合)

Redis 的集合相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。 set 结构可以用来存储活动中奖的用户 ID,因为有去重功能,可以保证同一个用户不会中奖两次。Set的底层实现是hahstabale和intset。

4.1 intset类型

在整数集合中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。

在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:

  •     首先扩展底层数组的大小,并且数组的类型为新元素的类型。
  •     然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
  •     整数集合升级后就不会再降级,编码会一直保持升级后的状态。
4.2 set源码解析
/* 
 * 添加元素到集合
 * 如果当前值已经存在,则返回 0 不作任何处理,否则就添加该元素,并返回 1。
 */
int setTypeAdd(robj *subject, sds value) {
    long long llval;
    if (subject->encoding == OBJ_ENCODING_HT) { // 字典类型
        dict *ht = subject->ptr;
        dictEntry *de = dictAddRaw(ht,value,NULL);
        if (de) {
            // 把 value 作为字典到 key,将 Null 作为字典到 value,将元素存入到字典
            dictSetKey(ht,de,sdsdup(value));
            dictSetVal(ht,de,NULL);
            return 1;
        }
    } else if (subject->encoding == OBJ_ENCODING_INTSET) { // inset 数据类型
        if (isSdsRepresentableAsLongLong(value,&llval) == C_OK) {
            uint8_t success = 0;
            subject->ptr = intsetAdd(subject->ptr,llval,&success);
            if (success) {
                // 超过 inset 的最大存储数量,则使用字典类型存储
                if (intsetLen(subject->ptr) > server.set_max_intset_entries)
                    setTypeConvert(subject,OBJ_ENCODING_HT);
                return 1;
            }
        } else {
            // 转化为整数类型失败,使用字典类型存储
            setTypeConvert(subject,OBJ_ENCODING_HT);

            serverAssert(dictAdd(subject->ptr,sdsdup(value),NULL) == DICT_OK);
            return 1;
        }
    } else {
        // 未知编码(类型)
        serverPanic("Unknown set encoding");
    }
    return 0;
}
4.3 set集合类型的经典使用场景如下
  • 微博关注我的人和我关注的人都适合用集合存储,可以保证人员不会重复;
  • 中奖人信息也适合用集合类型存储,这样可以保证一个人不会重复中奖。
五、sort set (有序列表)

sort Set是有序集合,从上面的图中可以看到sort Set的底层实现是ziplist和skiplist实现的,skiplist也叫做「跳跃表」,跳跃表是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。有序集合是由 压缩列表 (ziplist) 或跳跃列表 (skiplist) 来存储的,当元素个数小于 128 个,并且所有元素的值都小于 64 字节时,有序集合会采取 ziplist 来存储,反之则会用 skiplist 来存储,其中 skiplist 是从上往下、从前往后进行元素查找的,相比于传统的普通列表,可能会快很多,因为普通列表只能从前往后依次查找。

5.1  ZipList(压缩列表)

压缩列表(ziplist)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。压缩列表是列表键和哈希键底层实现的原理之一,压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间,压缩列表的内存结构图如下:

 redis 的压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空间

  • 压缩列表是支持双向遍历,所以才会有zltail_offset这个字段的,可以进行快速定位到最后一个元素。然后倒序查找(O(1))。
  • prevlen 表示的是前一个字段的长度,有人就有疑问了,为什么是前一个entry的长度,为什么不是自己的呢,其实他还有一个作用是在压缩列表倒叙遍历的时候,需要通过这个字段来快速定位到下一个元素的位置,由于他是一个连续的存储空间,已经知道当前元素的位置+这个空间地址就可以确定写一个entry的位置。为什么会这样呢?因为entry的大小是不一样的。如果是一样的话就可以根据下表进行行为(个人理解,有错误还请指出),且prevlen 是一个变长的整数,redis的常规操作,将不同长度使用不同的数据类型。节省内存。
  • encoding的意思是元素的编码类型,有了这个字段就可以决定元素内容的设定,内存大小的分配。防止内存分配浪费的一种方式。
5.2 skiplist 跳跃列表

skiplist由如下几个特点:

  •     有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
  •     每一层都是一个有序链表,只扫包含两个节点,头节点和尾节点。
  •     每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
  •     如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。  

5.3 sort set 使用场景

有序集合的经典使用场景如下:

  • 学生成绩排名
  • 粉丝列表,根据关注的先后时间排序
博文参考
关注
打赏
1657692713
查看更多评论
立即登录/注册

微信扫码登录

0.0485s