点 Redis 8 来了——而且它是开源的

了解更多

永无休止的 Redis 复制循环:是什么、为什么以及如何解决

Redis 的复制功能是一个不可或缺的工具——它既可用于提高 Redis 配置的可用性(您可以在这篇文章中了解更多关于 Redis 可用性的信息),也可通过对只读从库执行读取操作来对其进行横向扩展。

在实现复制时,Redis 利用其核心功能——即 RDB 文件——提供了一种简单优雅的机制,在大多数场景下都很有效。复制功能被广泛采用,包括在我们自己的 Redis Cloud 服务中,它是 Redis 的又一个有用且经过充分验证的能力。

然而,在某些情况下,激活 Redis 的复制功能可能并非易事。这些情况通常是罕见且极端的场景,例如数据集大小显著增长时。但在深入探讨这些细节之前,让我们先了解一下 Redis 复制的工作原理。

Yoda and Luke, Master and Apprentice
“遥远的星系中”

Redis 中的复制就像从库(或学徒)与其主库之间的一段对话。
这段对话大致如下

  1. 学徒:“我想学习,变得像你一样。”
  2. 主库:“我的年轻的帕达万,你必须要有耐心。嗯……”
  3. 主库派生自身,然后…
    1. 派生出的进程将数据集转储到磁盘文件(RDB)中,
    2. 主进程继续处理常规客户端请求,并且
    3. 对数据进行的任何更改都会被复制到主进程上的复制缓冲区中。
  4. 转储完成后,主库说:“趁热过来拿吧。
  5. 学徒通过网络读取文件并将其写入自己的磁盘。
  6. 学徒将文件存储到本地后,便会加载它。
  7. 加载完成后,学徒问道:“好了,我完成了我的循环。我准备好了。
  8. 如果缓冲区中有任何更改,主库说:“你准备好了?你对准备了解多少?感受原力吧!” 并将存储的更改重放到从库。
  9. 缓冲区中没有需要重放的更改后,主库说:“你不再需要训练了。你已经知道你需要知道的一切。
  10. 从那一刻起,主库收到的任何新的更改请求也会被重放到学徒(从库)。

上面的交流本质上让从库分两个阶段同步主库的数据集内容:首先复制完整的(尽管略微过时)数据主体;然后在一个较短的追赶期内应用仅包含更新的部分子集。

“大小无关紧要。看看我。你凭大小评判我吗?”

如前所述,某些数据库,根据其用途和目的,大小会显著增长。增长可能是突然的,也可能是随时间逐渐发生的,但无论如何,事实就是——您的数据库已经变得相当大了。而更大并不总是更好,尤其是在尝试引导(bootstrap)一个复制从库时。

当从库尝试与管理大型数据集(约 25GB 或更大)的主库同步时,会出现几个问题。首先,在快照(snapshotting)过程中,主服务器可能需要大量的 RAM,甚至高达数据库大小的 3 倍。尽管对于小型数据库也是如此,但随着数据库的增长,这一要求变得更难满足。其次,数据集越大,为快照目的派生(fork)另一个进程就越耗时且困难,这直接影响主服务器进程。

这种现象称为“派生导致的延迟”(latency due to fork),并在此处和 redis.io 上进行了解释。然而,让我们假设后者不是问题,并且通过投入足够的硬件,您已经为主服务器提供了足够的资源,使得创建快照和派生延迟可以忽略不计。请记住,所有派生工作完成后,从库还需要从主库复制文件。

令人遗憾的是,这通过客户端用于访问数据库的相同互连进行。大多数情况下,大型数据库被许多客户端使用,这些客户端会产生大量流量。此外,在云环境中,同一网络可能还用于访问类似 EBS 的网络附加存储。向该传输介质添加 10GB 量级的文件传输流量几乎不会减轻任何现有拥塞。实际上,恰恰相反。即使假设存在最佳网络条件,一个臃肿的 RDB 文件通过线路传输并写入本地磁盘的速度仍然存在物理限制。

总而言之,考虑到这些因素及其综合影响,从库需要时间来准备并就绪文件以进行加载。文件就绪后,从库也需要时间来加载文件。您不需要详细的模型或复杂的数学证明就能直观地理解一个事实:您的数据集越大,派生、转储、复制和将其加载到从库所需的时间就越长。

您可能会说:“那又怎样?”“我又不是每天都需要设置一个新的从库。我有时间,我可以等。“你必须忘掉你学到的东西”,而且你会一直等下去,永无止境。

从库将永远无法完成同步,复制也不会开始。这是因为在创建快照、传输并加载到从库期间,时间已经过去,而主库正忙于处理请求(在一个庞大且繁忙的数据库中,请求可能很多)。更新积累在专用的复制缓冲区中,但该缓冲区的大小最终是有限的,一旦耗尽,就无法再用来使从库保持最新。

由于没有有效的更新缓冲区来追赶,从库无法完成初步同步周期,这是实时主动开始从主库复制更新所必需的。为了纠正这种情况,Redis 在这些情况下的行为是从头开始重新启动从库的同步过程。

于是,学徒回到了起点,忘记了目前所学的一切,带着一个请求回到主库:“我想学习,变得像你一样。” 然而,由于基本情况保持不变,后续启动复制的尝试很可能面临与第一次迭代相同的命运。

新的希望

这种情况虽然罕见,但真实存在,并可能发生,Manohar 最早在此处提出过。即将发布的 Redis v2.8 肯定会改进它,并且未来几乎可以肯定开源社区会完全克服它。同时,如果您正在寻找即时解决方案,您可以访问我们的 github 下载我们的 Redis 2.6.14 版本。在此版本中,我们添加了一个客户端限流(throttling)机制,以便巧妙地为从库争取足够的时间来完成同步。我们的限流机制通过向主服务器对应用程序客户端请求的响应引入延迟来实现。乍一看这似乎违反直觉,但增加的延迟为从库提供了足够的“喘息空间”,使其能够在更新日志耗尽空间之前完成传输和重放更新日志,从而允许同步完成并开始复制。

在实现此机制时,我们添加了新的配置变量

slave-output-buffer-throttling

使用以下语法进行设置

CONFIG SET slave-output-buffer-throttling <low> <high> <rate> <delay>

其中

  • <low> 是缓冲区大小的阈值(以字节为单位),一旦超过此值,就会激活限流功能
  • <high> 是缓冲区在复制开始前允许达到的最大大小(以字节为单位)
  • <rate> 是估计的复制速率(以字节/秒为单位)
  • <delay> 是强制延迟的最大值(以毫秒为单位)

例如,以下设置

CONFIG SET slave-output-buffer-throttling 32768 131072 102400 1000

将导致复制过程按以下变化进行

  1. 主库派生自身,然后
    1. 派生出的进程将数据集转储到磁盘文件(RDB)中
    2. 主进程继续处理客户端请求,但
      1. 只要缓冲区大小小于 <low> 值(例如 32768 字节或 32MB),请求会正常处理
      2. 一旦缓冲区大小超过 <low> 阈值,主库将估计完成复制周期所需的时间,并可能通过在其响应中增加最高 <delay>(例如 1000 毫秒)的延迟来强制执行客户端限流
    3. 对数据进行的任何更改都会被复制到主进程上的复制缓冲区中

“……很难看清。未来总是在运动中。”

主库估计完成复制周期所需时间的方式如下

  • 当转储创建、被从库获取和处理完毕,并且从库准备好在线流式传输新更新后,复制周期被视为完成。
  • 主库依靠提供的 <rate> 参数作为一秒内可以处理的有效复制量。

在我们的示例中,假设数据集大小为 25GB。考虑到我们提供的速率为 100MB/s(或 102400 字节/秒),设置如下

slave-output-buffer-throttling

主库将估计复制周期将在 250 秒内完成(= 25GB / 100MB/秒)。

如果复制缓冲区增长过快,主库将通过延迟响应来启动限流。 <high> 参数确定在复制过程结束时允许的最大缓冲区大小,因此限流会在任何时刻按比例触发并应用。这意味着,例如,在周期开始 125 秒时,主库将假定已完成 50%。此时,如果复制缓冲区大小超过 64MB(这是 <high> 131072 字节值的 50%),主库将应用延迟。

实际引入的延迟与缓冲区超出限制的程度成比例,并且不会超过 <delay> 设置的 1000 毫秒,以保持服务器的响应性。出于同样的原因,服务器永远不会对新连接的第一个请求进行限流。

最后,

slave-output-buffer-throttling

以及标准的 Redis

client-output-buffer-limit

(在此处阅读更多信息 here)机制可以结合使用,因此您需要确保它们不会冲突。您可以通过设置

client-output-buffer-limit

高于

slave-output-buffer-throttling

例如

CONFIG SET client-output-buffer-limit 262144 131072 60

在此示例中,如果限流未能成功限制缓冲区大小——可能是由于创建巨大键的请求导致——那么标准的

client-output-buffer-limit

机制将启动,并在达到 256MB 或超过 <high> 128MB 限制并持续 60 秒以上时中断循环。

希望此解释和解决方案对您有所帮助!愿原力与你同在。