Diskless replication: a few design notes.

Salvatore Sanfilippo

无盘复制:几点设计笔记

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

将近一个月前,一些关注 Redis 开发的人在伦敦聚会,召开了首届 Redis 开发者会议。我们一起确定了一批亟待实现的功能(现在已整理在这个 GitHub issue 中:https://github.com/antirez/redis/issues/2045),其中有一项在当天被多次提及:无盘复制。

这个功能并非什么全新想法,之前已被多次提出,尤其是 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
… 后续是数据 …

我懒得去实现某种复杂的分块协议来逐块宣告大小,所以采用了一种更直接粗暴的做法。主节点会生成一个不可猜测、极难碰撞的 160 位随机字符串,并像这样发给从节点:

$EOF:796f255829a040e80168f94c9fe7eda16b35e5df\r\n
… 后续是数据 …
796f255829a040e80168f94c9fe7eda16b35e5df

所以,基本上这个字符串就因为概率上极小而保证不会与文件中的任何内容冲突,被用作文件结束标记。做法简单,却非常有效,也很简洁。

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

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

这个实现的另一个目标是能够同时服务多个从节点。乍看之下这似乎不可能,因为一旦 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 上……它同样非常适合无盘运行,尤其是对于缓存场景,在这些场景中,副本可以很好地提供数据冗余,即使多个实例崩溃重启导致集群中部分哈希槽的数据丢失,也可能不会太严重。

预计发布时间
===

代码的测试版已经可以在这里获取:https://github.com/antirez/redis/commits/memsync
它将在未来几天内合并到 unstable 分支,但计划是先等待一段时间的反馈和 bug 报告,之后再合并到 3.0 和 2.8 分支。这个功能非常有用,而且在关闭状态下与 Redis 核心其余部分的交互很少。计划就是把它移植到所有版本,并以“实验性”功能的名义发布一段时间。

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

评论