1. 数据结构
1-1. 动态字符串(SDS)
保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。
不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:
- C语言字符串底层是字符数组。获取字符串长度需要遍历字符数组个数,并减去结束标识:‘
\0
’。(时间复杂度O(n)) - 非二进制安全。(如果字符串包含‘
\0
’字符,会被当作结束标识符) - 不可修改。(字面量的字符串保存在常量池中,无法修改。如果需要修改要申请更多的内存空间将原先的字符数组放进去)
基于以上的问题,Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS
。
Redis是C语言实现的,其中SDS是一个结构体
,源码如下:
alloc:buf申请的内存,第一次申请的大小与len一样,之后都要比len大。
(name占4个字节,所以使用SDS_TYPE_8(255)即可)
相比于C语言字符串的优点:
- 获取字符串长度的时间复杂度为O(1)
- 支持动态扩容
- 减少内存分配次数
- 二进制安全
总结:
- Redis 字符串的底层实现的是简单动态字符串(
SDS
)。它是C语言的一个结构体。它其中有4个属性:已保存的字节数
、已分配的字节数
、SDS的头类型
、字符数组
。 - Redis为什么不使用原生C语言的字符串,而使用SDS呢?
- C语言的字符串要获取字符串长度时,需要遍历整个字符数组,时间复杂度O(n)。SDS可以直接从结构体的len属性读取,时间复杂度O(1)
- C语言的字符串非二进制安全,如果存储的字符是结束标识符,那么统计长度的时候会被结束。SDS是二进制安全的,直接读取len属性。
- SDS支持动态扩容,如果字符数组不足时,会自动申请空间,而且会进行内存预分配。
1-2. Intset
IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序(可以进行二分查找) 等特征。
结构如下:
其中的encoding包含三种模式,表示存储的整数大小不同:
Intset可以看做是特殊的整数数组,具备一些特点:
- Redis会确保Intset中的元素唯一、有序
- 具备类型升级机制,可以节省内存空间
- 底层采用二分查找方式来查询
总结:
- IntSet是Redis中set集合的一种实现方式,基于C语言的整数数组来实现,并且具备长度可变、有序、唯一等特征。
- 具备类型升级机制,可以节省内存空间
- 不适合存储大量的数据,因为底层是数组,需要连续的内存空间。
1-3. Dict
我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。
Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)
Dict的结构:
- dictht 类似java的HashTable,底层是数组加链表来解决哈希冲突
- Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash
Dict的伸缩:
- 当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
- 当LoadFactor小于0.1时,Dict收缩
- 扩容大小为第一个大于等于used + 1的2^n
- 收缩大小为第一个大于等于used 的2^n
- Dict采用渐进式rehash,每次访问Dict时执行一次rehash
- rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表
1-4. ZipList
ZipList是一种类似于“双端链表
”的结构,由一系列特殊编码的连续内存块
组成。可以在任意一端进行压入/弹出操作,并且该操作的时间复杂度为O(1)。
特性:
- 压缩列表的可以看做一种
连续内存空间
的"双向链表" - 列表的节点之间
不是通过指针连接
,而是记录上一节点和本节点长度来寻址
,内存占用较低 - 如果列表数据过多,导致链表过长,可能影响查询性能
- 增或删较大数据时有可能发生连续更新问题
总结:
- ZipList:压缩列表,可以从双端访问,采用连续的内存空间,不需要指针。
- 如果存储大量的数据,要申请一大片连续的内存空间,是非常困难的,所以通常存储少量的数据。
1-5. QuickList
QuickList的特点:
- 是一个节点为ZipList的双端链表
- 节点采用ZipList,解决了传统链表的内存占用问题
- 控制了ZipList大小,解决连续内存空间申请效率问题
- 中间节点可以压缩,进一步节省了内存
总结:
- QuickList: LinkedList + ZipList,是一个双端链表,每个节点是一个ZipList,存储上限高。
1-6. SkipList
SkipList的特点:
- 跳跃表是一个双向链表,每个节点都包含score和ele值
- 节点按照
score值排序
,score值一样则按照ele字典排序 - 每个节点都可以包含多层指针,层数是1到32之间的随机数
- 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
- 增删改查效率与红黑树基本一致,实现却更简单
1-7. RedisObject
1-7. 五种数据类型
String
String是Redis中最常见的数据存储类型:
- 其基本编码方式是
RAW,
基于简单动态字符串(SDS
)实现,存储上限为512mb
。 - 如果存储的SDS长度
小于等于44字节
,则会采用EMBSTR
编码,此时object head与SDS是一段连续空间。申请内存时只
需要调用一次内存分配函数,效率更高。(小于等于44字节是为了 + object head字节大小刚好小于等于内存中一个页的大小,只需要进行一次内存分配。) - 如果存储的字符串是
整数值
,并且大小在LONG MAX
范围内,则会采用INT
编码:直接将数据保存在RedisObject的
ptr指针位置
(刚好8字节),不再需要SDS了。
总结:
- Redis的String有3种编码方式:
raw
、embstr
、int
raw
:raw是基于SDS实现的,存储上限是512MBembstr
:如果SDS存储的字节大小小于等于44字节,则会采用embstr编码,此时头信息和SDS是一块连续的内存空间。SDS字节大小小于44,是为了加上头信息的大小刚好小于等于内存中一页的大小,只需要进行一次内存分配。int
:如果字符串存储的是整数值,并且是在8个字节以内,则会采用INT
编码
List
Redis的List结构类似一个双端链表,可以从首、尾操作列表中的元素:
- 在
3.2版本之前
,Redis采用ZipList
和LinkedList
来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码。 - 在
3.2版本之后
,Redis统一采用QuickList
来实现List
Set
Set是Redis中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高。
- 为了查询效率和唯一性,set采用
HT
编码(Dict)。Dict中的key用来存储元素,value统一为null
。 - 当存储的所有数据都是整数,并且元素数量不超过
set-max-intset-entries
时,Set会采用IntSet
编码,以节省内存。
ZSet
ZSet底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。
- ZSet实现方法一:HT(Dict)+ SkipList。HT保证键值存储、键必须唯一,SkipList保证可排序。缺点:Dict里面存储一份数据,SkipList中也需要存储一份数据。非常消耗内存。
- ZSet实现方法二:如果存储的数据量比较少时,采用 zipList。但zipList本身没有排序的功能,而且没有键值对概念,所以需要进行改进。
- ZipList是连续内存,因此score和element是紧挨在一起的两个entry,element在前,score在后
- score越小越接近队首,score越大越接近队尾,按照score值升序排列
Hash
- Hash结构默认采用
ZipList
编码,用以节省内存。ZipList中相邻的两个entry分别保存field和value - 当数据量较大时,Hash结构会转为
HT
编码,也就是Dict,触发条件有两个:
①ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
②ZipList中的任意entry:大小超过了hash-max-ziplist-value(默认64字节)
ZipList
编码
Ht
编码
2. 网络模型
2-1. 用户空间和内核空间
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:
进程的寻址空间会划分为两部分:内核空间、用户空间
用户空间只能执行受限的命令 (Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
内核空间可以执行特权命令(Ring0),调用一切系统资源
Liux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
例子:
- 下图的右边相当于我们的客户端,要发送请求到网卡,服务端的网卡接收到请求后,将响应数据再通过网卡发送出去。
- 首先,我们的应用程序(在用户空间)调用系统接口发送一个读取网卡数据的请求。
- 在调用系统接口时,首先判断内核空间的缓冲区buffer是否已经缓存了对应的数据,如果存在,将缓存区的数据copy到用户空间的buffer中。
- 如果不存在,内核空间的进程则将请求发送到网卡,等待网卡数据的返回。数据返回之后将网卡中的数据读取到内核空间的缓冲区,之后再copy到用户空间的缓冲区。
2-2. 阻塞IO(Blocking IO)
- 比如用户进程要获取磁盘的数据,此时要发起系统调用,切换成内核态,用户进程进行阻塞等待。
- 内核进程首先检查内核空间的内核缓冲区是否有数据,如果没有,则要将磁盘中的数据拷贝到内核缓冲区,然后再由内核缓冲区拷贝到用户缓冲区
2-3. 非阻塞IO(NonBlocking IO)
- 可以看到,非阻塞IO模型中,用户进程在第一个阶段(1.等待数据)是非阻塞,第二个阶段(2.从内核拷贝数据到用户空间)是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
- 非阻塞IO就是用户进程进行系统调用的时候,如果内核空间的缓冲区没有数据,则直接返回给用户进程。如果用户进程一直请求,内核空间一直没有,此时频繁的进行内核态和用户态的切换,会非常的消耗性能。
- 非阻塞IO 要与 IO多路复用一起使用才会提高性能
2-4. IO多路复用(IO Multiplexing)
IO多路复用: 利用单个用户线程来同时监听多个socket,并在某个socket可读、可写时得到通知。
监听socket的方式、通知的方式有多种实现,常见的有:
- select:
- poll:
- epoll:
IO多路复用 - select
- 使用
fd_set
的每一位记录要监听的socket。大小是固定的1024位。(在当前高并发的场景下,socket只能监听到1024还是不够) - 调用select的时候,将用户空间的fd_set拷贝到内核空间,内核空间的线程则会检查fd_set对应的socket是否有数据,如果有数据,则再fd_set拷贝到用户空间。(对于socket还没有数据的,置为0)
- 用户空间收到
fd_set
后,逐个遍历fd_set,如果发现其中有就绪的,则去读取对应socket的数据。
IO多路复用 - poll
与select的比较:
- select监听的fd数量不超过1024,而poll在内核采用的是链表,理论无上限。
- poll同样也需要将fd数组拷贝到内核空间,结束时也需要从内核空间拷贝到用户空间。
- poll同样返回的也是准备就绪的个数,所以在用户空间也需要遍历一遍fd数组。
IO多路复用 - epoll
select poll epoll 总结
- select :使用fd_set来记录要监听的socket,大小是固定的1024。并且在开始和结束时,分别需要进行用户空间和内核空间的拷贝。
- poll:利用链表结构解决select监听上限的问题,但同样在开始和结束时,分别需要进行用户空间和内核空间的拷贝。
- epoll:利用红黑树保存要监听的socket,性能更好。每个socket只需要拷贝一次到红黑树,减少了重复拷贝socket到内核空间。内核返回的是就绪的socket,所以无需用户空间再去进行判断,查看是哪个socket准备就绪。
2-5. 信号驱动IO(Signal Driven IO)
2-6. 异步IO(Asynchronous IO)
2-7. Redis网络模型
Redis是单线程吗?
Redis的命令处理是单线程。
Redis的网络处理是多线程。
为什么Redis要使用单线程
- Redis本身就是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟,而不是执行速度。
- 多线程还要考虑线程安全问题,实现复杂度也会大大增多。
3. 通信协议
Redis中采用的是RESP协议。
4. 内存策略
4-1. 过期策略
在学习Redis缓存的时候我们说过,可以通过expire
命令给Redis的key设置TTL(存活时间):
但有两个问题我们需要思考:
- Redis是如何知道一个key是否过期呢?
- Redis利用2个Dict分别记录key - value以及 key - ttl。如果需要判断一个key是否过期,可以去对应的Dict查看。
- 是不是TTL到期就立即删除了呢?
- 惰性删除:不是在TTL到期后就立刻删除,而是在访问一个key的时候,先检查该key的存活时间,如果已经过期才执行删除。
- 周期删除:每个一段时间,随机选取key进行检查,如果发现某个key已经过期,就会将其删除。有2种定期清理模式。
- slow:执行频率默认为10,每秒执行10次,每次不超过25ms
- fast:执行频率不固定,每次耗时不超过1ms
4-2. 淘汰策略
内存淘汰:就是当Redis内存使用达到设置的阈值时,Redis主动挑选部分key
删除以释放更多内存的流程。