Redis 数据结构

119 阅读9分钟

SDS(Simple Dynamic String,动态字符串)

  • 数据结构

    • len:字符串长度
    • alloc:分配的空间长度。字符串在变更的的时候有可能会变大,所以可以根据alloc - len去计算空时是否够用,是否需要扩容。
    • flags:sds类型(sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64)
    • buf[]:字节数组
  • 扩容情况

    • 如果当前sds长度小于1MB,就会进行翻倍扩容,原长度的2倍
    • 如果当前sds长度耽于1MB,扩容至 原长度 + 1MB
  • flsgs 类型

    • 一共有五种,区别在于他们的数据结构中的len、alloc成员变量的数据类型不同
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[];
};
  1. sdshdr8和sdshdr16,设计他们的时候长度和空间分配的时候不能超过2^8和2^16,以此类推。
  2. 这样设计可以灵活的保存不同大小的字符串,节省空间。

链表

  • 数据结构
typeof struct list {
    listNode *head // 指向链表的头节点
    listNode *tail // 指向链表的尾节点
    unsigned long len // 链表的元素个数
    listTypeMethods *methods;   // 操作链表的函数(dup、free、match)
    ........
} list;


typeof struct listNode {
    void *value;                // 节点存储的实际数据
    struct listNode *prev;      // 指向前一个节点
    struct listNode *next;      // 指向下一个节点
    dup、free、match
} listNode;
  • 在listNode节点的结构中有指向上一个节点的下一个节点的指针,他们都可以直线NULL,所以是一个无环链表
  • listNode节点value使用的void指针保存节点,可以通过list的dup、free、match函数为该节点设置该节点类型的特定函数。

压缩列表

  • 压缩链表设计特点是一种紧凑的数据结构,占用的是一片连续的内存空间。可以利用CPU缓存,针对不同长度的数据进行相应的编码,节省内存开销。

  • 设计结构

    • zlbytes:记录整个压缩列表占用内存的字节数
    • zltail:记录列表尾部节点地址距离起始地址,列表尾的偏移量(尾部条目的偏移量)
      • 设计目的:提供直接访问压缩列表尾部的能力,如果执行rpush、rpop操作的时候,就能避免每次从头部遍历
    • zllen:列表中的元素数量
    • zlend:列表的结束点,固定OXFF(255),列表的结束标志。列表的终点
      • 设计目的:在遍历压缩列表的时候知道何时结束,避免多余的遍历。
    • entry:列表节点
      • prevlen:记录前一个节点的长度,目的是为了实现从后向前遍历
      • encoding:记录当前节点实际数据的类型和长度,类型主要有两种:字符串和整型
      • data:当前节点的实际数据,长度和类型由encoding决定
  • entry中的prevlen记录的是上一个节点的长度

    • 前一个节点 小于254,prevlen占用1字节空间来保存长度
    • 前一个节点 大于等于254,prevlen总用5个字节空间来保存长度
  • entry中的encoding记录的是实际数据的长度和类型

    • 如果节点数是整数,则会使用1字节进行编码。确认整型之后就可以确认整型的大小了。
    • 如果节点是字符串,根据字符串大小,encoding会根据使用的字节数进行编码。encoding编码的前两个bit位表示数据的类型,后续的其他bit位表示字符串长度

压缩列表是一定缺陷的,空间扩展的时候要重新分配内存。在每个节点中都有一个字段去记录上一个节点的长度,如果当前有一个节点之前的长度为254,那么他下一个节点只需要用1字节去记录,但是如果当前节点有修改,长度超过254的时候,那么他的下一个节点就要使用5个字节去记录,就会导致后边的所有节点都要扩展。

哈希表

  • 数据结构
typedef struct dict {
    dictType *type;           // 字典类型,支持自定义类型(如字符串、哈希等)
    void *privdata;           // 私有数据
    dictEntry **table;        // 哈希表的数组
    unsigned long size;       // 哈希表大小
    unsigned long used;       // 当前哈希表已使用的槽数
    dictht ht[2];
    long rehashidx;
} dict;

typedef struct dictEntry {
    void *key; // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v; // 值
    struct dictEntry *next; // 下一个链表元素
} dictEntry;
  • 首先dict是一个哈希表的结构,dictEntry是哈希表的每一个元素

  • dict中的dictEntry是一个数组,存放每一个元素的dictEntry

  • dictEntry有一个指针,指向下一个元素的指针,这里是为了解决hash冲突的。

    • 哈希冲突:在进行哈希计算的时候,有可能两个key计算出来的hash值是一样的,这个时候就会出现一个坑需要占两个key的情况。这个时候就可以使用next这个指向下一个桶。这样就可以解决哈希冲突,这样就形成了一个哈希链表。
    • 但是如果链表太长的情况下会退化成链表的情况,这个时候就需要进行扩容。
  • 扩容机制(rehash)

    • redis在定义dict结构体的时候,定义了两个hash表,正常情况下只会写入第一个hash表
    • 如果触发扩容的情况下就需要将数据迁移到第二个hash表上,第二个hash表是第一个hash表的两倍
    • 触发时机
      • 负载因子 = hash表已保存节点的数量级 / hash表大小
      • 负载因子 >= 1:并且redis没有进行fork子进程的时候,也就是没有执行RDB快照或AOF重写的时候,就会进行rehash
      • 负载因子 >= 5:这个时候说明hash冲突已经非常严重了,无论是否进行RDB或AOF都要进行rehash
  • 扩容方式

    • 采用渐进式扩容,如果一次性全部迁移的情况下有可能会发生性能抖动,所以采用渐进式的方式
    • 当对当前hash进行增、删、改的时候就会顺序将原hash表上的所有key-value迁移到新的hash表上
    • 当请求到一定量的时候就可以全部迁移至新的hash表。
    • 在迁移过程中如果新增加的数据只会添加到新的hash表中吗,查询的情况就会先去旧的hash表中着,找不到就去新的hash表中着。
    • 这样旧的hash表就会慢慢变空了

这里和go中的map扩容机制有点相同,当到达一定阈值的时候,也就就是看负载因子,然后进行渐进式扩容。

整数集合

  • 底层来说他也是一片连续的内存空间
typedef struct intset {
    uint32_t encoding;  // 编码类型(INT16, INT32, INT64)
    uint32_t length;    // 元素个数
    int8_t contents[];  // 实际存储元素的数组
} intset;
  • 整数集合升级

    • 当前集合元素中的类型都是int16的类型,如果这个时候插入一个int32元素的数据这个时候就会触发升级
    • 这个时候就要进行扩展空间,这个时候就要按照新元素的类型进行分割,存放数据。
  • 跳表

    • Zset底层使用就数据结构就是跳表
typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 头节点和尾节点
    unsigned long length;                // 节点总数
    int level;                           // 当前跳表最大层数
} zskiplist;

typedef struct zskiplistNode {
    robj *obj;                // 成员对象(key)
    double score;             // 排序用的分数
    struct zskiplistNode *backward; // 后退指针(可逆)
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned int span;             // 跨度(用于计算排名)
    } level[]; // 变长数组,支持多层索引
} zskiplistNode;
Level 3:   A ───────────────→ D
Level 2:   A ──────→ C ─────→ D
Level 1:   ABCD
  • 一层一层的,感觉有点像二叉树,又不是二叉树的感觉
  • level[]中每一个元素元素代表跳表的一层,zskiplistNode是指向下一个跳表的指针,span表示跨度用来记录两个点之间的距离。
  • 每一层都包含多个节点,每一个节点通过指针进行连接。
  • 每个跳表的节点都有一个后向指针backward指向前一个节点,目的是为了方便跳表从尾节点开始访问,倒序方便。
  • 两个相邻两层节点的比例为2:1,但是创建节点的时候,是随机生成每个节点数的,并不是很严格的执行按照2:1的比例。
  • 层数的最大限制为64。
  • 在创建节点的时候,会生成发范围为0-1的一个随机数,如果这个随机数小于0.25那么层数就是一层,然后继续生成下一个随机数,直到随机数的结果大于0.25结束,最终确定该节点的层数。
  • 跳表与平衡树
    • 从内存的上比较,跳表比平衡树更灵活一些。
    • 范围性查找的时候,跳表的比平衡树操作更简单
    • 从算法角度去比较,跳表比平衡树要简单得多

quicklist

  • 结构:双向链表 + 压缩列表
typedef struct quicklist {
    quicklistNode *head;   // 头节点
    quicklistNode *tail;   // 尾节点
    unsigned long count;   // 总元素个数(所有节点中所有 entry 总数)
    unsigned long len;     // quicklistNode 节点个数(即链表长度)
    int fill;              // 压缩控制策略(每个 node 的最大 entry 数)
    unsigned int compress; // 压缩深度(靠近两端的 node 不压缩)
} quicklist;

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;     // 指向 ziplist 或 listpack 的数据
    unsigned int sz;       // 当前 ziplist/listpack 的字节大小
    unsigned int count;    // 当前压缩列表中的 entry 数
    unsigned char encoding : 2;  // 编码方式:ZIPLIST / LISTPACK
    unsigned char container : 2; // 存储方式:RAW / PLAIN
    unsigned char recompress : 1; // 标记该节点是否被压缩过
    void *extra;           // 预留
} quicklistNode;

  • 这里在qicklistNode中的*zl保存一个压缩列表
  • quicklist用的是链表
  • 插入数据的时候不像链表直接创建一个节点,而是会检查插入位置的压缩列表是否能容该元素,如果可以直接插入,如果不可以才会去创建
  • quicklist会控制quicklistNode结构的压缩列表的大小或元素个数,规避掉连锁更新的风险

以上就是差不多的redis底层数据结构。