Redis从0开始系列(一)—数据结构

120 阅读8分钟

一、前言

众所周知,redis中常用的数据结构有如下五种:

  • string
  • list
  • hash
  • set
  • zset

除此之外,跳表(skiplist)相信大家也不陌生,那么大家有没有想过跳表为什么不在上述的五种数据结构范围之内呢?

其实这里有一个称呼上的误区,我们一般称呼redis中的数据结构,其实是对应于redis中的对象(Object),也就是上文所述的五种对象的类型。在redis中每种对象类型至少对应了一种编码方式

image-20230522181528658

从图中可以看到,跳表其实是一种编码方式,一个zset对象的的编码方式可能由跳表或者压缩列表两种编码方式实现。

二、编码方式

常用的编码方式有以下几种:

  • 字符串
  • 链表
  • 字典
  • 整数集合
  • 跳跃表
  • 压缩列表

部分对象与编码方式的对应关系

image-20230522184118864image-20230522184118864

2.1 字符串

2.1.1 数据结构

redis没有使用C语音中自带的字符串数据结构,而是自定了一个字符串类型SDS

在 Redis 3.2 版本之后,SDS 由一种数据结构变成了 5 种数据结构,如下源码截图:

img

5 种数据结构存储不同长度的内容,Redis 会根据 SDS 存储的内容长度来选择不同的结构,源码实现对应 sds.c/sdsReqType,截图如下:

img

为了对 SDS 有一个更好的体感,这里以 sdshdr8 为例,执行指令:SET name Redis

img

执行上述 set 指令后,值对象对应的 SDS 结构如下图:

img

SDS 各个属性说明:

  • len:表示 buf 已用空间的长度,占 4 个字节,不包括 '\0';
  • alloc:表示 buf 的实际分配长度,占 4 个字节,不包括 '\0';
  • flags:标记当前字节数组是 sdshdr8/16/32/64 中的哪一种,占 1 个字节;
  • buf:表示字节数组,保存实际数据。为了表示字节数组的结束,Redis 会自动在数组最后加一个'\0',需要额外占用 1 个字节的开销;

从上面 SDS 的结构可以看出,SDS 依然遵循了 C语言中字符串以 \0 结尾的规则, 但是,\0占用的1 个字节空间并没有计算在 SDS 的 len 属性里面。

2.1.2 SDS与C字符串的比较

  • 获取字符串长度复杂度

C字符串不记录长度,获取长度必须遍历整个字符串,复杂度为O(N),SDS 在 len 属性中记录了 SDS 本身的长度, 获取 SDS 长度的复杂度为 O(1) ;

  • 缓冲区溢出

C字符串不记录自身的长度,每次增长或缩短一个字符串,都要对底层的字符数组进行一次内存重分配操作。如果在 append 操作之前没有通过内存重分配来扩展底层数据的空间大小,就会产生缓存区溢出;如果进行 trim 操作之后没有通过内存重分配来释放不再使用的空间,就会产生内存泄漏;

SDS 通过未使用空间解除了字符串长度和底层数据长度的关联,3.0版本用 free属性记录未使用空间,3.2版本用 alloc属性记录总的分配字节数量。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化的空间分配策略,解决了字符串拼接和截取的空间问题;

  • 二进制安全

C 字符串以 \0结尾(即 以 \0判断字符串结束),所以在 C字符串的内容里面不能包含 \0,否则会被认为是字符串结尾,因此,C字符串只能保存文本数据,不能保存像图片这样的二进制数据;

而 SDS 的 API 会以处理二进制的方式来处理存放在 bu f数组里的数据,不会对里面的数据做任何的限制。SDS 使用 len 属性来判断字符串是否结束,而不是空字符。

2.2 链表

2.2.1 数据结构

链表作为一种最常用的数据结构,在各种语言及数据结构中都会用到,一个链表的数据结构如下:

typedef struct list {
​
    // 表头节点
    listNode *head;
​
    // 表尾节点
    listNode *tail;
​
    // 链表所包含的节点数量
    unsigned long len;
​
    // 节点值复制函数
    void *(*dup)(void *ptr);
​
    // 节点值释放函数
    void (*free)(void *ptr);
​
    // 节点值对比函数
    int (*match)(void *ptr, void *key);
​
} list;

一个完整的链表包含以下内容:

  • 数据结构为 listNode的表头、表尾
  • 长度
  • 还提供了一些内置方法

其中 listNode的数据结构如下:

typedef struct listNode {
​
    // 前置节点
    struct listNode *prev;
​
    // 后置节点
    struct listNode *next;
​
    // 节点的值
    void *value;
​
} listNode;

可以看到:

  • 该链表节点为双端链表,即可以向前向后搜寻
  • 每个节点都包含对应的值

image-20230801164801295

2.3 字典

2.3.1 数据结构

字典同样为最常用的数据结构之一,主要通过 key-value的形式进行数据存储及查询。

字典在redis中也是最常用的编码方式之一:

redis> SET msg "hello world"
OK

此时我们在redis中即设置了一个键值对,key为 msg ,value为 hello world

字典的数据结构与java中的hashmap数据结构很像,都是使用数组+链表的形式进行存储:

typedef struct dictht {
​
    // 哈希表数组
    dictEntry **table;
​
    // 哈希表数组大小
    unsigned long size;
​
    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;
​
    // 该哈希表已有节点的数量
    unsigned long used;
​
} dictht;

其中:

  • table数组存储了所有的键值对,每个键值对的数据结构为 dictEntry
  • 其他描述该字典的数据

image-20230801171638248

dictEntry的数据结构为:

typedef struct dictEntry {
​
    // 键
    void *key;
​
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
​
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
​
} dictEntry;

每个键值对节点主要包含以下数据:

  • key保存键值对中的键
  • v用于保存键值对中的值
  • next指针用于指向下一个节点,即两个相同的索引

image-20230801171840775

2.3.2 解决hash冲突

当两个不同的key被hash后指向了同一个索引值,此时会发生hash冲突,解决冲突的办法有很多种,Redis使用拉链法解决此问题。

即每个节点都有一个next指针,当添加数据时发现该索引下不为空,则将该节点数据链接至列表末尾。

image-20230801172151549

2.4 跳表

跳表也是一种的数据结构,相比于普通的链表,有着查询较快的优点。

当使用链表查询时,查询复杂度为O(N)

在这里插入图片描述

例如当从1查询到7时,需要查询6次

而当使用跳表时,通过建立多层索引的方式:

在这里插入图片描述

只需要查询4次即可。

可以看到,跳表使用了二分法的思想使用多级索引加快查询速率

2.5 压缩列表

压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

但是,压缩列表的缺陷也是有的:

  • 不能保存过多的元素,否则查询效率就会降低;
  • 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。

因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。

接下来,就跟大家详细聊下压缩列表。

压缩列表结构设计:

img

压缩列表在表头有三个字段:

  • zlbytes,记录整个压缩列表占用对内存字节数;
  • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen,记录压缩列表包含的节点数量;
  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素

另外,压缩列表节点(entry)的构成如下:

image-20230911152712756

三、对象

Redis有那几种常用的数据结构?

  • string
  • list
  • hash
  • set
  • zset

这其实是一个烂大街的问题,然而这个问题的真正要问的其实应该是:

Redis有哪几种对象

3.1 对象与编码方式

对象与编码方式的关系很简单,像对于Java来说:

我们常用的String对象,其底层实现其实是char[]数组。

因此对象与编码方式的关系是,对象提供给用户直接使用,用户直接交互的是对象;而编码方式对用户来说是不可见的,是属于底层的存储方式,并且存储方式因为因为对象的值不同而做出改变。

image-20230522181528658

例如zset这个对象的编码方式就可以有 跳表或者压缩列表两种方式

3.2 对象与编码方式的对应关系

字符串对象的编码可以是 intraw 或者 embstr

列表对象的编码可以是 ziplist 或者 linkedlist

哈希对象的编码可以是 ziplist 或者 hashtable

集合对象的编码可以是 intset 或者 hashtable

有序集合的编码可以是 ziplist 或者 skiplist