Redis数据结构解析

Redis数据结构

详解:mystudy/Redis/Redis数据结构详解.md · Zhang-HaoQi/Knowledge - 码云 - 开源中国 (gitee.com)

SDS

struct sds {
    int len;// buf 中已占用字节数
    int free;// buf 中剩余可用字节数
    char buf[];// 数据空间
};

redis中常用的数据类型,并不是直接使用的C字符串,而是在其基础上进行了优化。sds与c字符串对比如下。

记录字符串长度

c字符串底层实现并没有记录字符串长度,如果想获取长度需要遍历整个字符串。获取字符串长度复杂度为O(n)

sds提供了len属性记录了字符串长度,获取字符串长度复杂度为O(1)

杜绝缓冲区溢出

strcat(s1, " Cluster"); 字符串你拼接,将Cluster拼接到s1上。 s1为dest,Cluster为src

C字符串不记录自身的长度,所以默认用户使用字符串拼接函数时,已经为dest(原始串)分配了足够多的内存,可以容纳src(拼接串)字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。、

C字符串拼接字符串时,S1后方拼接S2,要保证S1的空间能够满足S2所需空间,否则会占用S1之后的连续空间,如果S1之后的连续空间有数据,那么S2会覆盖掉这部分数据。

SDS字符串进行数据修改时,如将S1修改成S2,S1占用的空间小于S2,那么此时SDS会主动申请空间来满足修改后S2的存储。申请空间时,如果S2所需空间小于1M,那么会多申请S2所占用的空间,并多加1字节的空间用来存储“/0”,如果S2的空间大于1M,则在S2的基础上,多申请1M的空间,并多加1字节的空间用来存储“/0”。缩容时,不会主动释放掉空闲的空间,可以调用API主动删除。

SDS这样既可以避免缓冲区溢出,又减少了内存空间的分配次数。

支持二进制

C字符串存储字符数据,因为只能存储文本

SDS存储字节数据,可存储二进制流,支持文本,视频,图片等格式数据。

兼容C字符串函数

C字符串以’/0’空字符结尾,C字符串的API(函数)总会将C字符串的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符。

SDS为了能够使用这些函数,而非重写,在结构定义上也多分配了一字节来存储这个空字符,但并不是支持所有API。

Hash

//字典
typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash索引
    //当rehash不在进行时,值为-1
    in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

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

//entru
typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

image-20221118211019125

redis的hash结构由数组和链表组成,数组存储的是key经过hash算法后得到的值,链表存储的是value对象。

添加元素

添加元素时,先对key进行hash运算,计算出hash值,再与sizemask进行&运算,计算出index。如果出现hash冲突,则新添加的元素作为第一个子元素添加到链表中。

Redis使用MurmurHash2算法来计算键的哈希值,该算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。

rehash

负载因子 = 哈希表已保存节点数量/ 哈希表大小。

增加/删除元素会造成负载因子变化,hash表的扩容和缩容受负责因子影响。

dict中存了两个hash表(dictht),ht[o]存放元素,ht[1]用于扩容和缩容操作。

扩容/缩容执行步骤:

  1. 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
    1. 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的(2的n次方幂);
    2. 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次方幂);
  2. 将ht[0]中的所有键值对rehash到ht[1]上面:**rehash指的是重新计算键的哈希值和索引值,**然后将键值对放置到ht[1]哈希表的指定位置上。
  3. 迁移后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

哈希表的扩展与收缩条件:

当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  1. 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。

    Redis Bgsave 命令用于在后台异步保存当前数据库的数据到磁盘。

    Redis Bgrewriteaof 命令用于异步执行一个 AOF(AppendOnly File) 文件重写操作。重写会创建一个当前 AOF 文件的体积优化版本。

  2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同。

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

写时复制:子线程向磁盘写数据时,会读取主存中的数据,如果主存中的数据没有改变不影响,如果改变了,则拷贝一个副本,将副本写入磁盘。如果在BGSAVE过程中,发生了rehash,那么数据的内存空间发生变化,需要拷贝大量数据的副本,浪费内存空间。

另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作

渐进式rehash

如果redis存储数据量大,并且当前服务访问量比较大,如果直接进行rehash,一次性将所有的键值对移到新的hash表中,会严重影响redis性能。

redis使用渐进式hash,将rehash的操作均摊到每次的增删改查操作中。当执行操作时,会进行如下行为:

查找:先找ht[o],找到了将ht[0]的搬迁到ht[1],并删除ht[0]

删除:ht[o]有直接删除,没有则在ht[1]删除

增加:直接新增到ht[1]

更新:找到ht[o],进行删除,之后新增到ht[1]。

在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

小整数集合

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

Redis Sadd 命令将一个或多个成员元素加入到集合中,已经存在于集合的成员元素将被忽略。

127.0.0.1:6379> SADD codeholes 1 2 3
(integer) 3
127.0.0.1:6379> OBJECT ENCODING codeholes
"intset"
    
如果添加的是字符串,不是整数,那么类型是hashtable
127.0.0.1:6379> sadd school zhangsan
(integer) 1
127.0.0.1:6379> OBJECT ENCODING school
"hashtable"

实现方式

image-20221118132636945

struct intset<T> {
 int32 encoding; // 决定整数位宽是 16 位、32 位还是 64 位
 int32 length; // 元素个数
 int<T> contents; // 整数数组,可以是 16 位、32 位和 64 位
}

·整数集合是集合键的底层实现之一。

·整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。

·升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。

·整数集合只支持升级操作,不支持降级操作。

链表

typedef struct list {
    // 表头节点
    listNode * head;
    // 表尾节点
    listNode * tail;
    // 链表所包含的节点数量
    unsigned long len;
    // 节点值复制函数
    void *(*dup)(void *ptr);
    // 节点值释放函数
    void (*free)(void *ptr);
    // 节点值对比函数
    int (*match)(void *ptr,void *key);
} list;

typedef struct listNode {
    // 前置节点
    struct listNode * prev;
    // 后置节点
    struct listNode * next;
    // 节点的值
    void * value;
}listNode

Redis的链表实现的特性:

  1. 双向链表:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。

  2. 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。

  3. 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。

压缩链表

普通链表是双向链表,存储了prev和next两个指针,占用了一定内存空间;链表指向的对象在内存存储中并不是连续的,导致内存空间碎片化。

当一个哈希键只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。

127.0.0.1:6379> zadd programmings 1.0 go 2.0 python 3.0 java
(integer) 3
127.0.0.1:6379> OBJECT ENCODING programmings
"ziplist"
    
127.0.0.1:6379> HMSET runoobkey-one name "redis tutorial" description "redis basic commands for caching" likes 20 visitors 23000
OK
127.0.0.1:6379> OBJECT ENCODING runoobkey-one
"ziplist"
struct ziplist<T> {
 int32 zlbytes; // 整个压缩列表占用字节数
 int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
 int16 zllength; // 元素个数
 T[] entries; // 元素内容列表,挨个挨个紧凑存储
 int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
struct entry {
 int<var> prevlen; // 前一个 entry 的字节长度
 int<var> encoding; // 元素类型编码
 optional byte[] content; // 元素内容
}

image-20221118130204884

zltail_offset:快速定位到最后一个元素,prevlen记录了前一个元素的字节长度,这样可以实现从后往前遍历。

当元素的长度小于254时,prevlen使用1个字节表示,如果长度等于或大于254则使用5个字节表示。

添加元素

因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。

如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多。

级联更新

每个 entry 都会有一个 prevlen 字段存储前一个 entry 的长度。如果内容小于254 字节,prevlen 用 1 字节存储,否则就是 5 字节。如果某个 entry 经过了修改

操作从 253 字节变成了 254 字节,那么它的下一个 entry 的 prevlen 字段就要更新,从 1 个字节扩展到 5 个字节;如果这个 entry 的长度本来也是 253 字节,那么后面 entry 的prevlen 字段还得继续更新。

如果 ziplist 里面每个 entry 恰好都存储了 253 字节的内容,那么第一个 entry 内容的修改就会导致后续所有 entry 的级联更新,这就是一个比较耗费计算资源的操作。

删除的时候,也有可能造成级联更新。

级联更新对性能影响:

级联更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N 2)。

压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见。

出现连锁更新时,但只要被更新的节点数量不多,就不会对性能造成任何影响,对三五个节点进行连锁更新是绝对不会影响性能的;

因此,实际使用不同担心级联更新影响性能,平均复杂度还是O(N);

问题

  1. ziplist底层实现是数组,数组占的空间是连续的,如果存储数据过多,一方面会占用大片连续的空间,可能内存空间不够需要连续分配。另一方面可能造成ziplist转换成普通链表。

  2. 选择ziplist的时候:

    列表对象保存的所有字符串元素的长度都小于64字节;
    列表对象保存的元素量小于512个
    上面的是选择ziplist作为底层实现所必须满足的条件,如果没满足的话就选用linkedlist作为其底层实现。

快速链表

在redis3.2版本之前,它使用的是ziplist和linkedlist编码作为列表键的底层实现,元素少时用 ziplist,元素多时用 linkedlist。3.2之后,就采用了一个叫做quicklist的数据结构来作其底层实现。quicklist主要解决ziplist可能产生的问题。

struct ziplist<T> {
 int32 zlbytes; // 整个压缩列表占用字节数
 int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
 int16 zllength; // 元素个数
 T[] entries; // 元素内容列表,挨个挨个紧凑存储
 int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
struct ziplist_compressed {
 int32 size;
 byte[] compressed_data;
}
struct quicklistNode {
 quicklistNode* prev;
 quicklistNode* next;
 ziplist* zl; // 指向压缩列表
 int32 size; // ziplist 的字节总数
 int16 count; // ziplist 中的元素数量
 int2 encoding; // 存储形式 2bit,原生字节数组还是 LZF 压缩存储
 ...
}
struct quicklist {
 quicklistNode* head;
 quicklistNode* tail;
 long count; // 元素总数
 int nodes; // ziplist 节点的个数
 int compressDepth; // LZF 算法压缩深度
 ...
}

img

快速链表里面包含了多个ziplist,ziplist之间通过指针链接,这样既可以充分利用内存空间,又可以存储更多的元素。

quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个ziplist。ziplist 的长度由配置参数 list-max-ziplist-size 决定。

再压缩

当链表很长的时候,最频繁访问的就是两端的数据,中间被访问的频率比较低,为了进一步节约空间,Redis 还会对ziplist 进行压缩存储,使用 LZF 算法压缩,可以选择压缩深度。

0:是个特殊值,表示都不压缩。这是redis的默认值
1:表示quicklist两端各有一个节点不被压缩,中间节点进行压缩
2:表示quicklist两端各有两个节点不被压缩,中间节点进行压缩
3:表示quicklist两端各有三个节点不被压缩,中间节点进行压缩 依次类推。

紧凑链表

Redis 5.0 引入的新的数据结构 listpack,它是对 ziplist 结构的改进,在存储空间上会更加节省,而且结构上也比 ziplist 要精简。

struct listpack<T> {
 int32 total_bytes; // 占用的总字节数
 int16 size; // 元素个数
 T[] entries; // 紧凑排列的元素列表
 int8 end; // 同 zlend 一样,恒为 0xFF
}
struct lpentry {
 int<var> encoding;
 optional byte[] content;
 int<var> length;
}

image-20221118134617413

listpack 跟 ziplist 的结构几乎一摸一样,只是少了一个 zltail_offset 字段。ziplist 通过这个字段来定位出最后一个元素的位置,用于逆序遍历。

listpack的entry中使用length记录当前entry的长度,ziplist使用

元素的结构和 ziplist 的元素结构也很类似,都是包含三个字段。不同的是长度字段放在了元素的尾部,而且存储的不是上一个元素的长度,是当前元素的长度。正是因为长度放在了尾部,所以可以省去了 zltail_offset 字段来标记最后一个元素的位置,这个位置可以通过total_bytes 字段和最后一个元素的长度字段计算出来。

长度字段使用 varint 进行编码,不同于 skiplist 元素长度的编码为 1 个字节或者 5 个字节,listpack 元素长度的编码可以是 1、2、3、4、5 个字节。

Redis 为了让 listpack 元素支持很多类型,它对 encoding 字段也进行了较为复杂的设计

级联更新

listpack 的设计彻底消灭了 ziplist 存在的级联更新行为,元素与元素之间完全独立,不会因为一个元素的长度变长就导致后续的元素内容会受到影响。

取代ziplist

listpack 的设计的目的是用来取代 ziplist,不过当下还没有做好替换 ziplist 的准备,因为有很多兼容性的问题需要考虑,ziplist 在 Redis 数据结构中使用太广泛了,替换起来复杂度会非常之高。它目前只使用在了新增加的 Stream 数据结构中。

跳表

链表+map
struct zsl {
 zskiplistNode* header; // 跳跃列表头指针
 int maxLevel; // 跳跃列表当前的最高层
 map<string, node*> ht; // hash 结构的所有键值对
}

链表节点
typedef struct zskiplistNode {
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];
    // 后退指针
    struct zskiplistNode *backward;
    // 分值
    double score;
    // 成员对象
    robj *obj;
} zskiplistNode;

typedef struct zskiplist {
    // 表头节点和表尾节点
    structz skiplistNode *header, *tail;
    // 表中节点的数量
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;

image-20221118110235865

zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist)。

Redis 的跳跃表共有 64 层,意味着最多可以容纳 2^64 次方个元素。

增删改查

如果跳表只有一层,查询复杂度为O(n),如果有多层,则复杂度为O(lg(n))。

查询:定位到那个紫色的 kv,需要从 header 的最高层开始遍历找到第一个节点 (最后一个比「我」小的元素),然后从这个节点开始降一层再遍历找到第二个节点 (最

后一个比「我」小的元素),然后一直降到最底层进行遍历就找到了期望的节点 (最底层的最后一个比我「小」的元素)这样,就可以快速找到要查找的元素。

插入的时候,需要定位到位置,然后插入元素,但是新插入的节点,有多少层,需要使用算法分配。跳跃列表使用的是随机算法。

删除过程和插入过程类似,都需先把这个「搜索路径」找出来。然后对于每个层的相关节点都重排一下前向后向指针就可以了。同时还要注意更新一下最高层数 maxLevel。

更新策略:

  1. 假设这个新的score 值不会带来排序位置上的改变,那么就不需要调整位置,直接修改元素的 score 值就可以了。但是如果排序位置改变了,那就要调整位置。
  2. score发生改变,位置也改变,删除key,再新增。

在一个极端的情况下,zset 中所有的 score 值都是一样的,zset 的查找性能会退化为O(n),zset 的排序元素不只看 score 值,如果score 值相同还需要再比较 value 值 (字符串比较)

计算排名

redis Zrank 返回有序集中指定成员的排名。其中有序集成员按分数值递增(从小到大)顺序排列。

//添加元素
ZADD runkey 1 redis 2 mysql 3 java 4 go
//获取排名
ZRANK runkey redis
//获取所有排名a
ZRANGE runoobkey 0 -1 WITHSCORES

Redis 在 zskiplistNode的 forward 指针上进行了优化,给每一个 forward 指针都增加了 span 属性,span 是「跨度」的意思,表示从前一个节点沿着当前层的 forward 指针跳到当前这个节点中间会跳过多少个节点。Redis 在插入删除操作时会更新 span 值的大小。

当我们要计算一个元素的排名时,只需要将「搜索路径」上的经过的所有节点的跨度 span 值进行叠加就可以算出元素的最终 rank 值.

zset

总结

各数据类型使用结构

  1. String:SDS
  2. List:
    1. 存储整数使用的是intset。
    2. 存储其他数据在3.2之前,如果是整数或者是小字符串,使用ziplist,如果数据多使用的是普通链表。3.2之后使用的是快速链表
  3. hash:数据+链表。链表规则和list一样
  4. set:底层实现是hash,和hash保持一致
  5. zset:hash+跳表
  6. stream:实现是紧凑链表

参考资料

  1. redis设计与闪现
  2. redis深度历险
  3. 部分网络博文,地址丢失,侵权删。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

See you !

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值