Redis底层数据结构分析(一) —— SDS动态字符串

前言

家人们,先上几个链接:

Redis官网文档

Redis源码仓库

Redis是一个基于内存的高性能键值存储系统。Redis支持多种数据类型,包括字符串、哈希、列表、集合、有序集合等。每种数据类型在底层都有对应的数据结构实现。在本文中,我们将深入探讨Redis的数据类型和底层数据结构,并结合Redis 6和Redis 7的源码,分析以下几种底层数据结构:

  • SDS动态字符串
  • 双向链表
  • 压缩链表ziplist
  • 哈希表hashtable
  • 跳表skiplist
  • 整数数组intset
  • 快速列表quicklist
  • 紧凑列表listpack

💡 Tips:官网说明

💡 Tips:对应文件

总体数据结构大纲

💡 Tips:Redis的10大数据结构

  • Redis数据类型与数据结构之间的关系

在Redis6中:

而Redis7中有所变化:

由图中可知,底层的数据结构有所变化,在Redis7中不在推荐使用ziplist,而是使用listpack代替,但考虑兼容性,目前仍保留ziplist。具体在后面再介绍!

最终的展现如下图:

各个类型的数据结构的编码映射和定义:

K-V键值对

Redis中的K-V键值对到底是什么?

        我们知道Redis是key-value键值对系统,key一般是 String类型的字符串对象,而Value的类型就比较多了,比如:字符串、List、Hash、Set、Zset等对象,所以Redis将所有数据结构进行统一,通过redisObject对象统一表示value值,每一个对象都是一个redisObject结构体,这样所有的数据类型就都可以以相同的形式在函数间传递而不用使用特定的类型结构。(可以理解为redisObject就是string、hash、list、set、zset的父类,可以在函数间传递时隐藏具体的类型信息,所以作者抽象了redisObjec结构来到达同样的目的)

下面简单介绍一下redisObject

为了识别不同的数据类型,redisObject 包含了typeencodingptr三个属性。type表示value的类型,encoding表示value的编码方式,ptr指向value的实际存储数据。不同的类型和编码方式会有不同的数据结构来实现,比如字符串类型的value可以用intrawembstr来编码,分别对应整数、动态字符串或预分配空间的动态字符串。

下面的value就对应一个个redisObject, 但底层结构多种多样

注:虽然value对外暴露的是RedisObject, 但redisObject并不是value的最终数据结构,而是一个包装器,在Redis中定义了RedisObject结构体来表示string、hash、list等数据结构,它根据typeencoding来决定如何访问ptr指向的数据。使用redisObject来包装value有以下几个好处:

  • 可以统一管理不同类型和编码方式的value,方便进行操作和转换。
  • 可以根据value的特点选择合适的编码方式,提高存储效率和性能。
  • 可以在redisObject中添加额外的信息,比如引用计数、LRU时间戳等,方便实现一些功能,比如内存回收、过期删除等。

如何实现键值对(key-value)数据库的?

看到K-V键值对,我们最先想到的是什么,应该就是Java里面的Hashmap吧。那么Java中的Hashmap是如何实现的呢?熟背面经的你很轻松能回答,数组+链表+红黑树。那它又是怎么映射key和value的呢?

HashMap的key和value通过put()方法添加时,会将key和value封装成一个Entry对象,可以看做是保存了两个对象之间映射关系的一种集合。然后根据key的HashCode值计算出一个索引位置,将Entry对象存储在数组中的该位置。如果该位置已经有其他Entry对象,那么就会形成一个链表,新添加的Entry对象会插入到链表的头部。当要获取value时,可以使用get()方法,get()方法会根据key的HashCode值找到对应的索引位置,然后遍历该位置的链表,比较key是否相等,如果相等就返回value。

Redis也采用了Java中的思想,每个键值对都会有一个dictEntry,其结构如下:

下面这张图展示了键值对保存进Redis并进行读取操作的过程

说了一大堆理论,现在结合Redis的set hello world命令讲一下,这个命令应该是入门级命令吧。那么你敲了这个命令,Redis又是如何响应的呢?

set hello world 命令是一个基本的字符串操作,它表示设置一个key为hello,value为world的数据。当Redis接收到这个命令后,它会先检查是否有同名的key存在,如果有,则覆盖原来的value,如果没有,则创建一个新的key-value对。

对于这key-value键值对,之前也有提到,Redis使用了字典(dictionary)结构来存储所有的key-value对,每个键值对都是一个dictEntry(源码位置:dict.h),里面指向了key和value的指针,next 指向下一个 dictEntry

key 是字符串,但是 Redis 没有直接使用 C 的字符数组,而是存储在redis自定义的 SDS中(后面详细讲)。

value 既不是直接作为字符串存储,也不是直接存储在 SDS 中,而是存储在redisObject 中

下面就分别介绍一下底层的数据结构!

String数据结构

3大物理编码方式

之前有提过一嘴,在Redis中存储string类型虽然都是RedisObject, 但其内部对应的物理编码是变化的,底层对应的有三种物理编码类型:intembstrraw。这三种编码类型的区别如下:

int编码

        当value为long类型的64位有符号整数,且长度小于等于8字节,即 -2^{63} \sim 2^{63}-1,使用int编码。这种编码可以节省内存空间,因为Redis会预先建立10000个redisObject,值为0 - 9999的值,将这10000个redisObject作为共享对象所以如果我们set的值在0 - 10000之间,则指向共享对象,不需要创建新的redisObject

注:只有整数才会使用int,如果是浮点数,Redis内部是先将浮点数转为字符串值,然后再保存。

当字符串键值的内容可以用一个64位有符号整形来表示时,Redis会将键值转化为long型来进行存储,此时即对应 OBJ_ENCODING_INT 编码类型。内部的内存结构表示如下:

        Redis 启动时会预先建立 10000 个分别存储 0~9999 的 redisObject 变量作为共享对象,这就意味着如果 set字符串的键值在 0~10000 之间的话,则可以 直接指向共享对象 而不需要再建立新对象,此时键值不占空间

set k1 100
set k2 100

Redis6:


Redis7:

embstr编码

当value为小于44字节的字符串时,使用embstr编码。这种编码是一种简单动态字符串(Simple Dynamic String,SDS),是可以修改的字符串,内部结构实现上类似于Java的ArrayList,采用分配冗余空间的方式来减少内存的频繁分配。embstr编码的优点是它可以减少内存碎片,因为它只需要一次内存分配和释放

这里不是很好理解,为什么能够减少内存的频繁分配

        在传统的字符串实现中(c语言使用的是char数组,它没有string 类型),每当创建一个新的字符串对象时,都需要为其分配一个新的缓冲区来存储字符数据。如果使用传统字符串实现,在内存分配过程中就需要调用两次内存分配函数,分别创建redisObject和字符串对象,然后redisObject通过存储字符串的引用链接指向字符串的对象。当有大量的字符串创建或者过期,就会导致频繁的内存分配和释放,增加了内存管理的开销,且由于分配的空间都是离散的,就容易导致内存碎片的产生。

embstr考虑了上述的问题,于是它通过一次内存分配来分配一块连续的内存空间空间中包含redisObject和sdshdr(动态字符串)两个结构两者在同一个内存块中。如下图所示,redisObject中的ptr指针直接指向下面的sdshdr,这就相当于把字符串对象的字符数据存储在redisObject对象本身的内存中,而不是只存储引用,这样可以减少内存的频繁分配和释放,只要一次分配或释放即可实现内存的管理。

对于长度小于 44的字符串,Redis 对键值采用OBJ_ENCODING_EMBSTR 方式,EMBSTR 顾名思义即:embedded string,表示嵌入式的String。从内存结构上来讲, 即字符串 sds结构体与其对应的 redisObject 对象分配在同一块连续的内存空间,字符串SDS嵌入在redisObject对象之中一样。

raw编码

        当value为大于44字节的字符串时,使用raw编码。这种编码也是一种简单动态字符串(SDS),但是它需要两次内存分配和释放一次是分配redisObject结构体,一次是分配SDS结构体,分配空间不一定连续(存储的数据较大,无法直接分配一大块内存同时存放两个结构体)。raw编码的优点是它可以存储任意长度的字符串,最多可以达到512M。

当字符串的键值为长度大于44的超长字符串时,Redis 则会将键值的内部编码方式改为OBJ_ENCODING_RAW格式,这与OBJ_ENCODING_EMBSTR编码方式的不同之处在于,此时动态字符串sds的内存与其依赖的redisObject的内存不再连续了。

考虑下面的问题,明明没有超过阈值,为什么变成raw了?

判断不出来,就取最大Raw

SDS动态字符串

上面讲完了字符串的三大物理编码方式,现在讲讲SDS!

假如现在展现一个字符串:Redis,在C语言中字符串是以char[]形式存储的,最后一个‘\0’是表示结束符。试想以下场景:

1. 获取字符串的长度: 需要从头开始遍历,直到遇到 '\0' 为止,时间复杂度O(N)

2. 内存再分配问题:分配内存空间超过后,会导致数组下标越级或者内存分配溢出

3. 二进制安全问题:二进制数据并不是规则的字符串格式,可能会包含一些特殊的字符,比如 '\0' 等。前面提到过,C中字符串遇到 '\0' 会结束,那 '\0' 之后的数据就读取不上了

所以Redis没有直接复用C语言的字符串,而是新建了属于自己的结构-----SDS它与C语言中的字符串有所不同,主要体现在SDS是一种动态字符串,它可以根据需要自动扩容

在Redis数据库里,包含字符串值的键值对都是由SDS实现的(Redis中所有的键都是由字符串对象实现的即底层是由SDS实现,Redis中所有的值对象中包含的字符串对象底层也是由SDS实现)。

SDS源码分析

下面是Redis 6中SDS的定义:

typedef char *sds;

struct sdshdr {
    // 记录buf数组中已使用字节的数量
    // 等于SDS所保存字符串的长度
    int len;

    // 记录buf数组中未使用字节的数量
    int free;

    // 字节数组,用于保存字符串
    char buf[];
};

注意,在Redis7中,为了节省内存空间,SDS引入了五种不同大小的头部结构(sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64),根据字符串长度动态选择合适的头部结构。这些头部结构都是通过一个联合体sds来表示:

typedef char *sds;

typedef union sdshdr {
    struct __attribute__ ((__packed__)) {
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    } sdshdr5;
    struct __attribute__ ((__packed__)) {
        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[];
    } sdshdr8;
    struct __attribute__ ((__packed__)) {
        uint16_t len; /* used */
        uint16_t alloc; /* excluding the header and null terminator
		unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    } sdshdr16;
    struct __attribute__ ((__packed__)) {
        uint32_t len; /* used */
        uint32_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    } sdshdr32;
    struct __attribute__ ((__packed__)) {
        uint64_t len; /* used */
        uint64_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    } sdshdr64;
} sdshdr;

SDS的底层实现使用一个结构体表示字符串,结构体中包含字符串的长度、分配空间、SDS类型和字节数组信息。在SDS中,字符串的长度和空余空间都可以快速获取,因此SDS能够在插入、删除等操作中实现高效的内存管理。

Redis中字符串的实现,SDS有多种结构(sds.h):

sdshdr5:(2^5=32byte)

sdshdr8: (2 ^ 8=256byte)

sdshdr16:  (2 ^ {16}=65536byte=64KB)

sdshdr32:(2 ^ {32} byte=4GB)

sdshdr64: 2^{64} byte =17179869184G用于存储不同的长度的字符串。

        每种结构都有一个flags字段,用于记录该SDS的类型(即头部结构的大小),以及一些未使用的位(低3位表示类型,高5位为未使用位)。这样,通过获取flags字段的最低三位,就可以判断出该SDS的类型,从而获取其长度、分配空间等信息。例如,如果flags字段的最低三位是001,那么该SDS的类型就是sdshdr8,其长度和分配空间都是8位无符号整数。Redis在创建字符串时,会根据字符串的长度选择不同的SDS类型来存储它。

        len 表示 SDS 的长度,使我们在获取字符串长度的时候可以在 O(1)情况下拿到,而不是像 C 那样需要遍历一遍字符串。

        alloc 可以用来计算 free 就是字符串已经分配的未使用的空间,有了这个值就可以引入预分配空间的算法了,而不用去考虑内存分配的问题。

        buf 表示字符串数组,真存数据的。

逻辑图:

结论

只有整数才会使用 int,如果是浮点数, Redis 内部其实先将浮点数转化为字符串值,然后再保存。embstr 与 raw 类型底层的数据结构其实都是 SDS (简单动态字符串,Redis 内部定义 sdshdr 一种结构)。

那这两者的区别见下图:

int

Long类型整数时,RedisObject中的ptr指针直接赋值为整数数据,不再额外的指针再指向整数了,节省了指针的空间开销。

embstr

当保存的是字符串数据且字符串小于等于44字节时,embstr类型将会调用内存分配函数,只分配一块连续的内存空间,空间中依次包含 redisObject 与 sdshdr 两个数据结构,让元数据、指针和SDS是一块连续的内存区域,这样就可以避免内存碎片

raw

当字符串大于44字节时,SDS的数据量变多变大了,SDS和RedisObject布局分家各自过,会给SDS分配多的空间并用指针指向SDS结构,raw 类型将会调用两次内存分配函数,分配两块内存空间,一块用于包含 redisObject结构,而另一块用于包含 sdshdr 结构

Redis内部会根据用户给的不同键值而使用不同的编码格式,自适应地选择较优化的内部编码格式,而这一切对用户完全透明!

参考资料

153_redis高级篇之redis源码分析Hash类型底层结构概述_哔哩哔哩_bilibili

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值