Redis的底层数据结构

一、引言

Redis 的卓越性能,离不开底层数据结构的强力支撑。了解这些数据结构,是理解"Redis为什么这么快"这个问题的关键一环。

二、常用的5种数据类型及其底层结构

Redis常用的数据类型有string、hash、list、set、zset五种。Redis本身是一个key-value的键值对存储系统,这五种数据类型说的都是value的类型,key的类型是固定不变,永远是字符串。

2.1 string

string类型的编码方式有3种:

  1. int
  2. embstr
  3. raw
    int的底层是C语言的long类型,如果字符串的内容全部是整数,并且在long的范围内,就会触发int编码方式。
    int
    如果字符串不满足int编码,并且该字符串的长度小于等于44字节的话,就会触发embstr编码。
    在这里插入图片描述
    如果以上两种编码方式都不满足,那么将会触发raw编码方式,它的底层结构是SDS
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

这个就是SDS的结构,len字段表示字符串的长度,alloc字段表示分配的空间大小,flags字段用来标识类型,buf数组则是一个柔性数组。SDS的这个设计,使得string是二进制安全的。C语言的字符串,以 ‘\0’ 为字符串的结尾,这就意味着它无法存储包含 ‘\0’ 的二进制数据。然而,SDS的字符串结尾不是通过 ‘\0’ 来标识的,而是通过长度字段len来标识的,所以就算字符串中包含 ‘\0’ ,也可以安全的存储,不会被截断。

2.2 Hash

Hash存储的是键值对(field-value),底层的编码方式有两种:

  1. ziplist
  2. hashtable
    ziplist的结构如下:
    ziplist
  • zlbytes:ziplist的总长度字段。
  • zltail:最后一个节点(entry)与起始位置的偏移量,方便找到最后节点。
  • zllen:记录节点的数量。
  • entry:节点,实际存储数据的结构。
  • zlend:标识ziplist的尾部。
    ziplist是一块连续的内存,它不像普通的链表,每个节点用指针来指向其他节点,因为指针是有开销的,如果某个节点存的数据量少,就会出现指针占用的空间比实际存储的数据还要多的情况。简而言之,就是空间利用率不高。

entry(节点)的3个字段:

  • prevlen:前一个节点的长度,这是为了实现从后向前的遍历。
  • encoding:标识当前节点存储的数据类型,整数还是字符串,并且记录字符串的长度。
  • data:实际存储的数据。
    这三个字段都是不定长的。如果前一个节点的长度小于254字节,prevlen就占1个字节。如果前一个节点的长度大于等于254字节,prevlen就占5个字节。

如果要往ziplist中插入节点entry的话,需要挪动数据,就好比往数组中间插入数据一样,插入位置后面的元素都要往后挪动,这是它的一个不足之处。
另一个不足体现在 连锁更新
假设列表中有连续的节点 A, B, C…,它们的大小都在 250~253 字节之间。此时,它们的 prevlen 都只需要 1 字节 来存储。

  1. 现在,你在 A 前面插入了一个新节点 New,且 New 的大小是 300 字节。
  2. 节点 A 的 prevlen 发现前一个节点(New)长度 ≥ 254,于是 A 的 prevlen 需要从 1 字节扩展为 5 字节。
  3. 这导致节点 A 的总长度增加了 4 字节。
  4. 节点 B 发现前一个节点(A)的长度变了,B 的 prevlen 也需要扩展。
  5. 这种“多米诺骨牌”效应会一直向后传递,导致后续所有节点都需要重新分配内存和移动数据。

正是因为 ziplist 存在连锁更新的问题,Redis 7.0 引入了 listpack 来取代它。listpack 取消了 prevlen,改为记录“当前节点长度”和“下一个节点偏移量”,从而彻底解决了连锁更新问题,虽然牺牲了一点点内存(多了个偏移量字段),但换来了更稳定的性能。

ziplist(压缩列表)的触发条件有下面两条:

  • 哈希中元素数量少于 hash-max-ziplist-entries (默认 512 个)。
  • 哈希中所有键和值的长度都小于 hash-max-ziplist-value (默认 64 字节)。
    这两个字段我们都是可以在Redis的配置文件中进行配置的。
    配置
    演示
    哈希中只有2个元素(一个键值对就是一个元素),少于512个,并且键和值的长度都不超过64字节,所以编码方式为ziplist。
    只要上面的两个条件有一个不满足,那么将采用hashtable的编码方式。
    演示
    hashtable编码方式的底层是dict(字典)
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;     /* Next entry in the same hash bucket. */
    void *metadata[];           /* An arbitrary number of bytes (starting at a
                                 * pointer-aligned address) of size as returned
                                 * by dictType's dictEntryMetadataBytes(). */
} dictEntry;

这个是dict的节点,key是字段名,v是字段值,next指向的是同一个哈希桶中的下一个节点。所以,通过这个next字段我们便可以知道,dict处理哈希冲突的方法是链地址法。

struct dict {
    dictType *type;

    dictEntry **ht_table[2]; 
    unsigned long ht_used[2];

    long rehashidx; /* rehashing not in progress if rehashidx == -1 */

    /* Keep small vars at end for optimal (minimal) struct padding */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
    signed char ht_size_exp[2]; /* exponent of size. (size = 1<<exp) */
};

ht_table[2]:两张哈希表,ht_table[0]用于平时正常读写,ht_table[1]在rehash(扩容/缩容)的时候使用。
ht_used[2]: 每张哈希表中的元素个数。ht_used[0]标识第一张哈希表中的元素个数。
rehashidx: -1表示没有进行rehash(重新hash映射),大于等于0,表示正在进行rehash的桶索引。
pauserehash: 如果大于0,表示暂停rehash。
ht_size_exp: 表示哈希表大小的指数,1<<ht_size_exp[0]表示ht_table[0]的大小,用指数替代size,节省了不少内存。

dict整体的结构示意图:
dict
本质上,dict(字典)就是对哈希表的封装。
dict中有两张哈希表,一张用于平时的正常读写,另一张则是rehash的时候用到。
扩容
我们知道,负载因子 = used / size。当负载因子大于1的时候,就会触发扩容。
ht_table[1]会分配到旧哈希表(ht_table[0])的2倍空间。然后从rehashidx开始向后依次迁移数据。
不过,Redis并不是一次性完成数据的迁移的,因为一次性迁移大数据,是一个耗时的操作,会卡住主线程,所以Redis采用的是渐进式rehash
所谓的渐进式rehash就是把这个耗时的迁移过程化整为零,分散到多次请求和后台任务中逐步完成。
迁移的方式有以下两种:

  • 被动迁移: 每次对字典执行增、删、改、查操作时,除了执行命令本身,还会顺带将旧哈希表中的rehashidx索引位置上的整个哈希桶迁移到新的哈希表中,然后将rehashidx加1。
  • 主动迁移: Redis的后台定时任务会周期性的执行,专门分配少量时间(比如1毫秒)来主动迁移一批数据(比如100个桶)。

在进行rehash期间,新增的键值直接插入到新的哈希表中(ht_table[1])。这保证了旧的哈希表(ht_table[0])的数据量只减不增,最终能被清空。对于查找操作,会先在旧的哈希表(ht_table[0])中查找,如果没找到,再去新的哈希表中(ht_table[1])找。删除和更新操作,需要同时在两张哈希表中进行查找,然后完成相应的操作。这样能够确保两张表中的数据一致。
迁移完成后,Redis会释放掉旧的哈希表(ht_table[0])的空间。为了不改变其他代码的调用逻辑(它们默认去ht_table[0]找数据),Redis会把新的哈希表(ht_table[1])赋值给旧的哈希表(ht_table[0]),其实就是一个改变指针指向的过程。最后,将rehashidx置为-1。

缩容也是一样的过程。

2.3 list

list的底层结构从3.2版本开始,统一使用quicklist(快速列表)

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *entry;
    size_t sz;             /* entry size in bytes */
    unsigned int count : 16;     /* count of items in listpack */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* PLAIN==1 or PACKED==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;

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all listpacks */
    unsigned long len;          /* number of quicklistNodes */
    signed int fill : QL_FILL_BITS;       /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

quicklist宏观上是一个双向循环链表,它的每个节点都有指向前一个节点的prev指针和指向后一个节点的next指针,而且quicklist内,还有指向头结点的head指针和指向尾节点的tail指针。
微观上,也就是具体到一个节点内部,节点内部存的其实是一个ziplist
这种设计,保留了双向循环链表在两端插入和删除的高效性,同时有利用ziplist压缩了内存空间。
list

2.4 set

set,无序集合,它的底层结构有2种:

  1. IntSet
  2. Dict
typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;
  • encoding:编码方式
  • length:集合中的元素个数
  • contents:柔性数组,真正存储数据的地方

触发intset的条件:

  • 集合中所有元素都是整数。
  • 集中中元素的数量小于 set-max-intset-entries(默认值为 512)。

set
当不满足上面的任何一个条件时,set的编码方式将会变成hashtable,底层结构就是dict

2.5 zset

zset,有序集合,元素(member)按分数(score)来进行排序,底层有两种编码方式:

  1. ziplist
  2. skiplist + dict

ziplist的触发条件:

  • 有序集合元素数量少于 zset-max-ziplist-entries (默认 128 个)。
  • 所有元素的值和分数长度都小于 zset-max-ziplist-value (默认 64 字节)。

当不满足上面的任意一个条件时,采用第二种编码方式。
zset

typedef struct zskiplistNode {
    sds ele;  // 1. 成员对象
    double score;  // 2. 分值
    struct zskiplistNode *backward; // 3. 后退指针
    struct zskiplistLevel {  // 4. 层级结构
        struct zskiplistNode *forward; // 4.1 前进指针
        unsigned long span; // 4.2 跨度
    } level[]; // 4.3 柔性数组
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 1. 头尾指针
    unsigned long length; // 2. 长度
    int level; // 3. 最大层级
} zskiplist;

skiplist
上图就是跳表的结构,存的是score。最低层存储全量数据,然后按照一定的规则,向上增加层高,在查找数据时,从最高层开始查询。例如要找12号节点。通过上层的元素就可以过滤掉一半的元素,所以它的时间复杂度是O(log N)。

红黑树的效率也是O(log N),为什么不用红黑树?
zset这个结构,需要频繁的范围查询,跳变的最低层存储了所有元素,并通过指针连成链表,比红黑树更适合范围查询。

dict上面已经介绍过了,它存的是member -> score的映射,O(1)查分。

三、总结

总结

欢迎批评指正!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值