A few arguments about Redis Sentinel properties and fail scenarios.

Salvatore Sanfilippo

关于 Redis Sentinel 特性与故障场景的几点讨论

原文由 Salvatore Sanfilippo 发布,订阅该博客

昨天,分布式系统专家 Aphyr 发了一条推文,提到一家不愿透露名称的公司所经历的 Redis Sentinel 问题:

“哦,关于 Redis Sentinel,‘他们对主节点执行了 kill -9,导致了脑裂……’”

“然后旧主节点在没有任何数据的情况下重新上线,并把这种‘空数据’复制到了所有其他节点。最后只能从备份恢复。”

我当时想,天哪,我们是不是有什么严重的 bug。不过我向 Kyle 进一步了解情况后,他回复说,用户实际上完全禁用了主进程的磁盘持久化。没错:主节点就是被故意配置成重启后以一份空数据集启动的。

你猜怎么着?Twitter 上立刻掀起了一场风波。大家都为 Redis 用户深感担忧。可怜的 Redis 用户!总是处在危险之中。

当然,忧心忡忡固然是明智的表现,但我想走另一条路:提供更多信息。而且写这篇博文本身也很有意思,因为 Kyle 虽然在最初报告这个问题时缺少上下文,但在几条推文之后,在我看来,他已经精准地指出了我认为 Redis Sentinel 中真正可以改进的地方——这与上述事件并无直接关联,却已经在我的待办清单上放了很久。

不过在此之前,我们先更仔细地看看 Redis / Sentinel 在这次风波事件中的行为表现。

欢迎来到崩溃恢复系统模型

现实世界中的大多数分布式系统,都必须被设计成能够应对进程随时可能重启这一事实。请注意,这与网络分区问题截然不同——分区是指无法与其他进程交换消息;而这里的问题在于状态丢失。

更准确地说,如果一个分布式算法要求进程在重启后必须保证保留状态,却未能做到,那么从技术上讲,这就属于拜占庭故障:状态已损坏,进程不再可信。

而在一个由 Redis 实例和 Redis Sentinel 实例组成的分布式系统中,重启后的实例能够带着旧数据集重新启动是至关重要的。以空数据集启动属于拜占庭故障,Redis Sentinel 无法从这类故障中恢复。

但让我们先往回退一步。实际上,在类似的事件中,Redis Sentinel 可能根本没有直接参与。典型的情况是:如果一个配置错误的主节点重启得足够快,以至于 Sentinel 完全没有检测到任何故障,会发生什么。

  1. 节点 A 是主节点。
  2. 节点 A 重启,且已禁用持久化。
  3. Sentinel(可能)会发现节点 A 不可达……但持续时间不足以达到配置的超时阈值。
  4. 节点 A 再次可用,但重启后数据集完全为空。
  5. 所有的从节点 B、C、D……都会欣然地从它那里同步一份空数据集。

毕竟,主节点上的一切都按配置被清空了。而从节点上的一切也随之被清空,因为它们正在从被认为是数据集当前可信来源的主节点进行复制。

我们把 Sentinel 从这个等式中去掉,也就是上面时间线中的第“3”步,因为在示例场景中 Sentinel 根本没有起任何作用。

结果就是这样。你有一个 Redis 主节点和 N 个从节点正在复制。主节点重启,并被配置为以一份全新(空)的数据集启动。从节点再次从它(空数据集)进行复制。

我想这对 Redis 用户来说并不是什么新鲜事,这就是 Redis 复制的工作方式:从节点总会尽力成为主节点的精确副本。不过,我们来考虑一下其他模型。

例如,Redis 实例可以拥有一个持久化保存在 RDB / AOF 文件中的节点 ID。每次节点重启时,都会加载这个节点 ID。如果节点 ID 不对,从节点就完全不会从主节点进行复制。是不是安全多了?其实只安全了一点点。主节点可能存在另一种配置错误,导致重启后加载的是一份由于某种原因快照失败而滞后了数周的旧数据集。

因此,在一次错误的重启之后,我们虽然拥有正确的节点 ID,但数据集却陈旧到基本上等同于被清空,只是更难以察觉罢了。

然而,为了这点微不足道的安全性提升,我们却让系统变得更复杂、更难运维,而且从节点还可能因为 ID 不匹配而无法从主节点复制——这同样源于类似禁用持久化那样的运维失误,只不过要隐蔽得多。

那么,我们换个话题,来看一种 Sentinel 真正会参与其中的、并且可以改进的故障模式。

并非所有副本都是一样的

严格来说,Redis Sentinel 提供的是非常有限且易于理解的几项保证。

  1. 只要能够相互通信,所有的 Sentinel 最终都会就配置达成一致。实际上,每个子分区内部也始终会达成一致。
  2. 未经多数 Sentinel 进程授权,Sentinel 无法发起故障转移。
  3. 故障转移是严格有序的:如果一次故障转移在时间上更晚发生,它就会拥有更大的配置“编号”(用 Sentinel 的行话说就是 config epoch),并且总会覆盖较旧的配置。
  4. 最终,Redis 实例会被配置为与胜出的逻辑配置(即拥有更大 config epoch 的配置)保持一致。

这意味着数据集的语义是“最后一次故障转移胜出”。然而,这里缺失的信息是:在故障转移期间,会挑选哪个从节点来替代主节点?说到底,这是一个至关重要的特性。例如,如果 Redis Sentinel 挑选了一个刚以错误配置重启、数据已被清空的从节点,那就是 Sentinel 自身的问题。Sentinel 应该确保,即使在 Redis 是异步复制系统这一限制下,也会尽力为用户挑选最佳的从节点,并在没有可用从节点时拒绝执行故障转移。

这正是可以改进的地方,而目前在主节点故障时挑选从节点的过程是这样的:

  1. 如果一个从节点曾重启过,且在重启后从未与主节点成功连接并完成一次同步(数据传输),则会被跳过。
  2. 如果从节点与主节点断开连接的时间超过配置超时时间的 10 倍(即 Sentinel 判定主节点故障所需的不可达时长),则会被视为不合格。
  3. 在剩下的从节点中,Sentinel 会挑选拥有最佳“复制偏移量”的那个。

复制偏移量是 Redis 主从复制用来统计通过复制通道发送字节数量的一个数字。它在很多场景下都很有用,不仅仅用于故障转移。例如,在网络分区后的部分重同步中,从节点会向主节点请求:请从偏移量 X 开始给我数据,X 就是它最后收到的字节位置,以此类推。

然而,这个复制数字在用于挑选最佳晋升从节点时存在两个问题。

  1. 重启后它会被重置。乍一看这似乎无害,因为我们想挑选数字更大的从节点,而且重启后如果从节点无法连接,本来就会被跳过。然而,它并非真的无害,请继续往下看。
  2. 它仅仅是一个数字:它并不意味着某个 Redis 从节点是从特定主节点复制而来的。

另外请注意,当一个从节点被提升为主节点时,它会继承主节点的复制偏移量。因此,除了重启的情况外,这个数字会一直递增。

为什么“1”和 / 或“2”是次优的选择,又该如何改进?

设想这样一个部署。我们有节点 A、B、C、D、E。D 是当前的主节点,并与 E 一起被隔离在少数分区中。E 仍然从 D 复制,从它们的视角来看一切正常。

然而在多数分区中,A、B、C 可以相互通信,并且 A 被选为主节点。

随后 A 重启,重置了它的偏移量。B 和 C 从它那里复制,偏移量又从较低的数值重新开始。

一段时间后 A 发生故障,与此同时,E 重新加入了多数分区。

E 拥有的数据集比 B 和 C 的更陈旧,但它的复制偏移量却更高。不仅如此,E 甚至可以声称自己最近一直与主节点保持连接。

要改进这一点其实很容易。每个 Redis 实例都有一个“runid”,即每次运行都会变化的唯一 ID。这在部分重同步中很有用,可以避免从错误的主节点获取增量数据流。从节点应该公布它们最后成功复制的主节点的 run id,而 Sentinel 在故障转移时应确保只挑选那些从当前正在进行故障转移的主节点复制过的从节点。

一旦将复制偏移量与给定的 runid 绑定,你得到的便是一个衡量从节点更新程度的绝对指标。如果有两个可用从节点,且都能证明与旧主节点保持连续性,那么偏移量更高的那个必定是最佳选择。

然而,这也会在那些数据不太重要、可用性更重要的场景中带来可用性方面的问题。例如,当 A 崩溃时,如果只有 E 可用,即使它之前是从 D 复制的,有总比没有好。我想说的是,如果你需要的是高可用的缓存,且一致性不是大问题,那么使用类似 memcached 的 Redis 集群(在 N 个主节点间进行客户端一致性哈希)才是更合适的方案。

请注意,即使不检查 runid,仅仅让复制偏移量在重启后保持持久化,就已经能显著改善这一行为了。在上面的例子中,只有当 E 在与主节点一同被隔离于少数分区期间,接收到的写入比多数分区一侧的其他从节点更多时,才会被选中。

简而言之:我们必须修复这个问题。它与无数据集重启主节点无关,但拥有更正确的实现是有价值的。不过,这只会限制一类极难触发的问题。

这件事已经在我的待办清单上放了一段时间,祝贺 Aphyr 仅通过几条推文的交流就发现了一个真正的实现问题。至于 Aphyr 报告的那家匿名公司的故障,我认为目前试图去防范如此严重的配置错误是不可行的,但这清楚地表明,我们需要比现有文档更循序渐进、更易上手的 Sentinel 文档——现有文档只是试图描述系统是如何工作的。更明智的做法或许是从一份通用、合理的配置出发,并附上一份“不要做”清单,比如:不要关闭持久化,除非你能接受实例被清空的后果。

本文章由 muse-spark-1.2-contributor 进行翻译

评论