Redis数据结构

发布时间 2023-10-01 20:20:30作者: 已然落伞雨中行

本文大部分知识整理自网上,在正文结束后都会附上参考地址。如果想要深入或者详细学习可以通过文末链接跳转学习。

前言

本文主要介绍关于Redis的五种基本数据结构的底层实现原理,然后来分析我们常用的使用场景。先简单回顾一下知识点。

Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件. 它支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs和 地理空间(geospatial) 索引半径查询。

简单来说,Redis的数据结构主要分为五种基本的数据结构+三种高级的数据结构。我们下面所要介绍的就是这五种基本的数据结构的相关知识。

结构模型介绍

在我们基本的数据结构中,它又有自己的内部编码。

总结一下

(1)每种数据结构都有自己底层的内部编码实现,而且是多种实现,这样Redis会在合适的场景选择合适的内部编码。

(2)可以看到每种数据结构都有两种以上的内部编码实现,例如string数据结构就包含了raw、int和embstr三种内部编码。

(3)同时,有些内部编码可以作为多种外部数据结构的内部实现,例如ziplist就是hash、list和zset共有的内部编码。

结构模型基础

我们在上面了解到了关于键的基本数据结构。而我们Redis中的所有value都是以object的形式存在的。其通用结构结构源码如下:

typedef struct redisObject {
    unsigned [type] 4;
    unsigned [encoding] 4;
    unsigned [lru] REDIS_LRU_BITS;
    int refcount;
    void *ptr;
} robj;

(1)type指的就是我们的基本数据结构,如string,list等其他类型;

(2)encoding指的是这些结构内部类型的具体实现方式,如string可以用int来实现也可以用char[]来实现;list可以用ziplist或者链表来实现;

(3)lru表示本对象的空转时长,用于有限内存下长时间不访问的对象清理;

(4)refcount对象引用计数,用于GC;

(5)ptr指向以encoding方式实现这个对象实际实现者的地址,如String对象对应的SDS(Simple Dynamic String 结构)地址。

示意图如下:

String类型

关于string内部结构,上面也介绍了主要是以三种编码形式来组成的,分别是int,raw,embstr。这里int主要是用来存放整形值的字符串,embstr用来存放字符串的短字符串(大小不超过44个字节),raw存放字符的长字符串(大小不超过44个字节)。

SDS结构

我们在上面介绍了关于键的基本结构redisObject,但其实在我们的String中还用着另外一种结构,也就是我们的SDS结构(Simple Dynamic String 结构)。

从源码的文件里面可以看见,同样一组结构Redis使用泛型定义了好多次。那么为什么不直接用int类型呢?这里呢主要是因为当字符串比较短的时候,len和alloc可以使用byte和short来表示,Redis为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。

为什么不直接使用C字符串呢?

我们为什么要重新在定义一个SDS的动态字符串的结构?其实呢这主要是为了从Redis对字符串安全性和效率以及功能方面的要求。C 语言使用了一个长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组最后一个元素总是 '\0'。而在Redis中\0可能会被判定为提前结束而识别不了字符串。通过分析可以发现,总共有以下一些缺点:

(1)获取字符串长度为O(n),因为C字符串需要去遍历。

(2)不能很好的杜绝缓冲区溢出/内存泄漏的问题,因为原因同上,进行字符串拼接等其他操作获取长度的时候易出现问题。

(3)C字符串只能保存文本数据,因为必须符合某种编码(如ASCLL)。像一些\0就不易处理。

表格区别汇总如下。

C字符串SDS
获取字符串长度的复杂度为O(N) 获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出 API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配 修改字符串长度N次最多需要执行N次内存重分配
只能保存文本数据 可以保存文本或者二进制数据
可以使用所有<string.h>库中的函数 可以使用一部分<string.h>库中的函数

raw与embstr的区别

(1)redis并未提供任何修改embstr的方式,即embstr是只读的形式。对embstr的修改实际上是先转换为raw再进行修改。

(2)采用内存分配方式不同,虽然raw和embstr编码方式都是使用redisObject结构和sdshdr结构。但是raw编码方式采用两次分配内存的方式,分别创建redisObject和sdshdr,而embstr编码方式则是采用一次分配,分配一个连续的空间给redisObject和sdshdr。(embstr一次性分配内存的方式:1,使得分配空间的次数减少。2、释放内存也只需要一次。3、在连续的内存块中,利用了缓存的优点。)

String的应用场景

(1)缓存功能

字符串最经典的使用场景,redis最为缓存层,Mysql作为储存层,绝大部分请求数据都是 redis中获取,由于redis具有支撑高并发特性,所以缓存通常能起到加速读写和降低后端压力的作用。

(2)计数器

许多应用都会使用redis作为计数的基础工具,因为redis的INCR命令具有原子性的自增操作,在并发下也可以保证一个线程安全的问题。如果我们常见的论坛,网站的点赞数或者视频播放数就是使用redis作为计数的基础组件。

(3)共享session

出于负载均衡的考虑,分布式服务会将用户信息的访问均衡到不同服务器上,这样可能我们用户在第一次访问和第二次访问的时候不是同一台服务器的话,session不同步,就会导致重新登录。为避免这个问题可以使用redis将用户session集中管理。(示意图如下)

当客户端第一次发送请求后,nginx将这个请求分发给服务器实例M ,然后将服务器实例M 产生的Session 放入Redis中,此时客户端、服务器实例M 和Redis中都会有一个相同的Session,当客户端发送第二次请求的时候,nginx将请求分发给服务器实例N (已知服务器实例N 中无Session),因为客户端自己携带了一个Session,那么服务器实例N就可以拿着客户端带来的Session中的ID去Redis中找到Session,找到这个Session后,就能正常执行之后的操作。

(5)限流

我们的redis处于安全考虑或者在高并发访问,都会进行一个限流或者限速。比如防止某个接口被频繁调用而崩溃或者像手机验证码验证,防止短信接口不被频繁访问。

我们常见的限流算法有很多,如令牌桶,漏桶,计数器,滑动窗口等。而用String的话,就可以使用计数器,我们如果要设置一个一分钟最多只能访问100次的限流接口,只要设置键的一分钟过期时间就行,然后在一分钟之内通过计数器来进行计数。关于具体的限流算法,我后面会继续更新补充一下这一块的知识点。(点击跳转,待补充)

List类型

我们在最开始的图上面也介绍了,list列表的数据结构使用的是压缩列表ziplist和普通的双向链表linkedlist组成。元素少的时候会用ziplist,元素多的时候会用linkedlist。然后针对这两种的弊端又设计出了一个快速列表。关于双向链表,老数据结构不介绍了,这里重点介绍一下压缩列表和快速列表。

压缩列表

ziplist是一种压缩链表,它的好处是更能节省内存空间,因为它所存储的内容都是在连续的内存区域当中的。当列表对象元素不大,每个元素也不大的时候,就采用ziplist存储。但当数据量过大时就ziplist就不是那么好用了。因为为了保证他存储内容在内存中的连续性,插入的复杂度是O(N),即每次插入都会重新进行realloc。如下图所示,对象结构中ptr所指向的就是一个ziplist。整个ziplist只需要malloc一次,它们在内存中是一块连续的区域。

ziplist的结构表如下:

    1、zlbytes:用于记录整个压缩列表占用的内存字节数

    2、zltail:记录要列表尾节点距离压缩列表的起始地址有多少字节

    3、zllen:记录了压缩列表包含的节点数量。

    4、entryX:要说列表包含的各个节点

    5、zlend:用于标记压缩列表的末端

为什么数据量大不使用ziplist?

我们在上面也说到了,因为它的插入的时间复杂度是O(n),而且插入一个新的元素就要调用realloc进行扩展内存。取决于内存分配器算法和当前的ziplist内存大小,realloc可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能直接原地扩展。而如果我们的数据量大的话,那么重新分配内存和拷贝内存就会有很大的消耗。所以ziplist不适合大型字符串,存储的元素也不宜过多。

快速列表

其实这里如果看网上早期的博客很容易漏掉一个数据结构。我们的Redis早期版本list内部编码是ziplist或者linkedlist,但是这两者都有着自己的缺点。ziplist的数据量大不适合用,在上面也重点介绍了,而linkedlist的附加空间相对太高,prev和next指针就要占去16个字节,而且每一个结点都是单独分配,会加剧内存的碎片化,影响内存管理效率。

所以针对这两种编码数据结构,后续版本进行了改造了,诞生了quicklist。

quicklist是ziplist和linkedlist的混合体,它将linkedlist按段切分,每一段使用ziplist来紧凑存储,多个ziplist之间使用双指针串接起来。

ziplist的长度

quicklist内部默认单个ziplist长度为8k字节,超出了这个字节数,就会新起一个ziplist。关于长度可以使用list-max-ziplist-size来决定。

压缩深度

我们上面说到了quicklist下是用多个ziplist组成的,同时为了进一步节约空间,Redis还会对ziplist进行压缩存储,使用LZF算法压缩,可以选择压缩深度。

quicklist默认的压缩深度是0,也就是不压缩。压缩的实际深度由配置参数list-compress-depth决定。为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。

关于压缩具体介绍可以参考这里?点击跳转

List的应用场景

(1)消息队列

redis的lpush+brpop命令组合即可实现阻塞队列,生产者客户端是用lupsh从列表左侧插入元素,多个消费者客户端使用brpop命令阻塞时的“抢”列表尾部的元素,多个客户端保证了消费的负载均衡和高可用性。

(2)最新列表

list类型的lpush命令和lrange命令能实现最新列表的功能,每次通过lpush命令往列表里插入新的元素,然后通过lrange命令读取最新的元素列表,如朋友圈的点赞列表、评论列表。

(3)排行榜

适用于定时计算的排行榜。 list类型的lrange命令可以分页查看队列中的数据。可将每隔一段时间计算一次的排行榜存储在list类型中,如京东每日的手机销量排行、学校每次月考学生的成绩排名、斗鱼年终盛典主播排名等排行榜。

Hash类型

哈希类型的底层编码可以是ziplist也可以是我们的hashtable。ziplist在上面我们已经介绍了,这里我们着重介绍一下hashtable。

HashTable结构

我们的hashtable主要是通过dict来实现的。

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int iterators; /* number of iterators currently running */
} dict;

可以看到我们每个dict结构里面都有两个hashtable。(ht[0]和ht[1])

虽然dict结构有两个hashtable,但是通常情况下只有一个hashtable是有值的。但是在dict扩容缩容的时候,需要分配新的hashtable,然后进行渐近式搬迁,这时候两个hashtable存储的旧的hashtable和新的hashtable。搬迁结束后,旧hashtable删除,新的取而代之。

hashtable的结构和Java的HashMap几乎是一样的,都是通过分桶的方式来解决hash冲突的。第一维是数组,第二维是链表。而数组中存储的是第二维链表的第一个元素的指针。

渐进式rehash

所谓渐进式rehash是指我们的大字典的扩容是比较消耗时间的,需要重新申请新的数组,然后将旧字典所有链表的元素重新挂接到新的数组下面,是一个O(n)的操作。但是因为我们的redis是单线程的,无法承受这样的耗时过程,所以采用了渐进式rehash小步搬迁,虽然慢一点,但是可以搬迁完毕。

这里我们将说说扩容条件和缩容条件,然后再介绍一下rehash的过程。

扩容条件

我们的扩容一般会在Hash表中的元素个数等于第一维数组的长度的时候,就会开始扩容。扩容的大小是原数组的两倍。不过在redis在做bgsave(RDB持久化操作的过程),为了减少内存页的过多分离(Copy On Write),redis不会去扩容。但是如果hash表的元素个数已经到达了第一维数组长度的5倍的时候,就会强制扩容,不管你是否在持久化。

这里不扩容主要是为了尽可能减少内存页过多分离,系统后需要更多的开销去回收内存。

缩容条件

当我们的hash表元素逐渐删除的越来越少的时候,第一维数组长度太长也不是太好。redis于是就会对hash表进行缩容来减少第一维数组长度的空间占用。缩容的条件是元素个数低于数组长度的10%,并且缩容不考虑是否在做redis持久化。

这里不用考虑bgsave主要是因为我们的缩容的内存都是已经使用过的,缩容的时候可以直接置空,而且由于申请的内存比较小,同时会释放掉一些已经使用的内存,不会增大系统的压力。

rehash步骤

1、为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;

2、在几点钟(定时)维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始;

3、在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx加一;

4、当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束;

(采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。特别的在进行rehash是只能对ht[0]进行使得h[0]元素减少的操作,如查询和删除;而查询是在两个哈希表中查找的,而插入只能在ht[1]中进行,ht[1]也可以查询和删除。)

5、将ht[0]释放,然后将ht[1]设置成ht[0],最后为ht[1]分配一个空白哈希表。有安全迭代器可用, 安全迭代器保证, 在迭代起始时, 字典中的所有结点, 都会被迭代到, 即使在迭代过程中对字典有插入操作。

相关知识补充

hash函数

hashtable的性能取决于hash函数的质量,如果hash把key打散的比较均匀,就是一个好函数。redis默认的函数是siphash,不仅打散均匀而且性能还特别快。

hash攻击

hash攻击指的是如果我们的hash函数的打散不均匀的话,存在偏向性。那么黑客就有可能利用这种偏向性对服务器进行攻击,存在偏向性的hash函数在特定模式下的输入会导致hash第二维链表长度即为不均匀,导致查找速率急剧下降,从O(1)到O(n)。有限的服务器计算能力就会被hashtable的查找效率彻底拖垮。

Hash的应用场景

哈希结构相对于字符串序列化缓存信息更加直观,并且在更新操作上更加便捷。所以常常用于用户信息购物车等管理,但是哈希类型和关系型数据库有所不同,哈希类型是稀疏的,而关系型数据库是完全结构化的,关系型数据库可以做复杂的关系查询,而redis去模拟关系型复杂查询开发困难,维护成本高。

这里举一个实例,以用户id为key,商品id为field,商品数量为value,恰好构成了购物车的3个要素,如下图所示。

Set类型

Redis 的集合相当于 Java 语言中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。集合Set类型底层编码包括hashtable和inset。hashtable在上面介绍过了,我们就只介绍inset。

inset的结构

intset底层本质是一个有序的、不重复的、整型的数组、支持不同类型整数。

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

(1)encoding 的值可以是以下三个常量的其中一个 :

#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

(2)length就是数组的实际长度。

(3)contents 数组是实际保存元素的地方,数组中的元素有以下两个特性:

  • 没有重复元素;
  • 元素在数组中从小到大排列;

inset的查询

intset是一个有序集合,查找元素的复杂度为O(logN)(采用二分法),但插入时不一定为O(logN),因为有可能涉及到升级操作。比如当集合里全是int16_t型的整数,这时要插入一个int32_t,那么为了维持集合中数据类型的一致,那么所有的数据都会被转换成int32_t类型,涉及到内存的重新分配,这时插入的复杂度就为O(N)了。是intset不支持降级操作。

补充

这里需要注意的是,这里的说inset是有序不要和我们zset搞混,zset是设置一个score来进行排序,而inset这里只是单纯的对整数进行升序而已。

Set的应用场景

(1)交集,并集,差集。这里如交集可以用来如一个用户对娱乐 、体育比较感兴趣,另一个可能对新闻比较感兴趣,他们就有共同的标签,可以做互相推荐的功能,喜欢体育的人还喜欢娱乐。类似其他的功能都可以抽象一点去想象用法。

(2)随机数。这里可以使用spop/srandmember命令来获取随机数,可以做一个抽奖功能等。

(3)社交需求。类似sadd/sinter命令可以添加你有多少个朋友的共同好友等操作,类似可能认识的人。

(4)大胆发挥你的想象。

Zset类型

Zset有序集合和set集合有着必然的联系,他保留了集合不能有重复成员的特性,但不同的是,有序集合中的元素是可以排序的,但是它和列表的使用索引下标作为排序依据不同的是,它给每个元素设置一个分数,作为排序的依据。 (有序集合中的元素不可以重复,但是csore可以重复,就和一个班里的同学学号不能重复,但考试成绩可以相同)。

简单来说,它类似于 Java 中 SortedSetHashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。

zet的底层编码有两种数据结构,一个ziplist,一个是skiplist。这里因为ziplis也做了排序,所以也要简单再介绍一下。

ziplist排序

我们之前也介绍过了ziplist,底层就是压缩列表。这里我们为了实现排序,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。

压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放在靠近表尾的方向。可以参考示意图如下。

skiplist跳表

关于skiplist比较复杂,这里我只简单介绍一下,具体可以参考这篇文章,可以全面了解这个数据结构。点击跳转

skiplist是与dict结合使用的,结构如下:

/*
 * 跳跃表
 */
typedef struct zskiplist {
    // 头节点,尾节点
    struct zskiplistNode *header, *tail;
    // 节点数量
    unsigned long length;
    // 目前表内节点的最大层数
    int level;
} zskiplist;
/* ZSETs use a specialized version of Skiplists */
/*
 * 跳跃表节点
 */
typedef struct zskiplistNode {
    // member 对象
    robj *obj;
    // 分值
    double score;
    // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 这个层跨越的节点数量
        unsigned int span;
    } level[];
} zskiplistNode;

head和tail分别指向头节点和尾节点,然后每个skiplistNode里面的结构又是分层的(即level数组)。每一列都代表一个节点,保存了member和score,按score从小到大排序。每个节点有不同的层数,这个层数是在生成节点的时候随机生成的数值。每一层都是一个指向后面某个节点的指针。这种结构使得跳跃表可以跨越很多节点来快速访问。(前进可以跳跃式的跳过几个节点,而后退只能后退一个节点,可以看看下面示意图可比较容易理解)。

为什么不使用平衡树,而使用跳跃表?

这里redis的设计者antirez也给出了原因,主要是从内存占用、对范围查找、实现难易程度来考虑的。

(1)在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。

(2)平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。

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

为什么要使用skiplist与dict结合使用呢?

这里需要我们去思考一下,为什么我们要两个混合着使用呢?

其实呢,我们可以分析一下就知道了。首先是我们的跳跃表,可以跨过多个节点进行查询,时间复杂度是O(lgn)左右,但是可以保持有序性。我们的dict是用hashtable实现,因为是哈希,所以查询是O(1)的大小,但是是无序性。

而我们使用两者混合,在进行分数索引的时候查询使用跳跃表,进行数据索引查找的时候,可以使用哈希的O(1)查找。设想如果没有字典, 如果想按数据查分数, 就必须进行遍历O(logn)。两套底层数据结构均只作为索引使用, 即不直接持有数据本身.。数据被封装在SDS中, 由跳跃表与字典共同持有,而数据的分数则由跳跃表结点直接持有(double类型数据), 由字典间接持有。

Zset的应用场景

Zset的使用场景和set很是类似,而且还可以我们上面String做不了的实时排行榜。

(1)实时排行榜

比如我们要做一个一小时热搜,我们可以把当前的时间戳作为zset的key,把帖子ID作为member,点击数评论数作为score,当score发生变化时更新score。然后可以利用zrevrangezrange来来查到对应数量的在时间内的记录。

(2)延时队列

zset会按照score进行排序,如果score代表想要执行时间的时间戳。在某个时间将它插入zset集合中,它便会按照时间戳大小进行排序,也就是对执行时间前后进行排序。

(3)限流

滑动窗口是限流常见的一种策略。如果我们把一个用户的 ID 作为 key 来定义一个 zset ,member 或者 score 都为访问时的时间戳。我们只需统计某个 key 下在指定时间戳区间内的个数,就能得到这个用户滑动窗口内访问频次,与最大通过次数比较,来决定是否允许通过。

(4)发挥你的想象

总结

关于Redis的数据结构,这里介绍的其实也不是特别全。因为redis的一直在更新,而且很多知识点也不是一篇博客可以讲完,比如在redis5.0之后更新了紧凑列表listpack来替代了ziplist,但是因为ziplist应用在数据结构里面范围太大了,不太好更新,所以现在还没有取代,但是它却是比ziplist要好的存在,解决了ziplist存在问题。

所以,关于redis的知识点还是要继续学习,强烈推荐阅读《redis设计与实现》!!!

参考资料

《Redis设计与实现》

《Redis深度历险:核心原理与应用实践》

5种基本数据结构——《JavaGuide》

Redis五大基本数据结构详解

redis五种数据结构讲解及使用场景

Session+Redis实现Session共享

Redis的List的应用场景

redis基础知识

hash类型的应用场景 — — Redis实战经验

读懂才会用:Redis Zset的几种使用场景