A few arguments about Redis Sentinel properties and fail scenarios.

Salvatore Sanfilippo

关于 Redis Sentinel 属性和故障场景的几点论述

昨天,分布式系统专家 Aphyr(阿菲尔)发了一条推文,谈到一家不愿透露名称的公司所遇到的 Redis Sentinel 问题:

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

“然后旧主节点重新出现时没有任何数据,并把这份空数据复制给了其他所有节点。最后不得不从备份恢复。”

天啊,我当时想:我们遇到了一个很严重的 bug。不过我试着向 Kyle(凯尔)了解更多信息,他回复说,用户实际上从主进程中彻底禁用了磁盘持久化。没错:主节点是被有意配置成重启后带着一份被清空的数据集启动的。

猜猜怎么着?Twitter 上的争论立刻开始了。人们非常担心 Redis 用户。可怜的 Redis 用户!总是处于危险之中。

然而,虽然非常担忧确实是一种明智的表现,但我想走另一条路:提供更多信息。此外,这篇博文写起来也很有意思,因为 Kyle 在缺少上下文的情况下报告了这个问题,但在几条推文之后,他就能够——在我看来——找出 Redis Sentinel 中真正值得改进的方面。这个方面与所描述的事故无关,但早已在我的 TODO 列表里待了很久。

不过首先,让我们更仔细地看看 Redis / Sentinel 在这起戏剧性事故中的行为。

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

大多数现实世界中的分布式系统都必须设计成能够抵御进程随机重启这一事实。注意,这与被网络分区隔离的问题截然不同;后者指的是无法与其他进程交换消息。这里的问题则是状态丢失。

更准确地说,如果一个分布式算法要求进程保证重启后保留状态,而它没有做到这一点,那么从技术上讲,它经历的是拜占庭故障(Byzantine failure)。状态已经损坏,进程也不再可靠。

在由 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 选择“复制偏移量”(replication offset)最佳的那个。

复制偏移量是 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 重新加入多数分区。

与 B 和 C 的数据集相比,E 的数据集更新程度较低,但它的复制偏移量却更高。不仅如此,E 甚至可以声称自己最近还连接过其主节点。

改进这一点很容易。每个 Redis 实例都有一个 runid,即每次 Redis 运行时都会变化的唯一 ID。这对于部分重新同步很有用,可以避免从错误的主节点获取增量数据流。从节点应该公布自己上一次成功复制的主节点 runid,而 Sentinel 的故障转移应该确保只选择那些曾经从当前正在进行故障转移的主节点复制过数据的从节点。

将复制偏移量与某个 runid 绑定后,得到的就是衡量从节点更新程度的绝对指标。如果有两个从节点可用,并且二者都能证明与旧主节点保持连续性,那么复制偏移量更高的那个就一定是更好的选择。

不过,在数据不太重要而可用性更重要的所有场景中,这也会带来可用性方面的担忧。例如,如果 A 崩溃时只有 E 可用,那么即使 E 过去曾经从 D 复制数据,它仍然胜过什么都没有。我认为,当你需要高可用缓存而一致性并不是大问题时,使用类似 memcached 的 Redis 集群(客户端在 N 个主节点之间进行一致性哈希)才是正确的做法。

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

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

这件事已经在我的 TODO 列表里待了一段时间了,也要祝贺 Aphyr,仅通过几条来回交流的推文就识别出了一个真实的实现问题。至于 Aphyr 报告的那家匿名公司的故障,我认为目前试图防范严重的配置错误并不可行;不过,这清楚地表明我们需要更好的 Sentinel 文档,而且应该比现有文档更循序渐进。现有文档试图描述系统的工作方式。更明智的做法可能是从一套常见且合理的配置开始,并附上一份“不要做”清单,例如:不要关闭持久化,除非你能接受实例中的数据被清空。