如何应对Akka集群出现脑裂故障?- Andrzej


Akka Cluster是一款非常不错的软件。如果正确使用并用于正确的用例,它可以解决可扩展的分布式系统世界中的许多难题。它可以为您提供一种分布式共识机制,在此基础上,您可以实现Akka Persistence(事件溯源库)所必需的分布式Single Writer Principle(单写原则),尤其是对于像Cassandra、DynamoDB等这样的分布式事件存储。
Akka集群的唯一问题是网络分区,主机无响应可能导致脑裂情况。在这种情况下,您将失去对单写原则的所有保证,并且事件日志可能已损坏。两个(或多个)持久性参与者将同时为同一聚合产生事件。
正常事件是一连串的事件(对于给定的总XYZ),针对相同的节点单写着UUID,用严格的无间隙的单调递增的序列号,您将获得类似于以下内容的信息:

 persistence_id | sequence_nr | writer_uuid
----------------+-------------+--------------------------------------
        XYZ     |    49489583 | dde98298-7aae-4cce-a1a7-7cea478dfb52
        XYZ     |    49489584 | dde98298-7aae-4cce-a1a7-7cea478dfb52
        XYZ     |    49489584 | 69572683-ddf8-43d0-b4ff-bab8ee466d24
        XYZ     |    49489585 | dde98298-7aae-4cce-a1a7-7cea478dfb52
        XYZ     |    49489585 | 69572683-ddf8-43d0-b4ff-bab8ee466d24
        XYZ     |    49489586 | dde98298-7aae-4cce-a1a7-7cea478dfb52
        XYZ     |    49489586 | 69572683-ddf8-43d0-b4ff-bab8ee466d24
        XYZ     |    49489587 | 69572683-ddf8-43d0-b4ff-bab8ee466d24
        XYZ     |    49489588 | 69572683-ddf8-43d0-b4ff-bab8ee466d24

序列号为49489584、49489585、49489586的事件不应该是重复的,它们是完全不同的事件,由不同的写入者产生(请参阅writer_uuid)。这样,您的聚合将不再一致。

裂脑解析器
针对裂脑的第一道防线是裂脑解析器策略。一种算法,可帮助节点确定当前集群状态和成员资格。最常见的策略是:

  1. 静态定额:只要存活的节点数> =静态定额参数,大多数节点都将生存。这种策略通常非常适合固定大小的群集。
  2. 保持多数:在群集大小为动态且静态仲裁超出选项的情况下很有用。
  3. 保持最旧:当集群大小是动态的并且您正在使用Cluster Singletons时很有用。
  4. 保持引用:如果一个节点处理一些关键资源并且如果没有它,群集将无法运行,这是一个有趣的选择。

有一些相当不错的开源解决方案,例如akka-reasonable-downinglithium。就在最近,Lightbend决定将其裂脑解析器集成到Akka Cluster中,并使其开源。就我而言,我选择了静态仲裁策略,因此第一个库就足够了。将来,我可能会迁移到内置选项。
不幸的是,最近出现的生产问题向我证明,对于Akka Cluster中的裂脑,没有裂脑解析器是防弹解决方案。

如何模拟裂脑?
这是裂脑模拟的秘诀:

  1. 使用循环负载均衡器在Akka群集上(至少3个节点)施加一些(写入)负载
  2. 选择一个节点,并暂停冷冻它,例如:docker pause命令
  3. 15秒后解冻节点*: docker unpause
  4. 检查日志或日记的一致性

在应用程序日志中,您可能会注意到以下消息:

2020–05–01T12:47:56 — Scheduled sending of heartbeat was delayed. Previous heartbeat was sent [15456] ms ago, expected interval is [1000] ms. This may cause failure detection to mark members as unreachable. The reason can be thread starvation, e.g. by running blocking tasks on the default dispatcher, CPU overload, or GC.

之后不久出现:

2020–05–01T12:48:03.291 — Marking node [akka://as@1.2.3.4:29292] as [Down]

取消冻结节点后,该节点决定杀死自己需要7秒钟。在此期间,它将处理命令并执行写操作。同时,另外两个节点决定1.2.3.4不响应,因此应将其从群集中删除,并且其所有职责(分片,持久参与者等)都应由它们处理。失去了单一作家原则,我们只是创造了一个裂脑。在某个时候,应该启动一个新实例来替换发生故障的节点,并且您可能会看到akka.persistence.journal.ReplayFilter类似以下内容的日志消息:

Invalid replayed event [sequenceNr=49489584, writerUUID=69572683-ddf8–43d0-b4ff-bab8ee466d24] from a new writer. An older writer already sent an event [sequenceNr=49489584, writerUUID=dde98298–7aae-4cce-a1a7–7cea478dfb52] whose sequence number was equal or greater for the same persistenceId [XYZ]. Perhaps, the new writer journaled the event out of sequence, or duplicate persistenceId for different entities?

或者:

There was already a newer writer whose last replayed event was [sequenceNr=49489584, writerUUID=69572683-ddf8–43d0-b4ff-bab8ee466d24] for the same persistenceId [XYZ].Perhaps, the old writer kept journaling messages after the new writer created, or duplicate persistenceId for different entities?

实际上,您应该很高兴看到它们。我会在稍后解释。
为什么是7秒,为什么是15秒?对于小型群集stable-after,裂脑解析器策略的参数默认值为7秒。15秒正好足以引起大脑分裂。对于更长的stable-after时间,您将需要冻结该节点大约比2 * stable-after获得相同行为更多的时间。

什么时候会发生脑裂?
上面的log语句中已经提到了一些现实生活中的情况:

  1. 线程饥饿,例如通过在默认调度程序上运行阻止任务,
  2. CPU过载
  3. 垃圾收集(GC)。

在我看来,这不是调度程序,也不是GC。CPU负载达到极限,因为在共享环境中部署的某些其他应用程序存在故障。Docker限制无济于事,因为很难限制磁盘IO操作。由于某些恶意攻击,CPU可能也会冻结。

如何在裂脑中生存?
您可以做的第一件事是微不足道的。您可以增加stabe-after 期限。这将使您有更多时间解冻应用程序并从情况中恢复。当然,这不会消除问题。此外,您不能将这段时间过长,因为这会增加总的故障转移时间

1.非分布式数据库
由于脑裂,日志中事件损坏的问题将仅影响分布式事件存储。如果选择JDBC持久性插件,则数据库本身将防止您仅通过序列号上的唯一索引来破坏日志。

2.分布式数据库
如果您需要处理的负载和/或存储量超出单个SQL所能承受的范围,则基本上必须使用Cassandra或其他分布式数据库。全局唯一索引不再可用。这次,您的存储将无法帮助您保持日记的一致性。
即使这样,大脑裂开的情况也不会对您的应用程序一致性造成完全破坏。损坏的日志会影响处理的两个方面。写侧是持久性参与者状态,读侧是投影,它基于持久性查询读取模型。两者的处理方式不同。

让我们从好消息开始,尽管发生了脑裂故障,但由于重播filter,您的聚合状态仍将保持一致。这是修复损坏的日记的非常聪明的机制。默认情况下,它仅跳过来自旧编写器的事件(具有重复的序列号),并且您的聚合将与当前处理保持一致。
现在是坏消息。读取端-持久性查询将为您提供具有所有事件的流(不进行任何过滤)。是的,您的读取模型很可能会损坏。为什么不能对流事件使用相同的过滤策略?这可能是单独博客文章的主题。长话短说,只有当您知道何时停止(在恢复路径的情况下,您确切知道哪个事件是重播的事件)并且还需要批量处理事件时,才可以进行过滤。默认情况下,重播筛选器最多可批处理100个事件(akka.persistence.journal-plugin-fallback.replay-filter.window-size),以进行中断检测,因此,如果您的大脑分裂产生的错误事件多于window-size,那么您的聚合状态也会被破坏。在处理事件流时,按照定义,流没有尽头,如果引入批处理,它将增加恒定的额外延迟。这就是为什么实时修复事件并非易事。在Akka Persistence Query中,这是不可能的,因为当前的api不提供对writer UUID的访问,这对于此类事件过滤是必需的。

读取模型损坏怎么办?这取决于。在某些情况下,手动修复将是最简单的方法。我认为破坏事件的大脑应该是非常非常罕见的东西。可以通过基于汇总快照和大脑分裂后产生的事件删除和重建投影来修复某些投影。其他人将需要产生一些其他的愈合事件,这将表明补偿作用。一个非常优雅的解决方案,尽管成本很高。在某些情况下,不需要其他工作,例如,当您可以接受读取模型中的某种程度的不准确性时。
从实际的角度来看,我还建议创建一个虚拟投影以仅检测重复事件。这将帮助您验证脑裂的范围,并且监视此类异常会容易得多。

总结
毫无疑问,分布式系统具有挑战性。一旦您进入这个世界,任何事情都是理所当然的。没有什么是免费的。准备好做出艰难的决定和妥协。至少您不会在工作中感到无聊:)
Akka Cluster裂脑故障可能发生在每个人身上,但是您可以做好准备。我的最后一条建议是对所有事物进行非常详细的监视。之后,微调您的警报系统,不仅可以了解问题,还可以预测并阻止它们的发生。