我终于搞明白了Redis的底层数据结构
mhr18 2024-11-15 22:07 11 浏览 0 评论
Redis - 底层数据结构
简介
Redis 的底层数据结构主要以下几种:
- SDS(Simple Dynamic String, 简单动态字符串)
- ZipList(压缩列表)
- QuickList(快表)
- Dict(字典)
- IntSet(整数集合)
- ZSkipList(跳跃表)
简单动态字符串
在 Redis 中,并不会直接使用 C 语言自带的字符串结构作为实际的存储结构,而只是将字符串作为字面量使用,大多数情况使用自定义的 SDS 来表示字符串。
SDS 主要用于储存 Redis 的默认字符串表示、AOF 模块中的 AOF 缓冲区、客户端状态输入缓冲区。它的定义如下:
struct sdshdr { int len; // 记录 buf 数组中已使用字节的数量,等于 SDS 所保存的字符串的长度 int alloc; // 记录 buf 数组中未使用字节的数量 char buf[]; // 字节数组,用于保存字符串};
优点
相对于 C 语言的字符串实现,Redis 实现的 SDS 有以下优点:
- 通过记录 len 属性,实现常数级时间复杂度获取字符串长度
- 通过检查 len 属性,避免字符串在修改时出现缓冲区溢出的情况
- 通过记录 len 属性和 alloc 属性,对于修改字符串实现了空间预分配和惰性空间释放
- 实际存储的 buf 是一个字节数组,可以实现 SDS 安全操作二进制数据
- SDS 仍然以 \0 作为字符串结尾的标识,这样可以重用 C 语言字符串的部分函数
空间预分配
当 SDS 修改时需要扩展空间大小,程序不仅会为 SDS 扩展修改所需的空间,还会为 SDS 分配额外的未使用空间。这额外空间一般是 len 的大小,最大不超过 1MB。
这样可以减少连续执行字符串增长操作所需的内存重分配次数。
惰性空间释放
当 SDS 修改时需要缩短空间大小,程序并不会立即将多出来的空间进行空间重分配,而是使用 alloc 属性将这些空间大小记录下来,以待后续使用。
而且 SDS 也提供手动释放未使用空间的方法,这样可以避免浪费内存。
压缩列表
ZipList 实际是由一系列特殊编码的连续内存块组成的顺序型数据结构,是 Hash 类型底层实现的一种方式。
结构
一个 ZipList 结构由 zlbytes、zltail、zllen、entries、zlend 这些属性组成,这些属性顺序连接在一起组成了 ZipList:
zlbytes 用于记录 ZipList 占用的内存字节数,在对 ZipList 进行内存重分配或者计算 zlend 的位置时使用。
zltail 记录了 ZipList 表尾结点距离 ZipList 的起始地址有多少个字节,Redis 可以通过这个属性快速确定表尾结点的地址。
zllen 记录了 ZipList 包含的结点数量,当这个属性小于 UINT16_MAX(65535) 时,这个值就是 ZipList 包含的结点数量;这个属性大于 UINT16_MAX 时,则需要遍历整个 ZipList 才能计算得出结点数量。一个 ZipList 可以包含任意多个结点,每个结点可以保存一个字节数组或者一个整数值。
zlend 定义了特殊值 OxFF 用于标记 ZipList 的末端。
优点
ZipList 是一种节省内存的列表结构,对于普通的数组来说,其中每个元素占用的空间大小取决于最大的元素,而 ZipList 解决了这个问题。
因此,ZipList 在设计的时候,尽量让每个元素按照实际的内容大小存储,所以增加了 encoding 属性,使得程序可以根据不同的 encoding 属性来细化存储大小。
由于数组每个元素都占用相同的内存空间,在遍历数组时非常方便。
而 ZipList 每个元素存储的内存空间不一样,为了解决倒序遍历的问题,增加了 prevlen 属性来定位上一个元素的起始位置。
缺点
ZipList 内部的数据存储是一段连续的空间,并且没有预留内存空间,在移除结点时也是立即缩容,这表示每次写操作都会进行内存分配操作。
第二个缺点就是,在某种情况下,ZipList 会出现连锁更新的问题。
连锁更新
ZipList 存储了 prevlen 属性表示前一个元素的长度,如果前一个元素长度小于 254 个字节,则 prevlen 使用 1 个字节保存这个长度值,如果前一个结点大于 254 个字节,则 prevlen 使用 5 个字节保存这个长度值。
如果 ZipList 中正好存在连续多个长度介于 250~253 个字节的结点,这时需要在 ZipList 前面插入一个大于等于 254 个字节的新结点,后一个结点的 prevlen 需要从 1 个字节转换成 5 个字节,则后一个结点也会大于等于 254 个字节,后续的结点以此类推,将会造成这部分结点出现连续更新。
快表
Redis 在 3.2 版本之后新增了快表数据结构,它是一种以 ZipList 为结点的双端链表结构,可以理解成分段的 ZipList 链接在一起。
在 3.2 版本之前,Redis 使用 ZipList 或 LinkedList 来实现 List 类型,并且有一个选择的标准:
- 保存的所有字符串元素的长度都小于 64 字节,且保存的元素数量小于 512 个,选择使用 ZipList
- 否则使用 LinkedList 数据结构替代 ZipList
ZipList 要求有一段连续的内存空间,而使用 LinkedList 分配内存又会出现大量的内存碎片,因此 QuickList 对此做了优化,既避免出现大量的内存碎片,又避免一次性占用内存过大。
字典
字典在 Redis 中的应用非常广泛,比如 Redis 的数据库就是使用字典来作为底层实现的,对数据库的增、删、改、查操作都是构建在对字典的操作之上的。
哈希表结点
字典存储数据的最小结构就是哈希表结点,Redis 中的哈希表结点使用 dictEntry 结构表示,每个 dictEntry 都保存着一个键值对:
typedef struct dictEntry { void *key; // 键值对的键 union { // 键值对的值 void *val; // 可以是一个指针 uint64_t u64; // 可以是一个 uint64_t 整数 int64_t s64; // 可以是一个 int64_t 整数 } v; struct dictEntry *next; // 指向下个哈希表节点,形成链表} dictEntry;
这里值得注意的就是,next 指针会指向下一个哈希表结点,而它的功能就是用于解决哈希冲突,由此可见 Redis 的哈希表解决哈希冲突的方法是链地址法。
哈希表
哈希表是由多个哈希表结点组成的,Redis 中自定义的哈希表结构如下:
typedef struct dictht { dictEntry **table; // 哈希表数组 unsigned long size; // 哈希表大小 unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,总是等于 size - 1 unsigned long used; // 该哈希表已有节点的数量} dictht;
一般的,哈希表的物理存储结构都是数组,Redis 的哈希表结构也是如此,而这个结点数组中的每个元素都是一个指向 dictEntry 结构的指针。
字典结构
Redis 为了使哈希表结构更加具有通用性,最后是在自定义的 dictht 哈希表结构外层再包一层字典结构,即是 dict 结构:
typedef struct dict { dictType *type; // 类型特定函数 void *privdata; // 私有数据 dictht ht[2]; // 哈希表 int rehashidx; // rehash 索引,当 rehash 不在进行时,值为 -1} dict;
这里展示了另一个 dictType 的结构,其实这个结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数。以下是 dictType 的结构定义:
typedof struct dictType { unsigned int (*hashFunction)(const void *key); // 计算哈希值的函数 void *(*keyDup)(void *privData, const void *key); // 复制键的函数 void *(*valDup)(void *privData, const void *obj); // 复制值的函数 int (*keyCompare)(void *privData, const void *key1, const void *key2); // 对比键的函数 void *(*keyDestructor)(void *privData, const void *key); // 销毁键的函数 void *(*keyDestructor)(void *privData, const void *obj); // 销毁值的函数} dictType;
其实 dict 结构的 type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的。其中 privData 属性保存了需要传给那些类型特定函数的可选参数。
需要注意一下,字典结构的 ht 属性是一个长度为 2 的数组,也就是说,这个字典结构存储了两个 dictType 结构,其中一个用于存储实际使用的哈希表,另一个用于存储重新哈希的临时哈希表。
这个重新哈希还涉及到了 rehashidx 属性,表示的是重新哈希当前的进度。
哈希算法
当要将一个新的键值对添加到字典里面时,程序会先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表结点放到哈希表数组的指定索引上。
Redis 计算哈希值和索引值的流程是:通过 dict 中的 type 属性找到计算哈希值的函数,然后通过函数计算出对应的哈希值;确定对应的 dictht 结构之后,再根据 sizemask 和哈希值计算出索引值。
Redis 使用 MurmurHash2 算法计算键的哈希值,其优点就是对于有规律的输入值也能给出很好的随机分布性,并且算法的计算速度也非常快。
哈希冲突
相同的哈希值会计算出相同的索引值,这就会导致哈希冲突。
Redis 使用了链地址法解决哈希冲突,每一个哈希表结点都有一个 next 指针,多个冲突的哈希表结点就会使用这个 next 指针构成一个单向链表,这就解决了键冲突的问题。
这里需要注意一点,由于哈希表结点不存储链表的尾结点,为了速度考虑,哈希冲突时构建的单向链表使用头插法插入一个链表结点。
重新哈希
随着操作不断执行,哈希表保存的数据会逐渐增多或减少,为了让哈希表的负载因子维持在一个合理的范围内,Redis 会在必要的时候进行重新哈希的操作。
重新哈希指的是重新计算哈希表结点的哈希值和索引值,然后将键值对放到 ht 数组的另一个哈希表中。
Redis 对哈希表进行扩展操作的两个条件如下:
- 服务器目前没有正在执行 BGSAVE 命令或 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1。
- 服务器目前正在执行 BGSAVE 命令或 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5。
其中负载因子 = 哈希表已保存结点数量 / 哈希表大小。
另一方面,当哈希表的负载因子小于 0.1 时,Redis 会自动开始对哈希表进行收缩操作。
Redis 做自动扩展的条件包含两种情况的原因是,执行 BGSAVE 和 BGREWRITEAOF 命令的是服务器的子进程,而大多数操作系统都采用写时复制技术以优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子。
渐进式重新哈希
为了避免因为重新哈希导致停止服务的情况,Redis 做重新哈希不是一次性完成的,而是分多次、渐进式地完成的。这也是 dict 结构中存在 ht 数组的原因。
渐进式重新哈希的好处在于它采取了分而治之的方式,将重新哈希所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免集中式重新哈希而带来的庞大计算量。
整数集合
整数集合被 Redis 用于保存整数值的不重复集合,以下是整数集合的实现:
typedef struct intset { uint32_t encoding; // 编码方式 uint32_t length; // 集合包含的元素数量 int8_t contents[]; // 保存元素的数组} intset;
其中 contents 数组中存储的是整数集合中的元素,各个项按照从小到大进行排列,且数组中不包含任何重复值。
length 属性记录了整数集合包含的元素个数,也相当于 contents 的数组长度。
encoding 记录着整数集合的编码方式,虽然 contents 的定义是 int8_t 类型,但实际上 contents 数组存储元素的真正类型取决于 encoding 的值。
升级
整数集合的 contents 属性可以存储 int16、int32 或 int64 三种类型之一的数值,如果原本只存储了 int16 类型的 contents 数组插入一个 int32 类型的数值,这时就涉及到整数集合的升级操作。
每当要将一个整数插入到整机集合中时,Redis 都会先判断新元素的类型是否会比已存在的元素类型长,如果存在这种情况,整数集合需要先进行升级,才能将新元素添加到整数集合里面。具体的步骤如下:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间;
- 将现有元素都转换成与新元素的类型相同,并将转换类型后的数值放置到正确的位上,并保持原数组的顺序不变;
- 最后改变 encoding 的值,并将 length 加 1。
整数集合的升级操作是不可逆的,一旦对数组进行了升级,编码就会一直保持升级后的状态。
升级的好处
整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存。
因为 C 语言是静态类型语言,不同类型的整数值需要用不同的数组存储,而整数集合通过升级策略将有原本不同类型的整数添加到同一个数组中,减少了类型错误的情况。
同样的,整数集合通过使用一个数组存储了三种不同类型的整数,又确保升级操作只会在有需要的时候进行,这可以尽量节省内存。
跳表
跳表是一种有序的数据结构,它通过在每个结点中维持多个指向其他结点的指针,从而达到快速访问结点的目的。
Redis 的跳表包括了两个结构,一个是跳表结点的结构,另一个是存储跳表结点的外部结构。
跳表结点
以下是跳表结点的结构定义:
typedef struct zskiplistNode { struct zskiplistLevel { // 索引层 struct zskiplistNode *forward; // 前进指针 unsigned int span; // 跨度 } level[]; robj *obj; // 成员对象 double score; // 分值 struct zskiplistNode *backward; // 后退指针} zskiplistNode;
这里只是一个跳表结点的结构,概念比较多。
跳表的 zskiplistLevel 是一个数组,数组的长度表示结点有多少层索引,其中每个元素都包含一个指向其他结点的指针,程序可以通过这些指针加快访问其他结点的速度。每次创建一个新的跳表结点的时候,程序都会根据幂次定律随机生成一个介于 1 和 32 之间的值作为数组的大小。
forward 是指每个索引层都包含指向下一个具有相同高度索引层的结点。也可以将前进指针理解成链表的 next 指针,从相同层级的角度上看,每一个相同层级的结点都组成了类似于链表的结构。
span 记录了两个结点之间的距离,实际上是用来计算排位的:在查找某个结点的过程中,将沿途访问过的所有层的跨度累积起来,得到的结果就是目标结点在跳跃表的排位。
backward 用于从表尾向表头访问结点,对于最底层的链表来说,前进指针和后退指针使得这个链表成为一个双向链表。
结点的 score 即是 Redis 的有序集合中的分值。结点的成员是一个指向 SDS 对象的指针,这个 SDS 对象存储当前结点的值。对于相同分值的成员,Redis 会按照成员对象在字典序中的大小来进行排序,成员对象较小的结点会排在前面,而成员对象较大的结点会排在后面。
跳表
仅使用多个跳表结点就可以实现跳表,但是新增外部跳表结构可以使得程序更方便处理跳表。Redis 的跳表结构如下:
typedef struct zskiplist { struct zskiplistNode *header, *tail; // 头节点,尾节点 unsigned long length; // 节点数量 int level; // 目前表内节点的最大层数} zskiplist;
其中 head 指针和 tail 指针分别指向跳表的表头和表尾,通过这两个指针,Redis 定位跳表表头结点和表尾结点的时间复杂度为 ?(1)。
通过记录 length 属性,Redis 可以在 ?(1) 的时间复杂度内返回跳表的长度。
跳表使用 level 属性记录了表内结点的最大层数,但这个是不包含表头结点的层高的。
相关推荐
- C++开发必知的内存问题及常用的解决方法-经典文章
-
1.内存管理功能问题由于C++语言对内存有主动控制权,内存使用灵活和效率高,但代价是不小心使用就会导致以下内存错误:omemoryoverrun:写内存越界odoublefree:同一块内...
- 缓存用不好,系统崩得早!10条军规让你成为缓存高手
-
凌晨三点,我被电话惊醒:“苏工!首页崩了!”监控显示:缓存命中率0%,数据库QPS10万+,线程阻塞2000+。根本原因竟是同事没加缓存!不会用缓存的程序员,就像不会刹车的赛车手——...
- 彻底搞清楚内存泄漏的原因,如何避免内存泄漏,如何定位内存泄漏
-
作为C/C++开发人员,内存泄漏是最容易遇到的问题之一,这是由C/C++语言的特性引起的。C/C++语言与其他语言不同,需要开发者去申请和释放内存,即需要开发者去管理内存,如果内存使用不当,就容易造成...
- Java中间件-Memcached(Java中间件大全)
-
一、知识结构及面试题目分析缓存技术的大规模使用是互联网架构区别于传统IT技术最大的地方,是整体高并发高性能架构设计中是重中之重的关键一笔,也是互联网公司比较偏好的面试题目。按照在软件系统中所处位置...
- linux内存碎片防治技术(linux内存碎片整理)
-
推荐视频:90分钟了解Linux内存架构,numa的优势,slab的实现,vmalloc原理剖析Linux内核内存分配与回收Linuxkernel组织管理物理内存的方式是buddysystem(伙...
- Redis主从架构详解(redis主从配置详细过程)
-
Redis主从架构搭建Redis主节点配置创建主节点目录(/opt/redis-master),复制redis.conf到该目录下,redis.conf配置项修改#后台启动daemonizeyes...
- 揭开CXL内存的神秘面纱(内存c1)
-
摘要:现代数据中心对内存容量的高需求促进了内存扩展和分解方面的多条创新线,其中一项获得极大关注的工作是基于ComputeeXpressLink(CXL)的内存扩展。为了更好地利用CXL,研究人员建...
- 一文彻底弄懂 TPS RPS QPS(tps cps)
-
以下是关于RPS、QPS、TPS的核心区别与关联的总结,结合实际场景和优化建议:一、核心定义与区别RPS:RequestsPerSecond每秒请求数客户端到服务器的完整请求数量Web服务...
- 用Redis的“集合”找出你和朋友的“共同关注”
-
你是不是在刷抖音、微博、小红书的时候,常常会看到这样的提示:“你和XXX有共同关注的博主/朋友”?或者当你关注了一个新的明星,系统会推荐“你的朋友YYY也关注了这位明星”?这个看似简单的功能背后,其实...
- WOT2016彭哲夫:科班出身开发者对运维人员的期许
-
“运维与开发”是老生常谈的话题,前几天和一个运维人聊天,TA说一些公司运维岗位都不公开招聘了,这让众多运维人员情何以堪?是运维的岗位真的饱和了?是找到合适的运维人才难?还是有这样那样的因素?带着这些疑...
- Java程序员最常用的20%技术总结(java程序员要掌握什么)
-
我听说编程语言,经常使用的是其中20%的技术。在Java这门语言中,这20%包括哪些内容?找到一份Java初级程序员的工作,有哪些是必须掌握的,有哪些是可以现学现卖的?一个完整的Javaweb项目,有...
- 秒杀系统实战(四)| 缓存与数据库双写一致性实战
-
前言微笑挖坑,努力填坑。————已经拥有黑眼圈,但还没学会小猪老师时间管理学的蛮三刀同学本文是秒杀系统的第四篇,我们来讨论秒杀系统中「缓存热点数据」的问题,进一步延伸到数据库和缓存的...
- 头条评论精灵翻牌子(头条评论精灵翻牌子怎么弄)
-
关于“头条评论精灵翻牌子”功能,这通常是指平台通过算法或运营手段,将用户的优质评论随机或定向推送到更显眼的位置(如信息流顶部、独立曝光位等),以提升互动率和用户参与感。以下是详细解析和建议:一、功能理...
- 15个程序员们都应该知道的大模型高级提示词指令模板和示例
-
作为程序员你如何写大模型指令?你写的指令是不是更专业呢?下面是15个程序员使用的专业的大模型指令,如果早知道可以能节省你很多时间。这些指令可以用在chatgpt,deepseek等大模型。1.一键...
- MyBatis-Plus内置的主键生成策略有大坑,要注意!
-
昨天小伙伴使用Mybaits-Plus开发的项目线上(集群、K8S)出现了主键重复问题,其报错如下:Mybatis-Plus启动时会通过com.baomidou.mybatisplus.core.to...
你 发表评论:
欢迎- 一周热门
-
-
Redis客户端 Jedis 与 Lettuce
-
高并发架构系列:Redis并发竞争key的解决方案详解
-
redis如何防止并发(redis如何防止高并发)
-
开源推荐:如何实现的一个高性能 Redis 服务器
-
redis安装与调优部署文档(WinServer)
-
Redis 入门 - 安装最全讲解(Windows、Linux、Docker)
-
一文带你了解 Redis 的发布与订阅的底层原理
-
Redis如何应对并发访问(redis控制并发量)
-
oracle数据库查询Sql语句是否使用索引及常见的索引失效的情况
-
Java SE Development Kit 8u441下载地址【windows版本】
-
- 最近发表
- 标签列表
-
- oracle位图索引 (63)
- oracle批量插入数据 (62)
- oracle事务隔离级别 (53)
- oracle 空为0 (50)
- oracle主从同步 (55)
- oracle 乐观锁 (51)
- redis 命令 (78)
- php redis (88)
- redis 存储 (66)
- redis 锁 (69)
- 启动 redis (66)
- redis 时间 (56)
- redis 删除 (67)
- redis内存 (57)
- redis并发 (52)
- redis 主从 (69)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)