Diskless replication: a few design notes.

Salvatore Sanfilippo

无盘复制:几点设计笔记

大约一个月前,一些关注 Redis 开发的人在伦敦参加了首届 Redis 开发者会议。我们共同确定了一些亟需实现的功能(现已列在 GitHub issue 中:https://github.com/antirez/redis/issues/2045),在当天讨论的议题中,有一个被多次提及:diskless replication(无盘复制)。

这个功能并非全新想法,它曾被多次提出,尤其是 EC2 用户,他们深知主节点在从节点同步期间要保持良好性能并非易事。然而,有许多使用场景下你并不想触及磁盘,即使是在物理服务器上运行时也是如此,尤其是在将 Redis 用作缓存时。简而言之,Redis 的复制机制迫使用户即使不需要或不想要磁盘持久化,也必须使用磁盘。

回家后,我想尽快给参会的开发者一个反馈,因此我首先着手实现了在已识别问题列表中看起来最重要且最不简单的一项功能。接下来的几周里,关注点也将转移到 Redis 的开发流程上:如何处理 issue、新想法如何提交给 Redis 项目等等。关于这些其他重要事项的延迟深表歉意,目前至少能给大家带来一些代码 ;-) 

无盘复制带来了一些设计挑战。它看似简单,实则不然,因此既然我想多写博客,便想着记录一下该功能的内部工作原理。我相信一篇博文会让新功能的理解和采用变得更简单。

以往的复制工作原理
===

较新版本的 Redis 能够在与主节点的连接丢失后,重新连接主节点,并以增量方式继续复制过程,仅获取至今累积的差异。然而,当从节点断开时间过长、重启或是一个全新的从节点时,Redis 会要求其执行所谓的“全量重同步”。

这是一个简单的概念,意思是:为了设置这个从节点,我们将主节点的所有数据集传输给从节点。从节点会清空旧数据,从零开始重新加载新数据,确保其运行的是主节点数据的精确副本。一旦从节点成为主节点的精确副本,后续的变更就会随着主数据集因客户端发送的写命令而被修改,以普通 Redis 命令的形式增量地流式传输。

问题在于执行全量重同步所需的这种初始“批量传输”的方式。基本上,主节点会创建一个子进程来生成 RDB 文件。当子进程完成 RDB 文件生成后,父进程会使用非阻塞 I/O 将文件发送给从节点。最后,当传输完成时,从节点可以重新加载 RDB 文件并上线,接收新增写入的增量流。

然而,这意味着从主节点的角度来看,为了执行一次全量同步,我们需要:

1) 将 RDB 写入磁盘。
2) 再从磁盘加载 RDB 以发送给从节点。

“2”不算理想,但“1”要糟糕得多。例如,如果同时启用了 AOF,子进程以尽可能快的速度写入磁盘会大幅延迟 AOF 的 fsync()。在配置不当的情况下,尤其是使用非本地磁盘时,有时甚至只是因为内核参数调优不够完美,磁盘压力就会导致难以处理的延迟尖峰。Redis 2.8 引入的部分重同步在一定程度上缓解了这个问题,但你时不时仍需重启从节点,或它们离线时间过长,因此无法完全避免全量重同步。

与此同时,这一过程也有一些优点。RDB 保存代码也被复用于复制,使复制代码更简单。此外,在子进程生成 RDB 文件期间,新的从节点可以连接并进入队列:当 RDB 就绪时,我们可以同时向多个从节点提供数据。

总的来说,在许多部署中它运行得很好,并允许同时同步多个从节点。此外,许多用户在主节点端启用了 RDB 持久化但未启用 AOF,因此无论如何都会不时地持久化到磁盘。大多数裸金属用户在 Redis 进行持久化时几乎没有任何延迟,而且磁盘,尤其是本地磁盘,其性能易于预测:一旦子进程开始保存,你其实无需检查超时或是否耗时过长,它最终总会完成,通常在合理的时间内。

出于这些原因,基于磁盘的复制*仍然*是默认的复制策略,目前也没有计划将其移除,但现在我们有了一个替代方案,以服务于那些它表现不佳的使用场景。

那么什么是无盘复制?其想法是你可以让子进程通过套接字直接写入从节点,无需任何中间步骤。

套接字不是磁盘
===

关于无盘复制的显而易见的问题是,写入磁盘与写入套接字是不同的。首先 API 就不同,因为 RDB 代码原本是写入 C 语言的 FILE 指针,而写入套接字则是写入文件描述符的问题。此外,磁盘写入除非遇到严重的 I/O 错误(例如磁盘已满),否则不会失败,因此当写入失败时,你可以认为进程已中止。而套接字则不同,因为如果接收方速度慢且本地内核缓冲区已满,写入可能会被延迟。另一个有趣的问题是必须处理超时:如果接收方出现故障而停止从我们这里读取怎么办?或者只是 TCP 连接已死但我们没有收到重置信号等等。我们不能让向从节点发送 RDB 文件的子进程永远处于活动状态,必须有一种检测超时的方法。

幸运的是,将 RDB 代码修改为写入文件描述符是轻而易举的,因为为了解决一个完全不同的问题(Redis Cluster 的 MIGRATE/RESTORE),代码已经使用了一种名为“rio”(Redis I/O)的抽象,它抽象了 Redis 值以 RDB 格式的序列化和反序列化,因此你可以将一个值写入磁盘或内存缓冲区。我所做的是支持一种新的“rio”目标,称为 fdset:即一组文件描述符。这是因为如后文所述,我们需要同时写入多个文件描述符。

然而这还不够。主要的权衡之一是需要明确内存中的 RDB 传输将以下列两种方式中的哪一种进行:

1) 方式一:在缓冲区内的内存中生成完整的 RDB 文件,然后再传输。
2) 方式二:在 RDB 创建过程中,直接增量地写入从节点套接字。

方式一要简单得多,因为它基本上类似于写入磁盘的操作,只是在一种内存盘中进行。然而,显而易见的风险是会占用过多内存。方式二则稍显冒险,因为你必须在生成 RDB 文件的子进程处于活动状态时进行传输。然而,该功能的核心是针对磁盘可能较慢但*网络较快*的环境,且不要求过多额外内存,否则该功能就有变得无用的风险。因此选择了方式二。

然而,如果像这样流式传输 RDB 文件,就会出现一个新问题需要解决……从节点如何知道已到达 EOF?在开始传输时,我们并不知道传输会有多大。而在基于磁盘的复制中,大小是已知的,因此传输仅使用带有前缀长度的 Redis 协议“bulk”字符串进行,类似于:

$92384923423\r\n
… data follows …

我懒得去实现某种复杂的分块协议来宣告增量块的大小,因此采用了一种更简单粗暴的方法。主节点生成一个不可猜测且极不可能冲突的 160 位随机字符串,并向从节点发送类似如下内容:

$EOF:796f255829a040e80168f94c9fe7eda16b35e5df\r\n
… data follows …
796f255829a040e80168f94c9fe7eda16b35e5df

因此,基本上这个字符串由于概率极小而保证永远不会与文件内的任何内容冲突,被用作文件结束标记。简单但效果很好,且十分简洁。

对于超时,由于这是一个阻塞写入过程(因为我们处于保存子进程的上下文中),我直接使用了 SO_SNDTIMEO 套接字选项。这样我们就能确保必须取得进展,否则复制过程就会被中止。因此目前还没有办法对子进程的生命周期设置硬性时间限制,理论上存在一种极端情况,即从节点每隔 timeout-1 秒仅接收一个字节,从而形成极慢的传输。未来子进程可能会监控传输速率,如果速率降至合理值以下,就会报错退出。

同时服务多个从节点
===

该实现的另一个目标是能够同时服务多个从节点。起初这看起来不可能,因为一旦 RDB 传输开始,新的从节点就无法加入,而必须等待当前子进程结束并启动一个新的子进程。

然而,有一个非常简单的技巧可以覆盖许多使用场景,那就是一旦第一个从节点想要复制,我们就等待几秒钟让其他从节点也到达。这就覆盖了例如多个从节点批量重同步的典型情况。

因此,I/O 代码被设计为能够同时写入多个文件描述符。此外,为了即使在使用阻塞 I/O 的情况下也能并行传输,代码尝试在循环中向每个 fd 写入少量数据,以便内核在后台同时向多个从节点发送数据包。

可能代码本身就相当容易理解:

    while(len) {
        size_t count = len < 1024 ? len : 1024;
        int broken = 0;
        for (j = 0; j < r->io.fdset.numfds; j++) {
            … error checking removed …

            /* Make sure to write 'count' bytes to the socket regardless
             * of short writes. */
            size_t nwritten = 0;
            while(nwritten != count) {
                retval = write(r->io.fdset.fds[j],p+nwritten,count-nwritten);
                if (retval <= 0) {
                     … error checkign removed …
                }
                nwritten += retval;
            }
        }
        p += count;
        len -= count;
        r->io.fdset.pos += count;
        … more error checking removed …
    }

请注意,写入操作由 rio.c 的写入目标进行缓冲,因为我们希望仅在有一定数量的数据可用时才写入,否则可能会发送内部只有 5 字节数据的 TCP 数据包。

处理部分失败
===

处理多个从节点不仅仅是写入多个 FD,这相当简单。实际上很大一部分工作是处理部分从节点失败而无需阻塞其他所有从节点的进程。出错的文件描述符会被标记为相关的错误码,并且不会再尝试向它们写入。同时,代码会检测是否所有 FD 都已出错,并在全部出错时彻底中止进程。

然而,当 RDB 写入结束时,子进程需要报告哪些从节点已接收到 RDB 并可以继续复制过程。为此,进程之间使用了一个 Unix 管道。子进程返回一个从节点 ID 及其关联错误状态的数组,以便父进程也能妥善记录错误日志。

我认为这将如何更深层次地改变 Redis
===

无盘复制最终使得 Redis 主从集群可以实现完全无盘的体验。这意味着我们需要更好地支持这一使用场景。目前在禁用持久化的情况下运行复制是危险的,因为我曾认为既然复制无论如何都会触发持久化,就没有关闭持久化的必要。但现在情况变了……因此,已经有计划在无盘环境中更好地支持复制。同样的改进也将应用于 Redis Cluster……它同样非常适合无盘运行,尤其是对于缓存使用场景,在这些场景中副本可以很好地提供数据冗余,而即使多个实例崩溃重启导致集群中一部分哈希槽的数据丢失,也可能不是特别关键。

预计发布时间
===

代码已在此处以 beta 版提供:https://github.com/antirez/redis/commits/memsync
它将在未来几天内合并到 unstable 分支,但计划是稍作等待以收集反馈和错误报告,随后再合并到 3.0 和 2.8 版本中。该功能非常有用,且在关闭时与 Redis 核心其余部分的交互很少。计划是将其全面向后移植,并在一段时间内以“实验性”功能的形式发布。

原文由 Salvatore Sanfilippo 发布

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