Redis数据结构

95 阅读7分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第20天,点击查看活动详情

Redis为什么那么快?

除了redis所有的操作都在内存上之外,还因为实现的数据结构使得redis能让操作更加的高效。

redis数据结构并不是指String、list、hash这些。这些属于数据类型,是数据的保存形式,他们底层的实现方式用到了数据结构。

键值对数据库是怎么实现的?

redis中,key就是字符串对象,value可以是字符串对象也可以是集合数据类型的对象。

键值对是如何保存在redis中的呢?

redis使用哈希表保存所有键值对,因为可以用O(1)复杂度查找键值对,哈希表其实就是数组,数组中的元素叫做哈希桶。

redis的哈希桶是怎么保存键值对数据的呢?

哈希桶存放的是指向键值对数据的指针,键值对数据通过保存了 void * key 和 void * value 指针,指向实际的键对象和值对象,这样就保存集合数据也可以。

  • redisDb 结构,表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针;
  • dict 结构,结构体里存放了 2 个哈希表,正常情况下都是用「哈希表1」,「哈希表2」只有在 rehash 的时候才用,具体什么是 rehash,我在本文的哈希表数据结构会讲;
  • ditctht 结构,表示哈希表的结构,结构里存放了哈希表数组,数组中的每个元素都是指向一个哈希表节点结构(dictEntry)的指针;
  • dictEntry 结构,表示哈希表节点的结构,结构里存放了 **void * key 和 void * value 指针, key 指向的是 String 对象,而 value 则可以指向 String 对象,也可以指向集合类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象

void * key 和 void * value 指针指向的是 Redis 对象,Redis 中的每个对象都由 redisObject 结构表示。

  • type 数据类型list string等等
  • encoding 表示哪种数据结构
  • ptr指向底层数据结构等指针

SDS

自己封装了简单动态字符串,没有使用C语言的char*字符数组来实现。

C语言字符串的缺陷

c语言字符串是一个字符数组,用“\0”结尾,表示字符串的结束。

C语言获取字符串长度的函数strlen,遍历每个字符直到遇到结束符号。复杂度为O(n)

如果中间有结束符号就会停止计数。

而且C语言字符串只能保存文本,不能保存图片音频

而且字符串的操作函数很容易溢出。

可以改进的地方:

  • 获取长度复杂度O(N)
  • 结束符号“\0”不能在中间,所以不能保存二进制数据
  • 操作函数不高兴不安全

SDS结构设计

  • len 记录字符串长度 复杂度O(1)
  • alloc 分配给字符数组的空间长度,通过alloc -len 计算剩余大小,不足则自动扩容到修改所需大小。不需要手动修改,也没有缓冲区溢出问题
  • flags 用来表示不同类型的SDS,5种,sdshdr5 8 16 32 64。区别在于len 和 alloc成员变量的数据类型不同,比如sdshdr32 则都是 uint32_t,表示表示字符数组长度和分配空间大小不能超过 2 的 32 次方。能够灵活保存节省内存空间。
  • buf [] 字符数组,保存实际数据。也可以保存二进制数据

另外,除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了 attribute ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐

这是因为默认情况下,编译器是使用「字节对齐」的方式分配内存,虽然 char 类型只占一个字节,但是由于成员变量里有 int 类型,它占用了 4 个字节,所以在成员变量为 char 类型分配内存时,会分配 4 个字节,其中这多余的 3 个字节是为了字节对齐而分配的,相当于有 3 个字节被浪费掉了。

#include <stdio.h>

struct test1 {
    char a;
    int b;
 } test1;
 
int main() {
     printf("%lu\n", sizeof(test1));
     return 0;
}

如果不想编译器使用字节对齐的方式进行分配内存,可以采用了 attribute ((packed)) 属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。

#include <stdio.h>

struct __attribute__((packed)) test2  {
    char a;
    int b;
 } test2;
 
int main() {
     printf("%lu\n", sizeof(test2));
     return 0;
}

链表

List对象的底层实现之一,redis自己设计了一个,因为C语言没有链表。

链表节点结构设计

listnode

  • prev
  • next
  • value

redis的链表结构是个双向链表

链表结构设计

list

  • head
  • tail
  • len
  • dup 节点复制函数
  • free 节点释放函数
  • match 节点比较函数

链表的优势与缺陷

优点:

  • 获取头尾 复杂度 o(1)因为 head tail
  • 获取前后节点复杂度 o(1)因为next prev
  • 获取节点数量复杂度 o(1) 因为len
  • 使用void *指针保存值,可以保存不同类型的值

缺陷:

  • 每个节点内存不连续,无法利用CPU缓存,数组能很好利用缓存加速
  • 每个节点都需要分配结构,内存开销大

然后因为有这样的缺陷,所以有了压缩列表。内存比较紧凑。后来还有quicklist,listpack

压缩列表

压缩列表结构设计

连续内存块组成的顺序型数据结构

压缩列表表头:

  • zlbytes 整个列表占用的内存字节数
  • zltail 记录为节点距离起始地址多少字节,列表尾的偏移量
  • zllen 记录列表包含的节点数量
  • zlend 列表的结束点,固定值 0xFF(十进制255)

定位第一个元素和最后一个元素复杂度O(1)

查找其他元素复杂度O(n)不能存过多元素

压缩列表节点entry的结构:

  • prevlen 前一个节点节点长度
  • encoding 当前节点实际数据的类型以及长度
  • data 当前节点实际数据

插入数据时,压缩列表会根据数据是字符串还是整数,以及数据的大小使用不同空间大小的prevlen和encoding保存信息。

prevlen:

  • 如果前一个节点的长度小于254字节,prevlen需要1字节空间保存长度值
  • 大于等于254字节,需要5字节保存长度值

encoding:

  • 当前节点整数,使用1字节
  • 字符串,根据长度,1/2/5字节空间进行编码。

压缩列表的连锁更新

插入元素时,如果空间不够导致重新分配,从而导致后续元素的prevlen占用空间变化,最好导致每个元素的空间都要重新分配,造成性能下降。

举例:

如果有多个连续的,长度在250-253之间的节点,

因为小于254,所以prevlen使用1字节保存长度值。

这时插入大于等于254的新节点到列表头,

e1的prevlen就要扩展为5,导致整个e1都长度大于254,最好e2也得修改。这就是连锁更新。

但是如果节点数不多,连锁更新也能接受。