Redis-1-底层数据结构、为什么快
参考文章:
1.Redis是什么
Redis(REmote DIctionary Server)是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。
与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。
并且,Redis 存储的是 KV 键值对数据。
2.Redis为什么快
Redis 内部做了非常多的性能优化,比较重要的有下面 3 点:
- Redis 基于内存,内存的访问速度比磁盘快很多;
- Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到);
- Redis 内置了多种优化过后的数据类型/结构实现,性能非常高。
2.1 基于RAM(内存)
这个图里很明显了,毫秒、微秒,咱们还是在微秒的上一级纳秒。
2.2 单线程读写和I/O多路复用
咱们是基于内存的啊,内存,IO的场景不是那么多,Redis 的 IO 主要集中在客户端连接和请求处理上。
对,最直接的方式就是多线程+BIO,一个线程对应一处请求。
经过权衡,Redis 的选择了单线程加 IO 多路复用的方式。
2.2.1 单线程读写
- 避免了上下文切换的开销
在多线程或多进程模型中,操作系统需要频繁地进行上下文切换,这会带来一定的开销。上下文切换包括保存和恢复线程/进程的状态、CPU缓存的失效等。单线程模型避免了这些开销,因为它不需要在多个线程之间切换。
- 消除了锁竞争
在多线程模型中,为了保证数据的一致性和正确性,需要对共享资源进行加锁操作(如互斥锁、读写锁等)。加锁和解锁操作不仅增加了额外的开销,还可能导致线程竞争和死锁等问题。单线程模型没有这些问题,因为所有操作都是在同一个线程中顺序执行的,不存在锁竞争。
2.2.2 I/O多路复用
参考这篇文章:Java-IO-IO模型
Redis 使用了IO多路复用技术(如 select
、poll
、epoll
等)来处理多个客户端的连接。
IO多路复用允许单个线程监视多个文件描述符(即多个客户端连接),并在有IO事件发生时进行处理。
这使得单线程模型能够高效地处理大量并发连接,而不需要每个连接都创建一个新的线程。
2.3 高效的数据结构(底层数据结构)
Redis有五种基本的数据类型
数据类型 | 使用的数据结构 | 说明 |
---|---|---|
String | SDS | 简单动态字符串,预分配空间,O(1) 复杂度的长度查询 |
List | LinkedList、ZipList | 传统链表结构,适合频繁插入和删除;紧凑列表,适合少量小数据 |
Hash | HashTable、ZipList | 高效的键值对存储和查找,使用链地址法解决冲突 |
Set | IntSet、HashTable | 紧凑的数据结构,只存储整数,节省内存,适合快速查找 |
Sorted Set | SkipList、ZipList | 分层索引结构,平均 O(log N) 复杂度,适合快速插入、删除和查找 |
redis不是C语言写的嘛,后面经常可以看到各种数据结构在定义时,结构体中有个柔性数组,我们先介绍下柔性数组。
普通数组:数组的大小在定义时必须指定,数组的内存在编译时分配,大小固定,不可更改。
可以看到,普通的数组,大小固定,也不灵活,可能不够用,也可能用不够(浪费内存)。
柔性数组:C99标准引入的一种特性,主要用于结构体中。它允许在结构体中定义一个大小不固定的数组,通常用于需要动态分配内存的场景。
柔性数组必须是结构体的最后一个成员,并且不指定大小。例如:
需要使用
malloc
或者calloc
动态分配内存。例如:
然后呢,由于需要手动去动态内存分配,增加了编程的复杂性,还有就是自己写bug,容易内存泄漏。
举一个实际使用柔性数组的例子。
额,总结下,这个就是语法上的一个玩意,假设我想定义一个结构体,里面我想用到数组。
我定义的时候,我咋知道这玩意长度是多大?
咱们就可以定义成柔性数组,用的时候再填进去,只不过要记得先申请好内存,运行过程中呢,我也可以重新分配内存(如 realloc
)来调整柔性数组的大小。
我们可是狠狠赚一笔的Java程序员,早就忘了C语言了,上面叽叽歪歪的根本看不懂。
看下Java的数组,它可是改不了的。
完了!直接用数组,我点出来咋没有扩容的api,我想改大小怎么办。
但是馁,Java的ArrayList,似乎可以类比咱们的柔性数组,它就可以帮咱们扩充底层的Object[],必要时重新分配内存。
唉,那Java是不可以给数组搞个扩容的api吗?
Java 的设计理念倾向于通过更高层次的抽象来简化开发者的工作,而不是在底层数据结构上添加复杂的操作。
因此,咱们Java给你的是 ArrayList
等集合类来处理动态数组需求。
2.3.1 SDS
Redis没有使用传统的c字符串,而是自己搞了一个名为简单动态字符串(simple dynamic string,SDS)的玩意。
Reids自己构建的sds要比默认的c字符串性能更好,也更安全。
比如一个字符串叫"Redis"。
在传统C中:
C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符'\0'。
-
字符串是以
'\0'
结尾的字符数组。字符串的末尾是一个空字符,用于表示字符串的结束。 -
字符串
"Hello"
实际上在内存中的表示是{'H', 'e', 'l', 'l', 'o', '\0'}
。
在Redis中:
- free属性的值为0,表示这个SDS没有分配任何未使用空间。
- len属性的值为5,表示这个SDS保存了一个物字节长的字符串。
- buf属性是一个char类型的数组,数组的前五个字节分别保存了'R'、'e'、'd'、'i'、's'五个字符,而最后一个字节则保存了空字符'\0'。
关于末尾的'\0'
-
SDS遵循C字符串以空字符'\0'结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,
-
为空字符分配额外的1字节空间,添加空字符到字符串末尾等操作,都是由SDS函数自动完成的
-
遵循空字符串结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数。
好好好,类比Java,咱们现代的语言都封装了类似于String的类,当年的c语言啊用char[]一把梭还是太不方便了。
1.SDS将字符串长度获取优化到O(1)
这个比较明显,SDS直接存储了长度的,哪怕字符串很长,SDS获取长度也没啥问题,性能上没啥影响。
- C语言:O(N),必须遍历整个字符串。
- SDS:len属性直接记录了,O(1)。
2.SDS杜绝了相邻字符串溢出问题
还是先看看一个C的例子,假设我们内存中存储着2个紧邻的字符串。
- s1保存了字符串"Redis"
- s2则保存了字符串"MongoDB"
现在,我想将"Redis"修改为"Redis Cluster"。直觉告诉咱们,似乎,似乎好像得把"MongoDB"往后靠靠。
不然,就会出问题,"MongoDB"部分内容可能会被覆盖掉。
是的,C的字符串直接乱搞就是容易出问题,只不过咱们用C用的少了,都是高级语言,所以没啥感觉。
注意啊,这里是假设这两个玩意是相邻的,就是所谓的潜在的风险。
看个C的例子:
假设相邻,咱们可能输出成这样。
嗯,所以说呢,一般都会这样。给"Redis"这个字符串把空间搂大一点,先试试改成"Redis Cluster"能行不,长度不够,咱还要重新申请个够的空间。
麻烦吧,是挺麻烦的。
咱们的SDS相当于封装下上面这个安全操作的例子,通过自己的api操作,避免这个相邻字符串溢出问题。
3.SDS减少了修改字符串时的内存重分配次数
通过上面的例子,咱们也看到了,假设给"Redis"改成"Redis Cluster",放不下的话,我们需要重新申请个够用的内存空间。
当需要扩展字符串长度时,SDS 会提前预分配额外的空间。
这意味着即使一次修改需要少量空间,SDS 也会分配比实际需要更多的空间,以便未来的修改可以直接利用这些预分配的空间,而不需要再次重新分配内存。
即,发现要改长度的时候,我提前预分配一点。
- 小于1MB时:每次扩展时会分配原有长度的两倍空间。
- 大于等于1MB时:每次扩展时会分配原有长度加1MB的空间。
这种策略确保了在字符串长度不断增加时,可以尽量减少内存重新分配的次数,从而提高性能。
还记得前面的图和结构吗?free:记录buf数组中未使用字节的数量。
来个例子吧,假设就是上面这个"Redis"字符串,我们要加上"666"。
添加“666”前的状态
-
计算新字符串的长度
- 原始长度
len
= 5 - 新增长度
addlen
= 3 - 新的总长度
newlen
=len
+addlen
= 5 + 3 = 8
- 原始长度
-
检查是否有足够的空闲空间
-
当前的
free
= 0,显然不够。 -
重新分配空间
由于 newlen < 1024,因此按照规则,新长度加倍。
- 扩展后的新长度
newlen
=newlen
* 2 = 8 * 2 = 16
- 扩展后的新长度
-
更新SDS数据结构:
len
更新为 8free
更新为 16 - 8 = 8buf
添加新内容后的新缓冲区内容为{'R', 'e', 'd', 'i', 's', '6', '6', '6', '\0'}
最后变成这个样子
SDS的减少呢?减少长度:调整 len
和 free
,但不会立即回收内存。这有助于在未来的字符串增长操作中避免频繁的内存重新分配。
4.二进制安全
二进制安全(Binary Safe)是指字符串或数据结构能够处理包含任意字节值的数据,包括非打印字符和空字符(\0
)
-
C字符串的限制
在C语言中,传统的字符串是以空字符(
\0
)结尾的字符数组。这样的设计导致传统C字符串无法处理包含空字符的二进制数据,因为空字符会被解释为字符串的结束符。 -
SDS的优化
SDS在内部记录了字符串的长度,因此不需要通过空字符来标识字符串的结束,故可以包含任意字符,包括空字符(
\0
)。
2.3.2 LinkedList
注意2.3.6,早期Redis底层用的LinkedList和ZipList,在Redis3.2后,整合为快速列表(quicklist)。
双向链表是一种经典的数据结构,适用于包含大量元素或元素较大的列表。
Redis在元素数量较多或单个元素较大的情况下会使用双向链表来实现列表。
Redis中拥有4个节点的linkedlist示意图如下。
- 高效的插入和删除操作:在列表的任意位置进行插入和删除操作都是高效的。
- 双向遍历:可以从头到尾或者从尾到头遍历元素。
额,这里的LinkedList比较常见,不多说了。
嗯,注意下就是,咱们的LINDEX key index
命令时间复杂度为 O(N)。
双向链表嘛,查找指定下标的元素需要从链表的开头或结尾遍历元素,
2.3.3 ZipList
压缩列表是 List 、Hash、 Sorted Set 三种数据类型底层实现之一
压缩列表(Ziplist)是 Redis 中一种紧凑的数据结构,主要用于存储小规模的列表和哈希。
它是一种连续内存块,旨在减少小型列表或哈希的内存占用。
存储字符串和整数值。
- 整数:编码为实际整数而不是一系列字符
- 字符串:具体的字符
先来上个图。
又开始抽象了,啥玩意啊靠。
这个图,zl就是ZipList的简称。
-
头部
- zlbytes:列表占用字节数
- zltail:列表尾的偏移量
- zllen:列表中的 entry 个数
-
entry节点
- 额,就是一个个具体的数据节点。
-
尾部
- 结束标志符,值恒为
0xFF
。
- 结束标志符,值恒为
嗯,这里大概有点感觉了,就是把一堆数据仍在一起呗。
再来看下具体的entry元素。
-
previous_entry_length:前一个元素的长度,用于从当前元素向前遍历。
-
encoding:当前元素的编码类型及长度信息。
-
data:元素的具体数据。
现在我们大概有个印象了,等等,这玩意好像有点像数组啊。
特性 | 数组 | 压缩列表(Ziplist) |
---|---|---|
内存结构 | 连续内存块 | 连续内存块 |
元素类型和大小 | 固定类型和大小 | 可变类型和大小 |
内存管理 | 固定大小,初始化时分配 | 动态调整大小,插入/删除时调整 |
随机访问复杂度 | O(1),通过索引直接访问 | O(N),需要遍历和解析 |
插入操作复杂度 | O(N),插入位置后的元素需移动 | O(N),插入位置后的元素需移动 |
删除操作复杂度 | O(N),删除位置后的元素需移动 | O(N),删除位置后的元素需移动 |
存储效率 | 高,无额外元数据开销 | 高,通过紧凑存储减少内存开销,但有额外元数据 |
应用场景 | 需要快速随机访问,固定大小数据 | 小规模数据,高内存利用率,小型列表、哈希表、有序集合 |
好,那这玩意的优点就是内存开销小,没内存碎片,注意看那几个复杂度O(N),在数据量小的情况下,用用是没啥问题的。
-
小规模列表:适合存储元素数量较少且每个元素较小的列表。
-
小型哈希表:适合存储键值对数量较少且每个键值对较小的哈希表。
-
小型有序集合:适合存储元素数量较少且每个元素较小的有序集合。
额,咱们直接来个例子。
假设我们有一个压缩列表,用于存储字符串数据。我们将存储三个字符串:"hello", "world", "!"
- zlbytes:
19
(整个列表占用的字节数,包括头部和结束标志)。 - zltail:
13
(最后一个元素距离起始地址的偏移量,前两个元素加上头部bytes、tail、len这几个玩意,占13个字节)。 - zllen:
3
(列表中有三个元素)。 - entry:
- Entry 1:"hello" (长度5)
- Entry 2:"world" (长度5)
- Entry 3:"!" (长度1)
- zlend:
0xFF
插入:
插入一个新元素 "redis" 到 "world" 之后。
- 移动数据:需要移动 "!" 以腾出空间。
- 更新元数据:调整 zlbytes 和 zltail 的值。
- 插入元素:插入 "redis" (长度5)。
删除:
删除元素 "world"
- 移除数据:移除 "world",并移动 "redis" 和 "!" 填补空位。
- 更新元数据:调整 zlbytes 和 zltail 的值。
- 更新列表:删除后,元素数量减少。
查找:
希望查找元素 "world"
- 初始化:从列表的头部开始,读取第一个元素。
- 遍历:逐个检查每个元素的内容。
- 匹配:如果当前元素的内容与目标数据匹配,则返回该元素。
- 继续:如果不匹配,继续检查下一个元素。
- 结束:如果遍历到列表末尾(0xFF),则表示未找到目标数据。
哈哈,既然只存整数和字符串,我直接知道这个值了,又不是在值里面指向了某个玩意,我查出来干嘛呢?
哎,别犯浑,咱们要从整体的角度来看。
假设,我存的是一堆信息。
我想删除掉"msg666",看看我消息列表里还有啥玩意,那不就得查找给删除了。
现在是不是有点感觉了,就是我们要存一堆玩意,数据简单,量又不大,这个时候,用压缩列表,速度还行。
ziplist 适用于小规模、频繁读写的数据集合,但在处理较大数据集或频繁增删操作时,性能表现不佳。
哇靠,那链表也是一个个找的,看起来也是O(N)啊。
嗯,那么这里其实归根结底还是涉及到那个内存连续性的问题,看我最开始引用的文章:为什么读取连续内存没有比不连续的效率更高?
简单来说就是缓存命中率更高。咱们的cpu是有缓存机制的,会额外的取下旁边地址的东西缓存起来。
所以,我悟了。节点少的时候,ZipList不仅内存利用率更高,而且查询效率更快。
好,ziplist这玩意很抽象,我们最后再来总结下:
- 优点
- 内存高效:紧凑的内存结构,减少了内存碎片。
- 小数据存储:适用于存储小数据集合,读写操作效率高。
- 顺序存取:支持快速的顺序存取操作,适合于列表和哈希等结构。
- 缺点
- 大数据不适用:对于较大的数据集合,操作效率降低。
- 增删效率低:在列表中间进行增删操作时,可能涉及大量的内存拷贝,性能不佳。
- 固定长度:插入和删除元素时,需重新分配内存,影响性能。
2.3.4 HashTable
常见
2.3.5 IntSet
IntSet 是 Redis 中的一种内部数据结构,用于存储整数集合。
它被设计为在集合中的元素数量较少且都是整数时,通过紧凑的方式高效地存储这些元素。
IntSet
是无序的,并且不会包含重复的元素。
-
encoding
数据编码,表示intset中的每个数据元素用几个字节来存储。
- INTSET_ENC_INT16表示每个元素用2个字节存储
- INTSET_ENC_INT32表示每个元素用4个字节存储
- INTSET_ENC_INT64表示每个元素用8个字节存储。故intset中存储的整数最多只能占用64bit。
-
length
表示intset中的元素个数。
encoding
和length
两个字段构成了intset的头部(header)。 -
contents[]
是一个柔性数组,这个数组的总长度(即总字节数)等于
encoding * length
。
2.3.6 SkipList
跳表是一种分层的链表结构,每一层都是一个有序链表。最底层的链表包含所有元素,而每一层的链表是其下一层链表的子集。每个节点包含多个指向其他节点的指针,这些指针指向同一层的下一个节点或更高层的节点。
这讲的啥啊靠。
管他呢,既然是所谓的分层链表,我们先看一个常规的链表,7个节点。
上八股!链表的查询复杂度是O(N),得一个个往后找。
假设我找31,额,1-8-11-12-26-31。
添加或者删除呢,那不也得先找到了再操作。
比如我要在31后面再加个66。
加倒是很快哈,改下前后指向就行了,就是前面找到这玩意比较费时,走了6次。
那么,传统链表的优缺点就出来了。
- 增、删、改的操作几乎不耗时
- 查找操作,O(N)。但是,我增、删、改,它依赖查找啊~
哇靠,这个查找操作看似是关键啊,能优化吗?
我们将有序链表的部分节点分层,每一层都是一个有序链表。
- 在查找时优先从最高层开始向后
- 当到达某节点时,关注其next节点值。
- 如果next节点值小于要查找的值,则继续往后。
- 如果next节点值大于要查找的值 或 next指向NULL,则从当前节点下降一层,再继续向后查找。
好,这就是跳表。让我们分析一手,刚才那个查询31的啊,还记得跑了6次吗。
-
层数2:由1查找,下一个节点是11。
- 11的next节点是null,无法移动了,下降,到第1层。
-
层数1:回到11这个节点中
- 11的next节点是26,比预期值31小,移动到26这个节点。
- 26的next节点是61,比预期值31大,下降,到第0层。
-
层数0:回到26这个节点中
- 下一个值就是预期值31
唉,似乎,好像。
跳跃表的思想就是,在层数高及节点数量比较多时,可以跳过一些节点,这样查询效率就会大大提升。
最后,注意一下,因为我们是在比大小,所以,元素得是有序的。你看最开始的图,是SortZet用到了跳跃表。
结束本章前,我们再讨论一个问题:
Mysql的B+树和咱们Redis的跳表,时间复杂度都是O(logn),看起来跳表实现比B+树实现简单多了,咱Mysql咋不用跳表?
由于复习进度,这里我先不写了。
大概原因就是假设打满数据,2000w条。
Mysql的话大概是3层,跳表的话就是2分的思想,理想状态应该约为:
$$
\log_2(2000 \times 10^4) \approx 24.25
$$
这样看来,虽然跳表不用处理树节点那么多玩意,插入更快,但是查询时,IO次数多多了。
2.3.7 QuickList
quickList 是 Redis 中用于实现 list 数据结构的一种特殊数据结构,它在Redis 3.2版本之后被引入。
quickList 结合了 zipList 和 linkedList 的特性,将多个 zipList 通过双向链表连接起来,从而实现了高效的内存压缩和快速的读写操作。
嗯,前面提到了,就是将Node节点换成了ZipList,在Node节点定义能看到。
好,现在扯扯一个问题,为什么要改成QuickList?
这是我的理解哈
- 逻辑更明了。咱List的底层也不扯东扯西了,底层实现就是快速列表,逻辑更清晰了。哈哈,这点有点玄幻。
- 查询不一定慢。咱们的链表,查询的时候是O(n),我现在找到节点还要进去压缩列表,看起来麻烦了啊?
- 首先,在量小的情况下,尤其是只有2个元素的时候,压缩列表是O(1)的。
- 其次,哪怕压缩列表里的元素多了,退化到O(n),但是咱们链表上Node少了呀。
- 总的来说,不说比你开始LinkedList快,起码不比你慢多少吧。
- 遍历场景性能更优。我觉得这个点很重要,咱们压缩列表由于地址连续,遍历这种场景性能明显好多了(前文提到的缓存命中率)。
- 空间利用率更高。我都是内存数据库了,我还不爱内存吗,降低内存碎片(减少了内存分配和释放的频率,从而降低了碎片产生的概率)。
3.缓存三兄弟
“缓存三兄弟”通常是指缓存、持久化和高可用性,这是缓存系统的三个核心概念。
1.1 缓存
缓存是将数据暂时存储在高速访问的存储介质(如内存)中,以提高数据访问速度。
Redis 作为一个内存数据库,主要用于缓存数据。
缓存的优点包括提高系统响应速度和减少数据库负载。
1.2 持久化
持久化是指将数据从易失性存储(如内存)保存到非易失性存储(如磁盘)中,以确保数据在系统重启或故障时不会丢失。
1.3 高可用
高可用性是指系统能够在部分组件失效的情况下继续提供服务。
Redis 通过主从复制(Replication)和哨兵(Sentinel)机制来实现高可用性。
主从复制可以在主节点故障时迅速切换到从节点,哨兵机制则负责监控 Redis 实例并自动进行故障转移。
4.Redis配置有效期
-
命令行输入
使用
CONFIG SET
命令在运行时动态修改配置。例如,CONFIG SET maxmemory 100mb
。这些更改是临时的,Redis重启后会失效。 -
配置文件设置
在
redis.conf
文件中进行永久性配置,例如maxmemory 100mb
。这些更改在Redis重启后仍然有效。
__EOF__

本文链接:https://www.cnblogs.com/yang37/p/18233442.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!