非原创,笔记来自极客时间Redis课程内容笔记
课程地址:https://time.geekbang.org/column/100056701?tab=catalog
Redis
02 | 数据结构:快速的Redis有哪些慢操作?
Redis数据结构分为数据类型+底层结构
-
数据类型:String(字符串)、List(列表)、Hash(哈希)、Set(集合)、 Sorted Set(有序集合)
-
底层结构:简单动态字符串、双向链表、压缩列表、哈希表、跳表、整数数组
-
对应关系
由上可知,String只有一种底层实现,而List,Hash,Sorted Set,Set都有两种实现,通常情况下我们把这四种类型称为集合类型,他们最大的特点是一个键对应了一个集合的数据
这里提出三个问题
- 些数据结构都是值的底层实现,键和值本身之间用什么结构组织?
- 为什么集合类型有那么多的底层结构,它们都是怎么组织数据的,都很快吗?
- 什么是简单动态字符串,和常用的字符串是一回事吗?
键和值用什么结构组织?
为了实现快速访问,Redis使用了一个hash表来保存所有的键值对
一个hash表可以看成一个数组,数组的每个元素称为一个hash桶,一个hash表是由多个hash桶组成,每个hash桶中保存了键值对数据。
hash桶并不保存数据本身,而是保存指向地址的指针,无论string类型还是集合类型都是如此
所以不管hash表中有几个键,只要通过一次查询就可以找到对应的值,查询效率是O(1)。
为什么hash表变慢了?
当在一个有限长度的hash表中写入大量数时,hash冲突是不可避免的问题
解决hash冲突有两种方式,一种是hash链表,另一种是rehash
1.hash链表
若两个元素拥有相同的hash地址,则这两个元素会以单向链表的形式相连。查询时,若有链表的形式出现则会遍历此链表直到查询到需要的值
这种方式的缺点是当链表过长时,会严重拖慢查询时间,链表的时间复杂度为O(n)
问题一:那么为什么不像java中的hashmap一样当链表长度到达一定阈值时候就转换为红黑树?
即使链表转换为红黑树,查询时间复杂度是O(lgN),并没有从根本上解决问题,红黑树越高查询效率依然越低,redis需要的是O(1)的查询速度
2.rehash
rehash就是增加现有hash桶的数量,让主键增多的entry能在更多的空间分散保存,减少单个桶中元素的数量。
redis默认使用了两个全局hash表,hash1和hash2。一开始插入元素的时候使用hash1,此时的hash2并没有被分配空间,随着数据逐步增多,redis开始进行rehash,过程分为三步。
步骤1:给hash2分配比hash1更大的空间
步骤2:把hash1中的数据重新映射并拷贝到hash2中
步骤3:释放hash1的空间
其中步骤2如果数据量太多并且一次性拷贝的话会造成redis线程阻塞,于是redis采用了渐进式rehash
简单来说就是在步骤2拷贝数据时,Redis仍然可以正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 移动到哈希表 2 中;等处理下一个请求时,再顺带拷移动哈希表 1 中的下一个索引位置的 entries。如下图所示:
问题一:如果一个键值对很长时间都没有被操作到,那么rehash的时间是不是会变得很长?
不是,渐进式rehash执行时,除了根据键值对的操作来进行数据迁移,Redis本身还会有一个定时任务在执行rehash,如果没有键值对操作时,这个定时任务会周期性地(例如每100ms一次)搬移一些数据到新的哈希表中,这样可以缩短整个rehash的过程
问题二:rehash 期间,新的 操作请求(增删改查)到达,redis 是如何处理的?
rehash过程中对于哈希表的delete/find/update操作会在h[1]&h[2]生效, 比如find命令会依次在h[1]上查找,没有查找到则到h[2]上查找
对于put操作则只会在h[2]上生效
不同集合数据类型复杂度
和string类型不同,一个集合类型的值,第一步是要找到hash桶,再在集合中进行增删改查
集合操作的效率与以下几种因素有关
- 底层数据结构,使用hash表实现的集合会比链表实现的集合操作效率高
- 数据量,读写一个元素的效率肯定会比操作所有元素的效率高
先说结果
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1vjKjBJy-1650347541389)(…/AppData/Roaming/Typora/typora-user-images/image-20220211103654420.png)]
集合数据结构
之前上文提到过,集合数据结构有双向链表,整数数组,hash表,跳表,压缩表
hash表
上面介绍过,是通过hash函数计算,故复杂度为O(1)
双向链表,整数数组
遍历数组查找,复杂度为O(N)
压缩列表
压缩列表类似一个数组,和数组不同的是,压缩列表在表头有三个字段zlbytes(列表长度),zltail(列表尾的偏移量),zllen(列表中entry的个数),压缩列表的表尾还有个zlend,表示列表结束。
如果要找头尾的元素,就可以通过压缩列表也有的参数找到,如果要找中间的参数只能一个个遍历了,复杂度为O(N)
跳表
有序列表只能逐个查找元素,导致操作起来很慢,于是就有了跳表。
跳表在链表的基础上增加了多级索引,通过索引位置的指向实现数据的快速定位,时间复杂度为O(logN)
不同操作类型的复杂度
集合类型的操作有很多
- 读写单个元素:HGET,HSET
- 操作多个元素:SADD
- 遍历集合:SMEMBERS
总结一下口诀,可以避免高复杂度操作
- 单元素操作是基础
- 范围操作非常耗时
- 统计操作通常高效
- 例外情况只有几个
1.单元素操作
指的是每一种集合类型对单个元素进行操作
hash类型:HGET,HSET,HDEL
set类型:SADD,SREM,SRANDMEMBER 底层是hash表实现时
复杂度为O(1)
2.范围操作
指的是集合类型中的遍历操作,返回的是集合中的所有数据
hash类型:HGETALL
Set类型:SMENBERS
list类型:LRANGE
zset类型:ZRANGE
复杂度为O(N),应该尽量避免
3.统计操作
指的是对集合中的个数进行统计
LLEN,SCARD,复杂度为O(1),当数据类型是压缩列表,双向链表,整数数组时,可以很清楚知道集合中元素个数
4.例外
压缩列表和双向链表都会记录表头和表尾偏移量,所以list类型的LPOP RPOP LPUSH RPUSH这几个操作都是在列表都为增删元素,复杂度为O(1)
03 | 高性能IO模型:为什么单线程Redis能那么快?
我们通常说Reids是单线程,主要指的是Redis的网络IO和键值对读写是由一个线程来完成,这也是Redis对外提供键值存储服务的主要流程
但是Reis的其他功能,例如持久化,异步删除,集群数据同步等,都是由额外的线程执行的。
Redis为什么使用单线程?
要了解Redis为什么使用单线程,要先了解多线程的开销。
多线程的开销
日常开发中经常使用到多线程来提高系统并发度等情况,但是多线程的设计往往比较复杂,多线程也有一些额外的开销,比如锁,线程上下文切换等,若使用不好多线程会使效果适得其反
多线程开销增大例子
当用Redis的List结构做队列的时候,若有线程A和线程B对队列进行PUSH操作,然后线程A和线程B要对队列进行POP操作,这两种操作都要对队列的长度进行+1或者-1的操作,再多线程环境下,就要让A和B串行执行,资源开销就可能增大。
单线程的Redis为什么快?
先说结论
1.Redis的大部分操作都在内存上完成
2.底层采用了高效的数据结构,例如跳表和hash表
3.Redis采用了多路复用机制,能在网络IO操作中能并发处理大量的客户端请求
前面两个之前提到过了,接下来主要说说多路复用机制
基本 IO 模型与阻塞点
最开始介绍了一个SimpleKV,以Get请求为例,SimpleKV为了处理一个Get请求,需要进行以下步骤
- 监听客户端请求bind/listen
- 建立客户端连接accept,可能阻塞
- 从soket中读取请求recv,可能阻塞
- 解析客户端发送请求parse
- 根据氢气类型读取键值数据get
- 返回结果,即向soket中写回数据send
redis一个线程就能执行以上操作,示例图如下
其中2、3可能产生阻塞,若线程阻塞在网络请求中,那么就会导致整个Redis客户端阻塞,无法处理其他请求,不过幸运的是,socket网络模型本身支持非阻塞模式
非阻塞模式
socket网络模型的非阻塞模式设置主要体现在三个函数上,不同的操作调用后回返回不同的套接字类型,具体如下
04 | AOF日志:宕机了,Redis如何避免数据丢失?
Redis在日常中一般用于缓存,但是由于redis的数据存在在内存中,所以redis服务一旦出现问题,内存中的数据都会丢失。
这个问题其实好解决,只要从数据库恢复数据到redis就可以了,但是会有两个问题,第一个是要频繁访问数据库,会给数据库带来压力,第二是数据库的性能肯定不会有reids的好,所以我们就可以备份redis的数据,redis持久化主要有两大机制AOF和RDB。
AOF日志是如何实现的?
Redis和数据库不同,redis采用的是写后日志,也就是说,redis先执行命令,把数据写入内存,然后才记录日志
那为什么Redis要先执行命令再记录日志呢?
因为AOF中记录的日志都是以命令形式保存的,不像mysql的redolog,记录的是最后一次修改的数据。
下面来分析一下日志内容:
*3:表示当前命令有三个部分,每个部分都以"$+数字"开头
$3:第一个部分set有3个字节
$7:第二个部分testkey有7个字节
$9:第三个部分testvalue有9个字节
为了避免额外的检查开销(检查语法是否正确),Redis在想AOF中记录日志的时候并不会对这些命令进行额外检查,如果先记录日志再执行命令的话,日志中很有可能记录了错误的命令,在最后恢复时就会出错
而写后日志这种方式是先让系统执行命令,只要命令能执行成功,才会被记录到日志中,否则系统会直接向客户端报错。
除此之外,AOF还有一个好处,它是在命令执行后才记录日志,所以不会阻塞当前的写操作。
不过AOF也有两个潜在的风险
- 如果有一个操作还没有记录命令redis就宕机了,那么这个命令和相应的数据就会有丢失的风险
- 虽然AOF避免了对当命令的阻塞,但有可能会给下一个操作带来阻塞风险,这是因为AOF日志是在主线程中执行的,如果在写入磁盘的过程中,磁盘压力过大也会导致Redis无法快速响应
以上两个风险其实都和“写磁盘”这个操作有关,如果能有办法控制写磁盘的时机,这两个风险就是可控的了。
为此,AOF提供了三种写回策略
三种写回策略
- Aways,同步写回,每个命令执行完,马上将日志写回磁盘
- EverySec,每秒写回,每个命令执行完,先将命令写到内存缓冲区中,每隔一秒把缓冲区的内容写回磁盘
- No,操作系统控制写回,每个命令执行完,先将命令写到内存缓冲区中,再由操作系统决定什么时候持久化
无论哪种方法都无法从性能和数据可靠性上做到两全其美,对比如下,要根据具体业务来具体分析
AOF重写机制
并不是说选对了合适的回写策略就完全没问题了,如果AOF文件本身过大,也会产生性能问题
- 文件系统本身对文件大小有限制,无法保存过大文件
- 如果文件太大,之后往里面追加写命令的话效率也会边地
- 如果发生宕机,AOF记录中的命令一个个被执行,日志过大也会影响恢复到性能
基于以上问题的考虑,AOF的重写机制就派上用场了
简单来说,AOF的重写机制就是在重写时候,Redis根据现在原有的数据库创建一个新的AOF文件,也就是读取Redis中所有的键值对,然后对每个键值对用一条命令写入。
比如读到了一个键值对<mykey,myvalue>,那么就会记录一条“set mykey myvalue”的命令,这样在恢复的时候就能正确写入了。
这种方法就实现了“多变一”。无论这个键值对之前改过多少次,备份的时候只需要最终状态即可。示意图如下
AOF重写会阻塞吗?
虽然说重写会使日志文件变小,但是还是要写入磁盘,是否会造成磁盘I/O压力过大?
这是不会的,因为这个重写过程是由后台子进程bgrewriteaof来完成的
重写的过程可以总结为:一个拷贝,两处日志
一个拷贝
每次执行重写时,主线程fork出后台的bgrewriteaof子进程
此时fork动作会把主线程的内存拷贝一份给bgrewriteaof子进程,这里面就包含了数据库的最新数据
然后bgrewriteaof子进程就可以在不影响主线程的情况下,逐一把拷贝的数据 写成操作,记录重写日志
两处日志
因为主线程并未阻塞,如有新的操作进来还是会被记录,这是第一处
第二处是AOF重写日志,新的操作也会被记录在重写日志的缓冲区,这样重写日志也不会丢失最新的操作,等到拷贝的数据都写完之后,缓冲区的数据也会被记录到新的AOF文件以保证数据一致性
课后问题
1:AOF 日志重写的时候,是由 bgrewriteaof 子进程来完成的,不用主线程参与,我们今天说的非阻塞也是指子进程的执行不阻塞主线程。但是,你觉得,这个重写过程有没有其他潜在的阻塞风险呢?如果有的话,会在哪里阻塞?
有两个场景可能会有阻塞风险
a.fork子进程时,fork子进程的一瞬间肯定是会阻塞主线程的,上文提到的“把数据拷贝一份给子进程”其实并不会马上拷贝,而是采用“写时复制”的机制,当数据有变更,才会拷贝一份数据给子进程,若一个数据没有变更,那么主线程和子进程指针是一样的。
b.因为主线程并未被阻塞,还能处理新的操作,当大量操作涌入时,根据“写时复制”的机制,就需要申请内存空间等一系列操作保证子进程的数据是fork时候的数据
2:AOF 重写也有一个重写日志,为什么它不共享使用 AOF 本身的日志呢?
若共享AOF本身日志,父子线程对同一份文件进行操作必然会产品竞争问题,需要额外的开销来管理,再者若重写过程失败,AOF本身的日志就有可能遭受污染。
其他
05 | 内存快照:宕机后,Redis如何实现快速恢复?(RDB)
AOF日志有个比较明显的弊端,因为AOF记录的是命令,当日志文件较大时,需要一行行执行命令恢复,这个过程会非常耗时,影响到正常使用,而RDB基于内存快照的方式,记录的是数据,就不会有这种问题了。
对于内存快照这种备份方式,需要着重思考两个关键问题
- 对哪些数据进行快照?数据越多生成快照的时间自然会慢
- 进行快照备份时,数据还能正常进行增删改吗?如果有新的请求进来是否会阻塞?
对哪些数据做快照?
为了数据的准确性、可靠性,Redis执行的是全量快照,一个数据都不能少,数据量越多,生成快照的时间就越长,还需要把这份快照持久化到磁盘上,开销也会很大。
会阻塞主线程吗?
Redis提供了两个命令来生成RDB文件
- save:在主线程中执行,会导致阻塞
- bgsave:创建一个子进程,专门用于写入RDB文件,可以避免阻塞,也是Redis默认采用的策略
快照时候数据可以修改吗?
如果数据不能被修改,可以很好保证数据的一致性,但是会影响业务,这显然是不可取的,那么如何保证Redis和快照的一致性?
AOF在fork子进程的时候采用了写时复制机制。
前面提到bgsave是一个子进程,进程与进程之间应该是内存区隔离的,如果fork子进程的时候把数据都复制过来会很耗时,所以bgsave子进程会先和主进程共享一份内存数据。若一个数据被修改,则bgsave会拷贝一份修改之前的数据为副本,备份的时候按照数据副本来,示意如下
在生成快照的过程中,键值对C要修改为D,那么在修改前就会生成一份副本C`,主线程就会在副本上修改,子进程读到的还是原来的数据
文章中说“这块数据就会被复制一份,生成该数据的副本”,这个操作在实际执行过程中,是子进程复制了主线程的页表,所以通过页表映射,能读到主线程的原始数据,而当有新数据写入或数据修改时,主线程会把新数据或修改后的数据写到一个新的物理内存地址上,并修改主线程自己的页表映射。所以,子进程读到的类似于原始数据的一个副本,而主线程也可以正常进行修改。
可以每秒做一次快照吗?
假设a时刻生成了一份快照,过了t秒后,b时刻也生成了一份快照,那么在t秒内,修改的数据在a上是不存在的,在b上存在,所以t越小,后续恢复的数据越全面。
所以想要尽可能恢复数据,t值就要尽可能小,那么t值可以小到什么程度呢?可以每秒做一次快照?
虽然bgsave在后台执行,不会阻塞主线程,但是还会有一些额外的开销
- 快照是要保存到磁盘的,若频繁生成快照保存磁盘,就会给磁盘带来很大压力,还会有多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个有开始做了,容易形成恶性循环。
- 另一方面,bgsave的子进程需要通过fork来创建的,fork这个操作本身就会阻塞主线程,而且主线程的内存越大,阻塞的越久,如果频繁fork也会造成性能影响。
所以后续出现了“增量快照”,可以只对修改后的数据进行快照记录,避免每次全量快照
但是增量快照也会带来问题,“记录变更”这个动作是要耗费额外的性能的,若一直使用增量快照,有1w条记录修改,就要记录1w条记录,对内存也是很大的一个考验。
综上,RDB快照虽然恢复的速度快,但是很难把握生成快照的频率,频率太高会占用较大空间,还有可能阻塞主进程,频率太低就会丢失两个快照生成时间内的数据。
那么有没有一种方法能又能保证性能又能减少数据丢失呢?
AOF+RDB混用
Redis4.0之后提出了AOF和RDB昏庸的方法,简单来说,内存快照以一定频率执行,两次快照间的修改用AOF记录。
优点
- 快照不用很频繁生成,减少了磁盘压力和fork带来的额外开销,也减少了阻塞主进程的可能性
- AOF记录只存在于两次快照之间,不用完全记录全部操作,文件大小容易控制,恢复速度快
示意图如下
总结:
- 数据完整性要求较高时,AOF和RDB混用是一种很好的选择
- 如果允许某一小段时间数据丢失,可以只使用RDB
- 如果只用AOF,优先使用每秒写入,这样在性能和可靠性上做了一个平衡
06 | 数据同步:主从库如何实现数据一致?
Redis的高可靠性在于两点
- 数据尽量少丢失,AOF+RDB
- 尽量减少服务中断,增加副本冗余量(集群)
读写分离的主从库进行数据同步
当只有一个Redis实例,如果这个实例宕机,那么宕机和数据恢复期间是无法服务于业务的,这显然不合适,Redis提供了主从模式的部署方式,保证数据副本一致,主从库之间采用的是读写分离方式。
- 读操作,主从库都可以接收
- 写操作,主库先执行,然后主库将写操作同步给从库
为什么要采用读写分离呢?如果不采用读写分离会有什么样的后果?
例如对一个key进行三次修改,每次修改为不一样的值,落到三个库中,如下图所示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ougvtLpr-1650347541395)(…/AppData/Roaming/Typora/typora-user-images/image-20220218143839934.png)]
那么下次请求读取k1的值的时候,就有可能取到旧值,如果非要保证k1的数据在三个库上一致,就要控制并发问题,例如加锁之类的,这样也会有巨额的开销,这显然是不能接受的,所以采用了读写分离的策略。
采用读写策略之后,修改只需要在主库上进行即可,然后再由主库把数据“同步”给从库即可,那么这个“同步”是如何进行的呢?
主从库间的同步
主从库间如何进行第一次同步?
首先需要多台redis实例,假设现在有实例192.168.5.10和192.168.5.20,我们要把5.20这台当作从库,只需要执行以下命令,5.20就变成了5.10的从库,并从5.10上复制数据
replicaof 192.168.5.10 6379
第一次同步有三个阶段
- 建立连接
- 主库同步数据给从库,使用RDB
- 主库发送新写命令给从库
1.建立连接
主从库建立连接,协商同步,并且为全量复制做准备,从库发送命令请求同步,主库确认后就可以进行同步了。
具体为:
- 从库给主库发送psync命令,表示要进行数据同步,psync有两个参数
- runId,每个redis实例启动的时候都会生成一个随机的id用于唯一标识,当从库和主库第一次复制时,不知道主库的id,所以runId=?
- offset,第一次设置为-1,表示第一次复制
- 主库接收到从库的psync命令后,会用FULLRESYNC响应命令,也带上以上两个参数,
- runId,主库的id
- offset,目前复制进度
- 需要注意的是FULLRESYNC响应表示第一次复制采用的是全量复制,也就是说。第一次从库会全量复制主库的数据
2.主库同步数据给从库
在第二阶段,主库将所有数据同步给从库,从库收到RDB后,在本地完成数据加载
具体为:
- 主库执行bgsave命令,生成RDB文件,发送给从库
- 从库接收到RDB文件后,清空当前数据(为了避免之前数据的影响),然后加载RDB文件
- 生成快照的时候无法感知主库写操作,为了避免数据丢失,主库会有一块专门的内存replication buffer。记录RDB文件生成后的所有写操作
3.生成快照后的数据同步给从库
当主库完成RDB文件发送后,就会把此时replication buffer中的修改操作发送给从库,从库再重新执行这些操作,这样就能实现同步了
主从级联模式分担全量复制时的主库压力
通过上述分析,我们可以知道再一次全量复制中,对于主库来说有两个耗时的操作
- 生成RDB文件
- 传输RDB文件
如果从库数量有很多,而且都要和主库进行全量复制的情况下,就会导致主库忙于fork子进程生成RDB,也要传输RDB文件占用主库的网络带宽,就会拖慢整个Redis的服务
所以Redis采用了 ”主-从-从“模式来解决主库压力过大的问题。
这种级联模式可以将生成RDB和传输RDB的压力分散到从库上。
还可以从库关联从库,达到主-从-从的效果,也是通过replicaof命令
replicaof 所选从库的IP 6379
建立的关系如下
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zkfbWgAg-1650347541395)(https://gitee.com/zhengwh07/pic/raw/master/image-20220218163808413.png)]
主从库间网络断了怎么办?
主从库建立连接并完成第一次全量复制之后,因为主库会不停有新的操作进来,所以主从库之间维护了一个长连接,这样可以避免频繁建立连接的额外开销,但是其中有一个潜在的风险,如果网络连接中断或阻塞该怎么办?
在Redis2.8之前,用了一个简单粗暴的操作,如果主从之间断开连接,那么重新连接时,从库会进行一次全量复制,这样做的开销非常大。
2.8之后,主从库断开网络之后,主从库会采用增量复制的方式继续同步,主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。
repl_backlog_buffer 是一个环形缓冲区(有点类似于mysql的 redolog)主库会记录自己写到的位置,从库会记录自己读到的位置,刚开始的时候,主库指针和从库指针重合,随着主库不断写入,主库指针会偏离起始位置,对应的偏移量是master_repl_offse,主库接收的写操作越多,这个值就会越大。
同样的,从库在读取缓冲区操作的时候指针也会偏移,偏移量是slave_repl_offset,正常情况下,这两个偏移量基本相等。示意图如下
主从库重新恢复连接时,从库首先会给主库发送psync,并把当前读的位置的偏移量(slave_repl_offset)发送给主库,主库会对比自己的偏移量(master_repl_offse)和从库的偏移量,如果有差距的话,主库就要把两个偏移量之间的操作同步给从库。示意图如下
需要注意的是,因为 repl_backlog_buffer是一个环形的缓冲区,所以当缓冲区数据写满时又会从头开始写。
这就有可能导致从库未读取的操作被主库新写的操作覆盖了,这就会导致主从库间的数据不一致。这时候就必须进行全量同步才能保证数据的一致性
要避免这一情况,简单直接的办法就是调整repl_backlog_size这个参数,这个参数和缓存空间的大小有关。
缓冲空间的计算公式是
缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。
例:主库每秒写入2000个操作,每个操作大小为2kb,网络每秒能传输1000个操作,就要把剩余的1000个操作换成起来,至少需要2mb的缓冲空间。一版为了应付一些突发压力,一般把repl_backlog_size调整为2倍缓冲空间大小,即4mb。
这样一来,一般情况都能应付,若遇到压力突增的情况,还是可能会出现主从库不一致的情况。若压力突增,可以考虑增大repl_backlog_size的大小,或者使用切片集群部署方式,之后会讲到。
总结:
Redis主从库同步基本原理
- 全量复制
- 数据完整,较为耗时,第一次同步无法避免
- 建议一个redis实例不要太大,可以减少RDB文件生成、传输、重新加载的开销
- 为了避免多个从库给主库带来复制的压力,可以采用”主-从-从“的方式部署
- 基于长连接的命令传播
- 第一次同步之后就使用到命令传播实现同步
- 增量复制
- 主从间网络断开连接重新建立连接后,就要使用到增量复制,要注意repl_backlog_size这个参数的设置,防止主从数据不一致。
课后问答
-
前面提到,AOF记录的操作命令更加全面,相比于RDB丢失的数据更少,那为什么主从复制要使用RDB而不是AOF呢?
- RDB文件经过优化后,文件更小,更易于传输,恢复备份的速度快,AOF要一行行执行命令,恢复速度较慢
- 若使用AOF,就意味着要打开刷盘机制,选择不当会影响redis的性能,RDB只需要在定时备份和全量备份的时候才会生成快照,性能较好。
-
repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。
07 | 哨兵机制:主库挂了,如何不间断服务?
主从库的部署模式决定了主库的重要性。从库挂了还好,还能向其他从库或者主库请求数据,如果主库挂了,读操作可以从从库读,那么写操作呢?从库又是从哪里同步数据?
如果主库挂了,就要运行一个新的主库承接修改操作,比如说,把一个新的从库升级为主库,这就涉及到三个问题
- 主库真的挂了吗?
- 那么多从库,该选哪个从库作为主库?
- 怎么把新主库的相关信息通知给其他从库和客户端呢?
哨兵机制
在Redis主从集群中,哨兵机制是主从切换的关键机制,它有效解决了主从复制模式下上面提到的故障转移的三个问题。
哨兵主要负责三个任务:监控,选主,通知
-
监控:哨兵会像主库周期性的发起ping命令,检查主从库的运行情况,如果主、从库没有在规定的时间内响应哨兵的ping命令,哨兵就会把它标记为”下线状态“,如果主库为下线状态,那么就会进行第二步,选主。
-
选主:主库挂了后,哨兵就要在很多个从库中按照一定的规则选择一个从库实例并升级为新的主库。
-
通知:选出新的主库之后,哨兵要通知其他从库有新主库产生,此时哨兵会把新主库的连接信息发给其他从库,让他们执行replicaof命令和主库建立连接并复制数据,同时,哨兵会把新主库的连接信息发送给客户端,让它们把操作请求发送到新主库上。
其中通知任务比较简单,但是监控和选主就要做出以下决策
- 在监控任务中,哨兵判断主库是否属于下线状态(主观下线和客观下线)。
- 在选主任务中,哨兵要决定选择哪个从库升级为主库。
主观下线和客观下线
主观下线:
哨兵进程会使用ping命令检测它自己和主从库的网络连接情况,用来判断实例的状态。
如果主库或从库没有在规定时间内响应哨兵,这个库就被标记为”主观下线“。
如果是从库主观下线并不会影响集群的使用,如果是主库被标记为主观下线的话就要进行二次确认。
主库被标记为下线可能出现误判情况:网络阻塞,主库压力过大,哨兵的网络阻塞等。
哨兵集群减少误判情况:
为了减少误判的情况发生,哨兵往往也采用多实例部署,又称为哨兵集群,引入多个哨兵进行判断,就可以避免单个哨兵因为网络状况不好而误判主库下线的情况,同时,多个哨兵的网络同时不稳定性较小,也能减少误判的情况发生。
客观下线:
当多个哨兵标记主库为”主观下线“,那么就可以标记这个主库为”客观下线“。“客观下线"的原则是少数服从多数。
示例:
如下图所示,有一个主库,三个从库,三个哨兵
情况1:哨兵2判断下线,哨兵1、3判断上线,所以主库属于上线状态
情况2:哨兵1、2判断下线,哨兵3判断上线,所以主库处于客观下线状态
如何选定新的主库(筛选+打分)?
判断如果主库是客观下线的情况,就要进行新的选主过程了。
选择主库的过程分是 **“筛选+打分”**的形式,简单来说,我们在多个从库中先按照一定的筛选条件把不符合的去掉,然后再按一定规则给剩下的从库逐个打分,得分最高的成为新的主库。
筛选条件
首先要保证从库正在正常运行,这是最基本的,还要检查所选从库之前的网络情况,经常断连的从库也是不行的。
其中就要依靠down-after-milliseconds这个筛选条件,down-after-milliseconds是我们认定主从库锻炼的最大连接超时时间,如果在down-after-milliseconds毫秒内,主从节点都没有通过网络联系上,我们就开业i认为主节点断连了。
如果发生断联的次数超过了10次,就说明这个从库的网络状况不好,不适合作为新的主库。
打分
我们可以按照三个规则进行三轮打分,如果某一轮中,有从库得分最高,那么它就是主库了,如果从库的优先级都一样,那么哨兵开始进行第二轮打分。
- 从库优先级
- 从库复制进度
- 从库id号
第一轮:优先级高的从库得分高
优先级可以通过slave-priority 这个参数进行配置,如果你有两个从库a和b,其中a的内存更大,那么可以手动给a设置一个更高的优先级,在选主时,哨兵会给优先级高的从库打高分。如果有一个从库得了最高分,那么它将会被选为主库,如果没有则进行第二轮。
第二轮:和旧主库同步程度最接近的从库得分高
之前我们介绍过,在环形缓冲区repl_backlog_buffer中,主库用master_repl_offset记录当前的写操作位置,从库会用slave_repl_offset记录当前复制进度的位置,slave_repl_offset越大,和主库同步程度越接近。
如果是上图这种情况,从库2就会被选为新的主库。
第三轮:ID号小的从库得分高
每个实例都会有要给ID,类似于从库的唯一编号,如果前两轮得分都相同,那么就会按ID最小的得分高,来选举新的主库
课后问答
-
选主的过程中,客户端能否进行正常的请求呢?如果想要切换过程无感,还需要进行其他什么操作?
选主过程中正常情况下是能处理读请求,处理不了写请求的,如果需要无感切换,可以先把失败的请求缓存到消息队列中,重新产生主库之后需要把缓存更新到redis。
08 | 哨兵集群:哨兵挂了,主从库还能切换吗?
上面提到,可以通过哨兵机制来判断主库的客观下线,进行选主、通知操作。
哨兵是特殊的进程,可以通过部署哨兵集群减少误判的情况发生,同样的,如果其中一个哨兵挂了,其他哨兵也能够正常进行主从库切换的全套操作。
部署哨兵节点的命令如下,只需要主库的ip和端口即可,并没有集群信息或者其他哨兵的信息,那么哨兵是如何形成集群的呢?
sentinel monitor
基于 pub/sub 机制的哨兵集群组成
哨兵之间可以互相发现依靠的是redis的pub/sub机制,又称为发布/订阅机制
哨兵进程和主库建立连接并启动后,就可以在主库上发布消息,比如发布自身的连接信息(ip+端口),也可以从主库上订阅消息,获得其他哨兵的连接信息。
当多个哨兵都在主库上进行了发布/订阅后,他们之间就能知道彼此的ip+端口,组成哨兵集群。
我们也可以自己编写程序使用redis进行消息的发布和订阅,为了区分哨兵和其他的消息,消息会以频道的类型来区分,只有订阅了同一个频道的应用,才能进行消息间的交换。
假设在主从集群中,主库上有一个命名为“–sentinel–:hello的频道,所有的哨兵都订阅了这个频道,就能进行通信。
举个例子:如果哨兵1把自己的连接信息发送到“–sentinel–:hello“频道,哨兵2和哨兵3都订阅了该频道,那么2和3就能从这个频道直接获取到1的IP地址和端口号,这样一来,2和3可以和1建立网络连接, 就形成了哨兵集群。示意图如下
哨兵除了和主库建立连接形成集群外,还要和从库建立连接,因为要监控从库的健康状态,主库下线后也要进行选主。
哨兵和从库的通信是通过主库来进行的。哨兵向主库发送INFO命令,主库就会把从库列表返回给哨兵,哨兵就可以通过列表内的连接信息和从库建立连接。示意图如下。
哨兵除了和主从库建立连接之外,还需要和客户端建立连接,比如切换主库的时候要通知客户端进行切换。
基于 pub/sub 机制的客户端事件通知
从本质上说,哨兵就是一个运行在特定模式下的Redis实例,只不过它并不接收读写操作,只负责监控选主和通知的任务。所以每个哨兵也提供pub/sub机制,这样客户端就可以从哨兵订阅消息。
哨兵提供的消息频道有很多,重要频道如下
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jFyquumD-1650347541398)(…/AppData/Roaming/Typora/typora-user-images/image-20220223155210761.png)]
知道了这些频道之后,客户端就可以从哨兵这里订阅消息了。具体步骤:客户端读取哨兵配置文件–>获取哨兵地址和端口–>和哨兵建立网络连接–>订阅
如果要订阅”所有实例进入客观下线状态“事件,就可以执行如下命令
SUBSCRIBE +odown
当然也可以订阅所有事件
SUBSCRIBE +odown
当哨兵把新主库选出来之后,客户端就会看到以下事件,并切换为新主库的ip和端口
switch-master
由哪个哨兵执行主从切换?
确定由哪个哨兵执行主从切换的过程类似判断主库客观下线的过程,也是要进行投票的。
任何一个哨兵判断主库“主观下线”后,就会给其他实例发送is-master-down-by-addr命令,其他哨兵接收到这个命令后就会根据自身和主库的连接情况返回Y(赞成票)或N(反对票),示例图如下
上图哨兵2获得了一定的票数之后就可以标记主库为“客观下线”,这里判断的票数可以通过配置文件中的quorum参数决定。例如现在有5个哨兵,quorum设置为3,那么发起投票的哨兵只要获得三票赞成即可(自身一票+任意其他两个哨兵的一票)。
发起投票的哨兵判断主库为“客观下线”后,就可以给其他哨兵发送命令,表明希望自己可以执行主从切换,并让其他哨兵投票,这个过程称为”leader选举“。
在投票过程中,成为leader的哨兵需要满足两个基本条件
- 拿到半数以上的赞成票
- 赞成票的数量>=quorum
以下图为例子,这quorum=2
哨兵之间的投票很大一部分依靠的是选举命令的正常网络传播,如果网络压力太大,就会出现请求先后到达的情况,其实S1和S3都会向S2发出选举请求,谁的命令先到,S2就投给谁,下一个来的就会投否定票。
如果哨兵集群只有两个实例,其中一个挂了,另一个永远无法拿到>=2的票数,主从切换就会失败,所以一般部署至少3个哨兵实例。
课后问答
09 | 切片集群:数据增多了,是该加内存还是加实例
现有一个需求:要用Redis保存5000w个键值对,每个键值对大约512b,那么选择Redis实例的内存空间应该是多大呢?
5000w*52b=25GB,所以一般会选择一台32GB的Redis实例,剩下7GB也能保证系统正常运行持久化之类的操作。
但是在实际使用中,Redis的响应速度有的时候会非常慢,利用latest_fork_usec命令(表示最近一次 fork 的耗时)查询,有的时候达到秒级了。
在进行RDB持久化的时候,Redis会fork出子进程来完成,fork操作的用时是和Redis数据量正相关的,因为fork本身就是会阻塞线程的。
切片集群的设计可以有效避免这种情况发生。
切片集群又叫分片集群,指的是启动多个redis服务组成集群,然后按一定的规则把数据划分成多份,每一份用一个实例来保存。例如上文的25GB的文件,就可以平均分成5份,当然也可以不均分。用5台5G的服务器来保存。图示如下
这样在进行RDB备份的时候,数据量就会小很多,fork子进程也不会特别耗时。
如何保存更多数据?
当数据量增大的时,有两种扩展方案
-
纵向扩展:升级Redis实例配置,包括内存容量,cup等
-
优点:简单直接,不需要部署多台实例
-
缺点:数据量增加可能会导致redis变慢,如果不需要考虑持久化可以考虑纵向扩展
成本过高,内存和cpu比较贵
-
-
横向扩展:配置多台Redis组成集群
- 优点:不用特别担心成本和硬件限制,增加实例即可
- 难点:要解决数据分布和访问/管理的问题
数据切片和实例的对应分布关系
在切片集群中,数据要分布在不同的实例上,那么要如何实现对应的数据一定是会落到对应的实例上呢?
从redis3.0开始,官方就提供了一个名为Redis Cluster的方案用于实现切片集群。
Redis Cluster方案采用哈希槽(hash slot,又称为slot)来处理数据和实例间的映射关系。、
在Redis Cluster方案中,一个切片集群共有16384个slot,每个键值对会根据它的key进行运算落到对应的slot中。
具体步骤
- 首先对进行CRC16算法取一个16bit的值,
- 再用这个值对16834取模,得到0~16384范围内的模数,每个模数代表一个相应编号的slot
我们在部署Redis Cluster方案时,可以使用cluster creat命令创建集群,假设有N个实例,那么每个实例上的slot就为 16384/N个。
如果每个实例配置不同,我们希望高配置的实例可以承载更多,这时候就使用cluster meet命令手动建立实例间的连接形成集群,再使用cluster addslots指定每个实例上的哈希槽个数。
数据、哈希槽、实例这三者的映射关系如图示
若此切片集群有三台实例,有5个哈希槽,可以通过如下命令分配哈希槽
redis-cli -h 172.16.19.3 –p 6379 cluster addslots 0,1
redis-cli -h 172.16.19.4 –p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5 –p 6379 cluster addslots 4
这里要注意的是,再手动分配哈希槽时,需要把16384个哈希槽都分配完,否则Redis集群将无法正常工作。
客户端如何定位数据?
分配完哈希槽之后,客户端如何知道哪个key对应哪个实例呢?
一般来说,客户端和集群实例建立连接的时候,实例就会把哈希槽的分配信息发送给客户端,还会把自身的哈希槽信息发送给其他实例。
客户端接收到哈希槽信息后,会把哈希槽信息缓存再本地,当客户端请求键值对时,会先计算键所对应的哈希槽,这样就可以访问正确的实例了。
但是在集群中,实例和哈希槽的信息并不是一成不变的,最常见的变化有两个:
- 在集群中新增/删除实例
- 为了负载均衡,Redis需要把哈希槽在所有实力上重新分布一遍。
实例和实例之间可以根据相互通信感知这种变化,但是这种变化对客户端来说的无感知的,所以Redis Cluster提供了一种重定向机制,也就是说客户端向实例发送请求的时候,发现这个实例上并没有相应的数据,就会返回重定向的地址,客户端访问就能拿到想要的数据了。
GET hello:key
(error) MOVED 13320 172.16.19.5:6379
MOVED表示,“hello”这个key所对应的哈希槽是13320,实际是在172.16.19.5:6379这个实例上。客户端接收到MOVED命令后,顺便会更新本地的哈希槽信息,下次请求就会请求到新实例上了。
需要注意的是,MOVED需要数据完全迁移才能使用,如果迁移到一半就有请求进来,就会返回ASK命令
GET hello:key
(error) ASK 13320 172.16.19.5:6379
ASK命令简单来说就是让客户端再发一次请求到另一台实例,但是客户端接下来的请求还是会在原来的实例上。
ASK和MOVED最大的区别就是,ASK命令不会更新本地哈希槽,而MOVED会。