Redis | 青训营笔记

98 阅读8分钟

这是我参与「第五届青训营」伴学笔记创作活动的第14天

今天这篇文章主要是对课上介绍的数据结构和数据类型的补充

数据结构

SDS

c语言字符串存在的问题

  • 获取长度的时间复杂度为O(n)
  • 非二进制安全,不能包含'\0'
  • 不可修改,通过指针定义的字符串实际上是常量区的一个地址,不能通过操作指针来修改

Simple Dynamic String

包括sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64

//3.0之前
struct sdshdr {
    unsigned int len; /*已保存的字节数,不包含结束符*/
    unsigned int free; /*空闲空间*/
    char buf[];
};

//3.0之后
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* 已保存的字节数,不包含结束符*/
    uint16_t alloc; /* 已申请的字节数,不包含结束符*/
    unsigned char flags; /* 不同sds的头类型,用来控制sds的头大小*/
    char buf[];
};

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

扩容机制

如果对sds进行追加:新字符串长度小于1M,新空间为扩展后字符串长度的两倍+1,即len=newLength,alloc=2*newLength;新字符串长度大于1M,新空间为扩展后字符串长度+1M+1,即len=newLength,alloc=newLength+1M

简单来说:小于1M翻倍扩容,大于1M直接+1M

最大长度为512MB

sds优点

  • 获取长度时间复杂度O(1)
  • 支持动态扩容
  • 二进制安全
  • 减少内存分配次数

IntSet

集合的一种实现方式,基于整数数组,并且具备长度可变、有序等特征

typedef struct intset{
    uint32_t encoding; /* 编码方式,支持存放16位,32位,64位整数*/
    uint32_t length;   /* 元素个数*/
    int8_t contents[]; /* 数组*/
} intset;

插入元素

  1. 判断是否超出编码的表示范围,如果是,进入升级分支,否则进行2
  2. 通过二分法搜索当前元素是否在数组中已存在,在搜索过程中还会更新插入元素应处的position
  3. 数组扩容
  4. 将position之后的元素倒序copy
  5. 放置插入元素
  6. 修改属性

升级机制

当插入数据超出现有编码的表示范围时,会进行升级

  1. 升级编码,按照新的编码和元素个数进行扩容
  2. 数组倒序copy,防止正序copy时覆盖原有元素
  3. 将待添加的元素放入数组头或尾
  4. 修改encoding属性,修改length属性

Dict

由哈希表(DictHashTable),哈希节点(DictEntry),字典组成(Dict)

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

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

typedef struct dict{
    dictType *type; //dict类型,内置不同的hash函数
    void *privdata; //私有数据,在做特殊hash运算时使用
    dictht ht[2];   //一个存储当前数据,另一个为空,rehash时使用
    long rehashidx; //rehash的进度,-1表示未进行
    int16_t pauserehas; //rehash是否暂停,1则暂停,0则继续
} dict;

插入元素

首先对key进行hash运算,将得到的运算结果再与sizemask做&运算,得到entry的index(当size=2^n时,运算结果与hash做&运算等价于取模运算),当产生哈希冲突时,数组中的元素会替换为新插入的元素,将之前元素的地址赋给新插入元素的next指针

LoadFactor

used/size,即哈希表已保存节点的个数/哈希表的大小

扩容

  • LoadFactor >= 1,并且服务器没有执行BGSAVE或者BGREWRITEAOF
  • LoadFactor >= 5

当满足这两种情况时,Dict会进行扩容,调用dictExpand(dict,used+1)

收缩

当 LoadFactor < 0.1 且 size > 4 时,会进行收缩,调用dictExpand(dict,used)

dictExpand

这个函数有两个参数,一个是dict,另一个是size

它会找出第一个大于size的2^n,设为realsize,之后分配realsize大小的空间作为新的哈希表,将dict[1]置为新的哈希表,再设rehashidx为0

rehash

由于扩容/收缩之后哈希表size的改变,每个entry都需要重新映射,放置到正确位置上,这个过程就叫做rehash,如果数据量过多,我们想要一次执行完rehash会阻塞Redis,因此就有了渐进式rehash。

  1. 计算新的hash表的realsize

    1. 如果是扩容,realsize为大于等于used+1的最小2^n
    2. 如果是收缩,realsize为大于等于used的最小2^n
  2. 按照新的realsize申请空间,创建dictht,并赋值给dict.h[1]

  3. 设置dict.rehashidx=0,标识开始rehash

  4. 每次执行增删改查操作时,都检查一下rehashidx是否大于-1,如果是,则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且rehashidx++。直到ht[0]的所有数据都迁移到ht[1]

  5. 将ht[1]赋值给ht[0],将ht[1]初始化为空,释放原来的ht[0]内存

  6. 将rehashidx赋值为-1,标识rehash结束

注意

  • 在rehash过程中,数据不会同时存在于ht[0]和ht[1]
  • rehash过程中,新增命令直接写入ht[1],其他命令都会先在ht[0]和ht[1]上查找,再去执行
  • rehash时ht[0]只减不增

ZipList

双端链表,连续内存。可以在任一端push或pop

  1. zlbytes(uint32_t):记录整个ziplist占用的字节数
  2. zltail(uint32_t):记录尾结点到ziplist起始地址的偏移量
  3. zllen(uint16_t):记录节点数量
  4. entry(不定):节点
  5. zlend(uint8_t):特殊值0xff,用于标记结束

Entry

  1. previous_entry_length:前一个entry的长度,占1或5个字节,如果前一个entry的长度小于254个字节,用一个字节表示,否则用5个字节表示,其中5个字节中第一个为0xfe,后四个字节才是真实长度
  2. encoding:编码属性,记录contents的编码类型(字符串还是整数)以及长度,占1、2或5个字节
  3. contents:保存数据

字符串

编码编码长度字符串大小
00xxxxxx1 bytes<=63bytes
01xxxxxx xxxxxxxx2 bytes<=16383bytes
11..................................5 bytes<= 4294967295bytes

整数

编码编码长度整数类型
110000001 bytesint16_t
110100001 bytesint32_t
111000001 bytesint64_t
111100001 bytesint24_t
111111101 bytesint8_t
1111xxxx1 bytes直接在xxxx位置保存数值

连锁更新

当多个连续的entry的长度都即将达到254字节时,虽然这时候previous_entry_length都只占用一个字节,但如果在这多个连续entry前插入一个较大的entry时,由于第一个entry的previous_entry_length超出了254字节,导致后面多个entry的previous_entry_length都发生了改变,就会造成多次的申请内存,以及数据的移动,十分损耗性能

QuickList

产生背景

由于ZipList占用的是连续内存,不能存储过多的entry,因此当我们需要存储大规模的entry时,就可以采用QuickList。

QuickList是一个双端链表,它的节点是ZipList,通过前驱和后继指针将不同的节点连接起来

由于对于链表来说,一般频繁访问的都是头和尾,因此QuickList可以设置压缩中间节点

SkipList

传统链表查找元素的时间复杂度为O(N),如果我们给链表划分不同的层级,层级最低的节点与节点之间的跨度为1,越往上跨度越高,如果我们能够均匀化跨度,那我们就可以在链表上实现二分查找,达到O(logn)的查找,插入以及删除

Redis中跳表是根据score值从小到大排序的

typedef struct zskiplist {
    struct zskiplistNode *header, *tail; //头节点,尾节点
    unsigned long length; //节点数量
    int level; //最大层级
} zskiplist;

typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

数据类型

String

编码格式

  • raw:底层采用sds,存储的字符串长度如果小于44字节,会转换成embstr编码
  • embstr:底层采用sds,header和sds一起申请连续的内存空间,所以embstr是只读的,当我们对embstr编码的字符串进行修改时,会先将编码转换为raw
  • int:如果存储的是整数并且小于2^8,则可以将字符串的值直接赋给ptr指针

List

编码格式

  • 3.2之前,使用ZipList和LinkedList
  • 3.2之后,统一使用QuickList

Set

编码格式

  • dict:key为要存储的数据,value为null
  • 当存储数据都是整数且存储数量不超过set-maxintset-entries时,使用IntSet编码,以节省内存

ZSet

编码格式

zset要满足3个特点:键值存储,可排序,键唯一

如果使用Dict,无法满足可排序,如果使用跳表,无法满足键唯一,并且无法快速通过键获取值。因此Redis将这两种编码结合起来,作为zset的底层编码

  • 跳表和Dict:数据同时存储在跳表和Dict
  • ZipList:数据量较小时使用,将键值对作为两个entry进行存储,element在前,score在后,查找和排序都通过遍历的方式

Hash

编码格式

  • ZipList:数据量较小时使用,相邻的两个entry分别保存field和value
  • Dict