决战圣地玛丽乔亚Day44---Redis数据结构的底层实现和高可用
Redis为什么快?
前面讲了独有的数据结构+IO模型的选用。
除此之外还有什么因素的加持呢?
1.内存存储,避免磁盘IO的开销,提高读取速度
2.网络模型,使用Reactor模型,处理大量连接请求,同时保持低延迟和高吞吐
3.单线程处理请求。但是RDB,AOF等场景会用到多线程模式。
所以Redis的速度瓶颈是内存和网络带宽
单线程可以避免很多并发的问题,避免上下文切换的消耗
4.IO多路复用模型。
Redis数据结构的底层逻辑?为什么这么设计?
https://baijiahao.baidu.com/s?id=1739547372042800800&wfr=spider&for=pc
前面大概讲了一下数据结构的概念,接下来需要深入讨论一下底层是如何设计的?为什么Redis需要这样设计?
String:
底层是字节数组构成字符串,数据结构是SDS。
SDS的好处之前已经提过了,动态分配,二进制安全传输,惰性删除。
SDS还实现了字符串的拼接,字符串长度计算,字符串的截取等提高字符串的效率。
Redis对字符串的设计兼顾了内存效率,安全和处理效率。
Hash:
底层采用ziplist和hashtable两种形式:
如果hash对象保存的键值对数量小于512且键值对的字符串长度都小于64字节。使用ziplist,否则用hashtable
ziplist:
<uint32_t zlbytes>: 一个无符号整数,用于保存ziplist占用的字节数,包括zlbytes字段本身的四个字节。需要存储这个值,以便能够调整整个结构的大小,而不需要首先遍历它。
<uint32_t zltail> : 列表中最后一个条目的偏移量。
<uint16_t zllen> : 条目的数量。当有超过2^16-2个条目时,这个值被设置为2^16-1,我们需要遍历整个列表来知道它包含多少项。
<uint8_t zlend> : 一个特殊的条目,表示 ziplist 的结尾
entry:
prev_length:确定entry的长度,以找到下一个entry的位置
encoding:确定entry存储的数据类型和内容
Entry是存储信息的媒介。可以把相邻的Entry作为键值对。但是Redis为了提高存储效率,把多个Entry看做一个节点,
第一个entry存储节点的长度信息,长度是固定的。然后用剩下的Entry存放多组键值对,存不下需要新开节点存。
要存键值对,至少需要两个Entry,一个键一个值。
HahsTable实现Hash结构:
hashTable就是dict的实现。
1 2 3 4 5 6 7 | typedef struct dict { dictType *type; // 类型特定函数 void *privdata; // 私有数据 dictht ht[ 2 ]; // 哈希表 long rehashidx; // rehash 索引 unsigned long iterators; // 正在迭代的迭代器数量 } dict; |
1 2 3 4 5 6 | typedef struct dictht { dictEntry **table; // 哈希表数组 unsigned long size; // 哈希表大小 unsigned long sizemask; // 哈希表大小掩码,始终等于 size - 1 unsigned long used; // 已使用的节点数量 } dictht; |
1 2 3 4 5 6 7 8 9 10 | typedef struct dictEntry { void *key; // 键 union { void *val; // 值 uint64_t u64; int64_t s64; double d; } v; struct dictEntry *next; // 指向下一个键值对的指针,用于解决哈希冲突 } dictEntry; |
为什么Hash的时候,数据量小的时候用ziplist,数据量大的时候用hashTable?
1.HashTable 可以提高性能。当 Hash 中的元素数量比较大,或者元素的值比较大(比如对象、数组等)时,使用 HashTable 可以提高性能。因为 HashTable 采用了哈希表的数据结构,可以快速定位到对应的键值对,而且 HashTable 的查找、插入、删除等操作都可以在常数时间内完成,因此在数据量比较大的情况下,使用 HashTable 是更加高效的选择。
2.ziplist 可以节省内存。当 Hash 中的元素比较小且元素值也比较小(比如字符串、整数等)时,使用 ziplist 可以节省内存。因为 ziplist 是一种连续的内存结构,不需要像 HashTable 一样分配大量的指针和额外的内存空间,因此在数据量比较小的情况下,使用 ziplist 是更加节省内存的选择。
3.在数据量不确定的情况下,使用 HashTable 是更加稳定的选择。因为在数据量不确定的情况下,如果始终使用 ziplist,可能会因为数据量过大而导致内存占用过高,甚至导致内存溢出。而使用 HashTable,则可以根据实际数据量动态调整哈希表的大小,保证内存占用在可控范围内。
简单来说,数据量小的时候,使用ziplist更紧凑。hashtable分配的空间多,数据量小使用比较浪费。
hashtable的性能比ziplist要高,所以数据量多用hashtable。
List:
Ziplist和linkedList和quickList
ziplist:
一整块连续内存存储,利用率高。
修改操作性能差,每次数据变动都会引起内存realloc
当 ziplist 长度很长的时候,一次 realloc 可能会导致大批量的数据拷贝,进一步降低性能
linkedlist:
两端的push和pop操作方便,但是内存开销大
除了保存数据之外还要保存双端指针,就算是一个节点也需要头尾指针。
地址不连续,容易产生碎片
quicklist:
quicklistNode中有指向压缩列表的指针
空间效率和时间效率折中
结合linkedlist和ziplist的优点
使用选择:
ziplist在数据量小的时候选用,内存利用率高,但是只支持基础的插入,删除,访问,迭代,不支持高级操作。
linkedlist在数据量大的时候选用,方便头尾操作,所以频繁的操作头尾数据的时候可以使用。
quicklist通过ziplist来提高内存使用率,同时可以存储大量数据。需要注意quicklist的每个ziplist节点大小固定,如果超出单节点大小会分配新节点,就会导致内存使用率不如linkedlist。
Set:
hashTable和intset
inset:
- 结合对象保存的所有元素都是整数值
- 集合对象保存的元素数量不超过512个
整数数组,升序排列,查询方式一般是二分查找,一般是连续内存空间,对cpu高速缓存支持友好。
其他情况使用hashtable。
ZSet:
skiplist和ziplist
ziplist前面讲过,适用于存储元素数量比较少、并且每个元素的值比较小的 Sorted Set。
skiplist 的节点是按照元素值的大小进行排序的,并且可以支持范围查询等操作。在元素数量比较多、或者需要支持范围查询等操作的情况下,可以选择使用 skiplist 来实现 Sorted Set
为什么不用红黑树或者B+树之类的?
他们的效率都是Ologn
1.跳表实现简单,易读易于实现。
2.跳表的每个节点只记录下一层节点指针,B+树需要记录所有子节点。所以存储相同节点,跳表用的内存更少
但是需要注意,跳表说到底还是空间换时间的做法,如果索引层数过高也不行,所以插入节点会插入到随机的层数来控制层数不会过大。
3.跳跃表对于范围查询的支持不如B+ Tree,因为在跳跃表中,范围查询需要遍历整个有序集合,从最上层开始找,记录每一层,这会导致查询性能变得非常低效。而在B+ Tree中,范围查询只需要遍历部分叶子节点即可,因此B+ Tree对于范围查询具有更好的支持
作者关于Redis为什么选用跳表作为ZSET的实现的解释:
可以看到redis选择跳跃表而非红黑树作为有序集合实现方式的原因并非是基于并发上的考虑,因为redis是单线程的,选用跳跃表的原因仅仅是因为跳跃表的实现相较于红黑树更加简洁。
高可用:
1.数据持久化
宕机的后果:
1)访问请求打到数据库,数据库可能会挂掉
2)内存数据丢失
3)IO速度从内存级别到磁盘级别,性能急速下降
由于宕机的后果如此严重,我们可以通过RDB和AOF来提高服务的可用性。
宕机如果不可避免,我们就要想如何把数据恢复回来,不能说我宕机时候的数据就任由它丢失掉了。
首先:数据时存在内存中,宕机会丢失,那么存在磁盘是不是会好一些?那么如何存在磁盘上,如果每次写数据要同步到磁盘,那消耗太大了。如果同步到磁盘,还要尽量保持数据的一致性。
所以现在要解决的问题就是:如果把数据做备份?如何保证数据的一致性?恢复的速度能不能尽可能的快?
方法一、内存快照
RDB(全量):
把某一时刻的数据用快照形式,持久化到RDB文件中,相当于我保留一个存档。
那么多久存一次档比较合适?
生成快照的方式有两种:
save:同步阻塞,等快照存完再开始所有工作。
bgsave:非阻塞。fork一个子线程来完成持久化到快照的工作,主线程不受影响。
使用写时复制技术:
- fork出的子进程共享父进程的物理空间,当父子进程有内存写入操作时,read-only内存页发生中断,将触发的异常的内存页复制一份(其余的页还是共享父进程的)。
- fork出的子进程功能实现和父进程是一样的。如果有需要,我们会用
exec()
把当前进程映像替换成新的进程文件,完成自己想要实现的功能。
首先fork一个子进程处理快照持久化操作。子进程和父进程共享内存数据,所以子进程就读父进程的内存数据写到RDB文件中(父进程不受任何影响继续处理写操作)
如果父进程数据变动,read-only内存页发生中断,把触发异常的内存页复制一份副本出来(注意这里的副本虽然把触发异常的内存页复制出来,但是给出的副本是全量的),让子进程去读取写入到RDB文件中。
这样设计,在保证完整性的同时,又不会干扰主进程的修改操作。
但是RDB的方式比较耗性能,因为是全量的数据文件快照
fork 出来的 bgsave 子进程因为共享主线程的数据,一定程度上会阻塞主线程的运行,主线程的内存越大,阻塞时间越长。
RDB 建议采用二进制 + 数据压缩的方式写磁盘,文件体积小,数据恢复速度快。
优点:文件体积小,恢复速度快,写时复制技术不干扰主进程
缺点:性能消耗
AOF(增量):只记录对内存进行修改的指令记录。
模式一:写前日志,修改数据之前先记录日志,再修改。
模式二:写后日志,数据实际修改后,再记录日志。
Redis 接收到 set keyName someValue
命令的时候,会先将数据写到内存,Redis 会按照如下格式写入 AOF 文件。*3
:表示当前指令分为三个部分,每个部分都是 $ + 数字
开头,后面是3部分的具体内容:指令、键、值。数字
:表示这部分的命令、键、值多占用的字节大小。比如 $3
表示这部分包含 3 个字符,也就是 set
的长度。
写后日志比较推荐,因为不需要检查语法是否正确,写前日志要对指令的语法正确性进行检查。减少了开销。
写前日志会阻塞一下写指令的执行。写后日志不会。
AOF日志示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | # set keyName someValue * 3 $ 3 set $ 7 #长度为 7 keyName $ 9 #长度为 9 someValue # 执行 mset key1 1 ,key2 2 ,key33 3 # aof日志如下: * 7 # 本批命令需要往下读 7 行非 $ 开始的命令 $ 4 #接着读取 4 个字节宽度,‘mset’长度为 4 ,记为 $ 4 mset $ 4 #接着读取 4 个字节宽度,‘key1’长度为 4 ,记为 $ 4 key1 $ 1 #接着读取 1 个字节宽度,‘ 1 ’长度为 1 ,记为 $ 1 1 $ 4 key2 $ 1 2 $ 5 #接着读取的字节宽度,‘$key33’长度为 5 ,记为 $ 5 key33 $ 1 3 |
存在的问题:
1.写后日志。如果写后,没来及记录日志就宕机,命令数据丢失
2.AOF写后日志虽然不对当前写命令阻塞,但是是在主线程执行,磁盘压力会变大导致速度慢。
写回策略:
写的时候,把数据先写到内存的缓存中,而不直接写入内存,等缓存满了再写到磁盘。
如果服务器宕机怎么办?难道让缓存中的数据都丢失?
系统提供了fsync和fdatasync两个同步函数,可以强制把操作系统把缓存区数据同步到磁盘,从而确保数据安全性。
为了解决这个问题,AOF提供了参数appendfsync来控制持久化的效率和安全性:
always:同步写回可以做到数据不丢失,但是每次执行写指令都需要写入磁盘,性能最差。写前进行fsync
everysec:每秒写回,避免了同步写回的性能开销,但是如果服务发生宕机,会有大约1s时间周期的数据丢失,这种模式是在性能和可靠性之间做了妥协。
no:操作系统控制,执行写指令后就写入 AOF 文件缓冲,再执行后续的写磁盘指令,性能最好,但有可能丢失更多的数据
AOF重写机制:
为了避免AOF的文件越来越大提供了AOF重写机制。
由于AOF文件对每个键值对的操作可能不止一条,但是我们需要的只是最新数据,不用管之前是如何修改过来的过程。所以AOF重写相当于重新获取一遍最新的数据,舍弃了其中的过程指令。
1.触发重写,执行bgrewriteaof。
2.父进程fork子进程进行重写,fork子进程的同时父进程阻塞(因为需要复制父进程的页表等数据,阻塞时间和页表大小有关),fork完毕父进程继续接受指令。(子进程相当于fork时刻的父进程快照,和父进程共享那一刻的内存数据read-only)
3.子进程在创建AOF文件的同时,父进程继续处理命令请求,如果父进程修改了key-value,要同时把这个修改命令追加到AOF缓冲区和AOF重写缓冲区
4.子进程完成AOF重写工作后,发送信号给主进程。主进程接受信号后:把AOF重写缓冲区内容追加到新的AOF文件。新AOF文件改名覆盖现有文件。
RDB+AOF混合模式:
将 RDB 文件的内容和 rdb快照时间点之后的增量的 AOF 日志文件存在一起。这时候 AOF 日志不需要再是全量的日志,而是最近一次快照时间点之后到当下发生的增量 AOF 日志,通常这部分 AOF 日志很小
执行顺序:
如果存在RDB先加载RDB再重放AOF
如果没有RDB,就以AOF格式重放整个文件
这样快照就不用频繁的执行,同时由于 AOF 只需要记录最近一次快照之后的数据,不需要记录所有的操作,避免了出现单次重放文件过大的问题。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)