REDIS
TODO
- Github上更新的较这里勤
- 客户端命令待补
- 事件待补
- 事务待补
- 慢慢更新…-.-
- 跳表和红黑树的区别
- 为什么不使用红黑树代替跳表
- 网络模型
- 实战的环境搭建(以及总结脚本的编写)
- redis 配置文件中设置允许远程连接
数据结构和对象
数据结构
-
简单动态字符串(SDS)
:与c的string不同,Redis中的SDS多了以下几点属性free
属性: 作用如下- 扩容:
空间预分配(减少重分配次数)
: 当对sds进行修改的时候,为了防止频繁的申请内存,提供了预留空间
,并且有阈值,阈值为1m- 如:当对sds修改之后长度为30字节,则free=30,数组的总长度为30+30+1 1是’\n’
- 而如果修改之后为30m,则free=1m,总长度为30m+1m+1Byte
- 缩容:
惰性释放(减少内存重分配)
: 当需要对sds释放空间的时候,并不会立即就释放,而是将缩小的长度放在free上,留待下次扩容- 如: 30Byte的sds,缩容为20Byte之后,free=10,那么下次扩容3或者多少的时候就不需要重新申请内存了
- 总结:空间预分配和惰性释放
- 对比C字符串的优点:
- 获取字符串长度时间复杂度最低化O(1)
- 杜绝缓冲池溢出(扩容先先判断是否充足)
- 减少内存重分配次数(free属性的运用)
- 扩容:
len
属性:作用如下- 因为c获取字符串长度需要遍历,时间复杂度O(n),而当额外申明之后只需要O(1)
-
链表(是一个双向链表)
:当一个列表键中包含了数量较多的值或者数据过大的时候就会转为链表- 特点:
- 双向链表
- 无环
- 有表头head和表尾tail指针
- 长度计数器
- 特点:
-
字典
: 可以认为是Map,底层是链地址法解决冲突- 很多地方与Java的HashMap相似,但是也有不同点
- 底层属性方面: Redis中的存在
这么一个属性ht[2]
,这个属性是专门用来扩容的,扩容的时候会将元素都移动到ht[1],移动完毕之后再更换位置,使得每次扩容都是对ht[0]扩容 - 扩容方面:
- 当服务器正在执行BGSAVE|BGREWRITEAOF命令的时候,会适当的提高负载因子,
因为以上两个操作会fork出子进程,并且大多数操作系统都是写时复制,提高负载因子避免在此期间发生扩容
- 渐进式hash:
Redis字典扩容的时候不是直接就将所有的值重新移动到
,- 为ht[1]分配空间,字典同时持有ht[0]和ht[1]
- 维持一个变量
rehashIndex
,值设置为0表示正式开始 - rehash期间,每次对字典的操作之外,还会通过rehashIndex获取指定下表的table(table[rehashIndex])进行rehash移动到ht[1]上,完毕之后rehashIndex+1
- 随着不断的进行,ht[0]中的所有元素都会移动到ht[1]上,此时rehashIndex会设置为-1,表示结束
- 注意点:
在rehash的过程中,查找,修改,删除都会需要先查询ht[0]再查询ht[1]
,而添加的时候会直接添加到ht[1]中
- 当服务器正在执行BGSAVE|BGREWRITEAOF命令的时候,会适当的提高负载因子,
- 学到的:
渐进式hash
: 对于大批量的数据,扩容的时候可以采用这种方式扩容,很棒,当然前提是得有一个数组,用于扩容
-
跳跃表(有序)
:- 使用场景: 只在SortedSet和集群节点下用到
head表头指针
: 用于从表头开始往后遍历tail表尾指针
: 用于从后往前遍历level
: 最高的那一层- redis中跳跃表由zskipList+zskipListNode组成
- 每个节点的层高都是1-32的随机数
- 按照分值进行排序
-
整数集合intset
:- 使用场景: 集合键的底层实现之一
- 使用约束:
当一个集合只包含整数值元素,并且元素数量不多的时候
- 可以保存int16_t,int32_t,int64_t并且不重复,但是具体保存什么类型由
encoding而定
- 特性:
- 可以
升级
:既当原先的元素为int8_t,当插入65535,则会底层的编码会变为int32_t…但是不会降级
,可以提升灵活性(存储值多样)
和节约内存
- 可以
-
压缩列表ziplist
:- 使用场景:列表键和hash键的底层实现之一
- 使用约束:
当包含少量值,并且每个值要么是整数,要么是长度较短的字符串
- 数据结构为: 字节长度+尾节点地址到首节点的地址差+节点数目+…节点+zlend(代表结束)
- 压缩节点(Node)的数据结构:压缩节点可以存储
字节数组
也可以存储整数
- previous_entry_length: 前一个节点的长度,254位界,如前一个长度小于254,则这个值为n,否则为0xFE后面为前一个字节的长度
- encoding: 记录了节点的content属性锁保存的
数据类型以及长度
- 1字节长,并且二进制高位以11开头,则表示
存储的是整数值
- 1,2,5字节长,并且值的高位以
00,01,10开始
,则表明存储的是字节数组
,数组的长度为去除前2位(00,01,10即可)
- 1字节长,并且二进制高位以11开头,则表示
- content: 保存节点的值,
可以是一个个整数或者是字节数组
,值的类型由节点的encoding属性决定(二进制编码00,01,11等)
对象
- 对象的概念: 对象是数据结构的封装
- 对象的类型和编码
- Redis使用对象来表示数据库的键和值,但是
键对象永远都是String类型
- type(类型): 对象的type表明是什么类型
- REDIS_STRING 字符串对象
- encoding可以为:
- int(long类型整数)
- raw(简单的sds):当
值为字符串,并且长度大于39字节的时候使用raw
- embstr(emb编码的sds)
否则使用的就是emb编码的sds了
- 什么是embstr: 是专门用来保存短字符串的一种优化编码方式
- emstr的好处:内存分配次数raw2次,这个只需要1次
- 并且释放的时候也只要释放1次,raw2次
- 编码的转换:
- int和embstr都能转为raw
- int: 当值不再是整数值而是一个字符串值的时候就会转为raw
- 而embstr,redis没有提供任何api修改,所以对embstr encoding修改的任何操作都会使之为raw
- encoding可以为:
- REDIS_LIST 列表对象
- encoding可以为:
- ziplist(压缩列表)
- linkedlist(双端列表)
- 编码转换:
- 所有元素长度小于64字节,且元素数量<512则使用ziplist,
- encoding可以为:
- REDIS_HASH hash对象
- encoding可以为:
- ziplist:
键和值是紧紧挨在一起的
,如 字节长度+尾节点与头节点的地址差+元素的个数+键1+值1+键2+值2+…zlend(末尾) - hashtable: 在hashtable里
键和值都是string类型
- ziplist:
- 编码转换:
- 当元素的长度都<64字节,且数量<512,则使用ziplist (与上述一样)
- encoding可以为:
- REDIS_SET set集合对象
- encoding为:
- intset(整数集合):底层是数组
- hashtable(字典): 同理,键都是String类型,
但是值却都是为空
- 编码转换:
- 当
所有的元素都是整数
,并且数量<512
则使用intset,否则就是hashtable
- 当
- encoding为:
- REDIS_ZSET 有序集合对象
- encoding可以为:
- ziplist(压缩列表):当使用压缩列表的时候,与hash对象类似,hash对象是键值挤在一起,而ZSET则是键和score挤在一起
- skiplist:
注意:是与dict一起构成zset的
dict保存了键和score的映射,使得查找的时候可以O(1)查找
- 为什么使用dict+skiplist作为zset的底层结构: 原因在于
skiplist使得集合有序,但是查找需要为O(logN)
,而dict使得查找变为O(1),但是本身是无序的
,综合而言skiplist+dict使得集合有序且查找快
- 为什么使用dict+skiplist作为zset的底层结构: 原因在于
- 编码转换:
- 与上述类似,都是ziplist的特性:
当所有元素长度<64字节,且数量<128的时候会使用ziplist
- 与上述类似,都是ziplist的特性:
- encoding可以为:
- REDIS_STRING 字符串对象
- encoding(编码): 对象的编码属性指定对象的底层用什么数据结构
- REDIS_ENCODING_INT long类型的整数
- REDIS_ENCODING_EMBSTR embstr编码的简单动态字符串
- REDIS_ENCODING_RAW 简单动态字符串
- REDIS_ENCODING_HT 字典
- REDIS_ENCODING_LINKEDLISET 双端链表
- REDIS_ENCODING_ZIPLIST 压缩列表(
当底层为整数或者字符串,并且量不多的时候
) - REDIS_ENCODING_INTSET 整数集合(
当底层全为整数并且数量不多的时候
) - REDIS_ENCODING_SKIPLIST 跳表
- Redis使用对象来表示数据库的键和值,但是
Redis中的数据库
Redis数据库的构成:
- 主要是由dict和expires构成的
- dict: 保存了所有的键值对,相当于是一个Map,Key总是一个
字符串类型
,value就是key对应的值,可以是任意类型
- expires: 则负责保存过期键的过期时间
- dict: 保存了所有的键值对,相当于是一个Map,Key总是一个
过期删除策略
- 过期删除策略: redis采用
惰性删除
+定期删除
定时删除
: 内存友好,但是cpu不友好,redis不适用定时删除惰性删除
: cpu友好,但是内存友好- 惰性删除策略的实现:
所有读写在操作之前都会先对输入键进行检测
- 惰性删除策略的实现:
定期删除
: 上述两者的折中- 定期删除策略的实现:
- 每次运行的时候,都会从一定量的数据库中取出部分随机键进行检查
- 会有一个全局变量用于记录当前的进度,下次调用的时候就会接着上次的继续,当所有的都检查完毕之后就会置为0
学到的思路
:对于一个大任务,单线程情况下可以分批次执行
- 定期删除策略的实现:
- 过期涉及到持久化的时候:
- RDB持久化:可以分为生成RDB和载入RDB两个阶段
- 生成RDB文件: 当调用SAVE或者BGSAVE的时候,会对数据库中的键检查,
已过期的键不会存入到RDB文件中
- 载入RDB文件:
当RDB服务开启的时候,服务器才会对RDB进行载入
,- 主服务器模式: 载入时会对保存着的RDB文件中的
键进行检查
,未过期的才会被载入 - 从服务器模式: 不会检查,所有的键都会被载入到服务器中
- 主服务器模式: 载入时会对保存着的RDB文件中的
- 总结:
RDB不会包含过期的键
- 生成RDB文件: 当调用SAVE或者BGSAVE的时候,会对数据库中的键检查,
- AOF持久化:
- AOF写入:当
键已经过期,但是未删除
,AOF不会有任何影响,但是当某个键已经被删除了,则会向AOF中追加一条DEL命令,显示的记录已经该键已经被删除了
- AOF重写: 只会对未过期的键重写
- 总结:
AOF重写不会包含过期键,AOF写入不会有任何变化(既不管不顾),只有当某个键被删除的时候会追加一条DEL命令表示键删除了
- AOF写入:当
- RDB持久化:可以分为生成RDB和载入RDB两个阶段
- 复制模式下:在复制模式下,
过期删除策略都由主服务器控制
- 主服务器删除一个键后,会显示的通知从服务器删除(这点在下面也讲述了,就是命令传播阶段)
- 从服务器在收到客户端
读的命令的时候
,就算是过期的数据也不会删除 - 从服务器只有接收到主服务器DEL命令之后才会删除
- 总结:
键的删除必须是主服务器自主提出的
,从不会自主删除,还是会假设未过期主服务器删除键之后会向从服务器发送DEL命令,从才会删除
,保证了数据一致性
持久化
RDB持久化
- 什么是RDB持久化:既将数据库中的数据压缩成二进制文件,从而通过该文件可以还原状态
- 持久化
- 核心命令:
SAVE
: 会阻塞服务进程BGSAVE
: 会fork出一个子进程,注意是子进程,不是子线程,子进程持久化,父进程继续处理- 注意点:
SAVE
会阻塞服务器- 当客户端再次
BGSAVE
的时候会被拒绝,因为已经有子进程了,会竞争但是对于BGREWRITEAOF,会使其等待,这2个是FIFO的
-
持久化时机: 由配置而定,或调用而定
- 如配置中配置了save 1 9000
save 1 9000 表示1s内9000个key改变了
- 具体原理:通过2个变量
- dirty: 表示
距离上次持久化发送了多少次改变
- lastsave:
记录上次save或者bgsave的时间戳
- 或者是
调用了SYNC
命令
- 如配置中配置了save 1 9000
-
WIP
RDB持久化对于不同类型的对象有不同的策略(待补)
- 核心命令:
- 还原: RDB文件的载入是服务器启动的时候自动执行的,但是呢
注意,因为AOF频率比RDB高,所以优先级AOF高
,并且载入期间,服务不可用
AOF持久化(分为文件写入和文件同步)
- 定义: AOF持久化是通过redis服务器的写命令来记录数据库状态的,
每次执行完命令,都会以协议的格式存储到一个缓冲区aof_buf中
-
AOF文件写入:
- redis的服务器进程是一个事件驱动循环进程,
服务器每次结束一个事件循环之后
,都会调用flushAppendOnlyFile将aof_buf中的协议命令写到aof文件中
至于是否同步有如下策略 - 总结: 每次接收到一个命令都会写入到aof_buf缓存中,然后会写入到文件中
- redis的服务器进程是一个事件驱动循环进程,
-
文件同步:(注意是另外的线程来同步的)
- 有如下几种策略:
always
:每次执行事件完毕之后,都写入并同步到文件中
(注意是写入并且同步的)everysec
: 将aof_buf的所有内容写到aof文件中,如果上次同步时间间隔超过1s,则再次同步
no
: 写入到aof文件中,但是具体何时同步由服务器决定
- 总结: 子线程同步文件,always最安全,但是吞吐量差,everysec最好
- 有如下几种策略:
-
AOF文件的载入
- 就是通过伪造一个客户端读取aof文件中的协议命令再一遍遍重新执行一次
-
AOF重写: 随着服务器运行,命令越来越多,aof文件则会越来越大,
aof重写使得文件得以缩小
;子进程执行重写任务,所以不用考虑线程安全- 触发时机: 当aof文件过大的时候,会触发子进程重写
- 原理:
并不会去读取当前的aof文件
遍历所有的数据库,然后直接读取redisServer dict库中的所有键值对,然后读取值的类型
,根据不同的类型,总结最后用一条命令记录总结,而非多条命令,如原先 SADD name 1 ; SADD name “qwe”; SADD name “fff” 则最终会变成 SADD name 1 “qwe” “fff”最后生成新的AOF文件
- 注意: 多条命令整合成一条也是有限制的,因为可能添加的元素过多,所以会有个判断,默认还是64(意味着sadd name 后的值最多有64个),超过则会多条记录
重写期间的数据不一致性问题
:(因为是子进程重写,父子进程必然会有数据不一致性问题)- 解决方法:通过AOF重写缓冲区(非AOF缓冲区)
- 具体:
- 服务器执行客户端的命令
- 写入到AOF缓冲区(主服务器正常间隔一段时间刷新到aof文件中)
- 写入到AOF重写缓冲区
- 子进程通过访问当前的服务器,遍历数据库重写aof文件,并替代之前的aof文件,当子进程完毕之后,会通过信号量机制通知父进程,父进程收到之后,
会将aof重写缓冲区写入到aof文件中
(注意这个aof文件已经被子进程的代替了),然后atomic改名
- 具体:
- 解决方法:通过AOF重写缓冲区(非AOF缓冲区)
- 总结:
- 不会读取原先的文件,而是直接读取服务器,遍历然后将同类型的多条指令用一条指令代替
- 是子进程去重写,注意
- RDB在子进程生成rdb文件的时候,主进程的一些数据可能会丢失,aof更加可靠
-
事件(待补)
- Redis是一个
事件驱动程序
,处理两种事件:文件事件
:例如服务器与客户端通信,或者- 文件事件处理器: 基于Reactor模式
- 什么是Reactor模式: 事件驱动模式,有一个或者多个输入源,并且有一个事件分发者(Dispatcher),和事件处理者(RequestHandler)
- 文件事件处理器: 基于Reactor模式
时间事件
客户端(待补)
- Redis服务器与客户端是1:n的关系,因而内部Redise server维护了客户端的信息:通过一个结构体: redisServer中有 list *clients 这个属性(
这是一个链表
) - 客户端属性:
- 共性: 既客户端执行公共需要的部分,用人来比喻就是人都有嘴巴
- 个性:
与特定功能相关
,如操作数据时需要的db和dictid属性,以及事务的mstate属性 fd
属性: 在aof持久化重启载入aof文件的时候,就会通过fake-client
实现数据的重新记录,此时fd=-1,代表fake-client
,普通客户端都>-1名字:*name
属性标志属性:flags
:记录客户端的角色(role),以及客户端所处的状态- 主从复制时,REDIS_MASTER,REDIS_SLAVE分别代表主服务器和从服务器
- …待补
- 客户端的输入缓冲区(sds queryBuf)
- 还记得aof 的重写吗,它会将多条相同类型的语句合并为同一条,但是又不会无限长,就是缓冲区的限制
待补
多机数据库
复制
- A通过SLAVEOF去复制另外的服务器B,则B是
被复制的,是主服务器
,A是主动复制的,是从服务器
- 主从复制问题: 个人认为,开发者应该借鉴了TCP的快速重传和UDP的重传机制
- 所涉及的步骤:
SYNC(同步)
,PSYNC(部分同步)
,Command propagate(命令传播)
- 老版本的方法:
- 客户端发起SLAVEOF请求复制数据
- 首先同步主服务器,所以客户端发出
SYNC
请求 - 服务器收到SYNC请求,调用BGSAVE,生成rdb文件,并发送这个文件
- 从服务器接收,然后更新
命令传播阶段:
主服务器将缓冲区里的命令发给从服务器,从服务器使用使之数据一致- 问题在于:
-
- 从服务器断线问题,当从服务器断线重连之后,会
再次发起SYNC请求同步
,所以很明显很多数据会发生重复,效率低下,并且RDB文件很大,网络流量也浪费了
- 从服务器断线问题,当从服务器断线重连之后,会
-
- 新版本的方法: 2.8后开始,核心就是PSYNC,为什么我认为开发者借鉴了TCP的快速重传呢,原因就在于PSYNC
- 在这里我先自个儿先简单复习一下TCP的快速重传: 服务器每次发出一个包都会有ack,seq,len三个属性,seq=客户端回传的ack+len ,而client接收到后返回的ack就是为seq,代表,seq之前的数据我都接收到了,而快速重传,当server发送了a,b,c的包,客户端收到了a的包,假设b的包丢失了,收到了c的包,此时会回传的ack=b,然后server继续发送d,e,f,此时client 回传的包ack都为b,当server端收到3次之后则会再发送包b,收到之后会返回f(缓存队列,当然这点与这个复制无关),这就是快速重传机制
- 部分重同步实现的核心:
offset
代表主服务器的复制偏移量和从服务器的复制偏移量- 服务器每次发送N个字节,则这个偏移量会+N(类似于TCP中的len),而从服务收到之后也会加N,因而正常情况下,主从从这个值会相同
- 当从服务器宕机了,重新发起PSYNC,会携带这个offset,发现从服务器的offset与当前主服务器的offset不同,则会触发
重传
,哪里重传呢,与TCP类似,也有一个缓冲区(如下)
- 主服务器的
复制挤压缓冲区
- 是一个FIFO的队列,默认大小1MB,
会记录最近的命令
,接着上述,当主服务器收到offset不一致的时候,会判断从的offset+1是否在这个缓冲区中
- 如果在则会
返回CONTINUE表示以部分重传模式,然后会发送缺少的这些记录(主的offset-从的offset),这样就达到了同一点
, - 如果不在,则会类似于UDP,整个包重传(既发起SYNC,整个重新开始同步)
- 如果在则会
- 是一个FIFO的队列,默认大小1MB,
- 服务器的运行id: 不管是从服务器,还是主服务器都有unique id,在启动的时候会生成,
当第一次复制的时候,主服务器会将自己的id给从服务器并保存
- 当断线重新发起请求的时候会判断,从服务器上次连的是否是这次请求的(主服务器来判断,既判断是否相等),如果相等则表明是同一个主服务器,则会接着下一步(判断offset)
- 如果不是,则会类似于UDP发送整个包
完整重传
- 所涉及的步骤:
事务(待补)
- 事务通过MULTI,EXEC,WATCH等命令来实现
- 事务的实现:通常都是三个步骤
- 事务开始
- MULTI标志着事务的开始(其实就是修改客户端的状态为
事务状态
)
- MULTI标志着事务的开始(其实就是修改客户端的状态为
- 命令入队
- 当在事务状态时,对于
EXEC,DISCARD,WATCH,MULTI
会立即执行,而其他命令则会放入一个队列中
- 事务队列: 客户端有一个属性为
maste
属性,这个队列内部包含了一个事务队列
,以及入队命令的计数器
- 当在事务状态时,对于
- 事务执行
- 处于事务的客户端向服务器发送EXEC命令时,会被立即执行,服务器
遍历客户端的事务队列
,依次执行,最后将各个命令的结果一次全返回给客户端
- 处于事务的客户端向服务器发送EXEC命令时,会被立即执行,服务器
- 事务开始
- WATCH命令:
是一个乐观锁
,在EXEC执行之前见识某个key,如果执行的时候发现这个key改变了,则会拒绝执行事务
(既执行exec后返回nil)
环境搭建环节()
- 搭建,推荐