前言
本文讲解redis的高可用设计,包含单机和集群的高可用。
1、redis如何避免宕机造成数据丢失?
首先我们知道redis是内存数据库,一般都是用作缓存使用,那这个时候其实如果redis宕机,内存中的数据全部丢失,那么再次从数据库回源加载这些数据就行了。 问题:但是这种情况存在问题就是全部数据丢失,而且redis中缓存的都是一些热点数据或者极热点数据,那么这种数据回源会直接打挂DB。所以对于redis来说需要提供数据持久化的能力。
1.1、Redis提供的持久化机制
Redis提供了两种持久化机制,及AOF(Append Only FIle)日志和RDB快照。
1.1.1、AOF日志
我们使用的Mysql也有使用这种机制,Mysql的WAl(Write Ahead Log),每次在实际写数据前,先把修改的数据记到日志文件中,以便宕机进行恢复。不过,AOF是写后日志,redis会先执行命令,再把数据写入内存,然后才记录日志。
1.1.1.1、 为什么AOF要先执行命令再记录日志呢?
- 因为传统数据库,例如redo log,记录的是修改后的数据,而AOF里记录的是Redis收到的每一条指令,这些命令是以文本形式保存的,redis为了避免额外的检查开销,再向AOF里面追加日志的时候,没有对命令进行语法检查,所以就先执行命令再记录日志,只有命令执行成功才会被记录到日志中,否则就直接向客户端报错,这样就可以避免追加错误的命令。
- 因为是后追加,所以不会阻塞当前的写操作,不需要等追加日志完成再执行写命令。
1.1.1.2、 AOF的弊端?
- 如果刚执行完一个命令,然后还没有记录日志,这个时候宕机了,这个命令就丢失了,但是如果用作缓存的场景下,可以直接从数据库重新读取数据。
- 写后追加虽然不会阻塞当前写操作,但是会阻塞下条指令,因为AOF日志也是在住线程中执行的,如果需要把日志写盘,磁盘写压力大,就会导致写盘很慢,阻塞后续操作。
1.1.1.3、 AOF写回策略?
AOF机制提供三种策略(也就是appendfsync的三个可选值):
- Always(同步写回):每个写命令执行完成,立马同步把日志写回磁盘
- Everysec(每秒写回):每个写命令执行完,先把日志写到AOF文件的内存缓存中,然后每隔一秒把缓存中的内存写回磁盘
- NO(操作系统控制的写回):每个写命令执行完,只是先把日志写到AOF的内存缓冲区,由操作系统决定何时将缓存区内容写回磁盘。
三种写回策略都无法做到两全其美,原因如下:
- 同步写回可以做到数据基本不丢,但是每个写命令都要同步刷盘,会严重阻塞主线程的性能
- 采用靠操作系统控制写回,这样刷盘的实际就不在redis手中的,一旦数据没写回磁盘,宕机数据就丢失了。
- 每秒写回的策略在上面两种做了一个折中,虽然较少了对系统性能的影响,但是如果发生宕机也会造成一秒数据的丢失。
1.1.1.4、AOF重写
Redis在长期运行的过程中,AOF的日志会越来越长,如果实例宕机后,那么对整个AOF日志会非常耗时,导致实例长时间无法对外提供服务,所以需要定期对AOF进行重写。 Redis提供了bgrewriteaof指令用于对AOF进行重写,原理是开辟了一个子进程对内存进行遍历,转换成一系列Redis的操作执行并序列化到一个新的AOF文件中,序列化完毕后再把操作期间发生的增量AOF日志追加到这个新的AOF日志文件,追加完毕后就可以替代原有的AOF文件,重写完成。
1.1.2、内存快照
对Redis来说,快照实现类似于相机拍照的效果,就是把某一时刻内存的状态以文件的形式写到磁盘,这样即使宕机,快照文件也不会丢,可以通过加载RDB文件来恢复数据。
1.1.2.1、如何避免执行快照阻塞主线程?
对于redis,他的单线程模型导致,如果我们要生成RDB文件,势必会阻塞主线程,导致Redis的性能降低,所以Redis提供了两个命令来生成RDB,分别是save和bgsave
- save:在主线程中执行,会导致阻塞
- bgsave:创建一个子进程,专门用于写入RDB文件,避免主线程阻塞。
1.1.2.2、生成快照时,数据还能修改么?
因为我们记录的是开始生成快照时那一刻的数据,这个时候如果数据修改了,那么会破快状态的完整性,但是如果快照不能被修改,那么redis就无法执行对这些数据的写操作,会造成阻塞。所以Redis借助了操作系统的写时复制技术(Copy-On-Write)来进行数据段的分离,在执行快照的同时,正常处理写请求。 具体流程:当父进程对其中一个页面的数据进行修改,那么会把该页面复制一份分离出来,然后对这个复制的页面进行修改,这个子进程对应的页面是没有变化的,还可以吧原来的数据写入RDB文件,虽然这种随着修改操作的持续进行会导致越来越多的共享页面被分离出来,导致内存占用的增大,但是一般Redis中冷数据的比例是比高的,被分离的往往是其中的一小部分页面。
1.1.2.3、快照可以保证数据不丢失么?
在使用rdb来恢复内存状态的时候,如果我们是定期生成快照文件,如果评率太低,那么两次快照间一旦宕机,就会造成较多的数据丢失,但是如果频率太高,会产生额外的开销。所以在Redis4.0提供了一个混合使用AOF和RDB的方法,内存快照以一定频率执行,在两次快照之间,使用AOF日志记录这期间的所有命令。 这样快照也不需要很频繁执行,避免了fork操作对主线程的影响,AOF也只用记录两次快照间的操作,也避免了AOF过大的情况,避免重写开销。
示例:pandas 是基于NumPy 的一种工具,该工具是为了解决数据分析任务而创建的。
2、Redis如何实现高可用
虽然我们在上面保证数据尽量少丢,但是如果实例宕机,它在数据恢复期间是无法服务新的数据请求的。redis的高可用有两层含义:一个是数据尽量少丢失,一个是增加副本冗余数量,将数据同时保存在多个实例上,即使一个实例宕机,恢复数据需要一段时间也可以,其他实例也可以对外提供服务,不会阻塞请求执行。所以Redis提供了主从模式,保证数据的一致性,主从库之间采用的读写分离,主库负责读写请求,从库只负责读请求,主库定期同步写操作到从库。
2.1、主从库之间数据同步
2.1.1、如何进行第一次数据同步?
- 第一阶段是从库和主库建立链接,并告诉主库需要进行通过,具体是给主库发送psync命令,表示要进行数据同步,psync包含runId和offset,因为是第一次复制不知道主库的runId,所以runId=?,offset=-1表示是第一次复制。
- 主库收到psync请求,使用FULLRESYNC响应命令(表示全量同步),并带上两个参数返回给从库:主库的runId和主库目前的复制进行offset,从库收到相应后会收到两个值。
- 第二阶段,因为是全量同步,主库会执行bgsave命令,生成RDB文件,并把快照文件发给从库。从库收到文件后,会将当前内存数据清空,并进行全量加载。
- 在从库恢复数据的时候,主库还在接受请求,会把请求的指令写到内存中的replication buffer(定长的环形数组)。
- 从库加载完毕请求主库进行增量同步,这个时候主库会把buffer中的命令同步给从库,这个时候达到一致
- 这个流程存在一个问题,在从库进行快照回放的时候,主节点接受的写命令会记录到复制buffer中的,这个是定长环形数组,如果快照同步的时间过长或者复制buffer过小,都会导致同步期间的增量指令在复制buffer中被覆盖,导致快照同步完成后无法进行增量复制,会再次发起快照同步,从而陷入快照同步的死循环,所以需要根据快照的大小设置一个合适的复制buffer的size。
2.1.2、如果一个主库挂载太多从库,如果都需要和主库同步导致主库资源压力过大?
如果从库数量过多,且都要和主库进行全量复制,那么会导致主库忙于fork子进程生成RDB文件,进行数据全量同步。fork操作会阻塞主线程正常处理请求,从而导致主库响应客户端请求速度变慢,传输RDB也会占用主库的网络带宽,同样会给主库的资源带来压力。 这个时候我们就可以采用"主-从-从"模式,以级联的方式分散到从库上,这样从库在同步的时候就不会和主库进行交互,只要和级联的从库同步写操作就行了。
2.2、哨兵机制实现高可用
在上面我们分析了主从架构,可以让Redis在主节点挂了还可以从从节点进行数据读取,但是主库挂了,我们就无法服务客户端的写命令了,所以我们需要选取一个从节点来当作主节点对外提供写服务,所以Redis给我们提供了哨兵机制,可以让我们在主从模式下进行故障转移。
2.2.1、哨兵机制的基本流程
哨兵主要负责监控主库,主库故障进行选主,通知其他从库切换新主库。
- 监控:哨兵进程会定期给所有主从库发送PING命令,检测他们是否正常处于在线状态,如果从库没有在规定时间内响应哨兵的PING命令,哨兵就会把它标记为下线状态,如果是主库,那么就会启动自动切换主库流程。
- 选主:主库挂了,哨兵就需要从从库里按照一定规则选取一个从库作为新主库,对外提供服务
- 通知:哨兵会把新主库的链接信息发送给其他从库,让他们执行replicaof命令和新主库建立链接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让他们请求到新主库。
2.2.2、哨兵如何判断主库处于下线状态?
哨兵对主库的下线判断分为主观下线和客观下线两种
- 主观下线:哨兵进程通过PING命令检测它和主库的连接状态,如果发现PING命令响应超时了,那么哨兵就会标记主库为主观下线,标记为主观下线不能理解进行主库切换,因为存在误判的情况,可能主库根本没有下线,还在正常对外提供服务,可能是网络用塞,或者是产生网络分区了或者是主库压力较大无法及时响应PING命令,所以如果是误判,那么进行主从切换带来额外的开销,Redis为了减少误判,在判断进行主观下线后还会进行客观下线判断。
- 客观下线:因为哨兵也是集群模式部署的,所以客观下线是通过多个哨兵实例来一起判断,避免单个哨兵因为自身网络不好导致误判情况,在判断主观下线后,他会向其他的哨兵节点询问判断他们是否认为主节点处于下线状态,如果大多数的哨兵都认为主库主观下线了,那么主库会被标记为客观下线,后续会进行选主流程。客观下线的标准是,当有N个哨兵实例,要有N/2+1个实例判断主库为主观下线,才能判断主库为客观下线。
2.2.3、由哪个哨兵执行主从切换?
任何一个实例只要自身判断主库主观下线,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。一个哨兵获得仲裁所需要的赞成票(quorum N/2+1),标记主库为客观下线,这个时候这个哨兵就可以给其他哨兵发送命令,表示希望自己来执行主从切换,并让其他哨兵进行投票,这个过程称为“leader选举”,投票过程中,任何一个想成为leader的哨兵需要满足两个条件:1. 拿到半数以上的赞成票,2. 拿票的票数要大于等于哨兵配置文件中的quorum值。这个时候选举出来leader哨兵,他会进行故障转移流程。
2.2.4、如何从从库中选出新主库?
- 选主时,首先判断从库是否处于在线状态,过滤掉下线状态的从库,还会判断从库之前的网络状态,如果从库总是和主库断连,并且断连次数超出了阈值,我们认为这个从库网络不好进行剔除
- 经过上面的过滤后,我们首先比较从库的优先级,可以通过slave-priority 配置项设置从库的优先级,可能实例的配置不同,你可能给配置高的优先级设置的高,那么会选出有优先级高的作为主库,如果优先级一样,那么会判断哪个从库和旧主库的复制进度最接近,也就是那个从库的数据最新,那么选举它做新主库,如果复制进度一样,那么会判断从库的实例ID(从库的编号),在优先级和复制进度都相同的情况下,ID号最小的从库得分最高,会被选成新主库。
2.2.5、哨兵集群之间如何通信?
- 哨兵实例通信机制:哨兵之间可以互相发现,通过Redis提供的pub/sub机制,在主库中有一个"sentinel:hello"的频道,哨兵之间互相发现通信。
- 哨兵与主从库通信机制:哨兵和主从库的通信会通过哨兵向主库发送INFO命令,可以获取所有的从库信息,实现对主从库的监控。
3、Redis集群
我们知道单机的存储容量是有限的,我们一般会采用scale up 或者scale out,
- 如果我们采用纵向扩展,使用更好性能的服务器,但是会收到硬件和成本的限制,价格非常昂贵,这个成本远远大于部署多台便宜的服务器,而且内存非常大在持久化的时候,fork会阻塞主线程,并且生成RDB非常耗时,如果有写请求,采用COW机制,主线程会复制页面也会阻塞写请求。
- 纵向扩展有如上弊端,我们可以采用横向扩展,Redis给我们提供了Redis Cluster来组成切片集群,我们也可以采用Codis这种采用Proxy的模式组成集群。这样每个实例保存的数据就不会很大,避免fork子进程阻塞主线程导致的响应变慢。
3.1、Redis Cluster
Redis Cluster方案采用哈希槽来处理数据和实例之间的映射关系,一个切片集群一共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key被映射到一个哈希槽中。
3.1.1、数据和实例的映射关系?
当我们部署Cluster,Redis会自动把这些槽平均分布在集群实例上,例如集群中有N个实例,那么每个实例上槽的个数为16384/N个。而数据保存首先会根据键值对的key,按照CRC16算法计算一个16bit的值,然后再用这个16bit值对16384取模定位到对应的哈希槽,然后就可以根据槽定位到对应的实例了。
3.1.2、客户端如何定位数据?
上面说了我们首先根据键值对定位到所在的slot,然后我们需要知道slot分布在哪个实例,一般客户端先和集群实例建立链接,实例就会把哈希槽的分配信息发给客户端,但是集群刚建立,每个实例只知道自己被分配了哪些哈希槽,不知道其他实例拥有的哈希槽信息。但是Redis实例会把自己的哈希槽信息发给和他相连接的其他实例,来完成信息的扩散,当实例之间相互连接之后,每个实例就有所有的哈希槽的映射关系了。然后客户端和实例建立连接,获取哈希槽信息并缓存在本地,当客户端请求键值对时会先计算键对应的哈希槽,然后发送给对应实例即可。
3.1.3、实例和哈希槽映射关系变化,客户端怎么感知到?
在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:
- 在集群间,实例增加或删除,Redis需要重新分配Hash槽
- 为了负载均衡,Redis需要把哈希槽在所有实例上重新分布一般(初次均分哈希槽之后,可能由于热点key的原因,部分槽位的访问频繁,且可能这些槽位都对应在某一或几个实例上,导致所有实例之间压力不均衡,需要进行重新分布)
redis Cluster提供了重定向机制,客户端给一个实例发送数据读写操作时,这个实例上没有对应的数据,那么这个实例会给客户端返回MOVED命令响应结果,这个结果包含新实例的访问地址,然后客户端再请求新实例,并更新本地缓存的slot和实例的映射关系。
在下图中,假设slot2中的数据比较多,然后客户端向实例2发送请求,这个时候Slot2只有一部分数据迁移到实例3,还有一部分没有迁移,这个时候新旧节点都会存在部分key数据,然后这个时候客户端的请求的数据如果还在旧节点,那么旧节点正常处理,如果对应的key不再旧节点,那么有两种可能,要么数据在新节点,要么数据不存在,旧节点不知道是哪种情况,所以他会响应ASK报错信息,其中包含新节点的实例信息,然后客户端可以向新实例发送ASKING命令,这个命令的意思是,让这个实例允许客户端执行客户端接下来的发送的命令,然后客户端再向这个实例发送GET命令读取数据。ASK命令不会更新客户端缓存的哈希槽分配信息。 这里为什么需要执行一个不带参数的ASKING? 因为在迁移没有完成之前,按理说这个槽位不归新节点管理,如果这个时候向目标节点发送该槽位的指令,节点是不忍的,他会给客户端返回一个MOVED重定向指令告诉它去原节点执行,这样会形成重定向循环。ASKING指令的目标就是打开目标节点的选项,告诉它下一条指令不能不理,要当成自己的槽位来处理。
3.1.4、为什么采用哈希槽,而不是直接把key和实例作一个映射?
- 首先集群存储的key无法预估,可能非常多,如果存储每个key和实例的映射关系,这个映射表会非常大。而且集群在扩容、缩容会产生数据迁移,迁移时需要修改每个key的映射关系,维护成本太高。
- Redis Cluster采用去中心化的架构,那么节点间交互路由表,因为维护是按照key的维度,所以整个路由表特别大,导致占用过多的网络带宽,也不利于客户端的存储,占用过多内存。
3.1.5、Redis Cluster怎么进行容错的?
Redis Cluster是去中心化架构,实例之间通过Gossip协议通信,节点之间通过PING和PONG两种类型的消息保持心跳,
- 当一个节点超过NODE_TIMEOUT未得到另外一个节点的PONG恢复,那么就认为节点不可大,使用PFAIL(类似于哨兵机制的主观下线)标识这个不可达节点
- 在一定的时间窗口内,当集群中过半主节点都认为目标节点为PFAIL状态时,节点就会把该节点的状态提升为FAIl(类似于哨兵机制的客观下线)状态,然后FAIL消息会传播到集群中的所有可达节点。
- 主节点的从节点们会启动一个选举流程,在其他主节点的投票下,投票胜出的从节点才有机会提升为主节点,从节点发起选举必须满足如下条件:
- 从节点的主节点处于FAIL状态
- 从节点主节点负责的哈希槽不为空
- 为了保证节点数据的实效性,从节点和主节点之间断联必须小于指定时间。
- 得到大多数主节点的投票,那么从节点胜出会提升自己为主节点,然后向集群中所有可达节点发送PONG消息,然后会检测到该节点角色的变化,更新对应状态。
3.2、Codis
3.2.1、Codis整体架构
1.Codis集群包含了4类关键组件
- codis server:进行了二次开发的Redis实例,其中增加了额外的数据结构,支持数据迁移操作,主要负责具体的数据读写情趣
- codis proxy:接受客户端请求,并把请求转发给codis server
- Zookeeper 集群:保存集群元数据,例如数据位置信息和codis proxy信息
- codis dashboard 和 codis fe:共同组成了集群管理工具。其中,codis dashboard 负责执行集群管理工作,包括增删 codis server、codis proxy 和进行数据迁移。而 codis fe 负责提供 dashboard 的 Web 操作界面,便于我们直接在 Web 界面上进行集群管理。
3.2.2、Codis处理请求流程
- 为了让集群能接受并处理请求,要先使用codis dashboard设置codis server和codis proxy的访问地址
- 客户端读写数据,直接和codis proxy建立连接,codis proxy收到请求会查询请求数据和codis server的映射关系,把请求转发给响应的codis server进行处理,当codis server处理完请求后,把结果返回给codis proxy,proxy再把数据返回给客户端。
3.2.3、数据怎么在集群中分布?
- 在codis中,一个数据应该保存在哪个codis server中,也是通过哈希槽映射来完成的,Codis一共分成1024个slot,我们可以手动分配,也可以让codis dashboard自动分配。
- 客户端读写数据时,会使用CRC32算法计算key的哈希值,并把这个哈希值对1024取模得到对应的slot,然后根据slot和server的映射关系知道保存到哪个server上
3.2.4、手动设置哈希槽的映射关系,如何保证路由表的高可用?
因为slot和server的映射关系是分配完成的,如果集群故障了,那么映射关系就会丢失。在codis dashboard中分配好路由表,dashboard会把路由表发送给codis proxy,同时dashboard会把路由表保存在Zookeeper上,codis-proxy会把路由表缓存在本地。借助zookeeper来实现数据的高可用,自己就剩余了复杂的分布式一致性状态的维护。
3.2.5、Codis怎么保证集群的高可用?
- codis server:因为codis server就是Redis实例,所以redis的主从和哨兵都是可以使用的,所以codis可以是使用主从和哨兵来保证codis server的高可用。
- codis proxy:因为codis proxy使用Zookeeper来保存路由表,那么它本身成为一个无状态的,只是一个转发代理中间件,可以拉起多个codis proxy,发生故障,直接重启就行,从zk中拉取路由表信息。
- codis dashboard和codis fe:他们是后台作监控和配置的,所以对可用性的要求不高,不需要额外保证其可用性。
3.3、集群方案选择
- 从稳定性和成熟度来看,Codis 应用得比较早,在业界已经有了成熟的生产部署。虽然 Codis 引入了 proxy 和 Zookeeper,增加了集群复杂度,但是,proxy 的无状态设计和 Zookeeper 自身的稳定性,也给 Codis 的稳定使用提供了保证。而 Redis Cluster 的推出时间晚于 Codis,相对来说,成熟度要弱于 Codis,如果你想选择一个成熟稳定的方案,Codis 更加合适些。
- 从业务应用客户端兼容性来看,连接单实例的客户端可以直接连接 codis proxy,而原本连接单实例的客户端要想连接 Redis Cluster 的话,就需要开发新功能。所以,如果你的业务应用中大量使用了单实例的客户端,而现在想应用切片集群的话,建议你选择 Codis,这样可以避免修改业务应用中的客户端。
- 从使用 Redis 新命令和新特性来看,Codis server 是基于开源的 Redis 3.2.8 开发的,所以,Codis 并不支持 Redis 后续的开源版本中的新增命令和数据类型。另外,Codis 并没有实现开源 Redis 版本的所有命令,比如 BITOP、BLPOP、BRPOP,以及和与事务相关的 MUTLI、EXEC 等命令。Codis 官网上列出了不被支持的命令列表,你在使用时记得去核查一下。所以,如果你想使用开源 Redis 版本的新特性,Redis Cluster 是一个合适的选择。
- 从数据迁移性能维度来看,Codis 能支持异步迁移,异步迁移对集群处理正常请求的性能影响要比使用同步迁移的小。所以,如果你在应用集群时,数据迁移比较频繁的话,Codis 是个更合适的选择。
|