RabbitMQ(九)网络分区

RabbitMQ(九)网络分区

一. MQ与网络分区

1.1 网络分区对RabbitMQ的影响

网络分区可能会引起消息丢失或服务不可用,可以通过重启或配置自动化处理来解决。

RabbitMQ一般使用Federation或Shovel来解决广域网中的问题,对于网络分区的容错性不高。局域网环境下也可能会出现网络分区(如中继设备或网卡出现故障)。

不同分区中的节点会认为不属于自身所在分区的节点都已经挂了,对于队列、交换器、绑定的操作仅对当前分区有效。RabbitMQ 3.1版本后会自动探测网络分区,并提供了相应配置来解决该问题。

若原集群中配置了镜像队列,其又牵扯到两个或更多网络分区的节点时,每个网络分区都会出现一个master节点,导致每个分区的该队列都相互独立。即使网络恢复,该问题也不能解决。

1.2 为什么要引入网络分区?

镜像队列是一种环形的逻辑结构,如下图某队列有4个镜像,假设需要ack一条消息,会先在master节点上执行确认命令,之后转向B节点,再然后是C和D节点,D节点将执行操作返回给A节点,从而真正完成一条消息的确认。

这种数据一致性复制原理于ZK的Quorum(用于保证数据冗余和最终一致性的投票算法)不同,可以保证更强的一致性,在此模型下出现网络波动或网络故障会使数据链的性能大大降低。比如C节点网络异常,则整个数据链会被阻塞,继而相关服务也会被阻塞,所以需要引入网络分区来将异常节点剥离出整个分区,以确保RabbitMQ的可用性及可靠性。等待网络恢复后,再将异常节点加入集群。

通常网络分区都是由单个节点网络故障导致,所以会形成一个大分区和一个单节点的分区,若之前还配置了镜像,就可以在不影响服务可用性、不丢失消息的情况下从网络分区的情形下恢复。

二. 网络分区的判定

2.1 RabbitMQ内部如何判定出现分区

RabbitMQ集群节点内部通信端口默认为25672,两两节点间都会进行信息交互。当某个节点出现网络故障、端口不通,导致与此节点的交互中断,此处会有一个超时判定机制来判定是否网络分区

  • net_ticktime,默认为60秒。当发生超时会有 net_tick_timeout 的信息报出。集群内部每个节点间会每隔四分之一个net_ticktime记一次应答。若有任何数据被写入节点中就认为已应答;连续4次都未应答的节点认为已处于down状态,其余节点可以将其剥离出当前分区
  • heartbeat_time指客户端与RabbitMQ服务间通信的心跳时间,注意和net_ticktime的区别。

连续4次应答的总时间T的取值范围:0.75*net_ticktime < T < 1.25*net_ticktime

默认情况下,可以再45s < T < 75s间判定出 net_tick_timeout

2.2 判定出现网络分区的方法

(1)查看RabbitMQ服务日志

当Mnesia认定发生了网络分区后,会记录到RabbitMQ的服务日志中:

1
2
=ERROR REPORT==== 16-Oct-2017::18:20:55 ===
Mnesia('rabbit@node1'): ** ERROR ** mnesia_event got {inconsistent_database, running_partitioned_network, 'rabbit@node2'}

(2)使用rabbitmqctl工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ rabbitmqctl cluster_status
# 未发生网络分区:集群有三个节点
[
{nodes,[{disc, [rabbit@node1,rabbit@node2,rabbit@node3]}]},
{running_nodes, [rabbit@node2,rabbit@node3,rabbit@node1]},
{cluster_name,<<"rabbit@node1">>},
{partitions,[]}
]
# 发生网络分区:partitions内有相关内容,以下表示节点1和3分别与2发生了分区
[
{nodes,[{disc, [rabbit@node1,rabbit@node2,rabbit@node3]}]},
{running_nodes, [rabbit@node3,rabbit@node1]},
{cluster_name,<<"rabbit@node1">>},
{partitions,[{rabbit@node3,[rabbit@node2]},{rabbit@node1,[rabbit@node2]}]}
]

(3)通过Web管理界面

(4)通过HTTP API来获取节点信息

1
$ curl -i -u root:root123 -H "content-type:application/json" -X GET http://localhost:15672/api/nodes
  • localhost:RabbitMQ服务器地址
  • /api/nodes:返回一个JSON字符串,其中有partitions相关项,若其中有内容则表示发生了网络分区。

三. 模拟网络分区

通常很难观察到网络分区的发生,所以需要模拟出网络分区,方案有三类:

  • iptables封禁/解封IP地址或端口号。
  • 关闭/开启网卡。
  • 挂起/恢复操作系统。

3.1 iptables方式

RabbitMQ集群内部节点通信端口默认为25672,封禁该端口来模拟出 net_tick_timeout ,再开启此端口让集群判定网络分区的发生。

假设集群有三个节点,我们在node2上执行如下命令来封禁25672端口:

1
2
$ iptables -A INPUT -p tcp --dport 25672 -j DROP
$ iptables -A OUTPUT -p tcp --dport 25672 -j DROP

并同时监测各个节点的服务日志,当出现类似信息时表示已判定出 net_tick_timeout

1
2
3
4
5
=INFO REPORT==== 10-Oct-2017::11:53:03 ===
rabbit on node rabbit@node2 down

=INFO REPORT==== 10-Oct-2017::11:53:03 ===
rabbit@node2 down: net_tick_timeout

或者可以等待75秒之后确保发生了 net_tick_timeout ,此时还需要等待node2网络恢复之后才会判定出现网络分区。

解封命令:

1
2
$ iptables -D INPUT 1
$ iptables -D OUTPUT 1

node2节点已恢复与其它节点的通信,此时查看集群状态就可以发现出现了两个独立的分区。

上述是封禁端口来模拟,也可以封禁IP地址:

1
2
3
4
5
6
7
8
9
10
11
# 在node2上执行
$ iptables -I INPUT -s 192.168.0.2 -j DROP
$ iptables -I INPUT -s 192.168.0.4 -j DROP
# 解封
$ iptables -D INPUT 1
$ iptables -D OUTPUT 1

# 也可以分别在node1或3上执行
$ iptables -I INPUT -s 192.168.0.3 -j DROP
# 解封
$ iptables -D INPUT 1

如果集群的节点部署跨网段,也可以采用封禁整个网络段的方式来模拟:

1
2
3
4
5
6
7
# node1 102.168.0.2
# node2 102.168.1.3
# node3 102.168.0.4

$ iptables -I INPUT -s 192.168.0.0/24 -j DROP
# 解封
$ iptables -D INPUT 1

3.2 封禁/解封网卡的方式

与方式一同为模拟网络故障:

1
2
3
4
5
6
7
8
9
10
11
# 先用ifconfig来查询当前网卡编号
$ ifconfig
eth0 ...
# 在node2上关闭网卡
$ ifdown eth0
# 等待出net_tick_timeout后,再开启网卡
$ ifup eth0

# 也可以通过以下命令来模拟
$ service network stop
$ service network start

3.3 挂起/恢复操作系统的方式

发生挂起的节点不会认为自己已经失败或停止工作,但其它节点会这样认为,比如集群中某个节点运行在一台笔记本上,当笔记本合上此节点就挂起。等待T时间后,出现 net_tick_timeout 后,再恢复挂起的节点即可复现网络分区。

四. 网络分区的影响

4.1 未配置镜像

(1)对发送端的影响

假设有三个节点,与交换器绑定关系如下:

节点名称 交换器 绑定 队列
node1 exchange rk1 queue1
node2 exchange rk2 queue2
node3 exchange rk3 queue3

网络分区发生前,客户端1和2分别连接node1和node2并向对应队列发送消息,消息存入队列:

1
channel.basicPublish("exchange", "rk1", true, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());

使用iptables模拟网络分区后(关闭网卡会关闭客户端连接),使node1和node2处于不同分区中,对于客户端来说没有影响。

如果客户端1连接node1,并向queue2发送消息。模拟分区后,如果客户端在发送消息时将mandatory设置为true,网络分区之后可以通过抓包工具看到有Basic.Return将发送的消息返回,表示发生网络分区后,client1不能正确的将消息发送到queue2中

保证消息可靠性的方案:客户端若设置了ReturnListener来监听Basic.Return的信息,并附带有消息重传机制,则整个网络分区前后过程可以保证发送端消息不丢失。

在网络分区发生前,queue1进程存在于node1节点,queue2则于node2节点,网络分区发生后,node1不会创建新的node2,所以client1将消息发送到exchange后并不能路由到queue2。若没有采用上述方案,就会发生消息丢失。不过此时仍可以通过指令看到队列:

1
2
3
4
$ rabbitmqctl list_queues name
...
queue2
...

(2)对消费端的影响

假设网络分区发生前,客户端3和4分别连接node1和2,但分别消费queue2和queue1(不交换的情况和发送端一样无影响)。在网络分区发生后,客户端虽然不会报错,也可以消费到内容。但会有如已消费消息的ack失效等现象发生,网络分区恢复后,数据不会丢失。

如果分区之后,重启client3或有个新的客户端连接node1来消费queue2,会有报错:

1
com.rabbitmq.client.ShutdownSignalException: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - home node 'rabbit@node2' of durable queue 'queue2' in vhost '/' is down or inaccessible, class-id=60, method-id=20)

node1的服务日志中也有相关记录:

1
2
3
=ERROR REPORT==== 12-Oct-2017::14:14:48 ===
Channel error on connection <0.9538.9> (192.168.0.9:61294 -> 192.168.0.2:5672, vhost: '/', user: 'root'), channel 1:
{amqp_error,not_found,"home node 'rabbit@ndoe2' of durable queue 'queue2' in vhost '/' is down or inaccessible", 'basic.consume'}

未配置镜像的集群,在网络分区发生后,队列也会随着节点而分散在各自的分区中。

  • 对于消息发送方,可以成功发送消息,但会有路由失败的现象,需要配合mandatory等机制保证消息的可靠性。
  • 对于消息消费方,可能会有诡异、不可预知的现象发生,如已消费消息的ack失效。
  • 如果网络分区发生后,客户端与某分区重新建立通信链路,其分区若没有对应得队列进程,则会由异常抛出。
  • 如果从网络分区中恢复后,数据不会丢失,但客户端会重复消费。

4.2 已配置镜像

已配置镜像队列的情况要比未配置复杂得多。假设集群有三个节点,采用iptables方式将集群模拟分裂为两个网络分区[node1, node3]和[node2]。

镜像队列:

1
2
3
ha-mode:exactly
ha-param:2
ha-sync-mode:automatic

分区之前:

队列 master slave
queue1 node1 node3
queue2 node2 node3
queue3 node3 node2

分区之后,[node1, node3]分区中的队列有了新的部署,queue1未发生变化,queue2因为原宿主节点node2被剥离,所以node3提升为master,同时选择node1作为slave。queue3则重新选择node1为新的slave:

队列 [node1, node3]分区 [node2]分区
master slave master slave
queue1 node1 node3 node1 node3
queue2 node3 node1 node2 []
queue3 node3 node1 node2 []

对于queue1,网络分区前后不会对生产和消费造成影响。

对于queue2和queue3则会和未配置镜像一样,恢复后有可能会有数据丢失。

当有新的slave出现时,会自动同步master的数据,在同步的过程中,集群的整个服务都不可用,客户端连接会被阻塞。如果master中有大量的消息堆积,必然会造成slave的同步时间增长,进一步影响了集群服务的可用性。若配置了 ha-sync-mode=manual 在新的slave创建时不会同步master上旧的数据,若master节点发生了异常,则此部分数据会丢失。

4.3 网络分区造成的消息丢失如何解决?

  • 首先,消息发送端要有处理 Basic.Return 的能力。
  • 其次,检测到网络分区发生后,要迅速的挂起所有生产者进程。
  • 之后,连接每个节点消费分区中的所有队列数据。在消费完之后再处理网络分区。
  • 最后,再从网络分区中恢复生产者的进程。
  • 需要注意整个过程会伴有大量的消息重复,消费者客户端要做好相应的幂等性处理。

还有方案:集群迁移,将所有旧集群资源迁移到新集群来解决问题。

五. 如何处理网络分区

5.1 手动处理网络分区

从网络分区中恢复,首先要挑选一个信任分区,该分区有决定Mnesia内容的权限,发生在其余分区的改变不会记录在Mnesia而直接丢弃。然后重启非信任分区中的节点,若此时还有网络分区的告警,则紧接着重启信任分区中的节点。

(1)如何挑选信任分区?

挑选信任分区的优先级由高至低:

  • 分区中要有disc节点;
  • 分区中节点数最多;
  • 分区中队列数最多;
  • 分区中客户端连接最多。

(若有多个分区这些条件都相等,则随机挑选)

(2)如何重启节点?

重启RabbitMQ的方式:

  1. rabbitmqctl stop 关闭,rabbitmq-server -detached 启动(同时重启Erlang虚拟机和RabbitMQ);
  2. rabbitmqctl stop_app 关闭,rabbitmqctl start_app 启动(只重启RabbitMQ,从网络分区恢复推荐该种)。

(3)重启的顺序如何确定?

配置镜像队列出现的漂移现象:依次关闭节点,导致master最终都漂移到一个节点上,之后重启节点只会增加slave的个数,而不会改变master的分布。对于RabbitMQ来说,除了发布消息其余操作都在master上完成,因此压力都集中到了单个节点,不能很好的负载均衡。

重启顺序的两种方式:

  1. 停止其它非信任分区中的所有节点,然后再启动每一个节点,如果此时还有网络分区的告警,则再重启信任分区中的节点以清除告警。
  2. 关闭整个集群的节点,然后再启动每一个节点,需要确保第一个节点在信任分区。

如果采用挨个节点重启的方式,会有Mnesia内容权限归属问题,也有可能引起二次网络分区。

解决镜像队列漂移问题,可以在重启前先删除镜像队列的配置(需要在每个队列上都删除镜像队列):

  • 通过命令删除:

    1
    $ rabbitmqctl clear_policy [-p vhost] {mirror_queue_name}
  • 通过HTTP API或WEB界面删除:

    1
    $ curl -s -u {username:password} -X DELETE http://localhost:15672/api/policies/default/{mirror_queue_name}

(4)如何判断已从网络分区恢复?

可以参考第二节的内容,如使用 rabbitmqctl cluster_status 命令检测输出的partitions是否有节点信息。也可以通过Web界面或HTTP API的方式。

(5)总结步骤

  1. 挂起生产者和消费者进程,从而减少消息不必要的丢失,如果进程数过多且情况紧急,可以跳过该步骤。
  2. 删除镜像队列的配置。
  3. 挑选信任分区。
  4. 关闭非信任分区的节点,采用 rabbitmqctl stop_app 关闭。
  5. 启动非信任分区的节点,采用 rabbitmqctl start_app 启动。
  6. 检查网络分区是否恢复,如果已恢复则跳至步骤8,若还有网络分区报警则进行步骤7。
  7. 重启信任分区中的节点。
  8. 添加镜像队列的配置。
  9. 恢复生产者和消费者进程。

5.2 自动处理网络分区

RabbitMQ提供了三种自动处理网络分区的方法:

  • pause-minority模式
  • pause-if-all-down模式
  • autoheal模式
  • 默认为ignore模式,即不自动处理。

在配置文件 rabbitmq.config 中配置 cluster_partition_handling 参数:

1
2
3
4
5
6
7
[
{
rabbit, [
{cluster_partition_handling, igbore}
]
}
]

(1)pause-minority模式

该模式下,发生网络分区时,集群中的节点观察到某些节点down掉时会自动检测自身是否处于少数派(分区中的节点小于或等于集群中一半的节点数),RabbitMQ会自动关闭这些节点的运作。

1
2
3
4
5
6
7
[
{
rabbit, [
{cluster_partition_handling, pause-minority}
]
}
]

根据CAP原理,此处保证了分区耐受性P,确保在发生网络分区的情况下大多数同个分区的节点可以继续运行。少数派的节点在分区开始时关闭,分区结束后启动(只关闭RabbitMQ,不关闭Erlang)。处于关闭的节点每秒检测依次是否可连通到剩余集群,可以时启动自己。

当集群中只有两个节点时不适合采用该模式,因为任何一个节点失败发生网络分区时,两个节点都会关闭,在网络恢复时两个节点自动恢复网络分区,也有可能保持关闭状态。对于2V2和3V3等对等分裂的情况,在跨机器部署时很有可能发生,会关闭这些分区内所有节点。

(2)pause-if-all-down模式

该模式下,集群中的节点在和所配置列表中任何节点不能交互时才会关闭,配置如下:

1
2
3
4
5
6
7
8
9
[
{
rabbit, [
{cluster_partition_handling,
{pause_if_all_down, ['rabbit@node1'], ignore}
}
]
}
]
  • 如果一个节点与node1无法通信,则会关闭自身的RabbitMQ。
  • 如果是node1本身发生了故障造成网络不可用,其它节点都是正常,则所有节点都要关闭RabbitMQ,等待node1恢复后,各个节点再启动MQ从网络分区中恢复。

该模式下有ignore和autoheal两种配置,如果出现上一个所述的对等分裂,两台机器部署4个节点,机器间通信异常,但机器上两个节点保持通信,假设又配置了两个机器各一个节点,则这四个节点都不会自行关闭。此时把配置中的ignore改为autoheal可以处理这种情况。

(3)autoheal模式

该模式下发生网络分区时,RabbitMQ会自动决定一个获胜的分区,然后重启不在这个分区中的节点来恢复网络分区。

获胜分区的选择优先级从高到低:

  • 客户端连接数最多;
  • 节点数最多;
  • 节点名称的字典序。
1
2
3
4
5
6
7
[
{
rabbit, [
{cluster_partition_handling, autoheal}
]
}
]
  • 对于pause-minority模式,关闭节点的状态是网络故障时(判断出net_tick_timout)会关闭少数派分区中的节点,等待网络恢复之后(即出现网络分区后)启动关闭的节点来从网络分区中恢复。
  • auto模式再判定出net_tick_timout时不做动作,要等到网络恢复后才重启非获胜分区的节点。

(4)挑选哪种模式

允许MQ自动处理网络分区并不一定会又正面的效果,如果MQ处于一个不可靠的网络环境下,需要使用Federation或Shovel,就算恢复后也要谨防二次网络分区。

  • ignore:发生网络分区时不做任何动作,需要人工介入。
  • pause-minority:对于对等分区的处理不够优雅,可能会关闭所有的节点。可以应用于非跨机架、奇数节点数的集群中。
  • pause-if-all-down:对于受信节点的选择尤为考究,尤其是集群中所有节点配置相同的情况下,可以处理对等分区的情况。
  • autoheal:可以处理各种情况下的网络分区,但如果集群中有节点处于非运行状态,该模式会失效。

六. 多分区的案例

当集群中物理机是多网卡,当某个节点网卡发生故障可能会发生多个分区的情况。

假设集群有6个节点,每个节点的物理机都是4网卡(eth0到eth3),并采用bind0的绑定模式。当node6的eth0发生故障后,整个集群演变为6个分区,每个节点都是一个独立的分区。

网络分区前:

当node6网被关闭,对于bind0模式,交换机无法感知eth0网卡的故障,但node6节点能够感知本地eth0的故障。对于node3节点,其与node6的eth0网卡建立的长连接没有关闭,node3会向node6重试发送数据,但node6无法回应,除非主动关闭或等待长连接超时(默认7200s,即2小时)链路才会关闭。

node6网卡关闭后,node1、node3和node6发生变化:

  1. 与node6的eth0链路不通,node3等待net_ticktime的超时。node3相关日志:

    1
    2
    rabbit on node 'rabbit@node6' down
    node 'rabbit@node6' down: net_tick_timeout
  2. 待超时后,主动关闭连接。node3相关日志:

    1
    node 'rabbit@node6' down: connection_closed

    同时Erlang虚拟机尝试让node3与node6重新连接,因为node6其它网卡正常,所以连接可以正确建立。

    1
    node 'rabbit@node6' up
  3. 判定node3和node6之间产生了网络分区。

    1
    Mnesia('rabbit@node3'): ** ERROR ** mnesia_event got {inconsistent_database, running_partitioned_network, 'rabbit@node6'}
  4. 此时node3和node1还处于连通状态,同样node6和node1也处于连通状态,node3日志:

    1
    2
    3
    4
    Partial partition detected:
    * We saw DOWN from rabbit@node6
    * We can still see rabbit@node1 which can see rabbit@node6
    We will therefore intentionally disconnect from rabbit@node1

    表示node3和node6发生了网络分区,但node3发现node1和node6内部通信还未断,此时认为node1和node6处于同一个分区,node3准备主动关闭与node1的通信,所以node1和node3也发生了分区。

  5. 同时,对于node6来说,node1和node3还处于同一分区,所以node6也要讲node1置于node6本身分区之外,所以三个节点都将处于不同分区。

    node1日志:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    =ERROR REPORT==== 16-Oct-2017::14:20:54 ===
    Partial partition detected:
    * We saw DOWN from rabbit@node3
    * We can still see rabbit@node4 which can see rabbit@node3
    We will therefore intentionally disconnect from rabbit@node4
    =INFO REPORT==== 16-Oct-2017::14:20:55 ===
    node 'rabbit@node4' down: disconnect
    =INFO REPORT==== 16-Oct-2017::14:20:55 ===
    node 'rabbit@node4' up
    =ERROR REPORT==== 16-Oct-2017::14:20:55 ===
    Mnesia('rabbit@node1'): ** ERROR ** mnesia_event got {inconsistent_database, running_partitioned_network, 'rabbit@node4'}

    此时node1察觉node4与node3还有内部通信交换,主动将node4剥离出自身分区。

  6. 以此下去直到每个节点都分离出自己的分区。

对于这种情况采用自动模式的效果:

  • pause_if_all_down:挑选一个节点作为受信节点,重启剩余5个节点恢复。
  • autoheal:同样,查看日志可以发现,会等网络分区判定之后罗列出所有分区信息,再重启非获胜分区节点(同样是5个)。
  • pause_minority:最优雅,当有节点检测到net_tick_timeout后自行重启当前节点,阻止了网络分区进一步演变,处理效率最高。

参考:

🔗 《RabbitMQ实战指南》