redis学习第七章
通过持久化功能,redis保证了即使在服务器重启的情况下也不会损失(或少量损失)数据。
但是由于数据是存储在一台服务器上的,如果这台服务器出现硬盘故障等问题,也会导致数据丢失。为了避免单点故障,通常的做法是将数据库复制多个副本以部署在不同的服务器上,这样即使有一台服务器出现故障,其他服务器已然可以继续提供服务。为此,redis提供了复制(replication)功能,可以实现当一台数据库中数据更新后,自动将更新的数据同步到其他数据库上。
1.1 配置
在复制的概念中,redis数据库分为两类,一类是主数据库(master),另一类是从数据库(slave),这里的数据库指的是redis服务器。主数据库可以进行读写操作,当写操作导致数据变化时,会自动将数据同步给从数据库。而从数据库一般是只读的,并接受主数据库同步过来的数据。一个主数据库可以拥有多个从数据库,而一个从数据库只能拥有一个主数据库。
在redis中使用复制功能非常容易,只需要在从数据库中配置文件中加入“slaveof 主数据库地址 主数据库端口” 即可,主数据库无需进行任何配置。
例如:打开redis-cli -p 6379 作为主数据库
//打开从数据库
redis-cli -p 6380 --slaveof 127.0.0.1 6379//作为6379服务器的从数据库
此时在主数据库中的任何数据变化都会自动地同步到从数据库。我们打开redis-cli实例A并连接到主数据库:
$redis -cli -p 6379
再打开redis-cli实例B并连接到从数据库:
redis-cli -p 6380
这时我们使用INFO命令来分别在实例A和实例B中获取Replication节的相关信息:
redis A > INFO replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6380,state=online,offset=1,lag=1
master_repl_offest:1
可以看到实例A的角色(role)是master,即主数据库,同时已连接的从数据库(connect_slaves)的个数为1
同样在实例B中获取相应的信息为:
redis B > INFO replication
role:slave
master_host:127.0.0.1
master_port:6379
这里可以看到实例B的role是slave,即从数据库,同时其主数据库的地址为127.0.0.1,端口为6379.
在实例A中使用SET命令设置一个键的值:
redis A > set foo bar
OK
此时在实例B中就可以获得该值了:
redis B > get foo
bar
默认情况下,从数据库是只读的,如果直接修改从数据库的数据会出现错误:
redis B > set foo hi
(error) …
不过可以设置从数据库的配置文件中的slave-read-only为no以使从数据库可写,但是因为对从数据库的任何更改都不会同步给任何其他数据库,并且一旦主数据库中更新了对应的数据就会覆盖从数据库中的改动,所以通常的场景下不应该设置从数据库可写,以免导致易被忽略的潜在的应用逻辑错误。
配置多台从数据库的方法也一样,在所有的从数据库的配置文件中都加上slaveof参数指向同一个主数据库即可。
除了通过配置文件或命令行参数设置slaveof参数,还可以在运行时使用SLAVEOF命令修改:
redis > slaveof 127.0.0.1 6379
如果该数据库已经是其他主数据库的从数据库了,SLAVEOF命令会停止和原来数据库的同步,转而和新数据库同步。此外对于从数据库来说,还可以使用slaveof no one 命令来使当前数据库停止接收其他数据库的同步并转换成为主数据库。
1.2 原理
当一个数据库启动后,会向主数据库发送SYNC命令。同时主数据库接收到SYNC命令后会开始在后台保存快照(即RDB持久化的过程),并将保存快照期间接收到的命令缓存起来。当快照完成后,redis会将快照文件和所有缓存的命令发送给从数据库。从数据库收到后会载入快照文件并执行收到的缓存的命令。以上过程称为复制初始化。复制初始化解释后,主数据库每当收到写命令时就会将命令同步给从数据库,从而保证主从数据库数据一致。
当主从数据库之间的连接断开重连后,redis2.6以及之前的版本会重新进行复制初始化(即住数据库重新保存快照并传送给从数据库)。即使从数据库可以仅有几条命令没有收到,主数据库也必须要将数据库里所有数据重新传送给从数据库。这使得主从数据库断线重连后的数据恢复过程效率低下,在网络环境不好的时候这一问题尤其明显。redis2.8版的一个重要改进就是短线重连能够支持有条件的增量数据传输,当从数据库重新连接上主数据库后,主数据库只需要将断线期间执行的命令传送给从数据库,从而大大提高redis复制的实用性。
由于redis服务器使用tcp协议通信,所以我们可以使用telnet工具伪装成一个从数据库来与主数据库通信。首先在命令行中连接主数据库:
$telnet 127.0.0.1 6379
Trying 127.0.0.1…
Connected to localhost.
Escape character is ‘^]’.
然后作为从数据库,我们先要发送PING命令确认主数据库是否可以连接:
PING
+PONG
主数据库会回复+PONG。如果没有收到主数据库的回复,则向用户提示错误。如果主数据库需要密码才能连接,我们还要发送AUTH命令进行验证。而后向主数据库发送REPLCONF命令说明自己的端口号:
REPLCONF listening-port 6381
这时就可以开始同步的过程了:向主数据库发送SYNC命令开始同步(redis2.8以后从数据库发送PSYNC来实现增量复制),此时主数据库发送快照文件 和缓存的命令。目前主数据库中只有一个foo键,所以收到的内容如下:
SYNC
$29
REDIS0006?foobar?6_?"
从数据库会将收到的内容写入到硬盘上的临时文件中,当写入完成后从数据库会用该临时文件替换RDB快找文件(RDB快照文件的位置就是持久化时配置的位置,由dir和dbfilename两个参数确定),之后的操作就和RDB持久化时启动回复的过程一样了。需要注意的是在同步的过程中数据库并不会阻塞,而是可以继续处理客户端发来的命令。默认情况下,从数据库会用同步前的数据对命令进行相应。可以配置slave-server-stale-data参数为no来使从数据库在同步完成前对所有命令(除了INFO和SLAVEOF)都回复错误:
“SYNC with master in progress”
复制初始化阶段结束后,从数据库执行的任何会导致数据变化的命令都会异步地传送给从数据库,这一过程为复制同步阶段。同步的内容和redis通讯协议一样,比如我们在主数据库中执行SET foo hi,通过telnet我们收到了:
*3
$3
set
$3
foo
$2
hi
复制同步阶段会贯穿整个主从同步过程的始终,直到主从关系终止为止。
在复制的过程中,快照无论在主数据库还是从数据库都起到了很大的作用,只要执行复制就会进行快照,即使我们关闭了RDB方式的持久化(通过删除所有save参数)。redus2.8.18之后支持了无硬盘复制。
注: 乐观复制 redis采用了乐观复制(oprimistic replication)的复制策略,容忍在一定时间内主从数据库的内容是不同的,但是两者的数据会最终同步。具体来说,redis在主从数据库之间复制数据的过程本身是异步的,这意味着,主数据库执行完客户端请求的命令后会立即将命令的主数据库的执行结果返回给客户端,并异步地将命令同步给从数据库,而不会等待从数据库执行完客户端请求的命令后再返回给客户端。这一特性保证了启用复制后主数据库的性能不会受到影响,但另一方面也会产生一个主从数据库数据不一致的时间窗口,当住数据库执行了一条写命令后,主数据库的数据已经发生了变动,然而在主数据库将该命令传送给从数据库之前,如果两个数据库之间的网络连接断开了,此时二者之间的数据库就会是不一致的,从这个角度看,主数据库是无法得知某个命令最终同步给了多少个从数据库的,不过redis提供了两个配置选项来限制当数据至少同步给指定数量的从数据库时,主数据库才是可写的:
min-slaves-to-write 3
min-slaves-max-lag 10
上面的配置中,min-slaves-to-write表示只有当3个或3个以上的从数据库连接到主数据库时,主数据库才是可写的,否则会返回错误。
min-slaves-max-lag 表示允许从数据库最长失去连接的时间,如果从数据库最后与主数据库联系(即发送REPLICONF ACK命令)的时间小于这个值,则认为从数据库还在保持与主数据库的连接。例如,按照上面的配置,主数据库假设与3个从数据库相连,其中一个从数据库上一次与主数据库的联系是9秒前,这时主数据库可以正常接受写入,一旦一秒过后这台从数据依旧没有活动,则主数据库则认为目前连接的从数据库只有2个,从而拒绝写入。这一特性默认是关闭的,在分布式系统中,打开并合理配置该选项后可以降低主从架构中因为网络分区导致的数据不一致的问题。
1.3 图结构
从数据库不仅可以接收主数据库的同步数据,自己也可以同时作为主数据库存在,形成类似树结构,主节点为主数据库,下面有两个从数据库,从数据库下面还可以有从数据库。主数据库的数据同步到子节点的从数据库中,子节点的从数据库再同步到自己的子节点从数据库中,但是向中间层的从数据库中写入数据,不会同步到该从数据库的父节点和兄弟节点上,只会同步到该节点的子节点上。
1.4 读写分离与一致性
通过复制可以实现读写分离,以提高服务器的负载能力。在常见的场景中(如电子商务网站),读的频率大于写,当单机的redis无法应对大量的读请求时(尤其是叫耗资源的请求,如POST请求,如SORT命令等)可以通过复制功能建立多个数据库节点,主数据库只进行写操作,而从数据库复制读操作。这种一主多从的结构很适合读多写少的场景,而当单个的主数据库不能满足需求时,就需要使用redis3.0推出的集群功能。
1.5 从数据库持久化
另一个相对耗时的操作是持久化,为了提高性能,可以通过复制功能建立一个(或若干个)从数据库,并在从数据库中启用持久化,同时在主数据库禁用持久化。当从数据库奔溃重启后主数据库会自动将数据同步过来,所以无需担心数据丢失。
然而当主数据库崩溃时,情况就稍显复杂了。手动通过从数据库数据恢复主数据库数据时,需要严格按照以下两步进行:
1).在从数据库中使用SLAVEOF NO ONE 命令将从数据库提升成主数据库继续服务。
2).启动之前崩溃的主数据库,然后使用SLAVEOF命令将其设置成新的主数据库的从数据库,即可将数据同步回来。
注意: 当开启复制且主数据库关闭持久化功能时,一定不要使用Supervisor以及类似的进程管理工具令主数据库奔溃后自动重启。同样当主数据库所在服务器因故关闭时,也要避免直接重启。这是因为当主数据库重启后,因为没有开启持久化功能,所以数据库中所有数据都被清空,这时,从数据库依然会从主数据库中接收数据,使得所有的从数据库也被清空,导致从数据库的持久化失去意义。
但无论哪种情况手动重启以及数据恢复都是比较麻烦的,好在redis提供了一种自动化方案–哨兵,来实现这一过程。
1.6 无硬盘复制
redis复制的工作原理是基于RDB方式的持久化实现的,即主数据库端在后台保存RDB快照,从数据库端则接受并载入快照文件。这样的实现有点是可以显著地简化逻辑,复用已有代码,但是缺点也很明显。
1). 当主数据库禁用RDB快照时(即删除了所有的配置文件中的save语句),如果执行了复制初始化操作,rfedis依然会生成RDB快照,所以下次启动后主数据库会以该快照回复数据。因为复制发生的时间不能确定,这使得恢复的数据可能是任何时间点的。
2).因为复制初始化时需要在硬盘中创建RDB快照文件,所以硬盘性能很慢(如网络硬盘)时这一过程会对性能产生影响。举例来说,当使用redis作为缓存系统时,因为不需要持久化,所以服务器的硬盘读写速度可能较差,但是当该缓存系统使用一主多从的集群架构时,每次和从服务器同步,redis都会执行一次快照,同时对硬盘进行读写,导致性能降低。
因此从2.8.18版本开始,redis引入了无硬盘复制选项,开启该选项时redis在与从数据库进行复制初始化时将不会将快照内容存储到硬盘上,而是直接通过网络发送给数据库,避免了硬盘的性能瓶颈。
可以在配置文件中使用如下选项来开启该功能:
repl-diskless-sync yes
1.7 增量复制
在Redis2.8版实现了主从断线重连情况下的增量复制。
增量复制是基于以下3点实现的:
1). 从数据库会存储主数据库的运行ID(run id)。每个redis运行实例均会拥有一个唯一的运行ID,每当实例重启后,就会自动生成一个新的运行ID;
2). 在复制同步阶段,主数据库每将一个命令传送给从数据库时,都会同时把改名了存放到一个积压队列(backlog)中,并记录下当前积压队列中存放的命令的偏移范围;
3). 同时,从数据库接收到主数据库传来的命令时,会记录下该命令的偏移量。
这3点是实现增量复制的基础。当主从连接准备就绪后,从数据库会发送SYNC命令来告诉主数据库可以开始把所有数据同步过来了。而2.8版本后,不在发送SYNC命令取而代之的是PSYNC,格式为“PSYNC 主数据库的运行ID断开前最新的命令偏移量”。主数据库接收到PSYNC命令后,会执行一下判断来决定此次重连是否可以执行增量复制。
1).首先主数据库会判断从数据库传送来的运行ID是否和自己的运行ID相同。这一步骤的意义在于确保从数据库之前确实和自己同步的,以免从数据库拿到错误的数据(比如主数据库在断线期间重启过,会造成数据不一致);
2).然后判断从数据库最后同步成功的命令偏移量是否在挤压队列中,如果在则可以执行增量复制,并将挤压队列中相应的命令发送给从数据库。
如果此次重连不满足增量复制的条件,主数据库会进行一次全部同步(即redis2.6的过程一样)。
大部分情况下,增量复制的过程对开发者来说是完全透明的,开发者不需要关心增量复制的具体细节。2.8版本的主数据库也可以正常地和旧版本的从数据库同步(通过接受SYNC命令),同样2.8版本的从数据库也可以与旧版本的主数据库同步(通过发送SYNC命令),唯一需要开发者设置的就是积压队列的大小了。
挤压队列的本质是一个固定长度的循环队列,默认情况下纪要队列的大小为1MB,可以通过配置文件的repl-backlog-size选项来调整。很容易理解的是,积压队列越大,其允许的主从数据库断线的时间就越长。根据主从数据库之间的网络状态,设置一个合理的挤压队列很重要。因为积压队列存储的内容是命令本身,如SET foo bar,所以估算积压队列的大小只需要估计主从数据库断线的时间中主数据库可能执行的命令的大小即可。
与积压队列相关的另一个配置选项时repl-bakclog-ttl,即当所有从数据库与主数据库断开连接后,经过多久时间可以释放挤压队列的内存空间。默认为1小时。
在2.8版本中提供了哨兵工具来实现自动化的系统监控和故障恢复功能。
注意 redis2.6版也挺贵了哨兵工具,但此时的哨兵是1.0版本,存在非常多的问题,在任何情况下都不应该使用这个版本的哨兵。
2.1 什么是哨兵
哨兵的作用就是监控redis系统的运行状态,它的功能包括以下两个:
1). 监控主数据库和从数据库是否运行正常;
2). 主数据库出现故障时自动将从数据库转换为主数据库。
哨兵是一个独立的进程。在一个一主多从的redis系统中,可以使用多个哨兵进行监控任务以保证系统足够稳健,注意,此时哨兵不仅会监控主数据库和从数据库,哨兵之间也会相互监控。
2.2 使用哨兵
首先建立三个redis实例进行1主2从配置(使用INFO replication来查看是否配置成功)。
接下来配置哨兵。建立一个配置文件,如sentinel.conf,内容为:
sentinel monitor mymaster 127.0.0.1 6379 1
其中mymaster表示要监控的主数据库的名字,可以自己定义一个。这个名字必须仅由大小写字母、数字和“.-_”这三个字符组成。后两个参数表示主数据库的地址和端口号,这里我们要监控的是主数据库6379。最后的1表示最低通过票数,接下来执启动Sentinel进程,并将上述配置文件的路径传递给哨兵:
$redis-sentinel /path/to/sentinel.conf
需要注意的是,配置哨兵监控一个系统时,只需要配置其监控的主数据库即可,哨兵会自动发现所有复制该主数据库的从数据库。
启用哨兵后,哨兵输出如下内容:
[71835] 19 Feb 22:32:28.730 # Sebtinel runid is
…
其中+slave表示新发现了从数据库,可见哨兵成功地发现了两个从数据库。现在哨兵已经在监控这3个redis实例了,这时,我们将主数据关闭(杀死进程或使用SHUTDOWN命令),等待指定时间后(可以配置,默认30s),哨兵会输出以下内容:
[71835] 19 Feb 22:32:28.730 # +sdown…
[71835] 19 Feb 22:32:28.730 # odown …
其中+sdown表示哨兵主观认为主数据库停止服务了,而+odown表示哨兵客观认为主数据库停止服务了。此时哨兵开始执行故障恢复,即挑选一个从数据库,将其升格为主数据库。同时输出以下内容:
[71835] 19 Feb 22:32:28.730 # +try-failover…
[71835] 19 Feb 22:32:28.730 # +failover-end…
[71835] 19 Feb 22:32:28.730 # +switch-matser …
[71835] 19 Feb 22:32:28.730 # +slave …
[71835] 19 Feb 22:32:28.730 # +slave…
其中+try-failover表示哨兵开始进行故障恢复,+failover-end表示哨兵完成故障恢复,期间涉及的内容比较复杂,包括领头哨兵的选举、备选从数据库的选择等,此处只需要关注最后3条输出。+switch-master表示主数据库从6379端口迁移到6380端口,即6380端口的从数据库被升格为主数据库,同时两个+slave则列出了新的主数据库的两个从数据库,端口分别为6380和6379.其中6379就是之前停止服务的主数据库,可见哨兵并没有彻底清除停止服务的实例的信息,这是因为停止服务的实例有可能会在之后的某个时间恢复服务,这时哨兵会让其重新加入进来,所以当实例停止服务后,哨兵会更新该实例的信息,使得当其重新加入后可以按照当前信息继续对外提供服务。此例中6379端口的主数据库实例停止服务了,而6380端口的从数据库已经升格为主数据库,当6379端口的实例回复服务后,会转变为6380端口的实例(当前主数据库)的从数据库来运行,所以哨兵将6379端口实例的信息修改成了6380端口实例的从数据库。
那么此时我们将6379端口上的实例重新启动,看到哨兵监控到的变化为:
[71835] 19 Feb 22:32:28.730 # -sdown slave…
[71835] 19 Feb 22:32:28.730 # +convert-to-slave slave…
-sdown表示实例6379回复服务(与+sdown相反)同时+convert-to-slave表示将6379端口的实例设置为6380端口实例的从数据库。
2.3 实现原理
一个哨兵进程启动时会读取配置文件的内容,通过如下的配置找出需要监控的主数据库:
sentinel monitor master-name ip redis-port quorun
其中master-name,这个名字必须仅由大小写字母、数字和“.-_”这三个字符组成的主数据库名字,因为考虑到故障恢复后当前监控的系统的主数据库的地址和端口会产生变化,所以哨兵提供了命令可以通过主数据库的名字获取当前系统的主数据库的地址和端口号。
ip表示当前系统中主数据库的地址,而redis-port则表示端口号。
quorun 同来表示执行故障恢复操作前至少需要几个哨兵节点的同意。
一个哨兵节点可以同时监控多个redis主从系统,需要提供多个sentinel monitor 配置即可:
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel monitor mymaster 127.0.0.1 6379 4
同时多个哨兵节点也可以同时监控同一个redis主从系统,从而形成网状结构。
配置文件中还可以定义其他监控相关参数,每个配置选项都包含主数据库的名字使得监控不同主数据库时可以使用不同的配置参数:
sentinel down-after-milliseconds mymaster 60000
sentinel down-after-milliseconds othermaster 10000
上面两行配置分别配置了mymaster和othermaster的down-after-milliseconds选项分别为 60000 和 10000.
哨兵启动后,会与要监控的主数据库建立两条连接,这两个连接的建立方式与普通的redis客户端无异。其中一条连接用来订阅该主数据库的__sentinel__:hello频道以获取其他同样监控该数据库的哨兵节点的信息,另外哨兵也需要定期向主数据库发送INFO等命令来获取主数据库本身的信息,,由于当客户端的连接进入订阅模式时就不能再执行其他命令了,所以这时哨兵会使用另外一条连接来发送这些命令。
和主数据库的连接建立完成后,哨兵会定时执行下面3个操作。
1).每10s哨兵会向主数据库和从数据库发送INFO命令
2).每2秒哨兵会向主数据库和从数据库的__sentinel__:hello频道发送自己的信息
3).每1秒哨兵会向主数据库、从数据库和其他哨兵发送PING命令
这3个操作贯穿哨兵进程的整个生命周期中,非常重要,可以说了解了这3个操作的意义就能够了解哨兵工作的一般内容了。
首先发送INFO命令使得哨兵可以获得当前数据库的相关信息(包括运行ID、复制信息等)从而实现新节点的自动发现。前面说配置哨兵监控redis主从系统时只需要指定主数据库的信息即可,因为哨兵正是借助INFO命令来获取所有复制该主数据库的从数据库列表,而后对每个从数据库同样建立两条连接,两个连接的作用和前文介绍的与主数据库建立的两个连接完全一致。在此之后,哨兵会每10秒定时向一致的所有主从数据库发送INFO命令来获取信息更新并进行相应操作,比如对新增的从数据库建立连接并加入监控列表,对主从数据库的角色变化(由故障恢复操作引起)进行信息更新等。
接下来哨兵向主从数据库的__sentinel__:hello频道发送信息来与同样监控该数据库的哨兵分享自己的信息。发送的消息内容为:
<哨兵的地址>, <哨兵的端口>, <哨兵的运行ID>, <哨兵的配置版本>, <主数据库的名字>, <主数据库的地址>, <主数据库的端口>, <主数据库的配置版本>
可以看到消息包括的哨兵的基本信息,以及其监控的主数据库的信息。哨兵会订阅每个其监控的数据库的__sentinel__:hello频道,所以当其他哨兵收到消息后,会判断发送的哨兵是不是新发现的哨兵。如果是则将其加入一发现的哨兵列表中并创建一个到其的连接(与数据库不同,哨兵与哨兵之间只会创建一条连接来发送PING命令,而不是另外创建一条连接来订阅频道,因为哨兵只需要订阅数据库的频道即可自动发现其他哨兵)。同时哨兵会判断信息中主数据库的配置版本,如果该版本比当前记录的主数据库的版本高,则更新主数据库的数据。
实现了自动发现从数据库和其他哨兵后,哨兵要做的就是定时监控这些数据库和节点有没有停止服务。这是通过每隔一定时间向这些节点发送PING命令实现的。时间间隔与down-after-milliseconds选项有关,当down-after-milliseconds的值小于1s时,哨兵会每个down-after-milliseconds指定的时间发送一次PING命令,如果down-after-milliseconds大于1秒时,哨兵会每个1s发送1次PING命令。
sentinel down-after-milliseconds mymaster 60000 //1s
sentinel down-after-milliseconds othermaster 600 //600ms
当超过down-after-milliseconds选项指定时间后,如果被PING的数据库或节点仍未回复,则哨兵认为其直观下线(subjectively down)。主观下线表示从当前的哨兵进程看来,该节点已经下线。如果该节点是主数据库,则哨兵会进一步判断是否需要对其进行故障恢复:哨兵发送SENTINEL is-master-down-by-addr命令询问其他哨兵节点以了解他们是否也认为该主数据库主观下线,如果达到指定数量时,哨兵会认为其客观下线(objectively down),并选举领头的哨兵节点对主从系统发起故障恢复,但是故障恢复需要由领头哨兵来完成,这样可以保证同一是键只有一个哨兵节点来执行故障恢复。选举领头哨兵的过程使用了Raft算法:
1).发现主数据库客观下线的哨兵节点(下面称作A)向每个哨兵节点发送命令,要求对方选自己为领头哨兵。
2).如果目标哨兵节点没有选过其他人,则会同意将A设置成领头哨兵
3).如果A发现有超过半数且超过quorum参数值的哨兵节点同意选自己成为领头哨兵,则A成功成为领头哨兵
4).当有多个哨兵节点同时参选领头哨兵,则会出现没有任何节点当选的可能。此时每个参选节点将等待一个随机时间重新发起参选请求,进行下一轮选举,直到选举成功。
具体过程可以参考Raft算法的过程http://raftconsensus.github.io/。因为要成为领头哨兵必须有超过一半数量的哨兵节点支持,所以每次选举最多只会选出一个领头哨兵。
选出领头哨兵后,领头哨兵将会开始对主数据库进行故障恢复。故障恢复的过程如下:
1).所有在线的从数据库中,选择优先级最高的从数据库。优先级可以通过slave-priority选项来设置
2).如果有多个最高优先级的从数据库,则复制的命令偏移量越大(即复制越完整)越优先
3).如果以上条件都一样,则选择运行ID较小的从数据库
选出一个从数据库后,领头哨兵将向从数据库发送slaveif 命令来使其成为新主数据库的从数据库。最后一步则是更新内部的记录,将已经停止服务的旧的主数据库更新为新的主数据库的从数据库,使得当其回复服务时自动以从数据库的身份继续服务。
2.4 哨兵的部署
哨兵以独立进程的方式对一个主从系统进行监控,监控的效果好坏与否取决于哨兵的视角是否有代表性。如果一个主从系统的配置的哨兵较少,哨兵对整个系统的判断的可靠性就会降低。极端情况下,当只有一个哨兵时,哨兵本身就可能会发生单点故障。整体来讲,相对稳妥的哨兵部署方案是使得哨兵的视角尽可能地与每个节点的视角一致,即:
1).为每个节点(无论是主数据库还是从数据库)部署一个哨兵
2).使得每个哨兵与其对应的节点的网络环境相同或相近
这样的部署可以保证哨兵的视角拥有较高的代表性和可靠性。比如:当网络分区后,如果哨兵认为某个分区是主分区,即以为这从每个节点观察,该分区均为主分区。
同时设置quorum的值为N/2 + 1(其中N为哨兵节点的数量),这样使得只有当大部分哨兵节点同意后才会进行故障恢复。
当系统中的节点较多时,考虑到每个哨兵都会和系统中的所有节点建立连接,为每个节点分配一个哨兵会产生较多的连接,尤其是当进行客户端分片时使用多个哨兵节点监控多个主数据库会因为redis不支持连接复用而产生大量冗余连接,具体可见此 issue:http://github.com/antirez/redis/issues/2257。同时如果redis节点负载较高,会在一定程度上影响其对哨兵的回复以及与其同机的哨兵与其他节点的通信,所以配置哨兵时还需要根据实际生产情况配置。
即使使用哨兵,此时的redis集群的每个数据库依然存有集群中的所有数据,从而导致集群的总数据存储量受限于可用存储内存最小的数据库节点,形成木桶效应。由于redis中的所有数据都是基于内存存储,这一问题尤为突出了,尤其是当使用redis作为持久化存储服务使用时。
对redis进行水平扩容,在旧版redis中通常使用客户端分片来解决这个问题,即启用多个redis数据库节点,由客户端绝对每个键交由哪个数据库节点存储,下次客户端读取多个节点只存放总数据了的1/N。但对于需要扩容的场景来说,在客户端分片后如果想增加更多的节点,就需要对数据进行手工迁移,同时在迁移的过程中为了保证数据的一致性,还需要将集群暂时下线,相对比较复杂。
考虑到redis实例非常轻量的特点,可以采用预分片技术(presharding)来在一定程度上避免此问题,具体来说实在节点部署的初期,就提前考虑日后的存储规模,建立足够多的实例(如128节点),初期时数据很少,所以每个节点存储的数据也非常少,但由于节点轻量的特性,数据之外的内存开销并不大,这使得只需要很少的服务器即可运行这些实例。日后存储规模扩大后,索要做的不过是将某些实例迁移到其他服务器上,而不需要对所有数据进行重新分片并进行集群下线和数据迁移。
无论如何,客户端分片终归是有非常多的缺点,比如维护成本高,添加、移除节点比较繁琐等。redis3.0版本的一大特性就是支持集群(Cluster)功能。集群的特点在于用于和单机实例同样的性能,同时在网络分区后能够提供一定的可访问性以及对数据库故障恢复的支持。另外集群支持几乎所有的单机实例支持的命令,对于涉及多键的命令(如MGET),如果每个键都位于同一个节点中,则可以正常支持,否则会提示错误。除此之外集群还有一个限制是只能适用于默认的0号数据库,如果执行SELECT切换数据库则会提示错误。
哨兵和集群是两个独立的功能,但从特性来看哨兵可以视为集群的子集,当不需要数据分片或者已经在客户端进行分片的场景下哨兵就足够使用了,但如果需要进行水平扩容,则集群是一个非常好的选择。
3.1 配置集群
使用集群,只需要将每个数据库节点的cluster-enabled配置选项打开即可。每个集群中至少需要3个主数据库才能正常运行。
为了演示集群的应用场景以及故障恢复等操作,这里以配置一个3主3从的集群系统为例。首先建立启动6个redis实例,需要注意的是配置文件中应该打开cluster-enabled。如:
port 6380
cluster-enabled yes
其中port参数修改成实际的端口即可。这里假设6个实例的端口分别是6380、6381、6382、6383、6384、6385.集群会将当前节点记录的集群状态持久化地存储在指定文件中,这个文件默认为当前工作目录下的nodes.conf文件。每个节点对应的文件必须不同,否则会造成启动失败,所以启动节点时要注意最后为每个节点使用不同的工作目录。或者通过cluster-config-file选项修改持久化文件:
cluster-config-file nodes.conf
每个节点启动后都会输出类似下面的内容:
no cluster configuration found,I’m 节点运行ID
节点运行ID是节点在集群中的唯一标识,该运行ID是随机生成的。
启动后,可以使用redis命令行客户端连接任意一个节点使用INFO命令来判断集群是否正常启用了:
redis > info cluster
# Cluster
cluster_enabled:1
其中cluster_enabled为1 标识集群正常启用了。现在每个节点都是完全独立的,要将它们加入同一个集群里还需要几个步骤。
redis源代码中提供叻一个辅助工具redis-trib.rb可以非常方便地完成这一任务。因为redis-trib.rb使用Ruby语言编写的,所以运行前需要在服务器上安装Ruby程序。redis-trib.rb依赖于gem包redis,可以直接执行gem install redis来安装。
使用redis-trib.rb初始化集群,只需要执行:
$/path/to/redis-trib.rb crete --replicas 1 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:638 127.0.0.1:6385
其中create表示要初始化集群,–replicas 1表示每个主数据库拥有的从数据库个数为1,所以整个集群公有3(6/2)个主数据库以及3个从数据库。
执行完成后,redis-trib.rb会输出一些执行内容,以及集群具体的分配方案,如果觉得没问题输入yes来开始创建。
首先redis-trib.rb会以客户端的形式尝试连接所有的节点,并发送PING命令以确定节点能够正常服务。如果任何节点无法连接,则创建失败。同时发送INFO命令获取每个节点的运行ID以及是否开启了集群功能(即cluster_enabled为1)。
准备就绪后集群会向每个节点发送CLUSTER MEET命令,格式为CLUSTER MEET ip port,这个命令用来告诉当前节点指定IP和port上在运行的节点也是集群的一部分,从而使得6个节点最终可以归入一个集群。
然后redis-trib.rb会分配主从数据库节点,分配的原则是尽量保证每个主数据库运行在不同的IP地址上,同时每个从数据库和主数据库均不运行在用一Ip地址上,以保证系统的灾容能力,分配结果如下:
Using 3 masters:
127.0.0.1:6380
127.0.0.1:6381
127.0.0.1:6382
Adding replica 127.0.0.1:6383 to 127.0.0.1:6380
Adding replica 127.0.0.1:6384 to 127.0.0.1:6381
Adding replica 127.0.0.1:6385 to 127.0.0.1:6382
其中主数据库是6380、6381和6382端口上的节点(以下使用端口号来代替节点)。6383是6380的从数据库,6384是6381的从数据库,6385是6382的从数据库。
分配完成后,会为每个主数据库分配插槽,分配插槽的过程其实就是分配哪些键由哪些节点负责。之后对每个要成为子数据库的节点发送CLUSTER REPLICATE 主数据库的运行ID 来将当前节点转换成从数据库并复制指定运行ID的节点(主数据库)。
此时整个集群的过程即创建完成,使用redis命令行客户端连接任意一个节点执行CLUSTER NODES可以获得集群中所有节点信息,如在6380执行:
redis 6380 > CLUSTER NODES
节点ID 127.0.0.1:6385 slave
节点ID 0 时间戳 6 connected
节点ID 127.0.0.1:6383 slave
节点ID 127.0.0.1:6381 master - 0 时间戳 2 connected 5461-10922
节点ID 127.0.0.1:6380 myself,master - 0 0 1 connected 0-5460
…
redis-trib.rb是一个非常好用的辅助工具,其本质是通过执行redis命令来实现集群管理的任务。
3.2 节点的增加
如果想要向集群中加入新的节点,需要使用CLUSTER MEET命令实现。加入新节点非常简单,只需要向新节点(记做A)发送如下命令即可:
CLUSTER MEET ip port
ip和port是集群中任意一个节点的地址和端口号,A接收到客户端发来的命令后,会与该地址和端口号的节点B进行握手,使B将A认作当前集群中的一员。当B与A握手成功后,B会使用Gossip协议(是分布式系统中常用的一种通信协议)将节点A的信息通知给集群中的每一个节点。通过这一方式,即使集群中有多个节点,也只需要选择MEET其中任意一个节点,即可使新节点最终会加入整个集群中。
3.3 插槽的分配
新的节点加入集群后有两种选择,要么使用CLUSTER REPLICATE命令复制每个主数据库来以从数据库的形式运行,要么向集群申请分配插槽(slot)来以主数据库的形式运行。
在一个集群中,所有的键会被分配给16384个插槽,而每个主数据库会负责处理其中的一部分插槽。这时再看在创建redis集群时的输出:
M:节点ID 127.0.0.1:6380 slots:0-5460(5461 slots)master
M:节点ID 127.0.0.1:6381 slots:5461-10922(5462 slots)master
M:节点ID 127.0.0.1:6382 slots:10933-16383(5461 slots)master
上面的每一行表示一个主数据库的信息,其中可以看到6380负责处理0-5460这5461个插槽,6381复制处理5461-10922这5462个插槽,依次类推。虽然redis-trib.rb出书画集群时分配给每个节点的插槽都是连续的,但是实际上redis并没有此限制,可以将任意的几个插槽分配给任意的节点负责。
redis将每个键的键名的有效部分使用CRC16算法计算出散列值,然后取对16384的余数。这样使得每个键都可以分配到16384个插槽中,进而分配的指定的一个节点中处理。这里的键名的有效部分是指:
1).如果键名包含{符号,且在{符号后面存在}符号,并且{和}之间至少有一个字符,则有小部分是指{和}之间的内容;
2).如果不满足上一条规则,那么整个键名为有效部分。
如:键hello.world的有小部分为“hello.world”,键{user102}:last.name的有效部分为user102。如果命令涉及多个键(如MGET),只有当所有键都位于同一个节点时redis才能正常支持。利用键的分配规则,可以将所有相关的键的有效部分设置成同样的值使得县关键都能分配到同一个节点以支持多键操作。如,{user102}:first.name和{user102}:last.name来同时获取两个键的值。这是键与插槽的对应关系。
插槽的分配分为如下几种情况:
1).插槽之前没有被分配过,现在想分配给指定节点
2).插槽之前被分配过,现在想移动到指定节点
其中第一种情况使用CLUSTER ADD SLOT S命令来实现,redis-trib.rb也是通过该命令在创建集群时为新节点分配插槽的。该命令的用法为:
CLUSTER ADDSLOTS slot1 [slot2] … [slotN]
如想将100和101两个插槽分配给某个节点,只需要在该节点执行:CLUSTER ADDSLOTS 100 101即可。如果指定插槽以及分配过了,则会提示:
(error) ERR slot 100 is already busy
可以通过命令CLUSTER SLOTS 来查看插槽的分配情况,如:
redis 6380 > CLUSTER SLOTS
其中返回的内容一个三条记录,每条记录的前两个值标识插槽的开始号码和结束号码,后面的值则负责该插槽的节点,包括主数据库和所有的从数据库,主数据库始终排在第一位。
对于情况2,处理起来就相对复杂一些,不过redis-trib.rb提供来比较方便的方式来对插槽进行迁移。我们首先使用redis-trib.rb将一个插槽从6380迁移到6381的过程:
$/path/to/redis-trib.rb reshard 127.0.0.1:6380
其中reshard表示告诉redis-trib.rb要重新分片,127.0.0.1:6380是集群中的任一节点的地址和端口,redis-trib.rb会自动获取集群信息,接下来,redis-trib.rb将会询问具体如何进行重新分片,首先会询问想要迁移多少个插槽:
how many slots do you want to move (from 1 to 16384)?
我们只需要迁移一个,所以输入1回车。接下来redis-trib.rb会询问要把插槽迁移到那个节点:
what is the receiving node Id?
可以通过CLUSTER NODES命令获取6381的运行ID。输入并回车。接着最后一步是询问从那个节点移除插槽,我们输入6380对应的运行ID按回车后输入done,再按回车确认即可。
接下来输入yes来确认重新分片方案,重新分片即告成功。
这时候输入CLUSTER SLOTS命令获取当前插槽可以看到多出了一条记录,当前的0号插槽已经由6381负责,此时重新分片成功。
手工进行分片,使用如下命令:
CLUSTER SETSLOT 插槽号 NODE 新节点的运行ID
如果想把0号插槽迁回6380:
redis 6381 > CLUSTER SETSLOT 0 NODE 6380节点的运行ID
OK
此时重新使用CLUSTER SLOTS查看插槽的分配情况,可以看到以及恢复如初了。然而这样迁移插槽的前提是插槽中并没有任何键,因为使用CLUSTER SETLOT命令迁移插槽时并不会连同相应的键一起迁移,这就造成了客户端在指定节点无法找到未迁移的键,造成这些键对客户端来说“丢失了”。为此需要手工获取插槽中存在哪些键,然后将每个键迁移到新的节点中才行。
手工获取某个插槽存在哪些键的方法是:
CLUSTER GETKEYSINSLOT 插槽号 要返回的键的数量
之后对每个键,使用MIGRATE命令将其迁移到目标节点:
MIGRATE 目标节点地址 目标节点端口 键名 数据库号码 超时时间 [COPY] [REPLACE]
其中COPY选项表示不将键从当前数据库中删除,而是复制一份副本。REPLACE表示如果目标节点存在同名键,则覆盖。因为集群模式只能使用0号数据库,所以数据库号码始终为0。如要把键abc从当前节点(如6381)迁移到6380:
redis 6381 > MIGRATE 127.0.0.1 6380 abc 0 15999 REPLACE
至此,我们已经知道如何将插槽委派给其他节点,并同时将当前节点中插槽下所有键迁移到目标节点中,然而当要迁移的数据量比较大的时候,整个迁移的过程会花费比较长的时间,那么究竟在什么时候执行CLUSTER SETSLOT命令来完成插槽的交接呢?如果在键迁移未完成时执行,那么客户端就会尝试在新的节点读取键值,此时还没迁移完成,自然有可能读不到键值,从而造成相关键的临时丢失。相反,如果键迁移完成后在执行,那么在迁移时客户端会在就的节点读取键值,然后有些键值已经迁移到新的节点上了,同样会造成键的临时丢失。那么redis-trib.rb工具是如何解决这个问题的?
redis提供了如下两个命令同来实现在集群不下线的情况下迁移数据:
CLUSTER SETSLOT 插槽号 MIGRATING 新节点的运行ID
CLUSTER SETSLOT 插槽号 IMPORTING 源节点的运行ID
进行迁移时,假设要把0号插槽从A迁移到B,此时redis-trib.rb会一次执行如下操作:
1).在B执行CLUSTER SETSLOT 0 IMPORTING A
2).在A执行CLUSTER SETSLOT 0 MIGRATING B
3).执行CLUSTER GETKEYSINSLOT 0 获取0号插槽的键列表
4).对第3步获取的每个键执行MIGRATE命令,将其从A迁移到B
5).执行CLUSTER SETSLOT 0 NODE B 来完成迁移
从上面的步骤来看redis-trib.rb多了1和2的两个步骤,这两个步骤就是为了解决迁移过程键的临时丢失的问题。首先执行完前两步后,当客户端向A请求插槽0中的键时,如果键从在(即尚未被迁移),则正常处理,如果间不存在,则返回一个ASK跳转请求,告诉客户端这个键在B里。客户端接收到ASK跳转请求后,首先向B发送ASKING命令,然后在重新发送之前的命令。相反,当客户端向B请求插槽0中的键时,如果前面执行了ASKING命令,则返回键的内容,否则返回MOVED跳转请求。这样一来客户端只有能够处理ASK跳转,则可以在数据库迁移时自动从正确的节点获取到相应的键值避免了键在迁移过程中临时丢失的问题。
3.4 获取与插槽对应的节点
对于指定的键,可以根据前文所述的算法来计算其属于哪个插槽,但是如何获取某一个键由哪个节点负责的呢?
实际上,当客户端向集群中的任意一个节点发送命令后,该节点会判断相应的键是否在当前节点上,如果键在该节点中,则会向单机实例一样正常处理改名了;如果键不在该节点中就会返回一个MOVE重定向请求,告诉客户端这个键目前是由哪个节点负责的,然后客户端再将同样的请求向目标节点重新发送一次以获得结果。
一些语言的redis库支持代理MOVE请求,所以对于开发者而言命令重定向是透明的,使用集群与使用单机实例并没有不同,然后也有些语言的redis库并不支持集群,这时就需要在客户端编码处理。
还是上面的集群配置为例,键foo实际应该由6382节点负责,如果尝试在6380节点执行与键foo相关的命令,如:
redis 6380 > set foo bar
(error) MOVED 12182 127.0.0.1:6382
返回的是一个MOVE重定向请求,12182表示foo所属的插槽号,127.0.0.1:6382则负责该插槽的节点地址和端口,客户端收到重定向请求后,应该将命令重新想6382节点发送一个:
redis 6382 > SET foo bar
OK
redis命令行客户端提供了集群模式来支持自动重定向,使用-c参数来启用:
$redis-cli -c -p 6380
redis 6380 > SET foo bar
-> Redirected to slot [12182] located at 127.0.0.1:6382
OK
可见加入了-c参数后,如果当前节点并不负责处理的键,redis命令行客户端会进行自动命令重定向。而这一过程正式每个支持集群的客户端应该实现的。
然而相比单机实例,集群的命令重定向也增加了命令的请求次数,原先只需要执行一次的命令现在有可能需要依次发向两个节点,算上往返延时,可以说请求重定向对性能还是有些影响的。
为了解决这一问题,当发现新的重定向请求时,客户端应该在重新向正确节点发送命令的同时,缓存插槽的路由信息,即记录下当前插槽是由哪个节点负责的。这样每次发起命令时,客户端首先计算相关键时属于哪个插槽的,然后根据缓存的路由判断插槽由哪个节点负责。考虑到插槽总数相对较少(16384个2的14次方),缓存所有插槽的路由信息后,每次命令将均只发向正确的节点,从而达到和单机实例同样的性能。
3.5 故障恢复
在一个集群中,每个节点都会定期向其他节点发送PING命令,并通过有没有收到回复来判断目标节点是否已经下线了。具体来说,集群中的每个节点每隔1秒钟就会随机选择5个节点,然后选择其中最久没有响应的节点发送PING命令。
如果一定时间内目标节点没有响应回复,则发起PING命令的节点会认为目标节点疑似下线(PFAIL)。疑似下线可以与哨兵的主观下线类比,两者都表示某一节点从自身的角度认为目标节点是下线的状态。与哨兵的模式类似,如果要使在整个集群中的所有节点都认为某一节点已经下线,需要一定数量的节点都认为该节点疑似下线才可以,这一过程具体为:
1).一旦节点A认为节点B是疑似下线状态,就会在集群中传播该消息,所有其他节点收到消息后都会记录下这一信息
2).当集群中的某一节点C手机到半数以上的节点认为B是疑似下线状态时,就会将B记录为下线(FAIL),并且向集群中的其他节点传播该消息,从而使得B在整个集群中下线
在集群中,当一个主数据库下线时,就会出现一部分插槽无法写入的问题。这时,如果该主数据库拥有至少一个从数据库,集群就进行故障恢复操作来将其中一个从数据库转变成主数据库来保证集群的完整。选择哪个从数据库来作为主数据库的过程与在哨兵中选择领头哨兵的过程一样,都是基于Raft算法:
1).发现其复制的主数据库下线的从数据库(称作A)向每个集群中的节点发送请求,要求对方选择自己成为主数据库
2).如果收到请求的节点没有选过其他人,则会同意将A设置成主数据库
3).如果A发现超过集群中节点总数一半的节点同意选择机成为主数据库,则A则成功成为主数据库
4).当有多个从数据库节点同时参选主数据库,则会出现没有任何节点当选的可能,此时每个参选节点将等待一个随机事件重新发起参选请求,进行下一轮选举,知道选举成功
当某个从数据库当选为主数据库后,会通过SLAVEOF ON ONE将自己转换成主数据库,并将旧的主数据库的插槽转换给自己负责。
如果一个至少负责一个插槽的主数据库下线且没有响应的从数据库可以进行故障恢复,则整个集群默认会进入下线状态无法工作。如果想在这种情况下使用集群仍能正常工作,可以修改配置cluster-require-full-coverafe 为 no(默认为yes)
cluster-require-full-coverage no