赞
踩
我们在设计Redis架构时,我们会去想一个问题,生产环境如何设计一套高性能、高可用、可扩展的Redis架构?接下来我会带着大家从单机到集群,一步一步演进。
在网站最初时,用户量和并发量都比较小,我们采用SpringBoot + MyBatis + Redis架构,写个单体应用就行,此时Redis我们也采用单机版,系统很快就可以构建起来,并且维护成本低。
存在问题:
①如果Redis宕机,缓存里的数据都没了。
②如果Redis宕机,请求都会打到MySQL去,此时MySQL会存在性能瓶颈。
③如果读写请求量急增,Redis存在性能瓶颈。
④如果业务快速发展,数据量急增,此时Redis存在存储容量瓶颈。
Redis 的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制。
什么是持久化?
就是把内存里的数据保存到硬盘上。
必须使用数据持久化吗?
Redis的数据持久化机制是可以关闭的。如果你只把Redis作为缓存服务使用,Redis中存储的所有数据都不是该数据的主体而仅仅是同步过来的备份,那么可以关闭Redis的数据持久化机制。
但通常来说,仍然建议至少开启RDB方式的数据持久化,因为:
①数据量不是非常大时,RDB方式的持久化几乎不损耗Redis本身的性能,因为Redis父进程持久化时只需要fork一个子进程,这个子进程可以共享主进程的所有内存数据,子进程会去读取主进程的内存数据,并把它们写入RDB文件。
②Redis无论因为什么原因发送故障,重启时能够自动恢复到上一次RDB快照中记录的数据(自动加载RDB文件)。这省去了手工从其他数据源(如数据库)同步数据的过程,而且要比其他任何的数据恢复方式都要快
③服务器的硬盘都是T级别的,几个G的数据影响忽略不计Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持两种不同的持久化操作。
RDB
Redis提供了两个命令来生成 RDB 文件:
为了快照而阻塞写请求,这是系统无法接受的,因此Redis借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。
Redis在执行持久化时,会fork出一个bgsave子进程,这个子进程可以共享主进程的所有内存数据,bgsave子进程运行后,会去读取主进程的内存数据,并把它们写入RDB文件。
有小伙伴问,为什么要fork一个子线程?
redis是单线程程序,若单线程同时在服务线上的请求还需要进行文件IO操作,这不仅影响性能而且还会阻塞线上业务,因此这里主进程fork出一个进程,fork出的这个进程去完成快照操作。
快照持久化是 Redis 默认采用的持久化方式,我们可以根据业务需求配置下面参数:
- save 900 1 #每900秒(15分钟)至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
- save 300 10 #每300秒(5分钟)至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
- save 60 10000 #每60秒(1分钟)至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
key发生变化(key数据添加、修改、删除)
触发快照的几种方式
①服务器正常关闭时,会照一次快照 ./bin/redis-cli shutdown
②key满足一定条件,会照一次快照(通过上述Redis.conf配置)
③通过BGSAVE命令(在redis中执行)
手动触发RDB快照保存优点:
①RDB文件紧凑,体积小,网络传输快,适合全量复制
②与AOF方式相比,通过RDB文件恢复数据比较快更快
③RDB 最大化了 Redis 的性能,因为 Redis 父进程持久化时只需要 fork 一个子进程,这个子进程可以共享主进程的所有内存数据,子进程会去读取主进程的内存数据,并把它们写入 RDB 文件。
缺点:
①快照是定期生成的,所有在 Redis 故障时或多或少会丢失一部分数据
②当数据量比较大时,fork 的过程是非常耗时的,fork 子进程时是会阻塞的,在这期间 Redis 是不能响应客户端的请求的。
AOF
Redis会把每一个写请求都记录在一个日志文件里,在Redis重启时,会把AOF文件中记录的所有写操作顺序执行一遍,确保数据恢复到最新。
Redis 会在收到客户端修改指令后,先进行参数校验,如果没问题,就立即将该指令文本存储到 AOF 日志中,也就是先存到磁盘,然后再执行指令。这样即使遇到突发宕机,已经存储到 AOF 日志的指令进行重放一下就可以恢复到宕机前的状态。
日志文件太大怎么办?
AOF 日志在长期的运行过程中会变的很大,Redis重启时需要加载 AOF 日志进行指令重放,此时这个过程就会非常耗时。所以需要定期进行AOF 重写,给 AOF 日志进行瘦身。
AOF如何重写?
Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。每次执行重写时,主进程 fork 出一个bgrewriteaof 子进程,会把主进程的内存拷贝一份给 bgrewriteaof 子进程,对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。
Redis提供了AOF rewrite功能,可以重写AOF文件,只保留能够把数据恢复到最新状态的最小写操作集。
AOF 重写可以通过bgrewriteaof命令(在redis里执行)触发,也可以配置Redis定期自动进行:
- ## Redis在每次AOF rewrite时,会记录完成rewrite后的AOF日志大小,当AOF日志大小在该基础上增长了100%后,自动进行AOF rewrite。同时如果增长的大小没有达到64mb,则不会进行rewrite。
- auto-aof-rewrite-percentage 100
- auto-aof-rewrite-min-size 64mb
AOF默认是关闭的,如果需要开启,需要在redis.conf配置文件中配置
appendonly yes
AOF提供三种fsync配置,always/everysec/no,通过配置appendfsync指定,默认是everysec。
- appendfsync always # 每写入一条日志就进行一次fsync操作,数据安全性最高,但速度最慢(每次有数据修改发生时都会写入AOF文)
- appendfsync everysec # 折中的做法,交由后台线程每秒fsync一次(每秒钟同步一次,该策略为AOF的缺省策略)
- appendfsync no # 不进行fsync,将flush文件的时机交给OS决定,速度最快(从不同步。高效但是数据不会被持久化)
优点:
①数据安全性高,可以根据业务需求配置fsync策略
②AOF文件易读,可修改,在进行了某些错误的数据清除操作后,只要AOF文件没有rewrite,就可以把AOF文件备份出来,把错误命令删除,然后恢复数据
缺点:
①AOF方式生成的日志文件太大,即使通过AFO重写,文件体积仍然很大
②数据恢复速度比RDB慢
混合存储
如果我们采用 RDB 持久化会丢失一段时间数据。如果我们采用 AOF 持久化,AOF日志较大,重放比较慢。
Redis 4.0 为了解决这个问题,支持混合持久化。将 RDB 文件的内容和增量的 AOF 日志文件存在一起。
混合持久化同样也是通过 bgrewriteaof 完成的,不同的是当开启混合持久化时,fork出的子进程先将共享的内存副本全量的以 RDB 方式写入 AOF 文件,然后在将重写缓冲区的增量命令以 AOF 方式写入到文件,写入完成后通知主进程更新统计信息,并将新的含有RDB格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。简单的说:新的AOF文件前半段是RDB格式的全量数据后半段是AOF格式的增量数据。
于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。
那有小伙伴问了,你数据现在是存到磁盘里了,如果磁盘坏了怎么办?如果宕机后短时间不能恢复咋办(例如阿里云机房都出问题了)?
增加备份节点。
如果 Redis 的读写请求量很大,那么单个 Redis 实例很有可能承担不了这么大的请求量,如何提高Redis的性能呢?我们可以部署多个副本节点,业务采用读写分离的方式,把读请求分担到多个副本节点上,提高访问性能。要实现读写分离,就必须部署多个副本,每个副本需要实时同步主节点的数据。
单可用区(节点全部在一个可用区):无法应对机房级别的故障
如果上海可用区机房出现故障,整个Redis服务全部瘫痪,所以我们在平时部署时,需要把节点分散在不同的可用区,如果有小伙伴公司对可用性要求极高,可以研究下异地多活方案,在这里我就不展开了。
主从复制的三种方式:
①全量复制
②增量复制
③无盘复制
全量复制
假设我们有两个节点,A节点是 Master 节点,B节点是 Slave 节点。
当我们在节点B上执行slaveof
命令后,节点B会与节点A建立一个TCP连接,然后发送psync ${runid} ${offset}
命令,告知节点A需要开始同步数据。
参数介绍:
runid
:每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例offset
:偏移量,slave需要从哪个位置开始同步数据由于是第一次同步,Slave 节点不知道 Master节点的runid
,所以 Slave 节点会发送psync ? -1
,表示需要全量同步数据。
Master 节点在收到 Slave 节点发来的psync
后,会给slave回复+fullresync ${runid} ${offset}
,这个runid
就是master的唯一标识,slave会记录这个runid
,用于后续断线重连同步请求。
Master 执行 bgsave 命令,生成 RDB 文件,接着将文件发给 Slave。Slave 接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为Slave在通过 replicaof 命令开始和 Master 同步前,可能保存了其他数据。为了避免之前数据的影响,Slave 需要先把当前数据库清空。
在 Master 将数据同步给 Slave 的过程中,Master 不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主 Slave 的数据一致性,Master 会在内存中用 repl_backlog_buffer 记录 RDB 文件生成后收到的所有写操作。
最后,Master 会把 repl_backlog_buffer数据再发送给从库。这样一来,主从库就实现同步了。
全量复制的开销:
主节点:生成RDB文件会占用内存、硬盘资源,网络传输RDB的时候会占用一定的网络带宽资源
从节点:清空数据,若数据量大,需要消耗一定的时间,加载RDB也需要一定的时间
增量复制
在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,从库就会和主库重新进行一次全量复制,开销非常大。
在Redis在这方面进行了改进,在2.8版本之后,Redis支持增量同步。
主从因为故障断开,故障恢复后,他们重新建立连接,Slave 节点向 Master 节点发送数据 同步请求:psync ${runid} ${offset}
,Master 收到psync
命令之后,检查slave发来的runid
与自身的runid
一致,如果一致,说明之前已经同步过数据,这次只需要同步部分数据即可。
这里分为两种情况:
①如果offset在repl_backlog_buffer范围内,那么 Master 节点给 Slave 节点回复+continue
,表示这次只同步部分数据。之后 Master 节点把复制缓冲区offset
之后的数据给 Slave 节点,接下来 Slave 节点执行这些命令后就与 Master 数据一致了。
②如果offset不在repl_backlog_buffer范围内,说明断开连接很久了,如果offset在repl_backlog_buffer的内容已经被新的内容覆盖了,此时只能触发全量数据同步。
无盘复制
通常,全量复制需要在磁盘上创建RDB文件,然后加载到内存中,Redis支持无盘复制,生成的RDB文件不保存到磁盘而是直接通过网络发送给从节点。无盘复制适用于主节点所在机器磁盘性能较差但网络宽带较充裕的场景。需要注意的是,无盘复制目前依然处于实验阶段。
那有小伙伴会问,半夜主节点挂了,难道需要我爬起来处理?不是吧?
肯定不是,哨兵模式可以帮我们解决。
Redis 除了具有非常高的性能之外,还需要保证高可用,在故障发生时,尽可能地降低故障带来的影响,Redis提供了哨兵模式,来进行故障恢复。
哨兵主要负责做三件事:
①监控,监控主、从节点是否正常运行
②选主,Sentinel集群需要选择一个Leader来进行主从切换。
③通知,选主完成后,需要把新主库的连接信息通知给从库和客户端。
状态感知
哨兵启动后只指定了master的地址,要想知道整个集群中完整的拓扑关系怎么做呢?
哨兵每隔10秒会向每个master节点发送info
命令,info
命令返回的信息中,包含了主从拓扑关系,其中包括每个slave的地址和端口号。有了这些信息后,哨兵就会记住这些节点的拓扑信息,在后续发生故障时,选择合适的slave节点进行故障恢复。
那么有小伙伴会问,哨兵之间是如何通信的呢?
基于Redis提供的发布(pub)/订阅(sub)机制完成的。哨兵节点不会直接与其他哨兵节点建立连接,而是首先会和主库建立起连接,然后向一个名为"sentinel:hello"频道发送自己的信息(IP 和端口),其他订阅了该频道的哨兵节点就会获取到该哨兵节点信息,从而哨兵节点之间互知。
心跳检测
每一秒,每个 Sentinel 对 Master、Slave、其他哨兵节点执行PING命令,检测它们是否仍然在线运行,如果有节点在规定时间内没有响应PING命令,那么该哨兵节点认为此节点"主观下线"。
主观下线和客观下线
为什么需要客观下线机制?
因为当前哨兵节点探测对方没有得到响应,很有可能这两个机器之间的网络发生了故障,而 Master 节点本身没有任何问题,此时就认为 Master 故障是不正确的。
为了解决上述问题,客观下线应运而生,Sentinel一般会集群部署,引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。
假设我们有N个哨兵实例,如果有N/2+1个实例判断主库“主观下线”,此时把节点标记为“客观下线”,此时就可以做主从切换了。
选举领导者
假设Sentinel 判断主库“主观下线”后,就会给其他 Sentinel 实例发送 is-master-down-by-addr 命令,接着,其他 Sentinel 实例会根据自己和主库的连接情况,做出赞成和反对决定。
假设我们有N个哨兵实例,如果有#{quorum}个实例赞成,此时这个 Sentinel 就会给其他 Sentinel 发送主从切换请求,其他 Sentinel 会进行投票,如果投票通过,这个 Sentinel 就可以进行主从切换了,这个投票过程被称为Leader 选举。其实整体思想和Zookeeper一样的。
在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。
quorum一般我们都会配置成实例数量/2+1
此时会有小伙伴问,如果所有Sentinel都想成为Leader执行主从切换怎么办?
哨兵选举领导者的过程类似于Raft算法,每个哨兵都设置一个随机超时时间,超时后向其他哨兵发送申请成为领导者的请求,把超时时间都分散开来,在大多数情况下只有一个服务器节点先发起选举,而不是同时发起选举,这样就能减少因选票瓜分导致选举失败的情况。
后期我会专门写一个专栏为大家介绍所有一致性算法,例如:Paxos、Raft、Gossip、ZAB等,到时候具体给大家讲解Sentinel是如何解决上述问题的。
谁来做新的Master?
选择新master过程也是有优先级的,在多个slave的场景下,优先级按照:slave-priority配置 > 数据完整性 > runid较小者进行选择。
用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。如果所有从节点的 slave-priority 值一致,那就看谁的数据更完整。
如何判断谁的数据更完整呢?
主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 这个值记录当前的复制进度。此时,哪个从库的 slave_repl_offset 最接近 master_repl_offset。那么谁就可以作为新主库。
如果 slave_repl_offset都一致,那就比 runid,选择runid最小的 Slave 节点作为新主库。
选择出新主库,哨兵 Leader 会给该节点发送slaveof no one
命令,让该节点成为 Master。之后,哨兵 Leader会给故障节点的所有 Slave 发送slaveof $newmaster
命令,让这些 Slave 成为新 Master 的从节点,开始从新的Master 上同步数据(这里会进行全量复制)。最后哨兵 Leader 把故障节点降级为 Slave,并写入到自己的配置文件中,待这个故障节点恢复后,则自动成为新 Master 节点的 Slave。至此,整个故障切换完成。
如何通知新的客户端?
上面已经介绍了完整的故障切换全流程,故障切换后,主节点变化了,客户端如何感知呢?
基于Redis提供的发布(pub)/订阅(sub)机制完成的,客户端可以从哨兵订阅消息,故障转移后,客户端会收到订阅消息。
有小伙伴会问,如果写请求存在性能瓶颈咋办?
如果存储容量存在瓶颈咋办?
采用集群架构。
在大数据高并发场景下,Sentinel存在一些问题,写请求全部落在 Master 节点,Master 节点就一个实例,存储容量、CPU、内存、IO都存在瓶颈,如果我们扩容内存,会导致RDB文件过大,从而fork子进程时会阻塞较长时间。此时Redis 集群方案应运而生。
数据如何分片?
Redis Cluster 采用的是虚拟槽分区,一个集群共有 16384 个哈希槽,Redis Cluster会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N个。
Redis Cluster 会对 key 值进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。
扩容机制
如图所示,6379、6380、6381三个主节点,6382是6379的从节点,6383是6380的从节点,6384是6381的从节点,现在因为业务发展过快,需要进行扩容,我们新增一个主节点6385和一个从节点6386。
步骤一:首先需要为新节点指定槽的迁移计划,也就是将哪些节点的哪些槽迁移到新节点中。并且迁移计划要确保每个节点负责相似数量的槽,从而保证各节点的数据均匀。槽迁移计划确定后开始逐个把槽内数据从源节点迁移到目标节点中。
如上图所示,6379准备把自己的4097-5460槽迁移给新节点6385,6380准备把自己的9558-10921槽迁移给新节点6385,6381准备把自己的15019-16383槽迁移给新节点6385。
步骤二:迁移数据数据迁移过程是逐个槽进行的,每个槽迁移的流程如下流程说明:
①对目标节点发送cluster setslot {slot} importing {sourceNodeId}命令,让目标节点准备导入槽数据。
②对源节点发送cluster setslot {slot} migrating {targetNodeId}命令,让源节点准备迁出槽数据。
③源节点循环执行cluster getkeysinslot {slot} {count}命令,获取count个数据槽{slot}的键。
④在源节点上执行migrate {targetIp} {targetPort} key 0 {timeout} 命令把指定key迁移注意:Redis3.2.8后,使用pipeline传输
⑤重复步骤3、4直到槽下所有的键值数据迁移到目标节点。
⑥向集群内所有主节点发送cluster setslot {slot} node {targetNodeId}命令,通知槽分配给目标节点。
缩容机制
缩容的三种情况:
①下线迁移槽
②忘记节点
③关闭节点
槽迁移和扩容一样
MOVED
当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个 MOVED 指令并携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。
客户端收到 MOVED 指令后,要立即纠正本地的槽位映射表。后续所有 key 将使用新的槽位映射表。
案例:
先对 key 值进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位
计算哈希值命令:
127.0.0.1:6379>cluster keyslot"hello world"
命中
未命中
MOVED命令里包含两个信息,一个是槽的位置,一个是目标节点地址。
Asking
集群的伸缩(扩容/缩容)的时候,当我们去源节点访问时,发现key已经迁移到目标节点,会回复ask转向异常,收到异常后,先是执行asking命令,然后给目标节点再次发送命令,然后就会返回结果。
假如我们执行一个get key命令,这个key原来是在A节点,现在迁移到B节点,然后会给我们返回ASK转向异常,当我们收到ASK转向异常后,需要执行一条Asking命令给目标节点,然后在发送get命令。
ASK与MOVED共同点:
两者都是重定向
ASK与MOVED不同点:
槽位迁移感知
如果集群中某个槽位正在迁移或者已经迁移完了,客户端如何能感知到槽位的变化?
客户端保存了槽位和节点的映射关系表,当客户端收到moved指令的时候,他会去刷新槽位映射关系表,获取到最新的映射关系。当收到ask转向异常时,不会刷新槽位映射关系表,因为它是临时纠正。
容错
Redis Cluster 可以为每个主节点设置若干个从节点,单主节点故障时,集群会自动将其中某个从节点提升为主节点。如果某个主节点没有从节点,那么当它发生故障时,集群将完全处于不可用状态。不过 Redis 也提供了一个参数cluster-require-full-coverage可以允许部分节点故障,其它节点还可以继续提供对外访问。
原作者: Java程序鱼
原文链接: Redis高性能、高可用、可扩展的生产级架构如何设计?(高频面试题)
原出处: Java程序鱼-公众号
侵删
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。