Redis 没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:
故Redis 构建了一种新的字符串结构,称为简单动态字符串,简称SDS
。
SDS 的本质是一个结构体:
例如一个包含字符串“name” 的sds 结构如下:
它是根据len去读取字符串的。
SDS具备动态扩容的能力,如果要给 SDS 追加一段字符串,首先先会申请新内存空间;
内存预分配
当我们申请内存的时候应用程序无法操作硬件,跟内核进行交互,从用户态切换为内核态,申请内存这个动作非常消耗资源。故Redis提供了内存预分配,从而提高性能。
SDS的优点:
IntSet 是 Redis 中 set 集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序
等特征
结构如下:
其中的 encoding 包含三种模式,表示存储的整数大小不同:
contents[] 数组就相当于一个指针,指向数组第一个元素的位置。
为了方便查找,Redis 会将 intset 中所有的整数按照升序依次保存在 contents 数组中,结构如下:
现在数组中的每个数字都在 int16_t 的范围内,因此采用的编码方式是INTSET_ENC_INT16,每部分占用的字节大小位:
假如有一个intset,元素为 [5,10,20],采用的是 INTSET_ENC_INT16,则每个整数占2字节;
我们向其中添加一个数字:50000,这个数字超出了 int16_t的范围,intset 会自动升级编码方式到合适的大小。
IntSet 可以看做是特殊的整数数组,具备一些特点:
1、Redis 会确保 IntSet 中的元素唯一、有序
2、具备类型升级机制,可以节省内存空间
3、底层采用二分查找方式来查询
总的来说就是如果数据量不是很大用intset合适,若数据量特别大,则intset效率会低
Redis 是一个键值型(Key-Value)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict 来实现的。
Dict 由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)
当我们向 Dict 添加键值对时,Redis 首先根据 key 计算出 hash值(h),然后利用 h & sizemask 来计算元素应该存储到数组中的哪个索引位置。
size - 1 才能确保低位全是1
它添加元素的时候使用的是头插法。
字典的基本结构如图:
Dict 中的 HashTable 就是数组结合单链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict 在每次新增键值对时都会检查负载因子
(LoadFactor = used/size),满足以下两种情况之一时会触发哈希表扩容:
当每次删除元素的时候,也会对负载因子做检查,当LoadFactor < 0.1 时,会做哈希表收缩;
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size 和 sizemask 变化,而key 的查询与 sizemask 有关,因此必须对哈希表中的每一个 key 重新计算索引,插入新的哈希表,这个过程称为 rehash。
Dict 的 rehash 并不是一次性完成的,如果 Dict 中包含数百万的 entry,要在一次 rehash 完成,极有可能导致主线程阻塞,因此 Dict 的 rehash 是多分次、渐进式的完成,因此称为渐进式 rehash
。
1、计算新 hash 表的realeSize,值取决于当前要做的是扩容还是收缩:
2、按照新的realeSize 申请内存空间,创建dictht,并赋值给dict.ht[1]
3、设置dict.rehashidx = 0,表示开始rehash
4、每次执行增删改查操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry 链表 rehash 到 dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash 到 dict.ht[1]
5、将dict.ht[1]赋值给 dict.ht[0],给dict.ht[1] 初始化为空哈希表,释放原来的dict.ht[0] 的内存
6、将 rehashidx 赋值为 -1,代表 rehash 结束
7、在 rehash 过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在 dict.ht[0] 和 dict.ht[1] 依次查找并执行。这样可以确保 ht[0] 的数据只减不增,随着rehash 最终为空
ZipList 是一种特殊的 “双端链表”,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作,并且读操作的时间复杂度为O(1).
entry 长度不固定是为了节省内存,如果比如是固定8字节的话,如果有些entry只占一个字节,就会浪费内存。
ZipList 中的Entry 并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用以下结构:
注:ZipList 中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。例如:数值0x1234,采用小端字节序后实际存储值为:0x3412
ZipListEntry 中的encoding 编码分为字符串和整数两种:
它是用两个二进制位来标识是字符串还是整数
字符串:如果encoding 是以 “00”、“01” 或者 “10” 开头,则证明content是字符串
例如我们要保存字符串:“ab” 和 “bc”
存储“ab”时
整数:如果encoding是以“11”开始,则证明content是整数,且encoding固定只占用1个字节
例如,一个ZipList 中包含两个整数值:“2” 和 “5”
最后整个ZipList的结构如下:
ZipList各种各样的编码,最终的目的就是节省内存,它的使用限制是:在遍历时,只能从前遍历或者从后遍历,如果有很多节点,刚好要遍历的那个节点在中间,则效率就很低,所以ZipList在使用时对节点数量有限制
ZipList 的每个 Entry 都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:
假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图:
这时有一个新的entry要插入队首,并且这个entry的大小为254,故记录的时候要用5个字节记录,它原本是250字节,现在增加了4个字节,导致超出了254,后面的那些entry要记录你的长度,故后面的长度也变为254,后面的后面也是如此。
ZipList 这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新
。新增、删除都可能导致连锁更新的发生。连续更新问题会导致内存申请、销毁、数据迁移,对性能影响非常大
这种问题发生的概率极低,因为这个问题发生的条件是有N个连续的、长度为250~253字节之间的entry。目前这个问题 Redis 作者没有解决。新版的Redis作者引入了一个新的数据结构叫 ListPack(紧凑列表),只是在Stream结构底层使用了,并没有用到常见的数据结构,可能是因为改动太大,并没有修改它。
ZipList 虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请效率低。该如何做?
我们必须限制ZipList的长度和entry的大小
我们要存储大量数据,超出了ZipList最佳的上限该怎么办?
可以创建多个 ZipList 来分片存储数据
数据拆分后比较分散,不方便管理和查找,多个 ZipList 如何建立联系
Redis 在3.2 版本引入了新的数据结构 QuickList
,它是一个双端链表,只不过链表中的每个节点都是一个 ZipList。
为了避免 QuickList 中的每个 ZipList中的entry 过多,Reids 提供了一个配置项:list-max-ziplist-size 来限制
1、如果值为正,则代表 ZipList 的允许的entry个数的最大值
2、如果值为负,则代表 ZipList 的最大内存的大小,分5种情况
其默认值为 -2
除了控制 ZipList 的大小,QuickList 还可以对节点的 ZipList 做压缩。通过配置项 list-compress-depth 来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数:
默认值是 0。
QuickList 结构
QuickList 的特点:(结合了链表和 ZipList 的优点)
SkipList 首先是链表,但与传统链表相比有几点差异:
跳表允许存储的元素量可以很大,并且查询性能也高
SkipList 结构:
SkipList 的特点:
Redis 中的任意数据类型的键和值都会被封装为一个 RedisObject,也叫做 Redis对象
Redis 中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型
Redis 中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:
String 是 Redis 中最常见的数据存储类型
其基本编码方式 RAW,基于简单动态字符串(SDS)实现,存储上限为512mb
如果存储的SDS长度小于44字节,则会采用 EMBSTR 编码,此时 object head 与 SDS 是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高
选 44 字节的原因:
SDS 头占了3字节,尾占了1字节,合在一起就是48字节,再加上RedisObject 的头是16字节,总共64字节。64字节的特殊之处就是和Redis内存分配有关。Redis 内存分配算法在分配内存时,会以2的n次方去分配内存。64恰好是一个分片大小,故不会产生内存碎片
,推荐使用String时尽可能不要让字符串超过44字节。
如果存储的字符串是整数值,并且大小在 LONG_MAX 范围内,则会采用INT 编码:直接将数据保存在 RedisObject 的 ptr 位置(刚好8字节),不再需要SDS了
Redis 的 List 结构类似一个双向链表,可以从首、尾操作列表中的元素:
List 的结构:
Set 是 Redis 中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高。
判断是否要切换编码方式
Set 的结构:
Zet也就是 SortedSet,其中每一个元素都需要指定一个score值和member值:
故zset底层数据结构必须满足 键值存储、键必须唯一、可排序这几个需求。
1、SkipList:可以排序,并且可以同时存储score 和 ele值(member)
HT(Dict):可以键值存储,并且可以根据key 找 value
ZSet结构图:
当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:
① 元素数量小于zset_max_ziplist_entries,默认值128
② 每个元素都小于zset_max_ziplist_value字节,默认值64
ziplist 本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:
部分源代码:
Hash 结构与Redis 中的 ZSet 非常类似:
区别如下:
因此,Hash 底层采用的编码与 ZSet 也基本一致,只需要把排序有关的 SkipList 去掉即可:
部分源码:
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:
Linux 系统为了提高 IO 效率,会在用户空间和内核空间都加入缓冲区:
在这过程中影响效率的有:用户在等待数据时要花时间,还有数据的拷贝,比较影响效率。
在数据等待和数据拷贝阶段,用户进程都处于阻塞等待状态
非阻塞 IO 的recvfrom 操作会立即返回结果而不是阻塞用户进程。
在非阻塞IO 模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU 空转,CPU使用率暴增
无论是阻塞 IO 还是非阻塞 IO,用户应用在一阶段都需要调用 recvfrom 来获取数据,差别在于无数据时的处理方案:
没有数据
,阻塞 IO 会使进程阻塞,非阻塞 IO 使 CPU 空转,都不能充分发挥 CPU 的作用。有数据
,则用户进程可以直接进入第二阶段,读取并处理数据比如服务端处理客户端 Socket 请求时,在单线程情况下,只能依次处理每一个 socket,如果正在处理的 socket 恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有其他客户端socket 都必须等待,性能自然很差。
如果要增高效率,有两种方法:
①、增加更多的线程(多线程)开销大
②、谁的数据就绪了,用户就去读取数据(用户进程如何知道内核中数据是否就绪)
文件描述符(File Descriptor):简称FD,是一个从0开始递增的无符号整数,用来关联linux中的一个文件。在linux中一切皆文件,例如常规文件、硬件设备、网络套接字(socket)
IO 多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源。(复用的就是这个线程
)
监听 FD 的方式、通知的方式又有多种实现,常见的有:
差异:
信号驱动 IO 是内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 就绪时,会发出 SIGIO 信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待
缺点:当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出;而且内核空间与用户空间的频繁信号交互性能也较低
异步 IO 的整个过程都是非阻塞的,用户进程调用完异步 API 后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
在异步 IO 模型中,用户进程在两个阶段都是非阻塞状态 。
缺点:在高并发情境下,不停的去处理请求,不停地交给内核去处理,内核中积累的IO读写的任务越来越多,因为 IO 读写的效率低,每增加一次任务,可能就有大量的内存消耗。在高并发下,很有可能导致系统因为内存占用过多导致崩溃。
如果要使用异步IO,必须做好对高并发访问的限流
Redis 到底是单线程还是多线程?
在 Redis 版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
为什么 Redis 要选择单线程?
Redis 是纯内存操作
,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升上下文切换
,带来不必要的开销在 Redis 中可以通过 expire
命令给 Redis 的 key 设置TTL(存活时间)
当 key 的 TTL 到期以后,再次访问 name 返回的是 nil,说明这个key已经不存在了,对应的内存也得到释放。从而起到内存回收的目的。
Redis 本身是一个典型的 key-value 内存存储数据库,因此所有的key、value都保存在 Dict 结构中。不过在其 database 结构体中,有两个 Dict:一个用来记录 key-vlaue;另一个用来记录 key-TTL。
问题:
① Redis 是如何知道一个 key 是否过期呢?
② 是不是TTL到期就立即删除了呢?
惰性删除:并不是在 TTL 到期后就立即删除,而是在访问一个key的时候,检查该 key 的存活时间,如果已经过期才执行删除。
部分代码:
存在问题:有一些key 已经过期了,但是很长很长时间都没有人来访问它们,极端情况下再也没人访问,那么这些key 永远不会被删除。
周期删除:是通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。执行周期有两种:
SLOW模式规则:
FAST模式规则(过期key 比例小于10%不执行):
FAST 它不会对主线程造成太多的阻塞。
**内存淘汰:**就是当 Redis 内存使用达到设置的阈值时,Redis 主动挑选 部分key 删除以释放更多内存的流程.
Redis 会在处理客户端命令的方法 processCommand() 中尝试做内存淘汰:
Redis 支持8中不同策略来选择要删除的 key:
容易混淆的有两个:
LRU(Least Recently Used),最少最近使用
。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
LFU(Least Frequently Used),最少频率使用
。会统计每个 key 的访问频率,值越小淘汰优先级越高。
Redis 的数据都会被封装为 RedisObject 结构:
LFU 的访问次数之所以叫做 逻辑访问次数,是因为并不是每次key 被访问都计数,而是通过运算:
① 生成0~1之间的随机数R
② 计算 1 / (旧次数 * lfu_log_factor + 1),记录为P,lfu_log_factor 默认为10
③ 如果 R < P,则计数器 + 1,且最大不超过255
④ 访问次数会随着时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟(默认1),计数器 - 1
淘汰流程图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aUGBJucF-1665103615003)(E:\Typora笔记图片\image-20220721100906139.png)]
om:对设置了TTL 的key,随机进行淘汰。也就是从 db->expires中随机挑选。
容易混淆的有两个:
LRU(Least Recently Used),最少最近使用
。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
LFU(Least Frequently Used),最少频率使用
。会统计每个 key 的访问频率,值越小淘汰优先级越高。
Redis 的数据都会被封装为 RedisObject 结构:
[外链图片转存中…(img-SATXcXQ3-1665103615003)]
LFU 的访问次数之所以叫做 逻辑访问次数,是因为并不是每次key 被访问都计数,而是通过运算:
① 生成0~1之间的随机数R
② 计算 1 / (旧次数 * lfu_log_factor + 1),记录为P,lfu_log_factor 默认为10
③ 如果 R < P,则计数器 + 1,且最大不超过255
④ 访问次数会随着时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟(默认1),计数器 - 1
淘汰流程图