这是卓仔写过的最长的文章了,几乎把redis所有重要的内容都详细的学习了。
包括redis快的原因、网络io模型、数据结构、命令、持久化和高可用。
总体有5个原因:
1)完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。
2)数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的
3)采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗
4)使用多路I/O复用模型,非阻塞IO
5)使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求
6)redis是用C语言写的,本来就直接跟操作系统交互,命令执行快得飞起
这个非常重要:
Redis基于Reactor模式开发了自己的网络事件处理器——文件事件处理器,
1)文件事件处理器
文件事件处理器使用I/O多路复用程序来同时监听多个socket,并根据socket目前执行的任务来为socket关联不同的事件处理器。
当被监听的socket准备好执行连接应答、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用socket之前已关联好的事件处理器来处理这些事件。
这个文件事件处理器,是单线程的,redis才叫做单线程的模型.
文件事件处理器的结构包含4个部分:多个socket,IO多路复用程序,文件事件分派器,事件处理器(命令请求处理器、命令回复处理器、连接应答处理器,等等)。
多个socket可能并发的产生不同的操作,每个操作对应不同的文件事件,但是IO多路复用程序会监听多个socket,但是会将socket放入一个队列中排队,每次从队列中取出一个socket给事件分派器,事件分派器把socket给对应的事件处理器。
然后一个socket的事件处理完之后,IO多路复用程序才会将队列中的下一个socket给事件分派器。文件事件分派器会根据每个socket当前产生的事件,来选择对应的事件处理器来处理。
2)文件事件
当socket变得可读时(比如客户端对redis执行write操作,或者close操作),或者有新的可以应答的sccket出现时(客户端对redis执行connect操作),socket就会产生一个AE_READABLE事件。
当socket变得可写的时候(客户端对redis执行read操作),socket会产生一个AE_WRITABLE事件。
IO多路复用程序可以同时监听AE_REABLE和AE_WRITABLE两种事件,要是一个socket同时产生了AE_READABLE和AE_WRITABLE两种事件,那么文件事件分派器优先处理AE_REABLE事件,然后才是AE_WRITABLE事件。
3)文件事件处理器
如果是客户端要连接redis,那么会为socket关联连接应答处理器
如果是客户端要写数据到redis,那么会为socket关联命令请求处理器
如果是客户端要从redis读数据,那么会为socket关联命令回复处理器
4)客户端与redis通信的一次流程
流程图:
处理过程可以分为以下几个步骤:
客户端向redis发起一个socket请求,向redis的server socket请求连接,这里命名为socket01
server socket产生一个AE_READABLE事件,IO多路复用程序监听到事件后将这个socket01压入队列
文件事件分派器从队列中取出socket01,交给连接应答处理器
连接应答处理器会将socket01的AE_READABLE事件与命令请求处理器相关联
假设客户端执行set操作,这时命令请求处理器会从socket01读取key value,在内存中完成key value的设置
在内存中完成设置后,会将socket01的AE_WRITEABLE事件与命令回复处理器相关联,然后压入队列中
事件分派器拿到socket01后,交给命令回复处理器,由命令回复处理器向socket01写入本次操作的结果,比如OK,之后解除关联
以上就是一个命令在redis中执行的过程
4)总结:
所以文件事件处理器有4个部分:
多个socket,IO多路复用程序,文件事件分派器,事件处理器
socket会产生不同的事件(AE_REABLE和AE_WRITABLE)。
IO多路复用程序的作用是把监听并把请求socket放入队列。
文件事件分派器的作用是从队列取出socket,根据不同的事件分配给不同的事件处理器处理(应答处理器、命令请求处理器、命令回复处理器)。
再和之前的reactor模式进行对比学习:
先回顾下reactor模式:
reactor模式有3个部分:reactor、acceptor和handler (selector多路复用)。
reactor的作用是先注册一个acceptor,然后持续遍历selector(多路复用程序),也就是执行acceptor和handler。
acceptor的作用就是为每一个连接创建具体处理IO请求的Handler,并注册到selector.
handler 的作用就是接收处理每个socket的读写请求。
对比:
所以selector就是多路复用程序。reactor对应的就是文件事件分派器。
acceptor和Handler分别对应的是应答处理器(acceptor) 和命令请求处理器、命令回复处理器(Handler)。
问题:
1、为什么不采用多进程或多线程处理?
1.多线程处理可能涉及到锁
2.多线程处理会涉及到线程切换而消耗CPU
2、单线程处理的缺点?
1.耗时的命令会导致并发的下降,不只是读并发,写并发也会下降
2.无法发挥多核CPU性能,不过可以通过在单机开多个Redis实例来完善
redis主要提供了5种数据结构:字符串(String)、哈希(hash)、列表(list)、集合(set)、有序集合(short set)。
1)字符串(String)
简单动态字符串(SDS)
结构
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
}
1、len 保存了SDS保存字符串的长度
2、buf[] 数组用来保存字符串的每个元素
3、free 记录了 buf 数组中未使用的字节数量
优点:
命令:
1.设置值 :set key value
2.获取值:get key
3.批量设置值:mset key value
4.批量获取值:mget key
5.计数 :incr key
除了有 incr 自增命令外,Redis 中还提供了其它对数字处理的命令。例如:
decr key 自减
incrby key increment 自增指定数字
decrby key decrement 自减指定数字
incrbyfloat key increment 自增浮点数
6.追加值:append key value
append 命令可以向字符串尾部追加值。
7.字符串长度:strlen key
注意:每个中文占用 3 个字节。
8.设置并返回原值:getset key value
9.设置指定位置的字符:setrange key offeset value
10.获取部分字符串:getrange key start end
2)字典(hash)
字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键 key 都是唯一的,通过 key 可以对值来进行查找或修改。C 语言中没有内置这种数据结构的实现,所以字典依然是 Redis自己构建的。
结构
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于 size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}dictht
哈希表是由数组 table 组成,table 中每个元素都是指向 dict.h/dictEntry 结构,dictEntry 结构定义如下:
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry
这里可以参考java 1.7的hashmap原理,之前文章学习过。
哈希冲突和扩容和收缩和java1.7都差不多
命令:
1.设置值:hset key field value
2.获取值:hget key field
3.删除 field :hdel key field [field ...]
4.计算 field 个数:hlen key
5.批量设置或获取 field-value:
hmget key field [field ...]
hmset key field value [field value ...]
6.判断 field 是否存在:hexists key field
7.获取所有 field:hkeys key
8.获取所有 value:hvals key
9.获取所有的 field-value:hgetall key
10.计数
hincrby key field increment
hincrbyfloat key field increment
hincrby 命令和 incrby 命令的使用功能基本一样,都是对值进行增量操作的,唯一不同的就是 incrby 命令的作用域是 key,而 hincrby 命令的作用域则是 field。
11.计算 value 的字符串长度:hstrlen key field
3)列表(list)
主要redis的列表 底层是双向链表:
结构:
链表定义:
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode
通过多个 listNode 结构就可以组成链表,这是一个双向链表,Redis还提供了操作链表的数据结构:
typedef struct list{
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void (*free) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match) (void *ptr,void *key);
}list;
Redis链表特性:
1、双端:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。
2、无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。
3、带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。
4、多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。
命令
1.添加操作:
从右边插入元素
rpush key value [value ...]
从左边插入元素
lpush key value [value ...]
向某个元素前或者后插入元素
linsert key BEFORE|AFTER pivot value
linsert 命令在执行的时候首先会从当前列表中查找到 pivot 元素,其次再将这个新元素插入到 pivot 元素的前面或者后面。linsert 命令在执行成功后也是会有返回值的,返回的结果就是当前列表中元素的个数。
2.查找
获取指定范围内的元素列表
lrange key start stop
lrange 命令会获取列表中指定索引范围的所有元素。
通过索引获取列表主要有两个特点:
索引下标从左到右分别是 0 到 N-1,从右到左是 -1 到 -N。
lrange 命令中的 stop 参数在执行时会包括当前元素,并不是所有的语言都是这样的。
获取列表中指定索引下标的元素:lindex key index
获取列表长度:llen key
3.删除
从列表左侧弹出元素 lpop key
lpop 命令执行成功后会返回当前被删除的元素名称。
从列表右侧弹出元素 rpop key
删除指定元素:lrem key count value
lrem 命令会将列表中等于 value 的元素删除掉,并且会根据 count 参数来决定删除 value 的元素个数。
按照索引范围修剪列表:ltrim key start stop
4.修改
修改指定索引下标的元素:lset key index value
4)集合(set)、
set集合有2中储存方式:
intset(整数集合):当集合中的元素都是整数,并且集合中的元素个数小于 512 个时,Redis 会选用 intset 作为底层内部实现。
hashtable(哈希表):当上述条件不满足时,Redis 会采用 hashtable 作为底层实现。
当元素个数较少并且都是整数时,内部编码为 intset。
当元素不全是整数时,内部编码为 hashtable。
当元素个数超过 512 个时,内部编码为 hashtable。
intset结构:
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
命令:
添加元素:sadd key member [member ...]
删除元素:srem key member [member ...]
计算元素个数:scard key
判读元素是否在集合中:sismember key member
随机从 set 中返回指定个数元素:srandmember key [count]
从集合中随机弹出元素:spop key [count]
获取所有元素:smembers key
5)有序集合(short set)
有序集合也是一种集合,并且这个集合还是有序的。列表也是有序的,那它和有序集合又有什么不同呢?
有序集合的有序和列表的有序是不同的。列表中的有序指的的是插入元素的顺序和查询元素的顺序相同,而有序集合中的有序指的是它会为每个元素设置一个分数(score),而查询时可以通过分数计算元素的排名,然后再返回结果。
因为有序集合也是集合类型,所以有序集合中也是不插入重复元素的,但在有序集合中分数则是可以重复,那如果在有序集合中有多个元素的分数是相同的。
底层采用跳跃表实现。
跳表与AVL、红黑树...等相比,数据结构简单,算法易懂,但查询的时间复杂度与平衡二叉树/红黑树相当
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。具有如下性质:
1、由很多层结构组成;
2、每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点;
3、最底层的链表包含了所有的元素;
4、如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);
5、链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,另一个指向下一层的同一个链表节点;
1. 搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。
2. 插入:首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k层。
3. 删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。
命令
添加元素:zadd key [NX|XX] [CH] [INCR] score member [score member ...]
计算成员个数:zcard key
计算某个成员的分数:zscore key member
计算成员的排名
zrank key member
zrevrank key member
删除元素
zrem key member [member ...]
返回的结果为成功删除元素的个数,因为 zrem 命令是支持批量删除的。
增加元素分数:zincrby key increment member
返回指定排名范围的元素
zrange key start stop [WITHSCORES]
zrevrange key start stop [WITHSCORES]
返回指定分数范围的元素
zrangebyscore key min max [WITHSCORES] [LIMIT offset count]
zrevrangebyscore key max min [WITHSCORES] [LIMIT offset count]
返回指定分数范围元素个数:zcount key min max
删除指定排名内的升序元素:zremrangebyrank key start stop
删除指定分数范围元素:zremrangebyscore key min max
redis是一个内存数据库,数据保存在内存中,但是我们都知道内存的数据变化是很快的,也容易发生丢失。
Redis还为我们提供了持久化的机制,分别是RDB(Redis DataBase)和AOF(Append Only File)。
1、持久化流程
要有下面五个过程:
(1)客户端向服务端发送写操作(数据在客户端的内存中)。
(2)数据库服务端接收到写请求的数据(数据在服务端的内存中)。
(3)服务端调用write这个系统调用,将数据往磁盘上写(数据在系统内存的缓冲区中)。
(4)操作系统将缓冲区中的数据转移到磁盘控制器上(数据在磁盘缓存中)。
(5)磁盘控制器将数据写到磁盘的物理介质中(数据真正落到磁盘上
redis提供了两种持久化策略机制,也就是RDB和AOF。
2、RDB
RDB其实就是把数据以快照的形式保存在磁盘上。什么是快照呢,你可以理解成把当前时刻的数据拍成一张照片保存下来。
RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘。也是默认的持久化方式,这种方式是就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为dump.rdb。
优势
(1)RDB文件紧凑,全量备份,非常适合用于进行备份和灾难恢复。
(2)生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。
(3)RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
劣势
RDB快照是一次全量备份,存储的是内存数据的二进制序列化形式,存储上非常紧凑。当进行快照持久化时,会开启一个子进程专门负责快照持久化,子进程会拥有父进程的内存数据,父进程修改内存子进程不会反应出来,所以在快照持久化期间修改的数据不会被保存,可能丢失数据。
3.AOF机制
全量备份总是耗时的,有时候我们提供一种更加高效的方式AOF,工作机制很简单,redis会将每一个收到的写命令都通过write函数追加到文件中。通俗的理解就是日志记录。
持久化原理
他的原理看下面这张图:
每当有一个写命令过来时,就直接保存在我们的AOF文件中。
Redis会将每一个收到的写命令都通过write函数追加到文件中(默认是 appendonly.aof 文件)
当然由于OS会在内核中缓存 write 做的修改,所以可能不是立即写到磁盘上。
这样AOF方式的持久化也还是有可能会丢失部分修改,不过我们可以通过配置文件告诉Redis我们想要通过fsync函数强制OS写入到磁盘的时机(默认是:每秒fsync一次),有如下三种方式:
1)appendfsync always 每次收到写命令就立即强制写入磁盘,效率最低,但可保证完全的持久化,不推荐使用
2)apendfsync everysec 每秒钟强制写入磁盘一次,在性能和持久化方面做了很好的折中,推荐使用这个
3)appendfsync no 完全依赖OS,性能最好,持久化没保证
AOF 默认的是文件的无限追加,这样的话持久化文件会变得越来越大。
例如我们调用incr test命令100次,文件中必须保存全部的100条命令,其实有99条都是多余的。因为要恢复数据库的状态其实文件中保存一条set test 100就够了。
为了压缩AOF的持久化文件。Redis提供了bgrewriteaof命令。收到此命令Redis将使用与快照类似的方式将内存中的数据以命令的方式保存到临时文件中,最后替换原来的文件。
优点:
(1)AOF可以更好的保护数据不丢失,可以设置不同的 fsync 策略,比如无 fsync ,每秒钟一次 fsync ,或者每次执行写入命令时 fsync 。
AOF 的默认策略为每秒钟 fsync 一次,在这种配置下,Redis 仍然可以保持良好的性能,并且就算发生故障停机,也最多只会丢失一秒钟的数据。
fsync 会在后台线程执行,所以主线程可以继续努力地处理命令请求。
(2)AOF 文件是一个只进行追加操作的日志文件(append only log), 因此对 aof文件的写入不需要进行 seek。有点像kafka的顺序读写,和内存速度差不多。
(3)AOF日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写。
(4)AOF日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用flushall命令清空了所有数据,只要这个时候后台rewrite还没有发生,那么就可以立即拷贝AOF文件,将最后一条flushall命令给删了,然后再将该AOF文件放回去,就可以通过恢复机制,自动恢复所有数据
缺点
(1)对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大
(2)AOF开启后,支持的写QPS会比RDB支持的写QPS低,因为AOF一般会配置成每秒fsync一次日志文件,当然,每秒一次fsync,性能也还是很高的
4.对比:
redis 高可用有2种方式:
哨兵模式和集群模式:
哨兵模式:
哨兵的作用就是监控Redis系统的运行状况。它的功能包括以下两个。
监控主数据库和从数据库是否正常运行。
主数据库出现故障时自动将从数据库转换为主数据库。
sentinel发现master挂了后,就会从slave中重新选举一个master。
哨兵模式强调高可用
Sentinel 系统用于管理多个 Redis 服务器(instance), 该系统执行以下三个任务:
监控(Monitoring):Sentinel 会不断地检查你的主服务器和从服务器是否运作正常。
提醒(Notification):当被监控的某个 Redis 服务器出现问题时, Sentinel 可以通过 API 向管理员或者其他应用程序发送通知。
自动故障迁移(Automatic failover):当一个主服务器不能正常工作时, Sentinel 会开始一次自动故障迁移操作, 它会将失效主服务器的其中一个从服务器升级为新的主服务器, 并让失效主服务器的其他从服务器改为复制新的主服务器;当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址, 使得集群可以使用新主服务器代替失效服务器。
客户端中不会记录redis的地址(某个IP),而是记录sentinel的地址,这样我们可以直接从sentinel获取的redis地址,因为sentinel会对所有的master、slave进行监控,它是知道到底谁才是真正的master的,例如我们故障转移,这时候对于sentinel来说,master是变了的,然后通知客户端。而客户端根本不用关心到底谁才是真正的master,只关心sentinel告知的master。
集群模式
即使使用哨兵,redis每个实例也是全量存储,每个redis存储的内容都是完整的数据,浪费内存且有木桶效应。为了最大化利用内存,可以采用集群,就是分布式存储。即每台redis存储不同的内容,共有16384个slot。每个redis分得一些slot,hash_slot = crc16(key) mod 16384 找到对应slot,键是可用键,如果有{}则取{}内的作为可用键,否则整个键是可用键
Redis中的集群分为主节点和从节点。其中主节点用于处理槽;而从节点用于复制某个主节点,并在被复制的主节点下线时,代替下线的主节点继续处理命令请求。
集群至少需要3主3从,且每个实例使用不同的配置文件,主从不用配置,集群会自己选。
cluster是为了解决单机Redis容量有限的问题,将数据按一定的规则分配到多台机器。
集群模式提高并发量。
1)缓存穿透是指查询一个一定不存在的数据。由于缓存命不中时会去查询数据库,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。
解决方案:
是将空对象也缓存起来,并给它设置一个很短的过期时间,最长不超过5分钟
采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力
2)如果缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,就会造成缓存雪崩。
解决方案:
尽量让失效的时间点不分布在同一个时间点
3)缓存击穿,是指一个key非常热点,在不停的扛着大并发,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
解决方案:
可以设置key永不过期
学习组件不仅仅要学习它的用法和原理,还要分析它的优点,学习它的设计理念。
所以总结一下,为什么redis的性能如此优秀?
首先从分别从网络io和存储方式2个方面。
网络io中,redis自己实现了reactor模型(epoll),可以说是目前性能最好的网络io模型了。
存储采用 内存(肯定快)+ 高效的数据结构(比如说跳跃表)
说到内存存储肯定要涉及到持久化,持久化redis采用 快照方式(全量存储)RDB或 存储命令AOF的方式。
但是单线程方式虽然可以避免锁和线程切换的问题。 但并发缺无法保证,
还有就是可靠性也无法保证,(如果挂了怎么快速恢复和数据过大时需要分散数据)
所以还需要采用集群的方式(几乎所有的组件都有集群方式),
redis有两种集群方式,哨兵模式和集群模式,
哨兵模式只是监控主从节点,主节点挂掉立即切换从节点。 但每个节点还要存储所有数据,所以无法保证并发性和数据分散。
集群模式就可以把数据分散到不同的节点,同时保证了并发性和数据分散。
在和mysql对比一下:
redis很难做到事物, 目前采用lua脚本方式(卓仔不太会)。
集群方面 redis的
哨兵模式像mysql的主从备份方式,
集群模式有点像mysql的水平拆分(分表)。
参考文章:
https://www.jianshu.com/p/1f380ce18254
https://www.jianshu.com/p/b13968924503
http://www.freeoa.net/osuport/db/redis-data-structure_3278.html
https://www.cnblogs.com/ysocean/p/9080942.html
https://baijiahao.baidu.com/s?id=1654694618189745916&wfr=spider&for=pc