Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。 Redis 内置了 复制(replication),LUA脚本(Lua scripting), LRU驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)。本文从存储、基础数据结构、基础理论、使用以及实际应用场景对Redis进行详细解析。(重度引用老钱@公众号【码洞】、《Redis设计与实现》)
1. 存储
1.1 字符串SDS(Simple Dynamic String)
1.1.1 内部结构
1 | struct SDS<T> { |
上面的 SDS 结构使用了范型 T,为什么不直接用 int 呢,这是因为当字符串比较短时,len 和 capacity 可以使用 byte 和 short 来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。
前面我们提到字符串是可以修改的字符串,它要支持 append 操作。如果数组没有冗余空间,那么追加操作必然涉及到分配新数组,然后将旧内容复制过来,再 append 新内容。如果字符串的长度非常长,这样的内存分配和复制开销就会非常大。因此可以说取出来拼接后再存储开销巨大,某工作5年+的人竟然推荐我存list的新加元素的时候先取出来拼接成字符串再存回去!!!这里就知道是有多SB的想法。
SDS除了保存数据库的之外,还被用来做缓冲区使用!!!
与c字符串不同,SDS完全杜绝了内存溢出的问题。
- 减少字符串扩充带来的内存分配次数,(SDS扩容的时候可能会多分配一些空间,即空间预分配,另外在释放内存的时候采用惰性释放);
1.1.2 embstr vs raw
Redis 的字符串有两种存储方式,在长度特别短时,使用 emb 形式存储 (embeded),当长度超过 44 时,使用 raw 形式存储。首先来了解一下 Redis 对象头结构体,所有的 Redis 对象都有下面的这个结构头:
1 | struct RedisObject { |
不同的对象具有不同的类型 type(4bit),同一个类型的 type 会有不同的存储形式 encoding(4bit),为了记录对象的 LRU 信息,使用了 24 个 bit 来记录 LRU 信息。每个对象都有个引用计数,当引用计数为零时,对象就会被销毁,内存被回收。ptr 指针将指向对象内容 (body) 的具体存储位置。这样一个 RedisObject 对象头需要占据 16 字节的存储空间。
embstr 存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。而 raw 存储形式不一样,它需要两次 malloc,两个对象头在内存地址上一般是不连续的。
1.1.3 embstr最大容纳的长度为啥是44
- RedisObject对象头占据16个字节
- SDS对象头至少3个字节
- 内存分配器 jemalloc/tcmalloc 等分配内存大小的单位都是 2、4、8、16、32、64。为了能容纳一个完整的 embstr 对象,jemalloc 最少会分配 32 字节的空间,如果字符串再稍微长一点,那就是 64 字节的空间。如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。
- 留给 content 的长度最多只有 45(64-19) 字节了。字符串又是以\0结尾,所以 embstr 最大能容纳的字符串长度就是 44。
1.1.4 扩容
字符串在长度小于 1M 之前,扩容空间采用加倍策略,也就是保留 100% 的冗余空间。当长度超过 1M 之后,为了避免加倍后的冗余空间过大而导致浪费,每次扩容只会多分配 1M 大小的冗余空间。
1.2 dict
除了hash结构使用了字典(dict)外,整个Redis数据库中所有的key和value也组成了一个全局的字典。
1.2.1 内部结构
dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。但是在 dict 扩容缩容时,需要分配新的 hashtable,然后进行渐进式搬迁,这时候两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后,旧的 hashtable 被删除,新的 hashtable 取而代之。
1.2.1 Rehash
1.2.1.1 Rehash步骤
计算负载因子
负载因子=哈希表已经保存的节点数量/hash表的大小
load_factor =ht[0].used / ht[0].size
为字典的ht1哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量,也就是ht[0].used属性的值:
如果当前执行的是扩展操作,那么ht1的大小为第一个大于等于ht[0].used*2的2^n.
如果执行的是收缩操作,那么ht1的大小为第一个大于等于ht[0].used的2^n.
将保存在ht[0]中的所有键值对rehash到ht1上面:rehash指的是重新计算键的hash值和索引值,然后键键值对放置到ht1哈希表的指定位置上
当ht[0]包含的所有键值对都迁移到ht1之后,ht[0]变成空表,释放ht[0],将ht1设置为ht[0],并在ht1新创建一个空白的哈希表,为下一次rehash做准备。
1.2.1.2 渐进式hash
大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个O(n)级别的操作,作为单线程的Redis表示很难承受这样耗时的过程。步子迈大了会扯着蛋,所以Redis使用渐进式rehash小步搬迁。虽然慢一点,但是肯定可以搬完。搬迁操作埋伏在当前字典的后续指令中(来自客户端的hset/hdel指令等),但是有可能客户端闲下来了,没有了后续指令来触发这个搬迁,那么Redis就置之不理了么?当然不会,优雅的Redis怎么可能设计的这样潦草。Redis还会在定时任务中对字典进行主动搬迁。
1.2.2 查找过程
- hashtable的元素在第二维的链表上,首先需要确定元素在哪一个链表上。
1.2.3 扩容条件
正常情况下,当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。不过如果 Redis 正在做 bgsave,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (dict_can_resize)。
但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。
1.2.4 缩容条件
当 hash 表因为元素的逐渐删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的 10%。缩容不会考虑 Redis 是否正在做 bgsave。
1.3 ziplist
Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表 (ziplist) 进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。
1 | struct ziplist<T> { |
压缩列表为了支持双向遍历,所以才会有 ztail_offset 这个字段,用来快速定位到最后一个元素,然后倒着遍历。
1.3.1 添加元素
因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多。
1.4 intset
当set集合容纳的元素都是整数并且元素个数较少的时候,Redis会使用intset来存储结合元素。intset 是紧凑的数组结构,同时支持 16 位、32 位和 64 位整数。
1 | struct intset<T> { |
1.5 linkedlist
1.6 quicklist
考虑链表的附加空间相对太高,prev和next指针就要占据16个字节(64位系统为8个字节),另外每一个节点的内存是单独存储的,加剧了内存的碎片化,影响内存的效率,在后续的版本中,使用quicklist代替了ziplist和linkedlist。
quicklist是ziplist和linkedlist的混合体,它将linkedlist按段切分,每一段使用ziplist来紧凑存储,多个ziplist之间使用双向指针连接。
1 | struct ziplist { |
1.7 跳跃表(skiplist)
Redis 的 zset 是一个复合结构,一方面它需要一个 hash 结构来存储 value 和 score 的对应关系,另一方面需要提供按照 score 来排序的功能,还需要能够指定 score 的范围来获取 value 列表的功能,这就需要另外一个结构「跳跃列表」。zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist)。hash 结构在讲字典结构时已经详细分析过了,它很类似于 Java 语言中的 HashMap 结构。本节我们来讲跳跃列表,它比较复杂,读者要有心理准备。
http://zhangtielei.com/posts/blog-redis-skiplist.html
https://www.bilibili.com/video/BV1Q4411S76S?from=search&seid=13428887929805854246
skiplist的演示动画见这里。
设想如果跳跃列表只有一层会怎样?插入删除操作需要定位到相应的位置节点 (定位到最后一个比「我」小的元素,也就是第一个比「我」大的元素的前一个),定位的效率肯定比较差,复杂度将会是 O(n),因为需要挨个遍历。也许你会想到二分查找,但是二分查找的结构只能是有序数组。跳跃列表有了多层结构之后,这个定位的算法复杂度将会降到 O(lg(n))。
1 | typedef struct zskiplistNode { |
1 | typedef struct zskiplist { |
1.7.1 插入
- 随机层数:对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数。直观上期望的目标是 50% 的 Level1,25% 的 Level2,12.5% 的 Level3,一直到最顶层2^-63,因为这里每一层的晋升概率是 50%。不过 Redis 标准源码中的晋升概率只有 25%,也就是代码中的 ZSKIPLIST_P 的值。所以官方的跳跃列表更加的扁平化,层高相对较低,在单个层上需要遍历的节点数量会稍多一点。也正是因为层数一般不高,所以遍历的时候从顶层开始往下遍历会非常浪费。跳跃列表会记录一下当前的最高层数maxLevel,遍历时从这个 maxLevel 开始遍历性能就会提高很多。
- 找出搜索路径
- 创建新的节点(随机分配层数)
- 将搜索路径上的节点和这个新的节点串联起来
可能需要更新最大层数
以下为插入80和45到skiplist中演示。
1.7.2 删除
- 找出搜索路径
- 对每一层相关的节点重排前向后向指针
- 更新最大层数maxlevel
1.7.3 修改
Redis中对分数的修改就很粗暴,直接删除了重新插入,根本就不会判断值到底有没有发生改变!无脑删除无脑重新插入。
1.7.4 查找
跳跃列表有了多层结构之后,这个定位的算法复杂度将会降到 O(lg(n))。
1.7.5 性能比较
1.8 listpack
Redis5.0后引入了新的数据结构listpack,它是对ziplist的改进,在存储空间上更加节省,结构上比ziplist更加精简。
1 | struct listpack<T> { |
1.9 Rax
Rax 是 Redis 内部比较特殊的一个数据结构,它是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操作。Redis 五大基础数据结构里面,能作为字典使用的有 hash 和 zset。hash 不具备排序功能,zset 则是按照 score 进行排序的。rax 跟 zset 的不同在于它是按照 key 进行排序的。Redis 作者认为 rax 的结构非常易于理解,但是实现却有相当的复杂度,需要考虑很多的边界条件,需要处理节点的分裂、合并,一不小心就会出错。
你可以将一本英语字典看成一棵 radix tree,它所有的单词都是按照字典序进行排列,每个词汇都会附带一个解释,这个解释就是 key 对应的 value。有了这棵树,你就可以快速地检索单词,还可以查询以某个前缀开头的单词有哪些。
1.10 总结
基础数据结构 | 数据存储结构 |
---|---|
string | (1)当长度特别短的时候,使用emb. (2)当长度超过44的时候,使用raw. |
list | (1)早期版本:元素少用ziplist (2)早期版本:元素多用linkedlist (3)现在使用quicklist代替ziplist和linkedlist |
set | (1)底层实现为dict,只不过set中所有的value是NULL. (2)当set集合容纳的元素都是整数并且元素个数较少的时候,Redis会使用intset来存储集合元素 |
hash | (1)dict(字典)。 (2)hash容器对象在元素较少的时候,采用压缩列表(ziplist)进行存储。 (3) |
zset | (1)zset中存储value和score使用dict (2)zset需要按照score来排序,还需要使用score获取一定范围的value列表,使用跳跃表来实现这一功能 |
2. 基础数据结构
2.1 string
2.2 List
2.3 Set
2.4 hash
2.5 zset
3. 理论基础
3.1 事件
3.1.1 文件事件
3.1.1.1 API
- ae.c/aeCreateFileEvent
- ae.c/aeDeleteFileEvent
- ae.c/aeGetFileEvents
- ae.c/aeWait 函数接受(一个套接字描述符、一个事件类型和一个毫秒参数),在给定的时间内阻塞并等待套接字的给定类型事件产生,当事件成功产生或者等待超时,函数返回!
- ae.c/aeApiPoll函数接受一个sys/time.h/struct timeval结构为参数,并在指定的时间内,阻塞并等待所有被aeCreateFileEvent函数设置为监听状态的套接字产生的文件事件,但至少有一个事件产生或者超时,则结束,和aeWait相比,这个函数并没有指定文件事件的类型。
- ae.c/aeProcessEvents函数是文件事件分派器,(1)它首先调用aeApiPoll函数等待事件产生,(2)然后遍历所有已经产生的事件,(3)并调用相应的事件处理器来处理这些事件。(文件事件处理器包括:连接应答处理器、命令请求处理器、命令回复处理器、复制处理器)
- ae.c/aeGetApiName函数返回I/O多路复用程序底层所使用的的I/O多路复用函数库的名称,epoll或者select。
3.1.2 时间事件
3.2 事件轮询、多路复用
Redis 基于 Reactor 模式开发了自己的网络事件处理器: 这个处理器被称为文件事件处理器(file event handler):
- 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。 - 虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
- 文件事件处理器的四个组成部分, 它们分别是套接字、 I/O 多路复用程序、 文件事件分派器(dispatcher)、 以及事件处理器。
- Redis 的 I/O 多路复用程序的所有功能都是通过包装常见的
select
、epoll
、evport
和kqueue
这些 I/O 多路复用函数库来实现的, 每个 I/O 多路复用函数库在 Redis 源码中都对应一个单独的文件, 比如ae_select.c
、ae_epoll.c
、ae_kqueue.c
, 诸如此类。因为 Redis 为每个 I/O 多路复用函数库都实现了相同的 API , 所以 I/O 多路复用程序的底层实现是可以互换的。
I/O 多路复用程序总是会将所有产生事件的套接字都入队到一个队列里面, 然后通过这个队列, 以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字: 当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字
补充
epoll 是任务驱动模型,其在高并发场景下保证效率的关键点如下:
- socket 不再阻塞,服务器线程只需要抛出一个线程,避免大量线程的系统开销(BIO缺陷);
- 使用 select 多路复用,轮询发生在内核空间而不是在用户空间,不需要进行频繁的内核调用,避免大量的用户态到内核态转换带来的开销(NIO缺陷);
- 使用 mmap 共享内存,不需要进行多次的文件描述符数据的复制过程,节省系统资源(select 多路复用缺陷)
- 使用 wait 系统调用,即事件驱动模型,且链表天然有序(先后顺序),使事件任务能够顺序处理
3.3 管道pipeline
- Redis本身是基于Request/Response协议(停等机制)的,正常情况下,客户端发送一个命令,等待Redis返回结果,Redis接收到命令,处理后响应。在这种情况下,如果同时需要执行大量的命令,那就是等待上一条命令应答后再执行,这中间不仅仅多了RTT(Round Time Trip),而且还频繁调用系统IO,发送网络请求。为了提升效率,这时候pipeline出现了,它允许客户端可以一次发送多条命令,而不等待上一条命令执行的结果,这和网络的Nagel算法有点像(TCP_NODELAY选项)。pipeline不仅减少了RTT,同时也减少了IO调用次数(IO调用涉及到用户态到内核态之间的切换)。
- 应用举例:Redis 事务在发送每个指令到事务缓存队列时都要经过一次网络读写,当一个事务内部的指令较多时,需要的网络 IO 时间也会线性增长。所以通常 Redis 的客户端在执行事务时都会结合 pipeline 一起使用,这样可以将多次 IO 操作压缩为单次 IO 操作。比如我们在使用 Python 的 Redis 客户端时执行事务时是要强制使用 pipeline 的。
3.4 持久化
3.4.1 AOF
Redis 的服务器进程就是一个事件循环(loop), 这个循环中的文件事件负责接收客户端的命令请求, 以及向客户端发送命令回复, 而时间事件则负责执行像 serverCron 函数这样需要定时运行的函数。
因为服务器在处理文件事件时可能会执行写命令, 使得一些内容被追加到 aof_buf 缓冲区里面, 所以在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile 函数, 考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面, 这个过程可以用以下伪代码表示:
1 | def eventLoop(): |
3.4.1.1 AOF步骤
- 命令追加(append)
- 文件写入
- 文件同步(sync)
3.4.1.2 AOF重写步骤
- (1)执行客户端发送来的命令。
- (2)将执行后的命令追加到AOF缓冲区。
- (3)将执行后的写命令追加到AOF重写缓冲区。
3.4.1.3 使用到的指令或方法或系统函数
- flushAppendOnlyFile
- 参数appendSync:always,everysecond,no
- 系统函数:fsync,fdatasync可以强制操作系统将缓存数据写入到磁盘中,从而确保数据的安全性。
3.4.2 RDB
- 有两个Redis命令可以用来生成RDB文件,一个是SAVE,另外一个是BGSAVE。SAVE命令会阻塞Redis服务器进程,知道RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令。
- BGSAVE命令会派生一个子进程,由子进程负责创建RDB文件。
- 伪代码如下:
1 | def SAVE(): |
3.4.2.1 使用到的指令或方法或系统函数
- SAVE
- BGSAVE
- fork
3.4.3 混合持久化
3.5 通信协议
3.6 事务
3.6.1 事务的基本作用
每个事务的操作都有 begin、commit 和 rollback,begin 指示事务的开始,commit 指示事务的提交,rollback 指示事务的回滚。Redis 的事务根本不能算「原子性」,而仅仅是满足了事务的「隔离性」,隔离性中的串行化———当前执行的事务有着不被其它事务打断的权利。
1 | begin(); |
Redis 在形式上看起来也差不多,分别是 multi/exec/discard。
- multi 指示事务的开始;
- exec 指示事务的执行;
- discard 指示事务的丢弃。
3.6.2 watch(一种乐观锁机制)
3.7 内存分配
3.7.1 内存管理基础
3.7.2 Redis的内存淘汰策略
3.7.2.1 淘汰策略
当Redis内存超过物理内存限制的时候,内存数据会开始和磁盘数据产生频繁的交换(swap),交换会让Redis性能急剧下降。
当实际内存超过maxmemory的时候,Redis提供了几种可选的策略,让用户自己决定如何腾出新的空间以继续提供读写服务。
- noviction(不继续写请求)
- volatile-lru(尝试淘汰设置了过期时间的key,最少使用的key优先被淘汰,没有设置过期时间的key不会被淘汰)
- volatile-ttl(同上,但是淘汰的策略不是LRU,而是key剩余寿命ttl的值,ttl越小越优先淘汰)
- volatile-random(淘汰的key是过期集合中随机的key)
- allkeys-lru(淘汰的key对象是全体key的集合)
- allkeys-random(淘汰的key对象是全体key的集合)
- 总结:带有volatile的会对设置了过期时间的key进行淘汰,带有allkeys的会堆所有的key进行淘汰。
3.7.2.2 LRU算法
LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。
LRU,即:最近最少使用淘汰算法(Least Recently Used)。LRU是淘汰最长时间没有被使用的页面。
LFU,即:最不经常使用淘汰算法(Least Frequently Used)。LFU是淘汰一段时间内,使用次数最少的页面。
1 | # 基于Python放入OrderedDict(双向链表+字典)实现一个简单的LRU |
1 | // LinkedHashMap实现LRU; |
3.8 Rehash
当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于
1
; - 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于
5
;
其中哈希表的负载因子可以通过公式:
1 | # 负载因子 = 哈希表已保存节点数量 / 哈希表大小 |
3.9 定时任务
Redis 的定时任务会记录在一个称为最小堆的数据结构中。
3.10 db
3.11 复制
3.11.1 完整流程:数据同步+命令传播
3.11.2 重点
- Redis2.8以前的复制功能不能高校处理断线重连后的复制情况,Redis2.8之后新添加的部分重同步功能可以解决这个问题。
- 部分重同步通过复制偏移量、复制积压缓冲区、服务器运行ID三个部分实现。
- 在复制操作刚开始的时候,从服务器会成为主服务器的客户端,通过向主服务器发送命令请求执行复制步骤,而在复制操作后期,主从服务器会相互成为对方的客户端。
- 主服务器通过向从服务器传播命令来更新从服务器的状态,保持主从服务器一致,而从服务器则通过向主服务器发送命令来进行心跳检测,以及命令丢失检测。
3.12 哨兵
3.12.1 Sentinel系统选举领头Sentinel
Redis基于对Raft算法的实现领头Sentinel的选举方法。
3.12.2 故障转移
重点
- Sentinel只是一个运行在特殊模式下的Redis服务器,它使用了和普通模式不同的命令表,所以Sentinel模式能够使用的命令和普通Redis服务器能够使用的命令不同。
- Sentinel会读入用户指定的配置文件,为每一个要被监视的主服务器创建相应的实例结构,并创建连向主服务器的命令连接和订阅连接,其中命令连接用于向主服务器发送命令请求,而订阅连接则用于接收指定频道的消息。
- Sentinel通过向主服务器发送INFO命令来获得主服务器属下所有从服务器的地址信息,并为这些从服务器创建相应的实例结构,以及连向这些从服务器的命令连接和订阅连接。
- 在一般的情况下,Sentinel以每10秒1次的频率向被监视的主服务器和从服务器发送INFO命令,当主服务器处于下线状态,或者Sentinel正在对主服务器进行故障转移操作的时候,Sentinel向从服务器发送INFO命令的频率改为1秒1次。
- 对于监视同一个主服务器和从服务器的多个Sentinel来说,它们以两秒一次的频率,通过向被监视服务器的__sentinel__:hello频道发送消息来向其他的Sentinel宣告自己的存在。
- 每一个Sentinel也会从__sentinel__:hello频道中接收其他sentinel发来的消息,并根据这些消息为其他的Sentinel创建相应的实例结构,以及命令连接。
- Sentinel只会与主服务器和从服务器创建命令连接和订连接,sentinel和sentinel之间只会创建命令连接。
- Sentinel会以每秒一次的频率向实例(包括主服务器、从服务器、其他Sentinel)发送PING命令,并根据实例对PING命令的回复来判断实例是否在线,当一个实例在指定的时长中连续向Sentinel发送无效回复时,Sentinel将这个实例判断为主观下线。
- 当Sentinel将一个主服务器判断为主观下线时,它会向同样监视这个主服务器的其他Sentinel进行询问,看它是否同意这个主服务器已经进入主观下线状态。
- 当Sentinel收集到足够多的主观下线投票后,它将主服务器判断为客观下线,并发起一次针对主服务器的故障转移操作。
3.12.3 哨兵客户端原理
实现一个哨兵客户端的基本步骤如下:
graph TB st(( ))-.->a(1. 遍历哨兵集合获取到一个可用的哨兵节点.因为哨兵节点之间是共享数据的, 任意节点都可以获取到主节点的信息) a-->b(2. 通过 sentinel get-master-addr-by-name master-name API 来获取对应主节点的信息) b-->c(3. 验证获取到的主节点是不是真正的主节点, 防止故障转移期间主节点的变化) c-->d(4.保持和哨兵节点集合的联系,时刻获取关于主节点的相关信息)
1 | 2 |
3 | 4 |
3.13 集群
集群知识点汇总
知识点 | 描述 |
---|---|
节点 | |
槽指派 | |
命令执行 | |
重新分片 | |
转向 | |
故障转移 | |
消息 |
检查其他节点是否在线
基于Gossip协议检查节点是否在线。(Gossip—-p2p核心协议https://zhuanlan.zhihu.com/p/41228196)
重点
- 节点通过握手将其他节点添加到自己所处的集群中。
- 集群中的16384个槽可以分别指派给集群中的各个节点,每个节点都会记录哪些槽位指派给了自己,而哪些槽位指配给了其他节点。(槽的数量为什么是16384?)
- 节点收到一条命令请求的时候,会首先检查这个命令请求要处理的键所在的槽位是否是自己负责。如果不是的话将向客户端返回一个MOVED错误,MOVED错误携带的信息将指引客户端转向至正在负责相关槽位的节点。
- 对Redis集群的重新分片工作是由redis-trib负责执行的,重新分片的关键在于将属于某一个槽位的所有键值从一个节点转移到另外一个节点。
- 如果节点A正在迁移i至节点B,那么当节点A没能够在自己的数据库中找到命令指定的数据库键时,节点A将向客户端返回一个ASK错误,指引客户端到节点B继续查找指定的数据库键。
- MOVED错误表示槽的负责权已经从一个节点转移到了另外一个节点,而ASK错误只是两个节点在迁移槽的过程中使用的一种临时的措施。
- 集群里的从节点用于复制主节点,并在主节点下线的时候,代替主节点继续处理命令请求。
- 集群中的节点通过发送和接受消息来进行通信,常见的消息包括MEET、PING、PONG、PUBLISH、FAIL五种。
3.14 codis
Codis是一个分布式Redis解决方案,对于上层的应用来说,连接到Codis Proxy和连接原生的RedisServer没有明显的区别,有部分命令不支持。Codis底层会处理请求的转发,不停机的数据迁移等工作,所有后边的一切事情,对于前面的客户端来说是透明的,可以简单的认为后边连接的是一个内存无限大的Redis服务.
- Codis-proxy 实现redis协议,由于本身是无状态的,因此可以部署很多个节点
- Codis-config 是codis的管理工具,包括添加/删除redis节点添加/删除proxy节点,发起数据迁移等操作,自带httpserver,支持管理后台方式管理配置
- Codis-server 是codis维护的redis分支,基于2.8.21分支,加入了slot的支持和原子的数据迁移指令; codis-proxy和codis-config只能和这个版本的redis交互才能正常运行
- Zookeeper 用于codis集群元数据的存储,维护codis集群节点
3.15 Redis中零拷贝的使用
异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到PageCache。在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
3.16 Redis版本
4. 快速使用
一、前言
- 1、获取key的列表:KEYS pattern 通配符有 ?*[] 和转义 \。
- 2、key 是否存在: EXISTS key 存在返回 1,不存在返回 0。
- 3、建立 key 和删除 key:SET key 和 DEL key。
- 4、根据 key 获取该键所存储的 redis 数据类型:TYPE key。返回是 string、list、hash、set、zset。下面会对这5种返回的 redis 数据类型逐一讲解。
- 5、rename oldkey newkey:对 key 重命名,如果 newkey 存在则覆盖。
- 6、renamenx oldkey newkey:对 key 重命名,如果 newkey 存在则不覆盖。
- 7、randomkey:随即返回一个 key
- 8、move key db-index:将 key 移动到指定的数据库中,如果 key 不存在或者已经在该数据库中,则返回 0。成功则返回 1。
二、Redis数据类型、Redis数据命令
1、Redis数据类型一字符串类型:这个很好理解,一个key存储一个字符串。如果你要存数据呢?转换成Json或者其他的字符串序列化。
2、Redis数据命令一———字符串类型:
- 1)赋值:SET key value。如 set hello world
- 2)取值:GET key。如 get hello。返回是 world
- 3)自增:INCR key。就是 Mysql的AUTO_INCREMENT。每次执行 INCR key时,该key的值都会+1.若key不存在,则先建立一个0,然后+1,返回 1。如果值不是整数则报错。该操作是原子操作。
- 4)自减:DECR key。将指定 key 的值减少 1。 如 DECR num,就是 num-1
- 5)自增 N:INCRBY key increment 用来给指定 key 的值加 increment。如 INCRBY num 5 就是 num+5
- 6)自减 N:DECRBY key increment 用来给指定 key 的值减 increment。如 DECRBY num 5 就是 num-5
- 7)增加浮点数:INCRBYFLOAT key increment。
- 8)向尾部追加:APPEND key value。如set test:key 123、append test:key 456、get test:key 就是 123456
- 9)获取长度:STRLEN key。
- 10)同时给多个 key 赋值:MSET title 这是标题 description 这是描述 content 这是内容。
- 11)同时获取多个 key 的值:MGET title description content
- 12)位操作之获取:GETBIT key offset。如字符 a 在 redis 中的存储为 01100001(ASCII为98),那么 GETBIT key 2 就是 1,GET key 0 就是 0。
- 13)位操作之设置:SETBIT key offset value。如字符 a 在 redis 中的存储为 01100001(ASCII为98),那么 SETBIT key 6 0,SETBIT key 5 1 那么 get key 得到的是 b。因为取出的二进制为 01100010。
- 14)位操作之统计:BITCOUNT key [start] [end]:BITCOUNT key 用来获取 key 的值中二进制是 1 的个数。而 BITCOUNT key start end 则是用来统计key的值中在第 start 和 end 之间的子字符串的二进制是 1 的个数(好绕啊)。
- 15)位操作之位运算:BITOP operation resultKey key1 key2。operation 是位运算的操作,有 AND,OR,XOR,NOT。resultKey 是把运算结构存储在这个 key 中,key1 和 key2 是参与运算的 key,参与运算的 key 可以指定多个。
3、Redis数据类型二———散列类型:
Redis 是以字典(关联数组)的形式存储的,一个 key 对应一个 value。在字符串类型中,value 只能是一个字符串。那么在散列类型,也叫哈希类型中,value 对应的也是一个字典(关联数组)。那么就可以理解,Redis 的哈希类型/散列类型中,key 对应的 value 是一个二维数组。但是字段的值只可以是字符串。也就是说只能是二维数组,不能有更多的维度。
4 Redis 数据命令二——— 散列类型:
- 1)赋值:HSET key field value。如 hset user name lane。hset user age 23
- 2)取值:HGET key field。如 hget user name,得到的是 lane。
- 3)同一个key多个字段赋值:HMSET key field1 value1 field2 value2…
- 4)同一个KEY多个字段取值:HMGET key field1 fields2…
- 5)获取KEY的所有字段和所有值:HGETALL key。如 HGETALL user 得到的是 name lane age 23。每个返回都是独立的一行。
- 6)字段是否存在:HEXISTS key field。存在返回 1,不存在返回 0
- 7)当字段不存在时赋值:HSETNX key field value。如果 key 下面的字段 field 不存在,则建立 field 字段,且值为 value。如果 field 字段存在,则不执行任何操作。它的效果等于 HEXISTS + HSET。但是这个命令的优点是原子操作。再高的并发也不会怕怕。
- 8)自增 N:HINCREBY key field increment。同字符串的自增类型,不再阐述。
- 9)删除字段:DEL key field1 field2… 删除指定KEY的一个或多个字段。
- 10)只获取字段名:HKEYS key。与 HGETALL 类似,但是只获取字段名,不获取字段值。
- 11)只获取字段值:HVALS key。与 HGETALL 类似,但是只获取字段值,不获取字段名。
- 12)获取字段数量:HLEN key。
5、Redis数据类型三———列表类型
列表类型存储了一个有序的字符串列表。常用的操作是向两端插入新的元素。时间复杂度为 O(1)。结构为一个链表。记录头和尾的地址。看到这里,Redis 数据类型的列表类型一个重大的作用呼之欲出,那就是队列。新来的请求插入到尾部,新处理过的从头部删除。另外,比如微博的新鲜事。比如日志。列表类型就是一个下标从 0 开始的数组。由于是链表存储,那么越靠近头和尾的元素操作越快,越靠近中间则越慢。
6、Redis数据命令三———列表类型:
1)向头部插入:LPUSH key value1 value2…。返回增加后的列表长度。
2)向尾部插入:RPUSH key value1 value2…。返回增加后的列表长度。
3)从头部弹出:LPOP key。返回被弹出的元素值。该操作先删除key列表的第一个元素,再将它返回。
4)从尾部弹出:RPOP key。返回被弹出的元素值。
5)列表元素个数:LLEN key。key 不存在返回 0。
6)获取列表的子列表:LRANGE start end。返回第 start 个到第 end 个元素的列表。包含 start 和 end。支持负数索引。-1 表示最后一个元素,-2 表示倒数第二个元素。
7)删除列表中指定值:LREM key count value。删除 key 这个列表中,所有值为 value 的元素,只删除 count。如果有 count+1 个,那么就保留最后一个。count 不存在或者为 0,则删除所有的。如果 count 大于 0,则删除从头到尾的 count 个,如果 count 小于 0,则删除从尾到头的 count 个。
8)获取指定索引值:LINDEX key index。如LINDEX key 0就是列表的第一个元素。index可以是负数。
9)设置索引和值:LSET key index value。这个操作只是修改指定 key 且指定 index 的值。如果 index 不存在,则报错。
10)保留片段,删除其它:LTRIM key start end。保留 start 到 end 之间的所有元素,含 start 和 end。其他全部删除。
11)向列表插入元素:LINSERT key BEFORE/AFTER value1 value2。从列表头开始遍历,发现值为 value1 时停止,将 value2 插入,根据 BEFORE 或者 AFTER 插入到 value1 的前面还是后面。
12)把一个列表的一个元素转到另一个列表:RPOPLPUSH list1 list2。将列表 list1 的右边元素删除,并把该与元素插入到列表 list2 的左边。原子操作。
7、Redis数据类型四———集合类型:
集合类型是为了方便对多个集合进行操作和运算。集合中每个元素不同且没有顺序的概念,每个元素都是且只能是一个字符串。常用操作是对集合插入、删除、判断等操作。时间复杂度尾 O(1)。可以进行交集、并集、差集运算。例如文章 1 的有 3 个标签,是一个 Redis 数据类型集合类型存储。文章 2 有 3 个标签,有一个 Redis 数据类型集合类型存储。文章是 1 是 mysql,文章 2 是讲 redis。那么交集是不是就交出了一个数据库?(假设数据库这个tag在两篇文字都有)。集合类型在 redis 中的存储是一个值为空的散列表。
8、Redis 数据命令四———集合类型:
- 1)增加:SADD key value。
- 2)删除:SREM key value。
- 3)获取指定集合的所有元素:SMEMBERS key。
- 4)判断某个元素是否存在:SISMEMBER key value。
- 5)差集运算:SDIFF key1 key2…。对多个集合进行差集运算。
- 6)交集运算:SINNER key1 key2…。对多个集合进行交集运算。
- 7)并集运算:SUNION key1 key2…。对多个集合进行并集运算。
- 8)获取集合中元素个数:SCARD key。返回集合中元素的总个数。
- 9)对差集、交集、并集运算的结果存放在一个指定的 key 中:SDIFFSTORE storekey key1 key2。对 key1 和 key2 求差集,结果存放在 key 为 storekey 的集合中。SINNERSTORE 和 SUNIONSTORE 类似。
- 10)获取集合中的随即元素:SRANDMEMBER key [count]。参数 count 可选,如果 count 不存在,则随即一个。count 大于 0,则是不重复的 count 个元素。count 小于 0,则是一共 |count|个 元素,可以重复。
- 11)随即弹出一个元素:SPOP key。随即从集合中弹出一个元素并删除,将该元素的值返回。
9、Redis 数据类型五———有序集合类型:
集合类型是无序的,每个元素是唯一的。那么有序集合就是有序的,每个元素是唯一的。有序集合类型和集合类型的差别是,有序集合为每个元素配备了一个属性:分数。有序集合就是根据分数来排序的。有序集合是使用散列表和跳跃表实现的。所以和列表相比,操作中间元素的速度也很快。时间复杂度尾 O(log(N))。Redis 数据类型中的有序集合类型比 Redis 数据类型中的列表类型更加耗费资源。
10、Redis数据命令五——-有序集合类型
- 1)增加:ZADD key sorce1 value1 sorce2 value2…。
- 2)获取分数:ZSCORE key value。获取key的有序集合中值为 value 的元素的分数。
- 3)获取排名在某个范围内的元素列表:ZRANFGE key start stop [WITHSCORE]。获取排名在 start 和 end 之间的元素列表,包含 start 和 end2 个元素。每个元素一行。如果有WITHSCORE参数,则一行元素值,一行分数。时间复杂度为O(LOGn+m)。如果分数相同,则 0<0</a
0 - 4)获取指定分数范围的元素:ZRANGEBYSCORE key min max [WITHSCORE] [LIMIT offset count]。获取分数在 min 和 max 之间的元素列表。含两头。每个元素一行。如果有 WITHSCORE 参数,则一行元素值,一行分数。如果 min 大于 max 则顺序反转。
- 5)为某个元素增加分数:ZINCRBY key increment value。指定的有序集合的值为 value 的元素的分数 +increment。返回值后更改后的分数。
- 6)获取集合中元素的数量:ZCARD key。
- 7)获取指定分数范围内的元素个数:ZCOUNT key min max。
- 8)删除一个或多个元素:ZREM key value1 value2…
- 9)根据排名范围删除元素:ZREMRANGEBYRANK key start end。删除排名在 start 和 end 中的元素。
- 10)按照分数范围删除元素:ZREMRANGEBYSCORE key min max。
- 11)获得元素排名(正序):ZRANK key value。获取 value 在该集合中的从小到大的排名。
- 12)获得元素排名(倒序):ZREVRANK key value。获取 value 在该集合中从大到小的排名。
- 13)有序集合的交集:ZINTERSTORE storekey key1 key2…[WEIGHTS weight [weight..]] [AGGREGATE SUM|MIN|MAX]。用来计算多个集合的交集,结果存储在 storekey中。返回值是 storekey 的元素个数。AGGREGATE 为 SUM 则 storekey 集合的每个元素的分数是参与计算的集合分数和。MIN 是参与计算的分数最小值。MAX 是参与计算分数最大值。WEIGHTS 设置每个集合的权重,如 WEIGHTS 1 0.1。那么集合A的每个元素分数 1,集合B的每个元素分数 0.1
- 14)有序集合的并集:ZUNIONSTORE storekey key1 kye2…[WEIGHTS weight [weight..]] [AGGREGATE SUM|MIN|MAX]
5.应用场景
5.1 分布式锁
5.2 HyperLog
5.3 布隆过滤器
布隆过滤器[1](Bloom Filter)是由布隆(Burton Howard Bloom)在1970年提出的。它实际上是由一个很长的二进制向量和一系列随机映射函数组成,布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率(假正例False positives,即Bloom Filter报告某一元素存在于某集合中,但是实际上该元素并不在集合中)和删除困难,但是没有识别错误的情形(即假反例False negatives,如果某个元素确实没有在该集合中,那么Bloom Filter 是不会报告该元素存在于集合中的,所以不会漏报)。
5.4 简单限流
6. 缓存中间件比较
7. 深入讨论
7.1 Redis是否用到了零拷贝?
Redis复制的代码用的应该是socket的read和write,感觉用了这两个系统函数,全局搜索3.2.8版本的代码没有sendfile函数,判断Redis没有用到零拷贝。
8. 缓存使用问题
8.1 缓存雪崩
数据库服务器崩溃:系统平稳运行过程中,忽然数据库连接量激增。应用服务器无法及时处理请求。大量 408,500 错误页面出现。客户反复刷新页面获取数据。数据库崩溃。应用服务器崩溃。重启应用服务器无效。Redis 服务器崩溃。Redis 集群崩溃。重启数据库后再次被瞬间流量放倒
问题排查:在一个较短的时间内,缓存中较多的 key 集中过期。此周期内请求访问过期的数据,Redis 未命中,Redis 向数据库获取数据。数据库同时接收到大量的请求无法及时处理。Redis 大量请求被积压,开始出现超时现象。数据库流量激增,数据库崩溃。重启后仍然面对缓存中无数据可用。Redis 服务 器资源被严重占用,Redis 服务器崩溃。Redis 集群呈现崩塌,集群瓦解。应用服务器无法及时得到数据响应请求,来自客户端的请求数量越来越多,应用服务器崩溃。应用服务器、Redis、数据库全部重启,效果不理想
解决方案:
- 更多的页面静态化处理
- 构建多级缓存架构:Nginx 缓存 + Redis 缓存 + Ehcache 缓存
- 检测Mysq|严重耗时业务进行优化:对数据库的瓶颈排查(例如超时查询、耗时较高事务等)
- 灾难预警机制:监控 Redis 服务器性能指标(CPU占用、CPU使用率、内存容量、查询平均响应时间、线程数)
- 限流、降级:短时间范围内牺牲一些客户体验,限制一部分请求访问,降低应用服务器压力,待业务 低速运转后再逐步放开访问
- LRU 与 LFU 切换
- 数据有效期策略调整:
- 根据业务数据有效期进行分类错峰,A 类 90 分钟、B 类 80 分钟、C 类 70 分钟
- 过期时间使用固定时间 + 随机值的形式,稀释集中到期的 key 的数量
- 超热数据使用永久 key
- 定期维护(自动 + 人工):对即将过期数据做访问量分析,确认是否延时,配合访问量统计,做热点数据的延时
- 加锁,慎用!
缓存雪崩就是瞬间过期数据量太大,导致对数据库服务器造成压力。如能够有效避免过期时间集中,可以有效解决雪崩现象的出现(约40%),配合其他策略一起使用,并监控服务器的运行数据,根据运行记录做快速调整。
8.2 缓存击穿
数据库服务器崩溃:系统平稳运行过程中。数据库连接量瞬间激增。Redis 服务 器无大量 key 过期。Redis 内存平稳,无波动。Redis 服务器 CPU 正常。数据库崩溃
问题排查:Redis 中某个 key 过期,该 key 访问巨大。多个数据请求从服务器直接压到 Redis 后,均未命中。Redis 在短时间内发起了大量对数据库中同一数据的访问
问题分析:单个 key 高热数据。key 过期
解决方案:
- 预先设定:以电商为例,每个商家根据店铺等级,指定若干款主打商品,在购物节期间,加大此类信息 key 的过期时长。注意:购物节不仅仅指当天,以及后续若干天,访问峰值呈现逐渐降低的趋势
- 现场调整:监控访问量,对自然流量激增的数据延长过期时间或设为久性 key
- 后台刷新数据:启动定时任务,高峰期来临之前,刷新数据有效期,确保不丢失
- 二级缓存:设置不同的失效时间,保障不会被同时淘汰就行
- 加锁:分布式锁,防止被击穿,但是要注意也是性能瓶颈,慎重!
缓存击穿就是单个高热数据过期的瞬间,数据访问量较大,未命中 Redis 后,发起了大量对同一数据的数据库访问,导致对数据库服务器造成压力。应对策略应该在业务数据分析与预防方面进行,配合运行监控测试与即时调整策略,毕竟单个 key 的过期监控难度较高,配合雪崩处理策略即可。
8.3 缓存穿透
数据库服务器崩溃:系统平稳运行过程中。应用服务器流量随时间增量较大。Redis 服务器命中率随时间逐步降低。Redis 内存平稳,内存无压力。Redis 服务器 CPU 占用激增。数据库服务器压力激增。数据库崩溃
问题排查:Redis 中大面积出现未命中。出现非正常 URL 访问
问题分析:获取的数据在数据库中也不存在,数据库查询未得到对应数据。Redis 获取到 null 数据未进行持久化,直接返回。下次此类数据到达重复上述过程。出现黑客攻击服务器
解决方案:
- 缓存 null:对查询结果为 null 的数据进行缓存(长期使用,定期清理),设定短时限,例如30-60秒,最高 5 分钟
- 白名单策略:
- 提前预热各种分类数据 id 对应的 bitmaps,id 作为 bitmaps 的 offset,相当于设置了数据白名单。当加载正常数据时,放行,加载异常数据时直接拦截(效率偏低)
- 使用布隆过滤器(有关布隆过滤器的命中问题对当前状况可以忽略)
- 实施监控:实时监控 Redis 命中率(业务正常范围时,通常会有一个波动值)与 null 数据的占比。
- 非活动时段波动:通常检测 3-5 倍,超过 5 倍纳入重点排查对象。
- 活动时段波动:通常检测 10-50 倍,超过 50 倍纳入重点排查对象。
- 根据倍数不同,启动不同的排查流程。然后使用名单进行防控(运营)
- key 加密:问题出现后,临时启动防灾业务 key,对 key 进行业务层传输加密服务,设定校验程序,过来的 key 校验。例如每天随机分配 60 个加密串,挑选 2-3 个,混淆到页面数据 id 中,发现访问 key 不满足规则,驳回数据访问
缓存击穿访问了不存在的数据,跳过了合法数据的 Redis 数据缓存阶段,每次访问数据库,导致对数据库服务器造成压力。通常此类数据的出现量是一个较低的值,当出现此类情况以毒攻毒,并及时报警。应对策略应该在临时预案防范方面多做文章。
无论是黑名单还是白名单,都是对整体系统的压力,警报解除后尽快移除。