Redis数据结构详解

Redis有五种基础数据类型:字符串(String)、列表(List)、集合(Set)、有序集合(ZSet)和哈希(Hash)。

了解这些基础数据类型底层使用的数据结构,我们就能够更加清楚这些数据类型如何存储数据尽量节省内存,以及为何能够做到高效的增删查改操作。同时,能够更好的了解这些基础数据类型的特性。

下面介绍6种基础的数据结构:简单动态字符串SDS、整数集合IntSet、字典Dict、压缩列表ZipList、快速列表QuickList和跳表SkipList。

简单动态字符串SDS

Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组),而是使用自己的字符串实现SDS。

在Redis中C字符串只会用作字符串字面量(string literal),在一些无需对字符串修改的地方,比如打印日志:

c 复制代码
redisLog(REDIS_WARNING,"Redis is now ready to exit, bye bye...");

当Redis需要一个可以修改的字符串值时,就会使用SDS。比如,在Redis的数据库中,包含字符串的键值对在底层都是使用SDS实现的。

除了用来保存数据库中的字符串外,SDS还被用作缓冲区(buffer):AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区,都是由SDS实现的。

SDS的结构

Redis是用C语言实现的,它的本质是一个结构体,源码如下:

c 复制代码
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* buf已保存的字符串字节数,不包含结束标识 */
    uint8_t alloc; /* buf申请的总的字节数,不包含结束标识 */
    unsigned char flags; /* 不同的SDS头类型,用来控制SDS的头大小 */
    char buf[];
}

这里解释下几个参数:

  • len:用来记录字符串的字节数。这里len的类型为uint8_t,表示是一个占用8bit的无符号整数。那么其可以表示的长度为2的8次方 减1,即255字节。
  • alloc:记录buf数组申请的空间大小。

看到这里我们会发现,buf只能存储255字节的字符串,不够用。其实,Redis还定义了好几种结构:

c 复制代码
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags;
    char buf[];
}

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;
    uint8_t alloc;
    unsigned char flags;
    char buf[];
}

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;
    uint16_t alloc;
    unsigned char flags;
    char buf[];
}

struct __attribute__ ((__packed__)) sdshdr8 {
    uint32_t len;
    uint32_t alloc;
    unsigned char flags;
    char buf[];
}

这样,我们就明白了。根据要保存的字符串大小会选择使用对应的SDS结构体。

flags变量就是表示当前使用的是哪种SDS结构。几种定义如下:

c 复制代码
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

SDS遵循C字符串以空字符串结尾的管理,保存空字符串的1字节空间不计入SDS的len属性里面。这样做的好处是,SDS可以直接复用一部分C字符串函数库里面的函数。

SDS与C字符串的区别

  1. 获取字符串长度的操作高效。 C语言中的字符串并不会记录自己的长度。因此获取长度的时候需要遍历计算,时间复杂度为O(n)。SDS维护了len属性,将时间复杂度减低到了O(1)。
  2. 防止缓冲区溢出。 C字符串不记录自身长度带来的另一个问题就是容易造成缓冲区溢出(buffer overflow)。C语言两个字符串的拼接,需要保证分配了足够的空间,否则会产生缓冲区溢出。而SDS在进行修改之前,会判断是否有足够的空间,不够则先进行扩容再进行修改。
  3. 减少修改字符串带来的内存重分配次数。 C字符串每次修改都要进行内存重分配。而SDS通过空间预分配和惰性空间释放两种策略,减少内存重分配次数。空间预分配 ,分配内存时分配额外的空间。惰性空间释放,当SDS字符串缩短时,不立即回收释放的空间。
  4. 二进制安全。 C字符串必须以空字符结尾,因此只能存储文本数据,而不能存储图片、音频、视频、压缩文件等二进制数据。SDS通过len属性来记录字符串长度,因此可以存储二进制数据。

SDS的扩容公式

当SDS保存的字符串内容需要修改,且修改后的字符串大小大于申请的空间大小。那么SDS就需要扩容,重新申请新的内存空间:

  • 如果新字符串小于1MB,则新空间为扩展后字符串长度的两倍 + 1。
  • 如果新字符串大于等于1MB,则新空间为扩展后字符串长度 + 1MB + 1。

字典 Dict

Redis是一个键值型的数据库,而键与值的映射关系正是通过Dict来实现的。

Redis的字典使用哈希表作为底层实现,每个哈希表里面可以有多个哈希节点,而每个哈希表就保存了字典中的一个键值对。

字典的实现

Dict由三部分组成:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)。

哈希表定义如下:

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

table属性是一个数组,数组中的每个元素都是指向dictEntry的指针。每个dictEntry都保存了一个键值对。

哈希节点定义如下:

c 复制代码
typedef struct dictEntry {
    void *key; // 键
    
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v; // 值
    
    // 下一个entry的指针
    struct dictEntry *next;
} dictEntry;

key保存键值对的键,v保存键值对的值。键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。

next是指向另一个哈希节点的指针,形成链表,解决哈希冲突的问题。

字典定义如下:

c 复制代码
typedef struct dict {
    dictType *type; // dict类型,内置不同的hash函数
    void *privdata; // 私有数据,在做特殊hash运算时使用
    dictht ht[2]; // 一个Dict包含两个哈希表,其中一个是当前数据,另一个一般是空,rehash时使用
    long rshashidx; // rehash的进度,-1表示未进行。
}

type属性是一个指向dictType的指针。每个dictType保存一簇用于操作特定类型键值对的函数。Redis会为用途不用的字典设置不同的类型特定函数。

ht属性是一个包含两个项的数组。一般情况下,字典只会使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]进行rehash时使用。

rehashidx记录当前hash进度,如果当前没有进行rehash,值为-1。

数据存放步骤

当要往Dict添加键值对时,Redis首先会根据key计算出hash值(h),然后利用h&sizemask计算出元素应该存储到数组的索引位置。

Redis保证size的数量一定为2的n次方。因此h&sizemask的操作等价于h跟size取余的操作,具体原理这里不展开介绍。

当发生哈希冲突时,通过链地址法(separate chaining)解决哈希冲突。

哈希表的扩展和收缩

Dict就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,那么查询效率就会降低。

Dict在每次新增键值对时都会检查负载因子LoadFactor,以下两种情况都会触发哈希表扩容:

  • 哈希表的LoadFactor >= 1,并且服务器没有执行BGSAVE或者BGREWRITEOF等后台进程。
  • 哈希表的LoadFactor >= 5。

根据BGSAVE或BGWRITEOF命令是否正在执行,哈希表扩展所需的负载因子不同。这是因为在执行BGSAVE或BGWRITEOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy on write)技术来优化子进程的使用效率。所以在子进程存在期间,服务器会提高执行操作所需的负载因子,从而尽量避免在子进程存在期间执行哈希表扩展操作,避免不必要的内存写入操作,最大限度的节省内存。

每次删除元素时,也会对负载因子做检查,当LoadFactor < 0.1时,会做哈希表收缩。

rehash

不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key计算新的索引,插入到新的哈希表,这个过程叫做rehash。

同时,Dict的rehash是分多次、渐进式的完成,因此也叫做渐进式rehash。这是因为若Dict中存在数百万的entry,若要一次rehash完成,极有可能导致主线程阻塞。

过程如下:

  1. 计算新hash表的realSize,值取决于当前操作是扩容还是收缩。如果是扩容,那么新的size为第一个大于等于dict.ht[0].used + 1的 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 n 2^n </math>2n;如果是收缩,则新size为第一个大于等于dict.ht[0].used的 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 n 2^n </math>2n(不得小于4)。
  2. 按照新的realSize申请内存空间,创建dictht,并赋值给dict.ht[1]。
  3. 设置dict.rehashidx = 0,标识开始rehash。
  4. 每次执行增删查改操作时,都检查一下dict.rehash是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehsh到dict.ht[1],并将rehasidx++。直到dict.ht[0]的所有数据都rehash到dict.ht[1]。
  5. 将dict.ht[1]赋值给dict.h[0],将dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内容。
  6. 将rehashidx赋值为-1,表示rehash结束。

在rehash过程中,新增操作,直接写入ht[1];查询、修改和删除操作则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只增不减。

跳表 SkipList

跳表是一种有序数据结构,通过在节点中维护多个指向其他节点的指针,从而达到快速访问节点的目的。

跳表实现

跳表由zskiplistNode和zskiplist两个结构定义,其中zskiplistNode结构表示跳表节点,而zskiplist结构用于保存跳表的节点的相关信息,如节点的数量,以及指向表头和表尾节点的指针等。

跳表节点定义如下:

c 复制代码
typedef struct zskiplistNode {
    // 值
    sds ele;
    // 分值
    double score;
    // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned long span;
    } level[];
} zskiplistNode;

参数介绍:

  • 层。跳表节点的level数组,保存了指向其他节点的指针,程序可以通过这些节点提高访问其他节点的速度。每次创建跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个1和32之间的值作为level数组的大小,这个大小就是层的"高度"。
  • 前进指针。指向下一个节点的指针,用来从表头到表尾方向遍历。
  • 跨度。记录两个节点之间的距离。
  • 后退指针。用于从表尾到表头方向遍历。
  • 分值。跳跃表中的所有节点都按分值从小到大排序。若分值相同,则按照字典序排序。

跳表定义如下:

c 复制代码
typedef struct zskiplist {
    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;
    // 节点数量
    unsigned long length;
    // 层数最大的节点的层数
    int level;
} zskiplist;

header和tail节点分别指向表头和表尾节点。length记录节点数量。level记录表中层高最大的那个节点的数量,表头节点的层高不计算在内。

整数集合 IntSet

IntSet是Redis中Set集合的底层实现之一。当一个集合只包含整数值元素,并且这个集合的元素不多时,Redis就会使用整数集合作为Set集合的实现。

IntSet的实现

结构如下:

c 复制代码
typedef struct intset {
    uint32_t encoding; /* 编码方式,支持存放16位、32位、64整数 */
    uint32_t length; /* 元素个数 */
    int8_t contents[]; /* 整数数组,保存集合数据 */
}

contents数组是整数集合的底层实现:用来存放元素。元素在数组是有序的,并且不会重复。虽然intset结构将从contents声明为int8_t类型的数组,但实际上content数组并不存放任何int8_t类型的值,contents数组的真正类型由encoding决定。

encoding是用来指定contents数组中存放元素的大小的,有三种模式:

c 复制代码
#define INTSET_ENC_INT16 (sizeof(int16_t)) /* 2字节整数 */
#define INTSET_ENC_INT32 (sizeof(int32_t)) /* 4字节整数 */
#define INTSET_ENC_INT64 (sizeof(int64_t)) /* 8字节整数 */

IntSet升级

现假设有一个IntSet,里面存储了元素为 {5,10,20},采用的编码是INTSET_ENC_INT16。

我们要添加一个元素50000,这个数字超过了int16_t能表示的范围,那么IntSet会自动升级编码到合适的大小。

具体流程如下:

  1. 升级编码到INTSET_ENC_INT32,并按照新的编码和元素数量扩容数组。(数组中所有元素的编码都要升级)
  2. 倒序将数组中的元素拷贝到扩容后的正确位置。(倒序是因为拷贝元素时不会导致其他元素的值被覆盖)
  3. 将待添加的元素放入数组末尾。

升级之后新元素的摆放位置:

引发升级的新元素要么比所有元素都大,要么比所有元素都小。新元素大于所有元素,新元素会被放到底层数组的最开头。新元素小于所有元素,新元素放到底层数组的末尾。

升级的好处:

  • 提升灵活性。C语言是静态语言,为了避免类型错误,我们通常不会把两种不同类型的值放到同一个数据结里面。整数集合可以通过升级来适应元素,因此我们可以随意的把int16_t、int32_t或int64_t类型的整数添加到集合中,并不用担心类型错误,这种做法很灵活。
  • 节约内存。整数集合的升级机制使得可以同时保存3种不同类型的值,并且只在有需要的时候才进行升级,这样可以节省内存。

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

压缩列表 ZipList

压缩列表是Redis为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。

压缩列表的构成

属性 类型 长度 用途
zlbytes uint32_t 4字节 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算zlend的位置时使用。
zltail uint32_t 4字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节;通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。
zllen uint16_t 2字节 记录了压缩列表包含的节点数量:当这个属性的值小于UINT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量。当这个值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算出来。
entryX 列表节点 不定 压缩列表包含的节点,节点的长度由节点保存的内容决定。
zlend uint8_t 1字节 特殊值0xFF(十进制255),用于标记压缩列表的末端。

压缩列表节点的构成

  • previous_entry_length,记录前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节。如果前一个节点的长度小于254字节,那么用1字节来记录;如果前一个节点的长度大于等于254字节,则使用5字节记录,其中第一个字节固定为0xFE,后四个字节才是真实长度。
  • encoding,记录节点的content属性所保存数据的类型和长度。一字节、两字节或者五字节长,值的最高位为00、01、10的是字节数组编码,表示节点的content保存的是字节数组,长度为编码除去最高两位之后的其他位记录。;1字节长,值的最高位为11的是整数编码,表示content属性保存的是整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。

连锁更新问题

前面讲到每个节点的previous_entry_length保存了前一个节点的长度。

先考虑一种情况,在一个压缩列表中有多个连续的,且长度在250字节到253字节之间的节点e1 - en。这些节点的长度都小于254字节,因此占用1字节空间。接着,节点e1的内容修改后长度为255字节。那么e2节点的previous_entry_length需要从原来的1字节到占用5字节,那么e2节点的长度又会大于等于254字节,又会导致后续节点的更新。

Redis将这种在特殊情况下产生的连续多次空间扩展操作称为连锁更新(cascade update)。

除了添加新节点可能导致连锁更新外,删除节点也可能导致连锁更新。

连锁更新在最坏情况需要对压缩列表执行N次空间重分配操作,每次空间重分配的最坏复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n),所以连锁更新的最坏复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)。

需要注意的是,尽管连锁更新的复杂度较高,但它发生的几率是很低的:

  • 压缩列表要恰好有多个连续的、长度介于250-253之间的节点才可能触发连锁更新。在实际中,是很少见的。
  • 即使触发了连锁更新,只要被更新的节点不多,就不会对性能造成影响。

对象

Redis中的每一个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性、enccoding属性和ptr属性。

c 复制代码
typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 指向底层实现数据结构的指针
    void *ptr;
    
    // ...
}

type属性记录对象的类型。 这个属性的值可以是下表中常量的一个:

类型常量 对象的名称
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合对象

对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。

encoding属性的值可以是下表中常量的一个:

编码常量 编码所对应的底层数据结构
REDIS_ENCODING_INT long类型的整数
REDIS_ENCODING_EMBSTR embstr编码的简单动态字符串
REDIS_ENCODING_RAW 简单动态字符串
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 双端链表
REDIS_ENCODING_ZIPLIST 压缩列表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳跃表和字典
相关推荐
2401_882727576 小时前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
追逐时光者6 小时前
.NET 在 Visual Studio 中的高效编程技巧集
后端·.net·visual studio
04Koi.7 小时前
Redis--常用数据结构和编码方式
数据库·redis·缓存
大梦百万秋7 小时前
Spring Boot实战:构建一个简单的RESTful API
spring boot·后端·restful
斌斌_____7 小时前
Spring Boot 配置文件的加载顺序
java·spring boot·后端
路在脚下@8 小时前
Spring如何处理循环依赖
java·后端·spring
海绵波波1078 小时前
flask后端开发(1):第一个Flask项目
后端·python·flask
weisian1519 小时前
Redis篇--常见问题篇8--缓存一致性3(注解式缓存Spring Cache)
redis·spring·缓存
HEU_firejef9 小时前
Redis——缓存预热+缓存雪崩+缓存击穿+缓存穿透
数据库·redis·缓存
小奏技术9 小时前
RocketMQ结合源码告诉你消息量大为啥不需要手动压缩消息
后端·消息队列