Redis 底层的数据结构实现原理

Redis内存模型

数据
  • 作为数据库,数据是最主要部分; 这部分占用的内存会统计在used_memory中。
  • Redis使用键值对存储数据,其中的值(对象)包括5种类型,字符串、列表、哈希、集合、有序集合。
  • 这五种类型是Redis对外提供,实际上,在Redis内部,每种类型都有2种或更多的内部编码实现。
进程
  • Redis主进程本身运行肯定需要占用内存,如代码、常量池等;这部分数据大约几M,在大多数生产环境中与Redis数据占用的内存相比可以忽略。
  • 这部分数据不是jemalloc分配,因此不会统计在used_memory中。
  • 除了主进程之外,Redis创建的子进程也会占用内存,如Redis执行AOF、RDB重写时创建的子进程。
  • 当然,这部分内存不属于Redis进程,也不会统计在used_memory和used_memory_rss中。
缓冲内存
  • 缓冲内存包括客户端缓冲区复制积压缓冲区AOF缓冲区等;其中,客户端缓冲区存储客户端连接的输入输出缓冲;复制积压缓冲区用于部分复制功能;AOF缓冲区用于在进行AOF重写时,保存最近的写入命令。
  • 在了解相应功能之前,不需要知道这些缓冲的细节;这部分内存由jemalloc分配,因此会统计在used_momory中
内存碎片
  • 内存碎片是Redis在分配、回收物理内存过程中产生的。例如,如果对数据的更改频繁,而且数据之间的大差异很大,可能导致Redis释放的空间在物理内存中并没有释放。
  • 但Redis又无法有效利用,这就形成了内存碎片,内存碎片不会统计在used_memory中。
  • 内存碎片的产生与对数据进行的操作、数据的特点等都有关。此外,与使用的内存分配器也有关系;如果内存分配器设计合理,可能尽可能的减少内存碎片的产生。如果Redis服务器中的内存碎片已经很大,可以通过安全重启的方式减少内存碎片;因为重启之后,Redis重新从备份文件中读取数据,在内存中进行重排,为每个数据重新选择合适的内存单元,减少内存碎片。

Redis底层的优化

节省空间
  • 由于Redis是基于内存的数据库,所有的数据都是存储在内存中,所以如何优化存储,减少内存空间占用对成本的控制是一个非常重要的指标。特别时内存的容量越大,也意味着价格也越来越高。
精简的键名和键值
  • 精简的键名键值是最直观的减少内存占用的方式,如将键名very.improtant.person:20改成VIP:20。当然精简键名一定要把握好尺寸,不能单纯为了节约空间而使用不易理解的键名,这样容易造成命名不易维护和容易造成命名冲突。
  • 我们存储一个用户性别的字符串类型键的取值是male和female。我们可以将其修改为m和f来为每条记录节约几个字节的空间
内部编码优化
  • 有时候仅凭精简的键名和键值所减少的空间并不足以满足需求,这时就需要根据Redis内部编码规则来节省更多的空间。Redis为每种数据类型都提供了两种内部编码方式,以散列类型为例,散列类型是通过散列表实现的,这样可以实现O(1)的时间复杂度的查找、赋值操作,然而当键中的元素很少的时候,O(1)的操作并不会比O(n)有明显的性能提高,所以这种情况下,Redis采用了一种更为紧凑但性能稍差(获取元素的时间复杂度为O(n)的内部编码方式。内部编码方式的选择对于开发者来说是透明的,Redis会根据值的情况自动调整。当键中的元素变多时Redis自动将该键的内部编码方式转换成散列列表)如果想看一个键的内部编码方式可以使用OBJECT ENCODING 命令,例如:
    在这里插入图片描述
  • 这一点也挺重要的,Redis的每个键值都是使用一个redisobject结构体保存的,redisobject的定义如下,
    在这里插入图片描述在这里插入图片描述
  • 其中type字段表示的是键值的数据类型,取值可以是如下内容:
    在这里插入图片描述
  • encoding 字段表示的就是Redis键值的内部编码方式,取值可以使:
    在这里插入图片描述
  • 各个数据结构可能采用的内部编码方式以及相应的OBJECT ENCODING命令执行结果 如下图所示
    在这里插入图片描述 - redis的每种数据类型分别有其内部编码规则及其优化方式。

Redis数据结构

字符串底层实现
  • 大家知道,Redis是用C语言写的(即以空字符\0结尾的字符串数组)。但是Redsi并没有直接使用C字符串作为默认的字符串表示,而是使用了SDS。SDS是简单的动态字符(Simple Dynamic String)的缩写。
  • 他是自己构建了一钟简单动态字符串(Simple Dynamic String, SDS)的抽象模型,并将SDS作为Redis的模式字符串表示。
SDS定义
struct sdshdr{
	//记录buf数组中已使用字节的数量
	//等于 SDS 保存字符串的长度
	int len;
	//记录了 buf 数组中未使用字节的数量
	int free;
	//其中,字节数组,用于保存字符串
	char buf[];
}
简单动态字符串(SDS)

在这里插入图片描述

  • buf数组的长度=free+len+1
SDS的优点
  • 1、SDS在C字符串的基础上加入了free和len字段,带来了很多好处: 获取字符串长度: SDS是 O(1),C字符串是O(n)
  • 2、缓冲区溢出: 使用C字符串的API时,如果字符串长度增加(如 stract操作)而忘记重新分配内存,很容易造成缓冲区的溢出。而SDS记录了长度,响应的API在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
  • 3、存取二进制数据: SDS可以,C字符串不可以。因为C字符串以空字符串作为字符串结束的标识,而对于一些二进制文件(如图片等)。内容可能包含空字符串,因此C字符串无法正确存取;而SDS以字符串长度len类作为字符串结束标识,因此没有这个问题。
  • 4、由于SDS中buf仍然使用了C字符串(即以‘\0’结尾),因此SDS可以使用C字符串中的部分函数。但是,只有当SDS用来存储文本数据才可以这样使用,在存储二进制数据时则不行(’\0’不一定是结尾)
  • 5、最后,通过一个小案例来说明Redis字符串的好处,当执行set key foobar 命令时,存储键值需要占用的空间是sizeOf(redisObject)+sizeof(sdshdr)+strlen(foobar)=30字节。如下图左边部分展示:

在这里插入图片描述

  • 当键值可以用一个64位符号整数表示时,Redis会将键值转换成long来存储。如set key 123456,实际占用的空间是sizeof(redisobject)=16字节,比存储“foobar”节省了一半的存储空间,如上图右边部分所示。
散列类型
  • 散列类型的内部编码方式可能是REDIS_ENCODING_HT或者REDIS_ENCODING_ZIPLIST。
  • 当散列类型键的字段个数少于hash-max-ziplist_entries参数且每个字段和字段值的长度都小于hash-max-ziplist-value参数值时,Redis就会使用REDIS_ENCODING_ZIPLIST(压缩列表)来存储该键,否则就会使用REDIS_ENCODING_HT。转换过程是透明的,每当键值变更后Redis都会自动判断是否满足条件来完成转换。
  • REDIS_ENCODING_HT编码即散列表,可以实现O(1)时间复杂度的赋值取值操作,其字段和字段值都是使用redisobject来存储的,所以前面讲到的字符串类型键值的优化方法同样适用于散列类型键的字段和字段值。
  • REDIS_ENCODING_ZIP编码类型是一种紧凑的编码格式,他牺牲了部分读取性能以换取极高的空间利用率,适合在元素极少时使用。该编码类型同样还在列表类型和有序集合类型中使用。REDIS_ENCODING_ZIPLIST编码结构如下图所示:
    在这里插入图片描述
  • 其中zlbytes是uint32_t类型,表示整个结构占用的空间。zltail也是uint_32_t类型,表示到最后一个元素的偏移,记录zltai是的程序直接定位到尾部元素而无需遍历整个结构,执行从尾部弹出(对列表类型而言)等操作时速度更块。ziplen也是unit_t类型,存储的是元素的数量。
REDIS_ENCODING_ZIPLIST的组成部分
  • 第一部分用来存储前一个元素的大小以实现倒序排序,当前一个元素的大小小于254字节时第一个部分占用1个字节,否则会占用5个字节。
  • 第二、第三部分分别是元素的编码类型和元素的大小,当元素的大小小于或等于63个字节时,元素的编码类型是ZIP_STR_06B(即0<<6),同时第三部分用6个二进制位来记录元素的长度,所以第二、三部分总占用的空间是1字节。当元素的大小大于63字节或等于16383字节时,第二、三部分总占用的是2字节。当元素的大小大于16383字节时,第二、三部分总占用空间是5字节。
  • 第四部分是元素的实际内容,如果元素可以转换成数字的化Redis会使用相应的数字类型来存储以节省空间,并用第二、三个部分来表示数字的类型。
  • 使用REDIS_ENCODING_ZIPLIST编码存储散列类型时元素的排序方式是:元素1 存储字段1,元素2 存储字段值2 依次类推,如下图所示。
    在这里插入图片描述
  • 例如: 当执行命令HSET hkey foo bar 命令后,hkey键值的内部结构如上图所示。
  • 下面需要执行 HSET hkey foo anothervalue 时,Redis需要从头开始找到值为foo的元素(查找每次跳过一个元素以保证只查找字段名),找到后删除其下一个元素,并将新值anthervalue插入。删除和插入都需要移动后面的内存数据,而且查找操作页需要遍历才能完成,可想而知当散列键中数据多时性能将很低,所以不宜将hash-max-ziplist_entries和hash-max-ziplist-value两个参数设置的很大。
链表
  • 链表在Redis中应用非常广泛,列表(List)的底层实现之一就是双向链表。此外发布与订阅、慢查询、监视器等功能也用到了链表。
typedef struct listNode {
	//前置节点
	struct listNode *prev;
	//后置节点
	struct listNode *next;
	//节点的值
	void *value;
}listNode

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;

在这里插入图片描述

  • 列表类型的内部编码方式可能是REDIS_ENCODING_LINKEDLISTREDIS_ENCODING_ZIPLIST
  • REDIS_ENCODING_LIKEEDLIST编码方式即双向链表,链表中的每个元素是用redisObject存储的,所以此种编码方式下元素值的优化方法与字符串类型的键值相同。
  • 使用REDIS_ENCODING_ZIPLIST编码方式具体的表现和散列类型一样,由于REDIS_ENCODING_ZIP_LIST编码方式同样支持倒序访问,所以采用此种编码方式时获取两端的数据依然很快。
Redis链表的优势
typedef struct listNode {
	//前置节点
	struct listNode *prev;
	//后置节点
	struct listNode *next;
	//节点的值
	void *value;
}listNode

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;
  • 双向列表具有前置节点和后置节点的引用,获取这两个节点的时间复杂度都为O(1)
  • 与传统链表(单链表)相比,Redis链表结构的优势有:
    • 普通链表: 节点类保留下一个节点的引用,链表类只保留头节点的引用,只能从头节点插入、删除。
    • 无环: 表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问都是以NULL结束。
    • 带链表长度计数器,通过len属性获取链表长度的时间复杂度O(1)
    • 多态: 链表节点使用void* 指针来保存节点值,可以保存各种不同类型的值。
      在这里插入图片描述
集合类型
集合概述
  • 集合类型的内部编码可能是REDIS_ENCODING_HT或REDIS_ENCODING_INTSET。当集合中的所有元素都是整数且元素的个数小于配置文件中的set-max_intset-entries参数指定的值(默认值是512)时,Redis会使用REDIS_ENCODING_INTSET编码存储该集合,否则会使用REDIS_ENCODING_HT来存储。
  • REDIS_ENCODING_INTSET编码存储结构体intset的定义是:
typedef struct intset {
	unit32_t encoding;
	unit32_t_lengthl
	int8_t contents[];
}intset;
  • 其中contents 存储的就是集合中的元素值,根据encoding的不同,每个元素占用的字节大小不同,默认的encoding是INTSET_ENC_INT16(即2个字节),当新增加的整数元素无法使用2个字节表示时,Redis会将该集合的encoding 升级为INTSET_ENC_INT32(即4个字节)并调整之前所有元素的位置和长度。
  • REDIS_ENCODING_INTSET编码以有序的方式存储元素,使得可以使用二分算法查找元素,然而无论是添加还是删除元素,Redis都需要调整后面的元素的内存位置,所以当集合中的元素太多时,性能较差。
  • 当新增加的元素不是整合素或集合的元素数量超过了set-max-intset-entries参数指定值时,Redis会自动将该集合的存储结构转换成REDIS_ENCODING_HT。
有序集合类型
  • 有序集合类型的内部编码方式可能是REDIS_ENCODING_SKIPLIST 或 REDIS_ENCODING_ZIPLIST。同样在配置文件中可以定义使用REDIS_ENCODING_ZIP方式编码的时机:
    • zset-max-ziplist-entries 128
    • zset-max-ziplist-value 64
  • 当编码方式是REDIS_ENCODING_SKIPLIST时,Redis使用散列表和跳跃列表(skiplist)两种数据结构来存储有序集合类型键值,其中散列表用来存储元素值与元素分数的映射关系以实现O(1)的时间复杂度ZSCORE等命令。跳跃列表用来存储元素的分数及其元素值的映射以实现排序的功能。
  • 采用此种编码方式时,元素值是使用redisobject存储的,所以可以使用字符串类型键值的优化方式优化元素值,而元素的分数是使用duble类型存储的。
  • 使用REDIS_ENCODING_ZIPLSIT编码时有序集合存储的方式按照元素1的值,元素1的分数,元素2的值,元素2的分数的顺序排列,并且分数是有序的。
跳跃列表
  • 为什么使用跳跃列表,不使用普通链表或双向链表呢?
    • 原因: 普通单链表/双向链表 查询一个元素的时间复杂度为O(n),即使该单链表是有序的。
  • 跳跃列表查询元素的方式比较特殊,从最高的链表节点开始,如果比当前节点要大和比当前的下一个节点要小,那么则往下找,也就是和当前层的下一个层的节点的下一个节点进行比较,依次类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空,下图显示,最高链表节点是头节点,也就是header指向的节点。
    在这里插入图片描述
  • 跳跃列表插入元素
    • 首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,知道遇到反面为止,最后记录正面的次数作为插入的层数,当确定插入的层数k后,则需要将新元素插入到从底层到k层。
      -跳跃列表删除元素
    • 在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果珊瑚粗以后只剩下头尾两个节点,则删除这一层。
跳跃表代码
ypedef 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;
  • 最后,感谢读者的阅读,文章如有错误,评论或私信我,会及时加以改正,大家共同进步
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值