Skip to content

一、Redis主从模式

1、主从模式可以解决什么问题?

单节点的Redis,在该实例宕机之后,重启恢复之前就不能对外提供服务了。为了解决这个问题,我们很正常的想到,如果可以多冗余几个副本,一个宕机了,其余的副本还可以对外提供服务,那就没问题了。可是我们要怎么保证,多个副本之间的数据一致性呢?

实际上,Redis 提供了主从模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。

读操作: 主库、从库都可以接收; 写操作: 首先到主库执行,然后,主库将写操作同步给从库。

2、快速搭建主从

为了方便,本文使用docker 搭建redis相关服务,docker安装以及相关命令请参考 docker中文文档

第一步,我们先下载一个redis 的配置文件

bash
wget http://download.redis.io/redis-stable/redis.conf
#新建目录
mkdir /usr/local/redis/master
mkdir /usr/local/redis/slave
mkdir /usr/local/redis/slave1
cp redis.conf /usr/local/redis/master/
cp redis.conf /usr/local/redis/slave/
cp redis.conf /usr/local/redis/slave1/
#修改slave 配置
replicaof master ip 端口
masterauth yourpassword

第二步,下载redis最新的镜像

bash
docker pull redis:latest

第三步,启动主节点redis服务

bash
docker run -p 6379:6379 --name redisMaster -v /usr/local/redis/master/data:/data -v /usr/local/redis/master/redis.conf:/etc/redis/redis.conf -d -it  --restart=always redis:latest redis-server /etc/redis/redis.conf --requirepass "yourpassword"

第四步,我们启动slave redis服务

bash
#启动第一个slave
docker run -p 6380:6379 -p 63801:63801 --name redisSlave -v /usr/local/redis/slave/data:/data -v /usr/local/redis/slave/redis.conf:/etc/redis/redis.conf -d -it  --restart=always redis:latest redis-server /etc/redis/redis.conf --requirepass "yourpassword"

#启动第二个slave
docker run -p 6381:6379 -p 63811:63811 --name redisSlave1 -v /usr/local/redis/slave1/data:/data -v /usr/local/redis/slave1/redis.conf:/etc/redis/redis.conf -d -it  --restart=always redis:latest redis-server /etc/redis/redis.conf --requirepass "yourpassword"

第五步,检查主从是否ok

bash
#进入master容器
docker exec -it redisMaster bash
redis-cli
auth yourpassword
info

3、主从复制的过程以及原理

第一步是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。

第二步,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。

psync 命令包含了主库的 runID 和复制进度 offset 两个参数。

  • runID:是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为"?"
  • offset:此时设为 -1,表示第一次复制

第三步,主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

整个过程为:主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。

第四步,在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

主从断开连接后又重新连接从库如何同步数据?

当主从库断连后,主库会把断连期间收到的写操作命令,写入repl_backlog_buffer 这个缓冲区。repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大。同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。

repl_backlog_buffer

主从库的连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的 slave_repl_offset 发给主库,主库会判断自己的 master_repl_offset 和 slave_repl_offset 之间的差距。一个从库如果和主库断连时间过长,造成它在主库repl_backlog_buffer的slave_repl_offset位置上的数据已经被覆盖掉了,此时从库和主库间将进行全量复制。在和主库重连进行恢复时,从库会通过psync命令把自己记录的slave_repl_offset发给主库,主库会根据从库各自的复制进度,来决定这个从库可以进行增量复制,还是全量复制。

4、优化建议

  • 分担全量复制时的主库压力:主从模式对于主库来说主要是生成 RDB 文件和传输 RDB 文件比较耗时,所以我们可以选择主-从-从的主从级联模式。
  • 启用持久化:强烈建议在 master 和在 slave 中启用持久化。关闭了持久化并配置了自动重启的 master 是危险的。

例如:

我们设置节点 A 为 master 并关闭它的持久化设置,节点 B 和 C 从 节点 A 复制数据。 节点 A 崩溃,但是他有一些自动重启的系统可以重启进程。但是由于持久化被关闭了,节点重启后其数据集合为空。 节点 B 和 节点 C 会从节点 A 复制数据,但是节点 A 的数据集是空的,因此复制的结果是它们会销毁自身之前的数据副本。

二、Redis哨兵模式(Sentinel)

1、哨兵解决了什么问题?

前面主从模式我们解决了单节点redis宕机不能提供服务的问题。主从模式下,因为有其他数据副本,只要及时进行主从切换,这样我们还是可以保证redis是可用的。可是,问题随机而来,如果凌晨三点redis master 宕机了,这时候我们打电话给运维,让他从床上爬起来为我们进行主从切换吗?所以,sentinel应运而生。

哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知

监控: 是指哨兵进程在运行时,周期性地给所有的主从库发送 PING 命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就会把它标记为"下线状态";同样,如果主库也没有在规定时间内响应哨兵的 PING 命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。

选主: 主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。

通知: 在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

2、部署哨兵

bash
#新建目录
mkdir /usr/local/redis/sentinel
cd /usr/local/redis/sentinel
#下载配置
wget http://download.redis.io/redis-stable/sentinel.conf
#修改配置
vim sentinel.conf
#配置指示 Sentinel 去监视一个名为 mymaster 的主服务器, 这个主服务器的 IP 地址为 127.0.0.1 , 端口号为 6379 , 而将这个主服务器判断为失效至少需要 1 个 Sentinel 同意
sentinel monitor mymaster 127.0.0.1 6379 1
# 让sentinel服务后台运行
daemonize yes
# 修改日志文件的路径
logfile "/etc/redis/logs/sentinel.log"
# 启动sentinel容器
docker run -it --name sentinel -p 26379:26379 -v /usr/local/redis/sentinel/sentinel.conf:/etc/redis/sentinel.conf -v /usr/local/redis/sentinel/logs/sentinel.log:/etc/redis/logs/sentinel.log -d redis:latest --requirepass "yourpassword"
#进入容器
docker exec -it sentinel bash
#启动sentinel服务
redis-server /etc/redis/sentinel.conf --sentinel

redis sentinel 配置参考

3、哨兵选主的过程

在聊哨兵选主过程之前,我想和大家一起聊一下2个概念:主观下线和客观下线。

主观下线(Subjectively Down, 简称 SDOWN):指的是单个 Sentinel 实例对服务器做出的下线判断。

客观下线(Objectively Down, 简称 ODOWN):指的是多个 Sentinel 实例在对同一个服务器做出 SDOWN 判断, 并且通过 SENTINEL is-master-down-by-addr 命令互相交流之后, 得出的服务器下线判断。

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记为"主观下线"。如果检测的是从库,那么,哨兵简单地把它标记为"主观下线"就行了,因为从库的下线影响一般不太大,集群的对外服务不会间断。但是,如果检测的是主库,那么,哨兵还不能简单地把它标记为"主观下线",开启主从切换。因为很有可能存在这么一个情况:那就是哨兵误判了,其实主库并没有故障。可是,一旦启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。

那该如何避免呢?那就是引入哨兵集群。

通常会采用多实例组成的集群模式进行部署,这被称为哨兵集群。引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

启动sentinel的时候,sentinel monitor 最后一个参数就是指定多少个sentinel实例判断主库为"主观下线",才能最终判定主库为"客观下线"。

一次故障转移操作由以下步骤组成:

  1. 发现主服务器已经进入客观下线状态。
  2. 对我们的当前纪元进行自增,并尝试在这个纪元中当选。
  3. 如果当选失败,那么在设定的故障迁移超时时间的两倍之后,重新尝试当选。如果当选成功,那么执行以下步骤。
  4. 选出一个从服务器,并将它升级为主服务器。
  5. 向被选中的从服务器发送 SLAVEOF NO ONE 命令,让它转变为主服务器。
  6. 通过发布与订阅功能,将更新后的配置传播给所有其他 Sentinel,其他 Sentinel 对它们自己的配置进行更新。
  7. 向已下线主服务器的从服务器发送 SLAVEOF 命令,让它们去复制新的主服务器。
  8. 当所有从服务器都已经开始复制新的主服务器时,领头 Sentinel 终止这次故障迁移操作。

Sentinel 使用以下规则来选择新的主服务器:

  1. 在失效主服务器属下的从服务器当中,那些被标记为主观下线、已断线、或者最后一次回复 PING 命令的时间大于五秒钟的从服务器都会被淘汰。
  2. 在失效主服务器属下的从服务器当中,那些与失效主服务器连接断开的时长超过 down-after 选项指定的时长十倍的从服务器都会被淘汰。
  3. 在经历了以上两轮淘汰之后剩下来的从服务器中,我们选出复制偏移量(replication offset)最大的那个从服务器作为新的主服务器;如果复制偏移量不可用,或者从服务器的复制偏移量相同,那么带有最小运行 ID 的那个从服务器成为新的主服务器。

三、Redis分片集群模式(Redis cluster)

1、集群分片解决了什么问题?

在上面,我们了解了主从和哨兵,似乎现在已经可以保证redis的高可用了,但是真的如此吗?

我们来想象一个场景,有一天早上你一过来,发现redis存储报警了,说内存快不够用了,你的第一反应肯定是哪位开发大佬塞了那么多数据到redis还不删。了解之后开发大佬说这些都是需要的。那咋办呢?加内存?好的,加个内存解决问题,美滋滋~

周而复始,直到有一天线上反应redis有时候突然会反应很慢(连接超时),排查一波之后发现,原来每次慢的时候,都是redis在做rdb持久化,这时候你就意识到,原来内存啥时候都这么大了,一次持久化竟然耗时这么多。

那有没有其他方法解决Redis内存不够的问题呢?当然有,第一反应,加机器他不香嘛?数据分散到多台机器上,这样每台机器都可以保持一个很健康的状态。Redis 分布式解决方案,由此而来。

redis 的分布式解决方案很多,但是在redis cluster广泛使用之前,我们的国产骄傲,由豌豆荚团队推出的codis,则得到了一致好评。

codis虽然好,但是下面我还是和大家聊聊Redis自己推出的集群方式 redis cluster。

2、Redis Cluster

2.1 Redis cluster 介绍和实现原理

Redis集群介绍

Redis cluster是一个提供在多个Redis间节点间共享数据的程序集。 Redis集群并不支持处理多个keys的命令,因为这需要在不同的节点间移动数据,从而达不到像Redis那样的性能,在高负载的情况下可能会导致不可预料的错误。 Redis 集群通过分区来提供一定程度的可用性,在实际环境中当某个节点宕机或者不可达的情况下继续处理命令。

Redis 集群的优势:

  • 自动分割数据到不同的节点上。
  • 整个集群的部分节点失败或者不可达的情况下能够继续处理命令。

Redis 集群的数据分片

Redis 集群没有使用一致性hash,而是引入了哈希槽的概念。

Redis 集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽。集群的每个节点负责一部分hash槽。

举个例子,比如当前集群有3个节点,那么:

  • 节点 A 包含 0 到 5500号哈希槽
  • 节点 B 包含5501 到 11000 号哈希槽
  • 节点 C 包含11001 到 16384号哈希槽

这种结构很容易添加或者删除节点。比如如果我想新添加个节点D,我需要从节点 A, B, C中得部分槽到D上。如果我想移除节点A,需要将A中的槽移到B和C节点上,然后将没有任何槽的A节点从集群中移除即可。由于从一个节点将哈希槽移动到另一个节点并不会停止服务,所以无论添加删除或者改变某个节点的哈希槽的数量都不会造成集群不可用的状态。

Redis 集群的主从复制模型

为了使在部分节点失败或者大部分节点无法通信的情况下集群仍然可用,所以集群使用了主从复制模型,每个节点都会有N-1个复制品。

在我们例子中具有A,B,C三个节点的集群,在没有复制模型的情况下,如果节点B失败了,那么整个集群就会以为缺少5501-11000这个范围的槽而不可用。

然而如果在集群创建的时候(或者过一段时间)我们为每个节点添加一个从节点A1,B1,C1,那么整个集群便有三个master节点和三个slave节点组成,这样在节点B失败后,集群便会选举B1为新的主节点继续服务,整个集群便不会因为槽找不到而不可用了。

不过当B和B1 都失败后,集群是不可用的。

Redis 一致性保证

Redis 并不能保证数据的强一致性。这意味着在实际中集群在特定的条件下可能会丢失写操作。

第一个原因是因为集群是用了异步复制。写操作过程:

  1. 客户端向主节点B写入一条命令。
  2. 主节点B向客户端回复命令状态。
  3. 主节点将写操作复制给他的从节点 B1, B2 和 B3。

主节点对命令的复制工作发生在返回命令回复之后,因为如果每次处理命令请求都需要等待复制操作完成的话,那么主节点处理命令请求的速度将极大地降低。

节点迁移

Redis 迁移的单位是槽,Redis 一个槽一个槽进行迁移,当一个槽正在迁移时,这个槽就处于中间过渡状态。这个槽在原节点的状态为 migrating,在目标节点的状态为 importing,表示数据正在从源流向目标。

迁移工具 redis-trib 首先会在源和目标节点设置好中间过渡状态,然后一次性获取源节点的槽位的所有 key 列表(keysinslot 指令,可以部分获取),再挨个 key 进行迁移。每个 key 的迁移过程是以原节点作为目标节点的「客户端」,原节点对当前的 key 执行 dump 指令得到序列化内容,然后通过「客户端」向目标节点发送指令 restore 携带序列化的内容作为参数,目标节点再进行反序列化就可以将内容恢复到目标节点的内存中,然后返回「客户端」 OK,原节点「客户端」收到后再把当前节点的 key 删除掉就完成了单个 key 迁移的整个过程。

从源节点获取内容 => 存到目标节点 => 从源节点删除内容。

注意这里的迁移过程是同步的,在目标节点执行 restore 指令到原节点删除 key 之间,原节点的主线程会处于阻塞状态,直到 key 被成功删除。

如果迁移过程中突然出现网络故障,整个 slot 的迁移只进行了一半。这时两个节点依旧处于中间过渡状态。待下次迁移工具重新连上时,会提示用户继续进行迁移。

在迁移过程中,如果每个 key 的内容都很小,migrate 指令执行会很快,它就并不会影响客户端的正常访问。如果 key 的内容很大,因为 migrate 指令是阻塞指令会同时导致原节点和目标节点卡顿,影响集群的稳定性。所以在集群环境下业务逻辑要尽可能避免大 key 的产生。

在迁移过程中,客户端访问的流程会有很大的变化。

首先新旧两个节点对应的槽位都存在部分 key 数据。客户端先尝试访问旧节点,如果对应的数据还在旧节点里面,那么旧节点正常处理。如果对应的数据不在旧节点里面,那么有两种可能,要么该数据在新节点里,要么根本就不存在。旧节点不知道是哪种情况,所以它会向客户端返回一个-ASK targetNodeAddr 的重定向指令。客户端收到这个重定向指令后,先去目标节点执行一个不带任何参数的 asking 指令,然后在目标节点再重新执行原先的操作指令。

为什么需要执行一个不带参数的 asking 指令呢?

因为在迁移没有完成之前,按理说这个槽位还是不归新节点管理的,如果这个时候向目标节点发送该槽位的指令,节点是不认的,它会向客户端返回一个-MOVED 重定向指令告诉它去源节点去执行。如此就会形成重定向循环。asking 指令的目标就是打开目标节点的选项,告诉它下一条指令不能不理,而要当成自己的槽位来处理。

asking 指令和moved不一样,它是用来临时纠正槽位的。如果当前槽位正处于迁移中,指令会先被发送到槽位所在的旧节点,如果旧节点存在数据,那就直接返回结果了,如果不存在,那么它可能真的不存在也可能在迁移目标节点上。所以旧节点会通知客户端去新节点尝试一下拿数据,看看新节点有没有。这时候就会给客户端返回一个 asking error携带上目标节点的地址。客户端收到这个 asking error 后,就会去目标节点去尝试。客户端不会刷新槽位映射关系表,因为它只是临时纠正该指令的槽位信息,不影响后续指令。

moved 是用来纠正槽位的。如果我们将指令发送到了错误的节点,该节点发现对应的指令槽位不归自己管理,就会将目标节点的地址随同 moved 指令回复给客户端通知客户端去目标节点去访问。这个时候客户端就会刷新自己的槽位关系表,然后重试指令,后续所有打在该槽位的指令都会转到目标节点。

moved 和 asking 指令都是重试指令,客户端会因为这两个指令多重试一次。但是有想过会不会存在一种情况,客户端有可能重试2次呢?这种情况是存在的,比如一条指令被发送到错误的节点,这个节点会先给你一个 moved 错误告知你去另外一个节点重试。所以客户端就去另外一个节点重试了,结果刚好这个时候运维人员要对这个槽位进行迁移操作,于是给客户端回复了一个 asking 指令告知客户端去目标节点去重试指令。所以这里客户端重试了 2 次。

可能下线 (PFAIL-Possibly Fail) 与确定下线 (Fail)

因为 Redis Cluster 是去中心化的,一个节点认为某个节点失联了并不代表所有的节点都认为它失联了。所以集群还得经过一次协商的过程,只有当大多数节点都认定了某个节点失联了,集群才认为该节点需要进行主从切换来容错。

Redis 集群节点采用 Gossip 协议来广播自己的状态以及自己对整个集群认知的改变。比如一个节点发现某个节点失联了 (PFail),它会将这条信息向整个集群广播,其它节点也就可以收到这点失联信息。如果一个节点收到了某个节点失联的数量 (PFail Count) 已经达到了集群的大多数,就可以标记该节点为确定下线状态 (Fail),然后向整个集群广播,强迫其它节点也接收该节点已经下线的事实,并立即对该失联节点进行主从切换。

四、关于高可用存储架构的思考

架构的演进都是业务推动的,从上面redis的不同模式的存储架构我们可以看出,架构模式的演进本质是现有的模式不能满足日益发展的业务需求而做出的更新。为了避免单机宕机导致的服务不可用,引入了主从。为了实现主从自动切换,引入了哨兵作为协调者实现自动切换。

好在在现在分布式系统大行其道的时代,支持高可用已经是存储系统必备的能力了。

那实现存储高可用的本质是什么呢?

高可用的本质是通过数据冗余来实现的,也就是复制数据到不同的存储系统上来实现。

高可用带来的挑战有哪些呢?

我觉得实现高可用的主要的挑战是来自于多系统的协调,例如在多个服务间如何保证数据的一致性?如何判定节点是否可用?出现网络分区如何对外提供服务,也就是著名的CAP理论。

那么一个高可用的架构应该考虑哪些呢?

  1. 双机架构下:数据如何复制?节点的职责是什么?复制延迟和复制中断的情况如何处理?
  2. 集群架构下:均衡、容错、自动扩容如何做?

总结

Redis 提供了三种高可用方案:主从复制、哨兵模式和集群模式。根据不同的业务需求,可以选择合适的方案:

  • 主从复制:基本的数据冗余方案,提供读写分离
  • 哨兵模式:在主从基础上增加自动故障转移
  • 集群模式:实现数据分片,提供水平扩展能力

选择哪种方案需要根据业务的数据量、访问量、可用性要求等因素综合考虑。

参考资料

相关文章

Released under MIT License.