【Redis】底层数据结构

SDS

动态字符串,具备动态扩容能力。

  • 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
  • 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1,成为内存预分配。
struct __attribute__((__packed__))sdshdr8 {
    uint8_t len; /* 已使用长度,用1字节存储 */
    uint8_t alloc; /* 总长度,用1字节存储*/
    unsigned char flags; /* 低3位存储类型,高5位预留 */
    char buf[]; /*柔性数组,存放实际内容*/
};
struct __attribute__((__packed__))sdshdr16 {
    uint16_t len; /*已使用长度,用2字节存储*/
    uint16_t alloc; /* 总长度,用2字节存储*/
    unsigned char flags; /* 低3位存储类型,高5位预留 */
    char buf[]; /*柔性数组,存放实际内容*/
};
struct __attribute__((__packed__))sdshdr32 {
    uint32_t len; /*已使用长度,用4字节存储*/
    uint32_t alloc; /* 总长度,用4字节存储*/
    unsigned char flags; /* 低3位存储类型,高5位预留 */
    char buf[]; /*柔性数组,存放实际内容*/
};
struct __attribute__((__packed__))sdshdr64 {
    uint64_t len; /*已使用长度,用8字节存储*/
    uint64_t alloc; /* 总长度,用8字节存储*/
    unsigned char flags; /* 低3位存储类型,高5位预留 */
    char buf[]; /*柔性数组,存放实际内容*/
};

为什么不直接使用C语义中的字符串。

  • 获取字符串长度需要通过运算
  • 非二进制安全
  • 不可修改

intSet

数据结构

是redis中set集合的一种实现方式,基于整数数组来实现,具备长度可变、有序等特征。

结构如下:

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

} intset;
encoding

编码类型,决定每个元素占用几个字节。有如下3种类型。

  • INTSET_ENC_INT16:当元素值都位于INT16_MIN和INT16_MAX之间时使用。该编码方式为每个元素占用2个字节。类似于Java中的short类型

  • INTSET_ENC_INT32:当元素值位于INT16_MAX到INT32_MAX或者INT32_MIN到INT16_MIN之间时使用。该编码方式为每个元素占用4个字节。类似于Java中的int类型

  • INTSET_ENC_INT64:当元素值位于INT32_MAX到INT64_MAX或者INT64_MIN到INT32_MIN之间时使用。该编码方式为每个元素占用8个字节。类似于Java中的long类型

encoding字段在Redis中使用宏来表示,其定义如下:

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

每种编码类型实际的值如表所示。

INTSET_ENC_INT162
INTSET_ENC_INT324
INTSET_ENC_INT648
length

元素个数。即一个intset中包括多少个元素。

contents

存储具体元素。根据encoding字段决定多少个字节表示一个元素。

举个例子:
在这里插入图片描述

encoding字段为2,代表INTSET_ENC_INT16。length字段为4,代表该intset中有4个元素。根据encoding字段,每个元素分别占用两个字节,并且按从小到大的顺序排列,依次为-6、-1、1和2。

intset结构体本身占用8字节,4个元素按INTSET_ENC_INT16编码,每个占用2字节,8+4×2=16,正好是16个字节。

升级过程

现在假设intset中存放的数据为{5,10,20},如下图所示,以一个单位距离表示一个字节,当前存储的encoding为INTSET_ENC_INT16
在这里插入图片描述

现在添加一个数字:50000,这个数字超过了int16的存储范围了,因此,intset需要升级为INTSET_ENC_INT32编码方式。

升级流程如下:

① 升级编码为INTSET_ENC_INT32,每个整数占4个字节,并按照新的编码方式及元素个数进行扩容

② 倒序依次将数组中的元素拷贝到扩容后的正确位置

③ 将代添加到元素放入数组末尾

④ 最后,将intset的encoding改为INTSET_ENC_INT32,length改为4。

最后呈现的结果:
在这里插入图片描述

总结:

① inset中元素唯一、有序

② 具备类型升级机制、节省内存空间

③ 底层采用二分查找

QA
  1. 为什么要统一contents中元素的大小?

为了寻址方便,startPtr + (sizeof(int16) * index),这样只要知道了数据的脚标,就可以在O(1)的时间内获取对应的数据内容。

  1. 升级过程中为什么从后往前升级?

这样升级过程中,避免数据被覆盖丢失。

Dict

数据结构

redis是一个键值对数据库,底层的实现时Dict。Dict由三部分组成:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

typedef struct dictht {
    dictEntry **table;            /*指针数组,用于存储键值对*/
    unsigned long size;            /*table数组的大小*/
    unsigned long sizemask;        /*掩码 = size -1 */
    unsigned long used;            /*table数组已存元素个数,包含next单链表的数据*/
} dictht;
typedef struct dictEntry {
    void *key;                      /*存储键*/
    union {
         void *val;                  /*db.dict中的val*/
         uint64_t u64;
         int64_t s64;                /*db.expires中存储过期时间*/
         double d;
    } v;                            /*值,是个联合体*/
    struct dictEntry *next;        /*当Hash冲突时,指向冲突的元素,形成单链表*/
} dictEntry;
typedef struct dict {
    dictType *type;           /*内置不同的hash函数*/
    void *privdata;           /*私有数据,在做特殊的hash运算时使用*/
    dictht ht[2];              /*Hash表,键值对存储在此,其中一个是当前数据,另一个一般是空的,在rehash时使用*/
    long rehashidx;            /*rehash标识。默认值为-1,代表没进行rehash操作;不为-1时,
                                代表正进行rehash操作,存储的值表示Hash表ht[0]的rehash操
                                作进行到了哪个索引值*/
    int16_t pauserehash;      /* rehash是否暂停,1表示暂停;0则继续*/
} dict;
Dict扩容&缩容

Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下条件则出发扩容:

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

Dict在每次删除键值对时,也会对负载因子做检查,当LoadFactor<0.1时,会对哈希表做缩容。

Dict的rehash

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

① 计算新hash表的realesize,值取决于当前要做扩容还是缩容:

  • 扩容,则新的size为第一个大于等于dict.ht[0].used + 1的2n
  • 缩容,则新的size为第一个大于等于dict.ht[0].used的2n (不得小于4)

② 按照新的realesize申请内存空间,创建ditcht,并复制给dict.ht[1]

③ 设置dict.rehashidx = 0,标示开始rehash。

④ 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]

⑤ 将dict.ht[1]复制给dict.ht[0],给dict.ht[1]初始化为空的哈希表,释放原来dict.ht[0]的内存空间。

⑥ 设置dict.rehashidx = -1

Dict的渐进式rehash

Dict的rehash并不是一次性完成的,试想一下,如果dict中包含百万级的entry时,通过一次rehash完成,极有可能导致主线程阻塞。因此Dict分成多次、渐进式的完成。因此,被称为渐进式rehash。流程如下:

① 计算新hash表的realesize,值取决于当前要做扩容还是缩容:

  • 扩容,则新的size为第一个大于等于dict.ht[0].used + 1的2n
  • 缩容,则新的size为第一个大于等于dict.ht[0].used的2n (不得小于4)

② 按照新的realesize申请内存空间,创建ditcht,并复制给dict.ht[1]

③ 设置dict.rehashidx = 0,标示开始rehash。

④ 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]

④ 每次新增、查询、修改、删除操作时,都判断一下dict.rehashinx是否大于-1,如果是,则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并将rehashed++,直到将dict.ht[0]中的所有数据都rehash到dict.ht[1]中。

⑤ 将dict.ht[1]复制给dict.ht[0],给dict.ht[1]初始化为空的哈希表,释放原来dict.ht[0]的内存空间。

⑥ 设置dict.rehashidx = -1

需要特别说明的:对于新增操作,仅是在dict.ht[1]中进行,对于查询、修改、删除操作,则会在dict.ht[0]和dict.ht[1]中依次查找并执行。这样确保dict.ht[0]中的数据只减不增,随着rehash最终为空。

总结:

Dict结构:

  • 类似java的HashTable,底层通过数据+链表的方式解决哈希冲突
  • Dict包含两个哈希表,ht[0]常用,ht[1]用来rehash

Dict伸缩:

  • 当LoadFactor>5或者LoadFactor>=1且没有子任务执行时,Dict扩容
  • 当LoadFactor<0.1时,Dict缩容
  • 扩容大小为第一个大于used+1的2n
  • 缩容大小为第一个大于used的2n
  • Dict采用渐进式rehash,每次访问Dict进行一次rehash
  • rehash时ht[0]只减不增,新增操作只在ht[1]执行,其他操作在两个哈希表上执行。

ZipList

数据结构
zipList

zipList是一种特殊的“双端链表”,由一系列特殊编码的连续内存组成。可以在任意一端进行压入/弹出操作,时间复杂度O(1)。
在这里插入图片描述

属性类型长度用途
zlbytesuint32_t4字节记录整个压缩列表占用的内存字节数
zltailuint32_t4字节记录压缩列表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址
zllenuint16_t2字节记录压缩列表包含的节点数量。最大为UNIT16_MAX(65534),如超过会记录65535,但是真实节点需要遍历整个压缩链表才能得出
Entry列表节点压缩链表的各个节点,节点的长度由节点保存的内容决定
zlenduint8_t1字节特殊值0xff,用于标记压缩列表的末端
zipListEntry

ZipList中的entry并不像普通链表那样记录前后节点的指针,因为记录前后节点的指针要占用16个字节,避免浪费。而采用了下面的数据结构:
在这里插入图片描述

  • previous_entry_length: 前一个节点的长度,占用1个字节或者5个字节
    • 如果前一节点的长度小于254个字节,则用1个字节保存这个长度值
    • 如果前一节点的长度大于等于254个字节,则用5个字节保存这个长度值,第一个字节为0xfe,后面4个字节才是真实长度数据。
  • encoding: 编码属性,记录content的数据类型(字符串还是整数)及长度,占用1个、2个或者5个字节
  • content:负责保存节点的数据,可以是字符串或者整数

zipListEntry的编码分为字符串和整数两种:

  • 字符串:如果encoding是以“00”、“01”,“10”开头,则证明content是字符串
    在这里插入图片描述
    如我们要保存“ab”、“bc”,结构如下:

a的ASCII码为97(0110 0001),b的ASCII码为98(0110 0010),c的ASCII码为98(0110 0011)
在这里插入图片描述

  • 整数:如果encoding是以“11”开头的,则证明content是整数,且encoding固定占用1个字节
    在这里插入图片描述

如我们要保存两个整数值:“2”、“5”
在这里插入图片描述

ZipList连锁更新问题

ZipList的每个entry包含previous_entry_length来记录上一个节点的大小,长度为1个或者5个字节,假设连续N个entry的长度在250~253字节之间的entry,因此entry的previous_entry_length都用一个字节可以表示,当其中一个entry的长度超过了254,则导致边entry的previous_entry_length需要使用5个字节,进而引起该entry的总长度超过了254,连锁反应,连续的entry都会超过254,都需要更新,称之为连锁更新。

总结:

  1. 压缩链表可以看作一种连续内存空间的"双向链表"
  2. 列表节点之间不是通过指针而是通过记录上一个节点和本节点的长度来寻址,内存占用较低
  3. 如果列表数据过多,导致链表过长,可能会影响查询性能
  4. 增或删较大数据时有可能会发生连续更新问题

QuickList

数据结构
typedef struct quicklist {
    quicklistNode *head;     // 头节点指针
    quicklistNode *tail;     // 尾节点指针
    unsigned long count;       /* 所有zipList的entry的数量 */
    unsigned long len;        // zipList总数量
    int fill : QL_FILL_BITS;            // zipList的entry上限,默认值为-2
    unsigned int compress : QL_COMP_BITS; // 首尾不压缩节点数量
} quicklist;
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;                /* ziplist size in bytes */
    unsigned int count : 16;      /* count of items in ziplist */
    unsigned int encoding : 2;    /* RAW==1 or LZF==2 */
    unsigned int container : 2;   /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10;      /* more bits to steal for future usage */
} quicklistNode;

为了避免QuickList中每个zipList中的entry过多,Redis中提供了一个配置项:list-compress-depth来限制。

  • 如果为正值,则代表限制zipList中entry的个数的最大值
  • 如果为负数,则代表限制zipList内存的大小,分5中情况:
    • -1:每个ZipList的内存占用不能超过4kb
    • -2:每个ZipList的内存占用不能超过8kb,为默认值
    • -3:每个ZipList的内存占用不能超过16kb
    • -4:每个ZipList的内存占用不能超过32kb
    • -5:每个ZipList的内存占用不能超过64kb

除了控制ZipList大小外,QuickList还可以对节点的ZipList进行压缩。通过配置项list-compress-depth来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的,这个参数是控制首尾不压缩的节点个数:

  • 0:特殊值,代表不压缩
  • 1:标示QuickList的首尾各有1个节点不压缩,中间节点压缩
  • 2:标示QuickList的首尾各有2个节点不压缩,中间节点压缩
  • 以此类推
    在这里插入图片描述
    总结:
  1. 是一个节点为ziplist的双端链表
  2. 节点采用ZipList,解决了传统链表的内存占用问题
  3. 控制了ZipList大小,解决了连续内存空间申请效率问题
  4. 中间节点可以压缩,进一步节省内存

SkipList

skipList(跳表)与传统链表相比,差异点:

  • 元素按照升序排列存储
  • 节点可能包含多个指针,指针跨度不同
数据结构
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;   // 头尾节点指针
    unsigned long length;                   // 节点数量
    int level;                              // 最大的索引层级,默认是1
} zskiplist;
typedef struct zskiplistNode {
    sds ele;  // 节点存储的值
    double score;   // 节点分数、排序、查找用
    struct zskiplistNode *backward; // 前一个节点指针
    struct zskiplistLevel { 
        struct zskiplistNode *forward;  // 下一个节点指针
        unsigned int span;  // 索引跨度
    } level[]; // 多级索引数组
} zskiplistNode;

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值