Redis:常用数据类型的底层数据结构(略讲)

redis为使用者提供了10个数据类型和相关命令,并提供了持久化、事务、管道、发布订阅等功能,此外还支持replica、sentinel、cluster三种集群部署方式,那么它是如何使用C源码实现这些功能的呢?本文将介绍Redis源码中常用的五种数据类型(字符串、集合、列表、有序集合和哈希)的实现方法,更多其他内容可自行查阅相关源码。

目录

src内容介绍

Key-Value数据库实现

五大常用类型源码

数据类型与数据结构

编码与数据结构

String

SDS动态字符串

编码方式:INT

编码方式:EMBSTR

编码方式:RAW

总结:

Hash

编码方式:HashTable

编码方式:ziplist

编码方式:listpack

List

编码方式:ziplist+linkedlist

编码方式:quicklist

Set

编码方式:intset

编码方式:hashtable

ZSet

编码方式:ziplist

编码方式:listpack

编码方式:skiplist

总结


src内容介绍

以下是对Redis源码包的介绍,Redis的源码包包含在 文件夹中,其中采用了C语言来实现Redis。尽管不需要了解全部内容,但这里将简单介绍其中一些核心部分。:

server.h和server.c:所有服务器配置和共享状态都定义在一个名为 server 的全局结构中,其类型为 struct redisServer。

db.h和db.c:定义了Redis数据库的数据结构和操作,用于处理键值对的存储和检索。

dict.h和dict.c:实现字典数据结构的文件,它用于存储键值对。

object.h和object.c:object.h和object.c定义了Redis中的数据类型,包括字符串、列表、哈希等。这些文件包含了数据类型的通用操作。

t_string.c:字符串

t_list.c:列表

t_hash.c:字典

t_set.c和t_zset.c:集合及有序集合

t_stream.c:数据流

sds.c:简单动态字符串

intset.c:整数集合

ziplist.c:压缩列表

quicklist.c:快速链表

listpack.c:紧凑列表

rdb.h、rdb.c、aof.h和aof.c:持久化代码

...

Redis源文件的命名规则在不同版本之间存在很大的差异,建议在阅读源代码之前,先访问Redis的官方网站了解相应的介绍和规定。

Key-Value数据库实现

相对于Java的面对对象编程思想,C语言中只有struct结构体,要看懂Redis的源码,就必须从相关功能的数据结构(即结构体)和算法(即函数)入手。接下来介绍一下Redis的基本数据结构:

  • redisServer:包括了服务器的配置选项、运行状态信息、客户端列表、数据库列表等,通过事件循环接受客户端连接并处理命令请求。
  • redisDb:表示Redis服务器中的数据库(Redis支持多个数据库);每个redisDb包含一个字典(dict),用于存储键值对数据。
  • dict和dictht:dict是Redis中用于实现字典数据结构的抽象,负责存储键值对(每个dict中的键值对都由一个dictEntry表示);dictht(字典哈希表)是dict的底层实现,用于解决哈希冲突;一个dict包含两个dictht是为了实现字典的渐进式 rehashing(渐进式哈希重建,用于字典扩容)。
  • dictEntry:dict中的一个键值对条目,包含键值对的具体数据以及指向下一个条目的指针,以支持链式解决哈希冲突。val指针指向的是redisObject类型。

  • redisObject:redisObjec结构用于统一五种不同的数据类型,这样所有的数据类型就都可以以相同的形式在函数间传递而不用使用特定的类型结构。(可以理解为redisObjec就是string、hash、list、set、zset的父类,可以在函数间传递时隐藏具体的类型信息。)

type:当使用type指令时,返回的就是这里的type属性对应的类型。

endocing:对象底层存储的编码类型,同一种数据类型可能有不同的编码方式,如String就提供了3种:int、embstr、raw。

再redis-cli中可以使用debug object 查看一个key对应的redisObject:(需要在配置中更改:enable-debug-command local)

五大常用类型源码

现在我们已经了解了Key-Value数据库就是一个dict字典数据结构,其中包含的HashEntry就是键值对,key是string类型,value是redisObject结构,那么接下来我们就要详细了解redisObject中5个类型(string、hash、list、set、zset)对应的实际数据结构(SDS、Hashtable、quicklist、skiplist、listpack和intset)以及对应的编码方式。先看类型与实际数据结构的对应关系:

数据类型与数据结构

redis6:

  • String:SDS动态字符串
  • Set:hashtable+intset
  • Hash:hashtable+ziplist
  • List:quicklist+ziplist
  • ZSet:skiplist+ziplist

redis7:

  • String:SDS动态字符串
  • Set:hashtable+intset
  • Hash:hashtable+listpack
  • List:quicklist
  • ZSet:skiplist+listpack

然后再看编码与数据结构的对应关系:

编码与数据结构


即将开始讲解5大常用类型的源码。本文将介绍相关数据结构的概述,不涉及深入解析源码细节。

String

String类型的底层是SDS动态字符串,并且具有三种编码方式:INT、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[];
};

结构体讲解:

相较于C或Java中使用char[]来设计string,Redis使用SDS作为数据结构,这样的优点有:

  • C中使用'/0'作为字符串结尾,这需要保证字符串中没有'/0',因此是二进制不安全的;redis中添加了len属性,就可以将'/0'作为字符串的一部分进行储存。
  • 获得长度时,可以直接使用O(1)的len获得,不需要O(n)进行遍历获得长度。
  • alloc用来计算字符串已经分配的未使用的空间,方便空间预分配算法的执行,进而不用担心数组下标越级或者内存分配溢出问题。
    • 预分配算法:SDS 修改后,len 长度小于 1M,那么将会额外分配与 len 相同长度的未使用空间。如果修改后长度大于 1M,那么将分配1M的使用空间。
  • 有分配也有回收,当SDS缩短时,并不会回收多余的内存空间,而是使用 free 字段将多出来的空间记录下来。如果后续有变更操作,直接使用 free 中记录的空间,减少了内存的分配。
  • flags用于标记SDS的类型,有助于Redis更高效地处理字符串值。(flags是用于优化操作的属性,不用在意)
编码方式:INT

当字符串键值的内容可以用一个64位有符号整形来表示时,Redis会将键值转化为long型来进行存储,此时即对应 OBJ_ENCODING_INT 编码类型。(即原先存储指针的地方直接用来存储int值)

使用int方式的redisObject对象:

注意:redis会在启动时直接创建0~9999的redisObject,在创建值为0~9999的键值对时,DictEntry中的value直接指向对应的redisObject,而无需创建。(享元模式)

编码方式:EMBSTR

对于长度小于 44的字符串,Redis 对键值采用OBJ_ENCODING_EMBSTR 方式,即嵌入式的String(embedded string),字符串sds紧跟在在redisObject结构体之后,只分配一块连续的内存空间,而不是使用创建在其他内存中再使用指针指向。结构图示:

看源码:o->ptr = sh+1;说明就直接存储在当前robj后面。

robj *createEmbeddedStringObject(const char *ptr, size_t len) {
    robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len+1);
    struct sdshdr8 *sh = (void*)(o+1);

    o->type = OBJ_STRING;
    o->encoding = OBJ_ENCODING_EMBSTR;
    o->ptr = sh+1;
    o->refcount = 1;
    o->lru = 0;

    sh->len = len;
    sh->alloc = len;
    sh->flags = SDS_TYPE_8;
    if (ptr == SDS_NOINIT)
        sh->buf[len] = '\0';
    else if (ptr) {
        memcpy(sh->buf,ptr,len);
        sh->buf[len] = '\0';
    } else {
        memset(sh->buf,0,len+1);
    }
    return o;
}
编码方式:RAW

当字符串的键值为长度大于44的超长字符串时,Redis 则会将键值的内部编码方式改为OBJ_ENCODING_RAW格式。与EMBSTR的不同之处就是字符串会被存储到其他内存,再使用指针指向。结构图示:

注意:对EMBSTR进行修改时,会直接将编码类型改为RAW。

总结

判断流程:

 三种编码方式:

Hash

hash的实现在redis6和7中是不同的,6使用的是hashtable+ziplist,7使用的是hashtable+listpack。

我们先将redis6中的编码方式:

编码方式:HashTable

在redis6中有两个配置参数:hash-max-ziplist-entries(使用压缩列表保存时哈希集合中的最大元素个数)和hash-max-ziplist-value(使用压缩列表保存时哈希集合中单个元素的最大大小),当Hash类型键的字段个数大于hash-max-ziplist-entries或任意一个元素大于hash-max-ziplist-value,就会使用HashTable进行编码。其实质和Java中的hashtable一样都是哈希函数+数组+链表的形式。

OBJ_ENCODING_HT的哈希表结构:

这部分内容在之前的Key-Value数据库实现中讲过,这里就不做赘述了。

编码方式:ziplist

在redis6中,当Hash类型键的字段个数小于hash-max-ziplist-entries且任意一个元素都小于hash-max-ziplist-value,就会使用ziplist进行编码。

ziplist可以理解为是一种特殊的双向链表,不过不是靠指针进行链接,而是存储上一个节点长度和当前节点长度(通过牺牲部分读写性能,来节约内存的一种数据结构),适用于字段个数少,字段值小的场景里面(在ziplist中查找key时,是通过遍历链表实现的)。

结构图示:

  • zlbytes: 表示整个ziplist的长度(字节数)。
  • zltail: 指向ziplist中最后一个元素的偏移量(从ziplist的起始位置开始计算的偏移量)。
  • zllen: 表示ziplist中元素的数量。
  • entry1、entry2、...、entryN: 存储元素的条目,每个条目由一个或多个字节组成。
    • prevlen:前一个条目的长度,第一个条目的前一个条目的长度为0。
    • encoding:编码方式。
    • entrydata:元素的实际数据。

当遍历时,从后向前遍历,每次就解析完自己数据后,将offset减去prevlen,即可获得前一个元素的offset,然后再解析前一个元素。

缺陷:连锁更新问题:

当如果前一个节点的长度小于254字节,那么prevlen 属性需要用1字节的空间来保存这个长度值;如果前一个节点的长度大于等于254字节,那么prevlen 属性需要用5字节的空间来保存这个长度值。如果一个压缩列表中有多个连续的、长度在 250~253 之间的节点,这时将一个长度大于等于 254 字节的新节点加入到压缩列表,就会导致连续更新:

编码方式:listpack

redis7中使用了listpack来代替ziplist,并且引入了两个新配置属性:hash-max-listpack-entries和hash-max-listpack-value,当Hash类型键的字段个数小于hash-max-listpack-entries且每个字段名和字段值的大小小于hash-max-listpack-value时, Redis就会使用OBJ_ENCODING_LISTPACK来存储该hash。

listpack结构示意图:

  • total_bytes:4字节。
  • size:2字节。
  • end:1字节。

相较于ziplsit,listpackEntry中的len记录的是当前entry的长度,而非上一个entry的长度,因此避免了连锁更新的问题(如何避免的可自行查阅)。

注意:listpack的内存利用率是不如ziplist的,因为整数情况想ziplist的encoding中使用8位、16位、40位三种长度表示len,仅在40位情况下的第3-8位有内存浪费,而listpack全部使用uint32_t。(但实际上也相差不大)

List

List只有一种编码方式,不过在redis6和redis7中,List的实现也是不同的,redis6中是ziplist+linkedlist,redis7中是quicklist。

编码方式:ziplist+linkedlist

可以看做把ziplist作为节点的双向链表。

结构示意图:

使用quicklist的优点:Quicklist采用了一种分段式存储的方式,将列表元素分为多个小块(node),每个小块可以包含多个元素。这种设计能够有效地减小内存碎片,并且在处理小列表时,不会浪费大量的内存。每个节点只在需要时分配内存,这使得Quicklist在处理大量小列表或者动态变化的列表时,具有出色的内存效率。

编码方式:quicklist

仅仅是使用listpack替换了ziplist,并没有什么特别大的区别。

Set

Set具有两种不同的编码方式:intset和hashtable,分别适用于不同的key类型。

编码方式:intset

如果set的元素都是整数类型,就用intset存储。

每个intset结构由3个属性组成:encoding(编码方式),length(集合包含的元素数量),cotents(内容数组)。

  • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int_16类型的数组,数组中的每个项都是一个int_16类型的整数值(最小值为-32768,最大值为32767)。
  • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int_32类型的数组,数组中的每个项都是一个int_32类型的整数值(最小值为-2147483648,最大值为2147483647)。
  • 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int_64类型的数组,数组中的每个项都是一个int_64类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)。

当我们要将一个新元素添加到 intset 里面,并且新元素的类型比 intset 现有所有元素的类型都要长时,intset 需要先进行升级(将底层数组现有的所有元素都转换成与新元素相同的类型,复杂度为O(n)),然后才能将新元素添加到整数集合里面。

编码方式:hashtable

如果存在元素不是整数类型,就用hashtable(数组+链表的存来储结构)。key就是元素的值,value为null。

ZSet

与Hash类型,Zset同样拥有两种编码方式,当数据少、单个数据小时使用ziplist/listpack,当数据多或单个数据大时使用skiplist。

redis6中是ziplist+skiplist;redis7中是listpack+skiplist。

编码方式:ziplist

当有序集合中包含的元素数量超过配置属性zset_max_ziplist_entries的值(默认值为 128 ), 或者有序集合中新添加元素的长度大于配置属性zset_max_ziplist_value的值(默认值为 64 )时, redis会使用跳跃表作为有序集合的底层实现,否则使用ziplist。

排序过程:

  1. 当向 ziplist 中添加一个新的成员和分值时,Redis 会将它们追加到 ziplist 的尾部。
  2. 然后,Redis 会遍历 ziplist,对其中的成员和分值进行比较,按照分值的大小进行排序。这个排序过程会修改 ziplist 中元素的顺序,以确保它们按照升序或降序排列。
编码方式:listpack

与ziplist相似,只是在redis7中将数据结构变成了listpack。

编码方式:skiplist

跳表是一个典型的空间换时间解决方案,而且只有在数据量较大、读多写少的情况下才能体现出来优势。

由于链表,无法进行二分查找,因此借鉴数据库索引的思想,提取出链表中关键节点(索引),先在关键节点上查找,再进入下层链表查找,提取多层关键节点,就形成了跳跃表,但由于索引也要占据一定空间的,所以,索引添加的越多,空间占用的越多。

优点:查找是O(logN),其他链表是O(N);缺点:新增或者删除时需要把所有索引都更新一遍,要先找到要改动的位置,导致新增和删除的过程时间复杂度也是O(logN),普通链表是O(1)。

总结

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值