平衡与不平衡
#linux #database #redis

Floating Jerry from Rick and Morty

平衡在生活中至关重要。当我们的重点仅限于改善生活的单一方面时,我们会削弱整个系统。而且,如果您不为系统提供稳定性,它最终将在其自身的重量下崩溃(或者,在Jerry -Float的情况下)。

以Redis为例。它支持一个相对较高的吞吐量,但其持久性机制并非旨在支持高负载:redis中的写入越高,系统变得越脆弱。要了解为什么,我需要解释Redis Bgsave快照是如何工作的。

BGSAVE算法允许REDIS将其内存数据的时间点快照到磁盘上,或与其辅助复制品同步,作为背景过程。
REDIS通过称Linux为“ Fork()”来打开可以访问父母内存的子进程来做到这一点。从这一点开始,两个过程都具有相同的内容,并且它们的物理内存使用情况保持不变,但是一旦其中一个写入记忆,它就会创建页面的私有副本。

Redis Parent and Child Processes

此功能称为复印件,它是Linux内存管理系统的一部分。当一个过程之一复制内存页面时,Linux必须分配其他4KB的物理内存。

redis子进程不会更改其内存页面。它仅扫描条目并将其写入RDB快照。同时,父母的重新流程继续处理传入。每次触摸现有内存页面的写入,它有效地分配了更多的物理内存。假设我们的Redis实例使用40GB的物理内存。 fork()之后,父进程可能会重复每页,需要额外的40GB RAM:总共80GB。

但是,如果Redis的写入吞吐量足够低,则可能在复制所有页面之前完成快照过程。在这种情况下,它可能最终在40GB和80GB之间达到顶峰。 BGSAVE期间的这种不确定的行为使管理重新部署的团队引起了巨大的头痛:他们需要减轻内存过度提供的OOM风险。

keydb是一种重新释放的重新叉叉,并承诺将更快地重新进行重新进行,因此由于其多线程架构,可以维持更高的吞吐量。但是,当keyDB触发bgsave时会发生什么? KeyDB Parent进程可以处理更多的写入流量,因此在BGSAVE完成之前,它会改变与子进程共享的更多内存页面。结果,物理记忆使用增加,使整个系统面临更大的风险。

这是键DB的错吗?不,因为KeyDB不再是饥饿的内存,并且其BGSAVE算法与Redis的算法相同。但是,不幸的是,通过在Redis的不平衡体系结构中解决一个问题,它介绍了其他问题。

这种狭窄的方法并非键DB唯一。 AWS Elasticache引入了类似的封闭源功能,该功能与其在多CPU机器上的Redis实例相似。根据我的测试,它遇到了相同的问题:在高吞吐量的情况下,它无法产生快照,或者将吞吐量降低到荒谬的速率小于15k Qps。

蜻蜓快照

让我们退后一步,正式化问题。执行时间点快照的主要挑战是保留快照隔离属性
当记录并行更改时:如果快照在时间启动时t并在t+u结束,

它应该反映所有条目的状态,因为它们是t

因此,我们需要一个方案来处理由于传入更新而必须修改的记录,但尚未序列化。这引发了我们的问题:我们甚至如何认识到快照程序中的哪些条目已经被序列化,哪些没有?

蜻蜓为此使用版本化。它保持单调增加的反击,
每次更新都会增加。商店中的每个字典条目都捕获了其上一个更新版本,
然后将其用于确定该条目是否应序列化。快照开始时,它将下一个版本分配给自身,从而保留商店中所有条目的以下变体:entry.version < snapshot.version

然后,它启动了主遍布循环,该循环在词典上迭代并开始序列化磁盘。这是异步完成的,如下概念上所示,用fiber_yield()调用。

cpp showLineNumbers=199

snapshot.version = next_version++;

for (entry : table) {
   if (entry.version < snapshot.version) {
     entry.version = snapshot.version;
     SendToSerializationSink(entry);
     fiber_yield();
   }
}

循环不是原子,蜻蜓可以接受传入的写作。这些写作请求中的每一个都可以修改,删除或添加字典中的条目。通过检查遍历循环中的条目版本,我们确保不会序列化两次,或者在开始快照后修改的条目不会序列化。

上面的循环只是解决方案的一部分。我们还需要处理并行处理的更新:

cpp showLineNumbers=299

void OnPreUpdate(entry) {
  uint64_t old_version = entry.version;

  // Update the version to the newest one.
  entry.version = next_version++;  // Regular version update

  if (snapshot.active && old_version < snapshot.version) {
    SendToSerializationSink(entry);
  }
}

我们确保尚未序列化的条目(version小于snapshot.version)将在更新之前序列化。

主遍历环和OnPreUpdate钩都足以确保我们精确地将每个现有条目序列化,并且在快照开始后添加的条目完全不会被序列化。蜻蜓不依赖OS通用内存管理。

通过避免使用不平衡的fork()调用,并在更新期间将条目推入序列化水槽,建立了一种自然的后压机制,这是每个可靠的系统必须具有的。由于所有这些因素,无论数据集大小有多大,蜻蜓的内存开销都是恒定的。

真的很简单吗?不完全的。虽然如上所述的高级算法是准确的,但我尚未显示我们如何协调多个线程的快照,
或我们如何在每个键上浪费8个字节等的情况下维护uint64_t版本,等等。

乍一看,快照问题似乎并不重要,但它实际上是平衡基础的基石,它使蜻蜓成为可靠,性能和可扩展的系统。诸如快照之类的功能是将蜻蜓与其他尝试建立高性能存储店的尝试区分开来的事情之一,我认为开发人员社区认识到这一点。

Star History from Github