Redis原理
Redis数据结构
动态字符串SDS
字符串是redis中非常常见的一个数据结构 redis是由C语言实现 但是底层并没有采用C语言的字符串类型 因为C语言字符串存在很多问题 C语言字符串底层是字符数组
- 获取字符串长度需要运算 因为‘\0’的存在 所以每次获取长度都需要-1 会造成额外的运算
![]()
- 非二进制安全 由于‘\0’的存在 所以写入的字符串也不能带有\0 会造成不安全的字符串
- 不可修改 c语言对字符串的申请都是直接申请到常量池里 是不能修改的 而且拼接需要扩容 扩容就要重新申请空间 难以拼接
因此redis就构建了一种新的字符串结构 叫做SDS(Simple Dynamic String) 简单动态字符串
不过SDS底层也是字符串数组 但是SDS的字符数组是自己来维护的 然后SDS结构体声明了很多种 8字节 16字节 32,64字节 5字节的有但是已经被弃用了

SDS之所以叫动态字符串 具备动态扩容的能力 但是由于redis牵扯到模式切换 用户态和内核态切换导致申请内存性能占用太大 所以扩容是内存预分配方法扩容 就是会多申请一些空间
- 如果新字符串小于1M 则新空间为扩展后的字符串长度的两倍+1
- 如果新字符串大于1M 则新空间为扩展后的字符串长度+1M+1
SDS优势
- 获取字符串长度时间是O(1)
- 支持动态扩容
- 减少内存分配
- 二进制安全
IntSet
是redis种set集合的一种实现方式 基于整数数组实现 具有长度可变 有序等特征 结构如下

但是虽然基于整数数组实现 不过所有的增删改查全是靠IntSet自己维护实现的 因为IntSet自己规定了编码方式 所以全部东西就需要自己做 然后为了效率的保证 所以就会先排序 做一个升序的数组 再进行操作

为什么采用统一的编码方式来确定数字 是为了方便查找 因为是用指针来查找的 然后统一编码格式比如两个字节之后 查找公式就很简单了 只需要知道角标 知道每个数字所占字节数 就能通过起始地址直接找到所需数字
另提一嘴:角标从0开始是表示和起始空间间隔0个元素 1就表示间隔1个元素 然后查找的时候直接×角标即可 如果从1开始 就会多做一次不必要的减法运算 减少性能
如果插入的数据超过了编码方式的范围 比如插入50000 那么IntSet会有一个自动升级编码的功能
- 会先算好50000适合那种编码方式 确定下来升级之后的编码方式
- 然后将原数组倒序重新按角标拷贝到扩容后的正确位置 倒序是为了防止覆盖
- 放入新元素
- 更改头中的编码方式
而有序的实现底层是用二分查找来实现的 当每有一个新数据进来之后 都会根据二分来进行搜索 然后如果找到相同的值 直接返回 因为是set集合 确保唯一 然后最大返回队尾 最小返回队首 其他进行二分过程中返回位置 让原数组在这个pos之后的全部+1 当前数据就插入到pos这里
IntSet特点:
- redis会确保IntSet中的元素唯一
- 具备类型升级机制 节省内存空间
- 底层采用二分查找方式来查询
Dict
Dict实现
dictionary redis是一个键值型的数据库 可以根据key实现快速的增删改查 底层的关系就是通过Dict来实现的
Dict由三部分组成 哈希表(DictHashTable) 哈希节点(DictEntry) 字典(Dict) Dict中前两个是为了哈希运算 后两个是为了rehash 只有ht[2]其中一个是保存数据 另一个也是rehash 编码如下

然后java中的HashMap底层也是由Dict实现的 流程都是先创建一个entry数组 然后通过hash计算出key的值 向数组中放数据 然后如果两个数据的key一样就通过链表连接起来 叫做哈希冲突 因为Dict还有一个指针就是解决哈希冲突的 dicht中的size和sizemask就是来做哈希运算的
具体流程就是先确定哈希表的大小 就是size 规定size只能是2的n次方 然后向Dict添加键值对时 会先根据key计算出hash值(h) 然后利用h&sizemask得到对size求余的余数
因为sizemask是2的n次方-1 永远都是剩下位数的全1 所以h后面的二进制数据直接就能得到对size求余的结果 与运算效率更高 求余就是为了保证插入的数据在哈希表中 比如size是4 那么求余永远都是0 1 2 3 拿到余数之后就可以存放到哈希表中的所属下标位置了
结构如下

Dict扩容
Dict中的哈希表是数组加上链表 所以当数据过大的时候 会导致链表过长 那么查询效率就会变低 因此就需要扩容
Dict每次在新增键值对时都会检查负载因子(LoadFactor = used/size) 满足以下两种情况就会触发扩容
- 当LoadFactor >= 1 并且服务器没有执行BGSAVE或者BGREWRITEAOF时 开始rehash 后两种命令都是redis中非常吃cpu的命令 需要进行大量的读写 所以当cpu空闲且大于1 就会扩容
- 当LoadFactor > 5
Dict收缩
总不能一直扩容 当删除key的时候 也会对负载因子做检查 当LoadFactor < 0.1时 就会做哈希表收缩
Dict的rehash
无论是扩容还是收缩 都会新建一个hash表 因此就会导致hash的size和sizemask发生变化 之前计算的索引全部失效 就需要重新根据key建立索引 这个过程就是rehash 但是dcit的rehash不是一次性完成的 因为是主线程操作 数据量过大会阻塞 因此dict的rehash是多次 渐进式的完成 被称为渐进式rehash
过程是:
- 计算新hash表的realeSize 取决于当前是做扩容还是收缩
- 如果是扩容 那么新size就是第一个大于等于ht[0].used + 1的2的n次方
- 如果是收缩 那么新size就是第一个大于等于ht[0].used的2的n次方
- 按照新的realeSize申请新的内存空间 创建dictht 并且赋值给dict.ht[1]
- 设置dict.rehashidx = 0 表示开始rehash
- 每次执行新增 查询 修改 删除操作的时候 都检查一下dict.rehashidx是否大于1 如果大于 则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1] 并且将rehashidx++ 直到dict.ht[0]的所有数据都rehash到dict.ht[1] 也就是每次操作都会rehash一个索引下的数据 逐渐完成
- dict.ht[1]赋值给dict.ht[0] 给dict.ht[1]初始化为空哈希表 释放原来的dict.ht[0]的内存
- 将rehashidx赋值为-1 代表rehash结束
注意:
在rehash过程中 新增操作 是直接写入ht[1]的 查询修改删除都会在dict.ht[1]和dict.ht[0]一次查找并执行 可以确保ht[0]的数据只减不增
ZipList
压缩表 是一种特殊的双端链表 不存在指针 有特殊编码的连续内存块组成 可以在任意一端进行压入/弹出操作 并且这种操作的时间复杂度是O(1)
示例图如下:


entry所占的内存是不固定的 会根据数据的大小动态分配内存
ZipListEntry
ZipListEntry的结构如下

-
previous_entry_length 前一节点的长度 占1或5个字节 方便倒序遍历
-
如果前一节点的长度小于254字节 则采用1个字节来保存长度值
-
如果大于 就是5个字节保存 第一个字节为0xfe固定值 后四个才是真实长度
-
encoding 编码属性 记录content的数据类型(字符串还是整数)以及长度 占用1或2或5个字节
-
contents 负责保存节点的数据 可以是字符串或整数
当遍历的时候 正序就算出起始位置的下一个entry 起址加上entry字节数 倒序就用起址减去当前entry存放的previous_entry_length
注意:
ZipList存储长度的数值采用小端字节序 低位字节在前 高位字节在后 如果是0x1234 小端字节序存储值就是0x3412 因为大部分都是地位存储 方便读取
Encoding编码
-
字符串 如果encoding是以"00" "01" "10"开头 则证明content是字符串存储
![]()
例如存储'ab'和'bc' 整个的ZipList应该是
![]()
注意此处有小端字节序 -
整数 就是encoding以"11"开头 证明其是整数 且encoding表示整数固定占用1个字节
![]()
ZipList连锁更新问题
当插入 删除的时候 有可能插入数据过大导致后一个节点的pre_entry_len本来需要1个字节 更新之后需要五个字节 带上content超出了254 导致后一个节点需要更新pre_entry_len为5个字节 后面的有可能都会变化 这种连续更新多次扩展空间的操作就是连锁更新问题 新增 删除都会出现这个问题
ZipList特性
- 压缩列表可以看成是一种连续内存空间的"双向链表"
- 列表的节点之间不是通过指针连接 而是记录上一届点和本节点的长度来寻址 内存占用较低
- 如果列表数据过多 导致链表过长 可能会影响查询性能
- 增或删较大数据时有可能发生连续更新问题
QuickList
快表 引入快表回答三个问题
- Q1 ZipList虽然节省内存 但申请内存必须是连续空间 如果内存占用较多 申请效率低 怎么办
限制ZipList的长度和entry大小 - Q2 存储大量数据 超出了ZipList的最佳上限怎么办
用多个ZipList分片存储 做数据分片 - Q3 数据拆分后多个ZipList如何建立联系
就需要QuickList 是一个双端链表 只不过链表中每一个节点都是一个ZipList
QuickList结构如下

避免ZipList中entry过多 占用内存较大 可以通过配置list-max-ziplist-size来控制entry
- 如果是正数 就是允许的entry个数的最大值
- 如果是负数 就是每个entry所占内存的最大值 -1是4kb -2是8kb以此类推到64kb 默认是-2
QuickList还可以对节点的ZipList压缩 通过配置list-compress-depth来控制压缩数量
- 0表示不压缩
- 1表示QuickList首尾各有一个节点不压缩 中间节点压缩
- 2表示QuickList首位各有两个节点不压缩 中间节点压缩
以此类推 默认是0
整体QuickList结构

QucikList特点
- 节点为ZipList的链表
- 节点采用ZipList 解决了传统链表的内存占用问题
- 控制ZipList的大小 解决了来纳许内存空间申请效率问题
- 中间节点可以压缩 进一步节省内存
SkipList 跳表
跳表首先是链表 但是不一样 区别如下
- 元素按照升序排列
- 节点可能包含多个指针 指针跨度不同
链表遍历太慢 所以空间换时间思想 结构如下

注意 这个上面的数字不是节点的值 节点的值是一个SDS字符串 数字可以理解为索引
具体图如下

特点:
- 跳表是一个双端链表 每个节点都有score和ele值 score便于排序 ele才是真正的数据
- 节点按照score排序 score一样则按照ele字典排序
- 每个节点包含多层指针 层数是1-32随机数 按照算法来
- 不同层指针到下一个节点的跨度不同 层级越高 跨度越大
- 增删改查效率和红黑树基本一致 实现却简单
RedisObject
Redis中任意数据类型的键和值都会被封装成一个RedisObject 就是redis对象 源码如下

十一种编码格式如下

五种数据类型
redis中数据类型的不同 所用的编码方式也会不同 具体如下

五种数据类型
String
String是redis中最常见的类型
- 最基本编码方式是RAW 基于简单动态字符串实现(SDS) 存储上限是512MB
- 如果存储的SDS长度小于等于44字节 就会采用EMBSTR编码方式 此时ObjectHead和SDS是一段连续空间 申请内存时只需要调用一次内存分配函数 效率更高
为什么是小于等于44 是因为redis申请内存的时候是以2的n次方去申请的 SDS44加上头尾4和ObjectHead16就是64字节 刚刚好内存对齐 不会产生碎片 - 如果存储的字符串是整数 且大小在LONG_MAX范围内 就会采取INT编码方式 取消SDS 直接将数字写在ptr指针里(刚好8字节)
![]()
List
redis3.2版本之前 List实现使用LinkedList加上ZipList实现的 并且元素个数小于512且大小小于64字节时采用ZipList 否则是LinkedList
但是3.2版本之后 List就是用QuickList实现
源码如下

如果输入LPUSH key v1 v2 会被client客户端把命令封装到argv[]数组中 根据空格分开 也就是该命令长度为4 在argv[2]之后就是具体存储的数值 arg[1]是key
就可以根据这个解析出来所要存放的值以及key 并且源码会判断key是否存在 如果不存在 就会创建一个QuickList 然后用RedisObject对象中的ptr指针指向他

Set
Set是redis中的单列集合 存在特点
- 不保证有序性
- 保证元素唯一(来判断元素是否存在)
- 求交并差集
set中大部分命令都需要来查询元素是否存在 因此
- 为了查询效率和唯一性 set采用HashTable也就是Dict编码 Dict中的key存放元素 value为null 因为Dict是数组加链表 内存是碎片化的 而且指针很多
- 所以如果存放的数据都是整数 并且元素数量不超过set-max-intset-entries时 Set会采用IntSet编码来节省内存
但是会有个问题 每当插入新元素的时候 都会对编码方式进行判断 如果是Dict编码 直接插入 但是如果是IntSet编码 就会判断 插入数据是否是整数 以及插入完成后判断大小有没有超范围 只要有一项没完成 就会进行编码方式转换成Dict

Zset
Sorted 每一个元素都需要一个score和member:
- 根据score排序
- member必须唯一
- 根据member查分数
因此满足这些需求 就只有两个结合 SkipList和HashTable(Dict) SkipList做排序 有score值和ele值 Dict可以根据key查value值

这是两者结合的 但是问题还是有 就是非常吃内存 因为同样数据存储了两份 还有大量指针
因此当元素数量不多 Zet会采用ZipList结构来省内存 需要满足两个条件
- 元素数量小于zet-max-ziplist-entries默认是128
- 元素大小小于zet-max-ziplist-value字节 默认是64
所以每次Zset插入新数据时 如果为空需要创建 就会对元素数量和元素大小进行判断 如果超了 就创建完整的Zset 如果没有 就用ZipList
那么随着添加元素过程中 就会发生数据类型转换 也就是每次真正执行ADD操作时 都会进行判断元素是否唯一 然后看编码方式是不是ZipList 如果是 就会对元素数量和元素大小进行比较 从而转换
但是ZipList本身没有排序功能 而且没有键值对概念 因此需要业务编码实现
- ZipList是连续空间 所以可以使键值两个entry前后存放 element在前 score在后
- score越小越接近队首 按照score值进行升序排序
Hash
特点:
- 键值存储
- 根据键获取值
- 键必须唯一
Hash和Zset使很像的 
都是key中存放了键值对 根据键找值 成对出现
所以实现跟Zset差不多 只是没有了排序的SkipList
- 默认采用ZipList编码 为了节省内存 ZipList中相邻的两个entry存放键值field value
![]()
- 当数据量较大时 会转换成HasbTable(Dict)编码
- ZipList中元素数量超过了hash-max-ziplist-entries 默认是512
- 任意entry大小超过了hash-max-ziplist-value 默认是64字节
Redis网络模型
用户空间和内核空间
任何Linux发行版 如Ubuntu centos 其系统的内核都是Linux 所有的应用都需要通过Linux内核和系统交互

为了避免用户应用导致冲突甚至内核崩溃 所以用户应用和内核是分离的
- 进程的寻址空间会划分位两部分 内核空间和用户空间 在内存长比如是32位的电脑 那么带宽也就是32位 然后存储器也就是32位就是4G也就是4G的内存 4G的内存中3G是用户空间 1G是内核空间
- 用户空间只能执行受限的命令(ring3) 不能直接调用系统资源 需要使用内核提供的接口
- 内核空间可以执行特权命令(ring0) 调用一切资源
Linux为了提高IO效率 会在用户空间和内核空间都加入缓冲区(buffer)
- 写数据时 把用户缓冲数据拷贝到内核缓冲区 然后写入设备
- 读数据时 从设备读取数据到内核缓冲区 然后拷贝到用户缓冲区
所以影响读写最大的因素就是等待内核响应和写入缓冲区 因此会产生多种IO模型
阻塞IO
阻塞IO(Blocking IO) 就是两个阶段都必须阻塞等待

也就是用户态发起recvform命令请求内核态时 会阻塞等待内核态一直到有响应为止 然后内核态收到请求发现没有数据 就会调用系统资源准备数据 这个过程中有两种解决 一种是直接返回 另一种是阻塞等待 阻塞IO就是内核态会一直等 然后有了数据再拷贝 拷贝工程中也会一直等 用户也会一直等 直到返回给用户ok 才算完成
因此性能低下
非阻塞IO
No Blocking IO 就是用户的recvform命令请求到内核 内核会直接响应结果 如果没有 就响应异常信息

用户发起请求时 内核没有数据 直接返回 但是用户还会反复发请求 叫忙轮询或者忙等待 然后直到内核准备好数据之后进行拷贝 但是这个过程中 用户还是在阻塞 也就是准备数据阶段用户忙等 拷贝阶段用户阻塞
虽然是非阻塞IO 但是反而性能没有提高 反而因为忙等待机制 导致CPU使用率提高
IO多路复用
无论是阻塞IO还是非阻塞IO 第一阶段都是要发送recvform命令来获取数据 区别就是阻塞IO没有数据会一直阻塞等待 非阻塞IO没有数据就会一直询问等待 都是阻塞
因此在单线程情况下 服务端处理客户端Socket请求时 只能依次处理每一个Socket 如果正在处理的Socket恰好未就绪 就会阻塞 其他客户端Socket都必须等待 性能就会很差
因此提高效率的方法
- 开启多线程 但是多线程又会加大性能的使用
- 不依次获取 直接监听 哪个用户应用数据就绪了 就处理哪个
文件描述符(File Descriptor) 简称FD 是一个从0开始递增的无符号整数 用来关联Linux中的文件 Linux中 任何都是文件 如视频 硬件设备 还有网络套接字(Socket)
IO多路复用 就是利用单个线程来同时监听多个FD 并在某个FD可读可写得到通知 避免无效等待 充分利用CPU的资源

大致流程思想就是 用户不再向内核发起recvform请求 而是先发起select请求 区别就是recvform请求只发送一个FD 没有结果就等待 但是select请求是发送多个FD 如果都没有结果 也会等待 但是只要有一个FD准备好了 就可以调用recvform来获取数据了 是一种有效等待
监听FD的方式 通知的方式也有多种 常见有仨
- select
- poll
- epoll
差异就是select和poll只会通知用户进程有FD就绪 但是不会告诉用户是哪个FD就绪 需要用户自己挨个遍历 而epoll在告诉用户有FD就绪的同时还会把已经就绪的FD写入到用户空间 直接处理即可
Select实现
Select是最早实现Linux的IO多路复用的方案

整体流程就是创建完FD集合之后 因为FD最终都以二进制比特位保存的 然后先把FD传递给内核态进行一次拷贝 执行select命令内核态就会依据FD集合进行遍历看看哪个就绪了 直到就绪之后把就绪的写入到FD集合 再拷贝回去 然后用户态根据拷贝的结果再遍历 得到FD序号 之后就是再次发送请求不断循环 直到全部处理完
因此这个select两次拷贝两次遍历 性能并不好
存在的问题就是
- 需要拷贝FD两次
- 得到结果后用户不知道具体哪个FD就绪 需要遍历一次
- 监听的FD数量不能超过1024
Poll实现
poll模式对select模式进行了简单的改进 但是性能上并没有太大的提升

跟select对比
- select的FD采用比特位固定1024 poll的FD采用链表 理论无上限
- 但是FD过大反而导致遍历耗时 性能反而下降
总体来说 性能并没有提升 还是两种遍历 两种拷贝 只解决了FD大小问题
epoll实现
epoll较select和poll有较大不同 主要提供了三个函数

首先会直接在内核态里创建eventpoll的实例 然后给一个epfd唯一标识到实例 再向里面的eventpoll里的红黑树中添加FD 并且每一个FD都有一个回调函数 只要这个FD就绪之后 就会把这个FD放到relist中来记录就绪的FD 然后用户等待就绪函数会创建一个空的events数组 等待函数返回给用户就绪的FD数量 内核会把就绪的FD直接拷贝到数组里 这样全程只有一次拷贝
总结
select模式存在的三个问题:
- 能监听的FD最大不能超过1024
- 每次select都需要把所有要监听的FD拷贝到内核空间
- 每次要遍历所有的FD来判断就绪状态
poll模式的问题:
- poll利用链表解决了select中监听FD上限的问题 但是依然要遍历所有的FD 如果监听过多 性能反而会下降
epoll中如何解决的:
- 基于epoll实例中的红黑树保存要监听的FD 理论无上限 且增删改查效率非常高 性能不会随监听的FD数量变多而下降
- 每个FD都只需要一次epoll_ctl添加到红黑树 以后每次epol_wait不需要传递任何参数 不需要重复拷贝FD到内核空间
- 内核会将就绪的FD直接拷贝到用户空间的指定为止 用户进程无序遍历所有的FD就知道就绪的FD是谁
epoll时间通知机制
当FD有数据可读时 调用epoll_wait就可以得到通知 时间通知模式有两种
- LevelTriggered LT 当FD数据可读时 会重复通知多次 直到数据处理完成 是Epoll的默认模式
- EdgeTriggered ET 当FD数据可读时 只通知一次 不管数据是否读完
正常当要拷贝listhead中的FD数据到用户态时 会先断开指针 然后再拷贝 并且会做一个判断 如果是LT 就会重新给指针接上 如果不是 就会直接删掉 因此如果没读完的话 在ET模式下就会直接删掉
不过两者各有优劣
- LT模式下因为重复通知的问题 本来前两个进程就可以处理完FD中的数据了 但是重复通知了 会导致惊群问题 把所有的进程唤醒 但其实只需要两个进程就解决了
- ET不会发生惊群问题 但是可能导致数据读不完 解决方式就是手动的把未读完的接回去或者读的时候采用非阻塞IO一直读 直到读完
推荐采用ET模式 ET避免了惊群现象 结合非阻塞IO读FD数据
基于Epoll模式Web的服务流程
基本流程如图

只拿Web服务举例 Web服务端比如nginx appache等 客户端就是向这些服务端发起的请求 然后最开始创建epoll实例 再创建服务端的FD 把服务端的FD叫做ssfd提交给内核态 让内核态监听 因为内核态监听了很多FD 所以有监听有结果之后判断事件类型 如果是EPOLLIN 再判断是不是SSFD可读 如果是 就代表有新客户端连接 然后接受FD 再写入到内核态 如果不是新的ssfd 也就是正常请求得到数据响应 那么直接读取请求数据 写出响应即可
信号驱动IO
就是跟内核建立一个SIGIO的信号关联并设置回调 当内核有FD就绪时 就会发出信号通知用户 在这之间用户可以执行其他业务 不用阻塞等待 但是真正请求过来拷贝数据的时候 用户还是需要阻塞的

也有缺点 就是不适合高并发场景 因为当大量IO操作时 信号较多 信号队列有可能溢出 而且内核态与用户态频繁交互性能较低
异步IO
异步IO整个过程都是非阻塞 最开始用户发出aio_read命令告诉内核态FD以及各种其他需求 然后FD从准备数据到数据拷贝整个阶段都不通知用户态 直到拷贝完成 再通知用户态拷贝成功 用户态直接取数据即可 在整个期间 用户态都可以处理其他请求 因此整个过程非阻塞 但是同样不适用于高并发场景 高并发下要做好数据限流 不让内核态过多消耗导致系统崩溃
同步和异步
同步异步在IO操作看来 跟阻塞非阻塞并没有关系 而是看用户态和内核态在数据拷贝过程中是同步的还是异步的 如果用户态内核态拷贝过程中二者是同步的 那就是同步IO 反之就是异步IO

Redis是单线程吗 为什么选择单线程
Redis到底是单线程还是多线程
- 如果仅仅是redis核心业务(命令处理)部分 就是单线程
- 如果是整个redis 就是多线程
在redis版本迭代过程中 有两个引入多线程
在redis4.0中 加入多线程来异步处理一些耗时较长的任务 比如删除命令unlink
在redis6.0中 在核心网络模型引入了多线程 进一步提高对于多核CPU的利用率
为什么redis要选择单线程
- 抛开持久化不谈 redis是纯内存操作 执行速度非常快 这是redis速度快的最主要原因 因此redis的性能瓶颈不是执行速度 而是网络延迟 因此多线程不会带来巨大的性能提升
- 多线程反而会导致过多上下文切换 带来不必要的开销 这个只是在单核情况下
- 引入多线程就要面临线程安全问题 必然会引入线程锁这样的手段** 复杂度变高** 同时相对来说性能也会下降 然后还会发生跟redis之前版本的不匹配
redis单线程多线程网络模型变更
redis通过IO多路复用来提高网络性能 并且支持不同的多路复用实现 以支持各种环境 还对这些实现进行了统一的封装 让他们有了共同的API

上面只是固定的API和对应的环境设置
下面是redis单线程的具体网络模型整个流程

- 创建serverSocket 绑定固定端口 比如6379
- aeEventLoop 创建epoll实例 就是红黑树和就绪队列
- 注册FD 监听FD
- beforesleep 会定义一个迭代器 指向server.clients_pending_write队列 开始循环遍历待写的客户端中的数据 然后会绑定监听客户端的FD并且添加写处理器 之前绑定的是读处理器 现在beforesleep就是要监听写FD 要准备从队列中取数据写回到客户端中
- aeApiPoll 等待 epoll_wait 等待之后就会发生两个结果
- server socket监听服务器 如果是server socket有读事件 就会触发tcpAccepthandler 连接应答处理器 代表有新的客户端连接进来了 就要注册新的FD 放到红黑树 监听FD
- client socket监听客户端如果是client socket有读事件 就会触发readQueryFromClient 命令请求处理器 然后就会把请求的数据变成字节写到客户端缓冲区里 写入的都是字节
1.然后需要对缓冲区里放的字节数据进行解析 解析成SDS字符串放到argv[]数组里
2.取出argv[0] 命令名称 并且执行命令 把结果再写回到客户端缓冲区里
3.然后把客户端添加到server.clients_pending_write队列中 等待被写出
简化一下流程就是IO多路复用监听三个FD 服务器读 客户端读 客户端写 每当监听到之后 就会通过事件派发 分给不同的处理器来做事
Redis6.0之后 就引入了多线程 目的就是为了提高网络IO的效率 所以在解析命令 写响应结果采用了多线程 核心命令运行 IO多路复用事件派发仍然是主线程执行

通信协议
RESP协议
Redis是一个CS架构软件 通信一般分两步
- 客户端向服务端发送命令
- 服务端解析并执行命令 返回相应结果给客户端
所以客户端发送命令格式 服务端响应结果格式都必须有规范 就是通信协议
在Redis中采用的RESP(Redis Serialization Protocol)协议
- Redis1.2引入RESP协议
- Redis2.0中称为Redis服务端通信标准 RESP2
- Redis6.0升级到了RESP3 增加更多数据类型支持新特性--客户端缓存 但是跟RESP2差别太大 所以默认使用的还是RESP2
RESP的数据类型
在RESP协议中 会根据首字母的不同 区分五种数据类型
-
单行字符串 以 + 开头 跟上字符串 以CTRL(\r\n)结束 不是二进制安全的 例(+hello\r\n)
-
错误 error 以 - 开头 跟上信息 以\r\n结束 非二进制安全 例(-error Message\r\n)
-
数值 以 : 开头 跟上数字 以\r\n结束 例(:10\r\n)
-
多行字符串 以 $ 开头 记录两部分 跟SDS一样 第一部分是占了多少字节 第二部分是数据 二进制安全 因为是按长度记录 例($4\r\nname\r\n)
- 如果数字是0 代表是个空字符串
- 如果数字是-1 代表字符串不存在
-
数组 以 * 开头 后面跟上元素个数 再跟上元素数据
![]()
根据java命令模拟redis客户端 在D:\code\Redis\redis-client-demo
Redis内存回收
Redis之所以性能好 就是存储在内存里 但是单节点redis内存不宜过大 会影响持久化和主从同步性能
可以通过修改redis配置来调节最大内存

内存到上限的时候 就不能再存储了 所以就需要内存回收策略
内存回收策略-过期策略
可以通过expire命令来给Redis的key设置TTL(过期时间) 这就是应用过期策略
DB结构
redis本身是一个键值key-value型的数据库 所有的key value都保存在Dict中 不过有两个Dict 一个保存key value 一个保存key TTL

这里的key value的Dict存储的不是数据 而是指向真正存储数据的指针 存放的地址 为了简化 结构如下

所以利用两个Dict一个记录value 一个记录TTL 只记录有过期时间的 没有的不记 通过查询Key TTL的Dict 就能知道每一个key的过期时间了
Key到过期时间也不是立即删除 而是惰性删除或者周期删除
惰性删除
也叫延时删除 并不会在TTL到期后就立即删除 而是访问一个key的时候 检查key的存活时间 如果过期 就执行删除 但是会有一些问题 就是如果这个key长时间不访问 就一直不会删除 所以会有周期删除
周期删除
就是通过一个定时任务 周期性的抽样部分过期的key 然后删除 模式有两种
- Redis会设置一个定时任务serverCorn() 按照server.hz的频率来执行过期key清理 模式是SLOW serverCorn()里获取了一个时钟 就是redis维护的微妙级别的时钟
- Redis会在每个事件循环前通过beforsleep()函数 执行过期key清理 模式是FAST
大致流程就是会初始化的时候 在1ms后直接执行serverCorn函数 然后先SLOW模式清理一下 然后执行aeMain函数创建epoll实例 然后一直while循环 监听之前会有一个beforesleep函数执行 并且在最后会继续调用serverCorn函数来SLOW模式清理一遍 但是只有最开始是1ms立即执行 后面都是100ms执行一次 如果没到时间 是不会执行的 而FAST模式会在while循环里一直执行 因为速度快 只有1ms左右 而SLOW模式有几十ms
SLOW模式规则
- 执行频率受server.hz影响 默认是10 每秒执行10次 每个周期100ms
- 执行清理耗时不超过一次执行周期的25% 也就是25ms
- 逐个遍历db 逐个遍历db中的bucket(角标) 抽取20个key判断是否过期
- 如果没到时间上限(25ms) 并且抽样的过期ket比例大于10% 再进行一次抽样 否则结束
FAST模式规则
- 执行频率受beforesleep()调用影响 但两次FAST模式间隔不能低于2ms
- 执行清理耗时不超过1ms
- 逐个遍历db 逐个遍历db中的bucket(角标) 抽取20个key判断是否过期
- 如果没到时间上限(1ms) 并且抽样的过期ket比例大于10% 再进行一次抽样 否则结束
总结
Redis中Key的TTL记录方式
- 在redisDB中通过一个dict记录key的TTL
过期Key的删除策略
- 惰性清理 每次查找key判断是否过期 如果过期 删除
- 周期清理 定期抽样部分key 判断是否过期 如果过期 删除
定期清理两种模式
- SLOW模式默认频率是10 每次不超过25ms
- FAST模式频率不固定 但两次间隔不能低于2ms 每次耗时不超过1ms
内存淘汰策略
内存淘汰 就是redis内存超过了设置的阈值 然后redis就会主动挑选部分key进行删除释放内存
内存淘汰会在客户端命令进来之前都会检查一次内存超没超 但是如果没有设置内存阈值或者在执行lua脚本 就不会检查 因为lua脚本删除之后可能会出现问题 把后续key给删了
淘汰策略
redis支持8种不同策略来选择删除key

- LRU (Least Recently Used) 最少最近使用 最久未使用 删除
- LFU (least Frequently Used) 最少频率使用 频率最低 删除
Redis的数据都会被封装成RedisObject

LFU的访问次数叫逻辑访问次数 因为并不是真的key被访问的所有次数 而是通过算法
- 生成0-1的随机数R
- 计算1/(旧次数 * lfu_log_factor + 1) 记录为P lfu_log_factor默认是10
- 如果R < P 则计数器加1 最大不超255 次数越大 P越小 但是有个问题就是一开始频率高 后面频率低 所以次数会衰减
- 访问次数随时间衰减 每次访问都会计算和上一次访问时间每隔lfu_decay_time分钟(默认是1) 就 - 1 也就是每隔几分钟 减几
整体其实分两个大方向 一个是对有TTL的key进行删除 一个是对全体key进行删除 然后再细分四种算法 一个随机key 一个算出最小TTL 一个LRU 一个LFU
因此整体流程如下

-
最开始判断内存够不够
-
不够就判断策略是不是 不删除key
-
不是再判断策略是删除全体key还是有TTL的key
-
然后再判断是不是随机删除
-
随机删除就直接随机挑 删完之后判断内存符不符合所需 不符合继续重复
-
不是随机删除就先创建一个淘汰池 获取DB 避免全部挑选key耗时过长 抽样挑选key
-
判断内存策略
- 如果是TTL 就用最大TTL-TTL做idleTime
- 如果是LRU 就用now-LRU做idleTime
- 如果是LFU 就用255-LFU计数做idleTime
-
再判断是否可以存入淘汰池 因为淘汰池可能为满 所以要判断合不合适
-
然后按idleTime升序放入淘汰池
-
然后看是否有下一个DB 有就循环
-
没有就倒序删除淘汰池的key 判断内存符不符合所需
长久以往循环下来 抽样会越来越符合实际数据
Redis集群
主从集群
单节点Redis并发能力是有上限的 然后为了提升redis并发能力 可以搭建主从集群来解决 实现读写分离 主节点负责写 从节点负责读 然后主节点再做数据同步

搭建主从节点
搭建Redis集群的时候 网络模式需要选择host 不要用桥接创建虚拟网卡进行端口映射了 用host相当于直接暴露在宿主机下 然后相当于普通的一个进程
开启redis直接用docker compose开启 建立主从先通过docker exec -it r2 redis-cli -p 7002进到r2中 然后在r2中执行slaveof 192.168.88.130 7001即可让r2称为r1的从节点
可以通过info replication查看主从状态
主从同步原理
当主从第一次同步连接或断开重连时 从节点会发送psync请求 开始数据同步

全量同步 就是把所有数据都给从节点
增量同步 就是把从节点断开连接所缺失的给从节点
replicationID 每一个master节点都有自己唯一ID 简称replid 可以通过replid判断是否是第一次来 建立连接之前每个节点的replid都不一样 建立连接之后 主从节点replid一样 所以通过判断replid是否相同 来判断是第一次连接还是重连
offset maser中有一个缓冲区(repl_backlog) repl_backlog中写入过的数据长度 写的越多 offset越大 主从的offset一致就代表数据一致 从节点发起psync时会携带上replid和offset 前者判断是否全量同步 后者就是用来增量同步 如果offset二者不同 把缺失的数据给到从节点即可完成增量同步

主从同步优化
master中的缓冲区(repl_backlog) 总共只有1mb backlog时采用的环形数组方式 也就是master和slave的offset会随着不断地写 去实现覆盖之前已经同步的数据 从而实现一致的数据同步 不需要再对缓冲区清空的操作
正常情况下 master写数据 slave同步数据 在缓冲区中是不会出问题的 只要二者差距小于一圈 都能追上 但是如果从节点宕机太久 导致主节点超过了从节点 把从节点的offset覆盖掉了 导致backlog中只有master的offset 这种情况下 从节点会直接做全量同步
但是全量同步又有问题 如果主节点数据过大 全量同步走磁盘会很慢
优化主从集群
- 在master中配置repl-diskless-sync yes 开启无磁盘复制 避免全量同步的磁盘IO
- 在redis单节点上内存占用不要太大 减少RDB导致的过多磁盘IO
- 适当提高repl_baklog的大小 尽可能避免全量同步
- 限制一个master上的slave数量 如果太多可以采用主从从链式结构 减少压力 但是同样的会有时效性的问题
哨兵原理
Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复 具体作用如下
- 监控 Sentinel会不断检查主从状态是否按预期工作
- 自动故障切换 如果master故障 Sentinel会将一个slave提升为master 故障恢复也是如此
- 通知 当发生故障转移时 master发生变化 Sentinel会把最新节点角色信息推送个所有的Redis客户端
服务状态检测
Sentinel基于心跳机制检测服务状态 每隔一秒向集群的每个实例发送ping命令 如果正常集群会回复pong
- 主观下线 如果Sentinel节点发现该实例未在指定时间内响应 就认为该实例主观下线
- 客观下线 如果超过指定数量(quorum)的Sentinel都认为该实例主观下线 那么该实例客观下线 quorum最好是Sentinel数量的一半以上
选举新的master
发现master故障之后 Sentinel就要选一个slave作为master 依据
- 首先判断slave于master断开时间长短 超过指定值就会排除该节点
- 然后判断从节点中的slave-priority值 越小优先级越高 默认一样
- 判断slave的offset值 越大说明数据越新 就越高
- 最后判断slave的运行id大小 越小越高 就是随机选
最主要的就是offset offset越大 优先级越高
实现故障转移
当选定了slave点位master后 转移的步骤如下
- Sentinel给备选的slave节点发送slaveof no one 命令 让该节点称为master
- Sentienl给其他的slave发送slaveof 192.168.150.101 7002命令 让其他的slave称为新master的从节点 开始同步数据
- 最后Sentinel将故障节点标记为slave 故障节点恢复后会自动称为新的master节点的slave节点
Redis分片
搭建分片集群
主从和哨兵可以解决高可用 高并发读的问题 还需要解决
- 海量数据存储
- 高并发写
使用分片集群可以解决 分片集群就是多个主从集群
- 集群中有多个master 每个master保存不同数据
- 每个master都可以由多个slave节点
- master之间通过ping检测彼此健康状态
# 进入任意节点容器
docker exec -it r1 bash
# 然后,执行命令
redis-cli --cluster create --cluster-replicas 1 \
192.168.88.130:7001 192.168.88.130:7002 192.168.88.130:7003 \
192.168.88.130:7004 192.168.88.130:7005 192.168.88.130:7006
散列插槽
Redis集群中 共有16384个hash slots 每个master节点都会分配一定数量的hash slots
Redis数据不是与节点绑定 而是根据key做hash运算 然后对16384取余 得到这个key的slot值 然后根据该slot值做读写操作
而且不一定是对整个key做运算 分两种情况
- 当key包含{}时 根据{}中的字符串计算hash slot
- 当key不包含{}时 则根据整个key计算hash slot
例如 key时num 就根据num计算hash slot key时{hmall}num 就根据hmall计算
注意 连接redis时由于做了分片 所以需要redis-cli -c -p 7001 加-c参数 表示以分片执行 并且每次计算完还会根据hash slot重定向








浙公网安备 33010602011771号