Redis PSYNC2 bug post mortem

Salvatore Sanfilippo

Redis PSYNC2 缺陷复盘

四天前,一位用户在 Redis 的 Github 仓库中提交了一个严重问题。这个问题与 Redis 4.0 新引入的 PSYNC2 复制协议(PSYNC2 replication protocol)有关,而且非常严重。PSYNC2 为 Redis 复制带来了许多好处,包括在发生故障转移后,甚至在从节点主动重启后,只交换差异而不是整个数据集即可完成重新同步的能力。问题就出在后一个特性上:使用 PSYNC2 时,RDB 文件会附加复制信息。从节点重启后,复制元数据会被重新加载,从节点便能够尝试执行 PSYNC,与主节点进行握手,并接收自上次断开连接以来的差异。

从 Redis 运维的角度看,这些都是好消息。不过,尽管 PSYNC2 自 Redis 4.0.0 稳定版引入以来一直相当稳固,但涉及重启从节点的这一功能显然缺乏可靠性。这个功能存在两个问题:第一,它是在最后时刻加入 PSYNC2 的,并不属于最初的设计文档。它更像是我们在 PSYNC2 中所做工作的一个显而易见的扩展,但没有像规范的其他部分那样接受同等程度的审查,以发现潜在的 bug 和问题。第二个问题源于这一功能比最初看起来复杂得多,因为从节点重启后,要真正恢复复制在从节点一侧的全部状态是很棘手的。此外,无法恢复某些状态信息,大多数时候并不会导致明显的 bug,因此很难通过集成测试发现。只有在特定条件出现时,缺少某些状态才会造成问题。例如,如果无法在从节点复制状态中正确重建当前选中的 DB,那么只有在不同 Redis DB 中都有写入操作时才会产生问题;而且,这种 bug 只有在特殊条件下才会导致当前选中的 DB 无法被正确保存。

在许多 Redis 贡献者的帮助下,尤其是在 Alibaba 的 Redis 开发者、Github 用户 @soloestoy 的帮助下,我们最近一直在改进 PSYNC2 中的一些潜在问题。经过对目前所写全部补丁的大量测试,所有这些工作原本将在几天后最终进入 Redis 4.0.3。不过,当我收到 issue #4483 后,我意识到必须加快 Redis 4.0.3 的发布,因为这个问题比我们此前发现的其他 Redis 4 PSYNC2 问题严重得多。

issue #4483 描述的 bug 相当简单,却带来了很大的问题。从节点重启并从 RDB 重新加载复制状态后,主节点的复制积压缓冲区中可能包含以 EVALSHA 命令形式存在的 Lua 脚本执行记录。然而,从节点的 Lua 脚本引擎会在重启后清空所有脚本,因此无法处理这类命令。结果是,从节点不会处理由 Lua 脚本产生的写入操作,除非这些脚本使用了“commands replication”;而这并不是默认方式,默认方式是复制脚本本身。

我有些陷入恐慌……于是写了几个备选补丁并提交到该 issue 中。最后,我们选择了不要求与旧版本 RDB 不兼容的那个补丁,因此我马上发布了 Redis 4.0.3。不过我犯了一个错误……我在办公室工作而不是在家办公已经有几周了。平时我不会在晚上工作,但为了 4.0.3,回到家后我打开家里的笔记本电脑,将补丁合并进 4.0 分支,并做了一些测试。第二天回到办公室后,我改用另一台电脑继续工作,却没有意识到自己漏掉了一个我在家里合并、但没有推送到仓库的 commit。

所以,我实际上发布了一个包含所有 PSYNC2 修复、唯独缺少复制 bug 最重要修复的 4.0.3。我随后立即发布了新的补丁版本 Redis 4.0.4,其中包含了这个复制修复。这已经够糟糕了:升级 Redis 是一项计划中的工作,没人希望因为我在准备发布时犯错而不得不升级两次……但最糟糕的事情还在后面。4.0.4 中加入的修复,也就是关于执行 PSYNC2 的重启从节点进行脚本复制的修复,存在一个错误,而且在所有复制集成测试中都完全没有被发现:这个修复涉及将 Lua 脚本直接存储在从节点内存中的 RDB 里,以便之后重新加载。但我们没有考虑到,负责加载脚本的函数如果发现脚本已经在内存中,就会触发 assert;因此,当从节点从主节点接收到完整同步并开始加载 RDB 文件时,会因为某个重复脚本已经存在于内存中而立即崩溃。一位用户通过 Twitter 很快报告了这个问题,我在从收到报告到 4.0.5 可用的不到 45 分钟内修复了它,但这并不能消除这一连串交付修复失败所造成的所有潜在问题。

上述问题是由多方面原因造成的:

  1. 考虑到 PSYNC2 的复杂性以及代码变更量,Redis 4.0 过早地作为稳定版发布了。下一次,即使需要延迟发布,我也会等待更久,并让 Redis 的新主版本在最后一个 release candidate 阶段停留更长时间,以便我们能在发布 GA 版本前发现这类 bug。
  2. 我在试图交付 issue #4483 的修复时过于仓促。即使这个 bug 很严重,也应该花些时间弄清楚发生了什么,以及修复本身可能造成哪些问题。此外,我当时太着急了,没有足够仔细地检查组成 4.0.3 的 commit 集合,导致其中缺少一个修复,并因此需要再次发布。
  3. Redis 4.0 拥有非常严格的 PSYNC2 单元测试和集成测试,包括模拟连续故障转移、在每一步检查数据一致性的测试。但由于这次 bug,我发现测试从未尝试将 PSYNC2 与 Lua 脚本复制结合起来。这一点必须改进。此外,从节点 RDB 重启后的 PSYNC2 复制也必须得到增强。

我会先从“3”中的改进着手,并在今后每次发布新版本前吸取“1”和“2”中的教训。对于这次不得不处理我的错误的所有人,我表示诚挚的歉意;同时也要特别感谢 @soloestoy,感谢他提供了极其大量的帮助和支持。