此内容已使用自动机器翻译服务进行翻译,仅供您参考及阅读便利。其中可能包含错误、遗漏,或与原始英文版本存在理解方面的细微差别。如有疑问,请参考原始英文版本。
什么是 Quicksilver?
Cloudflare 在 125+ 国家/地区 330 个城市设有服务器。所有这些服务器都运行 Quicksilver,这是一个键值数据库,包含我们很多服务的重要配置信息,到达 Cloudflare 网络的所有请求都会查询到它。
由于在处理请求时使用 Quicksilver,因此 Quicksilver 的速度非常快;目前,它可以在不到 1 毫秒内响应 90% 的请求,在不到 7 毫秒内响应 99.9% 的请求。大多数请求仅针对几个键,但有些请求针对数百个甚至更多键。
Quicksilver 目前包含超过 50 亿个键值对,总大小为 1.6 TB,在全球范围内每秒提供超过 30 亿个键。鉴于我们的数据集一直在增长,而且新的用例也在不断增加,保持 Quicksilver 的快速运行带来了一些独特的挑战。
Quicksilver 过去常常在各地所有服务器上存储所有键值,但每台服务器上可以使用的磁盘空间量显然是有限的。例如,Quicksilver 使用的磁盘空间越多,留给内容缓存的磁盘空间就越少。此外,每增加一个包含特定键值的服务器,存储该键值的成本就会增加。
这就是为什么磁盘空间使用情况是 Quicksilver 团队在过去几年中一直进行的主要战斗的原因。过去几年,我们做了大量工作,但现在我们终于创建了一个架构,让我们能够超越磁盘空间的限制,并最终使 Quicksilver 更好地可扩展。
在过去一年中,Quicksilver 数据库的大小增长了 50%,达到约 1.6 TB
我们之前谈到
故事的第一部分解释了 Quicksilver V1 如何在世界各地的每台服务器上存储所有键值对。这是一个非常简单、快速的设计,运行良好,是一个很好的开始方式。但随着时间的推移,发现从磁盘空间的角度来看,这种方法的扩展性并不好。
问题在于,磁盘空间很快就会耗尽,以至于没有足够的时间来设计和实施完全可扩展的 Quicksilver 版本。因此,首先创建了 Quicksilver V1.5。与 V1 相比,每台服务器使用的磁盘空间减少了一半。
为此,为 Quicksilver 引入了一种新的代理模式。在这种模式下,Quicksilver 不再包含完整数据集,仅包含一个缓存。所有缓存未命中都会在另一台运行 Quicksilver 且包含完整数据集的服务器上查找。每台服务器运行大约 10 个独立的 Quicksilver 实例,并且全部拥有具有不同键值集的不同数据库。我们将具有完整数据集的 Quicksilver 实例称为 副本 。
对于 Quicksilver V1.5,特定服务器上的这些实例中有一半将以代理模式运行 Quicksilver,因此将不再拥有完整的数据集。另一半将以副本模式运行。这在一段时间内效果不错,但并不是最终的解决方案。
构建这个中间解决方案还有一个额外的好处,即允许团队获得运行更加分布式的 Quicksilver 版本的经验。
问题
Quicksilver V1.5 不能完全扩展有几个原因。
首先,单个实例的大小不是很稳定。键空间归使用 Quicksilver 的团队所有,而不属于 Quicksilver 团队所有,而这些团队使用 Quicksilver 的方式经常发生变化。此外,虽然大多数实例随着时间的推移而增长,但有些实例实际上已经变得更小,例如当团队优化 Quicksilver 的使用时。其结果是,实例的划分在一开始是良好平衡的,很快就变得不平衡。
其次,在分析每台服务器上的缓存需要多少键空间时,假设了三天内被访问的所有键就代表了一个足够好的缓存。事实证明,这种假设是完全错误的。此分析估计我们需要大约 20% 的键空间用于缓存,结果证明这并不完全准确。虽然大多数实例的缓存命中率确实很高,20% 或更少的键空间在缓存中,但事实证明,一些实例需要更高的百分比。
但主要问题在于,将我们网络上 Quicksilver 使用的磁盘空间减少 40% 实际上并没有使其更具可扩展性。存储在 Quicksilver 中的键值数量不断增长。只花了大约两年的时间,磁盘空间就再次不足了。
解决方案
除了少数特殊的存储服务器外,Quicksilver 不再包含完整数据集,而只是缓存。任何缓存未命中的情况都将在我们存储服务器上的副本中查找,这些服务器具有完整的数据集。
可扩展性问题的解决方案是由一个新的见解带来的。事实证明,许多键值实际上几乎从未使用过。我们将这些称为冷密钥。导致这些冷门密钥的原因有所不同:一些密钥太旧,没有得到很好的清理,一些密钥仅在特定区域或特定数据中心使用,一些密钥很长时间没有使用,或者根本没有使用(例如例如,从未查询过的域名或已上传但从未使用的脚本)。
起初,团队一直在考虑通过将整个数据集分割成分片,并将其分布到不同数据中心的服务器上,来解决我们的可扩展性问题。但对完整数据集进行分片会增加大量复杂性、极端情况和未知因素。分片也不能针对数据位置进行优化。例如,如果键空间被拆分为 4 个分片,并且每个服务器各得到一个分片,则该服务器只能从其本地数据库提供 25% 的所请求键。冷密钥仍将包含在这些分片中,并且会不必要地占用磁盘空间。
另一种数据结构在数据本地化方面更好,并且明确避免存储从不使用的键是缓存。因此,决定只有少数具有大磁盘的服务器来维护完整的数据集,而所有其他服务器将只有一个缓存。这与 Quicksilver V1.5 明显有很大出入。当时已经在较小规模上进行了缓存,因此所有组件都已经可用。缓存代理和数据中心间发现机制已经就位。它们自 2021 年起使用,因此经过了彻底的实战考验。但还需要添加一个组件。
有人担心,让所有服务器上的所有实例都连接到少数几个具有副本的存储节点,会因为连接过多而使其过载。因此增加了一个 Quicksilver 中继。对于每个实例,将在每个数据中心内选择一些服务器,Quicksilver 将以中继模式运行。中继将维持与存储节点上副本的连接。数据中心内的所有代理都会发现这些中继,所有缓存未命中都将通过它们中继到副本。
这种新架构运行良好。不过,缓存命中率仍需提高。
预取未来
每个已解析的缓存未命中都会由数据中心的所有服务器预取
我们有一个假设,预取在同一数据中心内的其他服务器上缓存未命中的所有键,能够提高缓存命中率。因此,进行了分析,分析确实表明,在一个数据中心的一台服务器上缓存未命中的每个键,很有可能在不久的将来某个时候在同一数据中心的另一台服务器上也缓存未命中。因此,建立了一种机制,将中继上所有已解析的缓存未命中分发给所有其他服务器。
数据中心中的所有缓存未命中都是通过向中继发出请求来解决的,中继随后将请求转发给存储节点上的副本之一。因此,实现预取机制的方式是让中继发布所有已解析的缓存未命中流,供同一数据中心中的所有 Quicksilver 代理订阅。然后,将生成的键值添加到代理的本地缓存中。
这种策略称为反应式预取,因为它只使用同一数据中心内缓存未命中直接导致的键值填充缓存。这些预取是对缓存未命中的反应。另一种预取方式称为预测性预取,这种算法会尝试预测哪些尚未被请求的键在不久的将来会被请求。人们尝试了一些进行这些预测的方法,但并没有带来任何改善,因此这个想法被放弃了。
启用预取后,性能最差的实例的缓存命中率达到了 99.9% 左右。这就是我们想要实现的目标。但是,在将其推广到我们网络的更大范围时,团队发现这种新架构的尾部延迟太高了,他们需要更高的缓存命中率。
该团队使用了名为 dnsv2 的 Quicksilver 实例。这是一个对延迟非常敏感的实例,因为它是 DNS 查询服务的实例。底层的一些 DNS 查询需要多次查询 Quicksilver,因此,任何增加的 Quicksilver 延迟都会成倍增加。这就是为什么我们决定对 Quicksilver 缓存进行另一项改进。
一级缓存命中率平均为 99.9% 或更高。
回到分片
在转到另一个数据中心的副本之前,首先在数据中心范围的分片缓存中查找缓存未命中
要求更高缓存命中率的实例也是缓存性能最差的实例。缓存工作时有一定的保留时间,即键值最后一次访问后在缓存中保留的天数,超过指定时间后就会被从缓存中逐出。对缓存的分析显示,这个实例需要更长的保留时间。但是,较长的保留时间也会导致缓存占用更多的磁盘空间,导致原本不可用的空间增加。
但是,在运行 Quicksilver V1.5 时,我们已经注意到这样一种模式:与大型数据中心相比,缓存在较小的数据中心中的性能通常要好得多。这引发了带来最终改进的假设。
事实证明,规模较小、服务器较少的数据中心,通常需要的缓存磁盘空间也较少。反之亦然,数据中心中的服务器越多,Quicksilver 缓存就需要越大。这很容易解释,因为数据中心越大,通常服务的人群越多,因此请求的多样性也就越多。更多服务器还意味着数据中心内的可用磁盘空间更多。为了利用这种模式,重新引入了分片的概念。
我们的密钥空间被拆分为多个分片。数据中心中的每台服务器都分配一个分片。它们包含缓存,而不是那些包含完整数据集的分片。这些缓存分片由数据中心内的所有缓存未命中填充。这一切形成了一个使用分片技术在数据中心范围内分布的缓存。
如上所述,对完整数据集进行分片所带来的数据局部性问题,可以通过同时保留本地每服务器缓存来解决。分片缓存是对本地缓存的补充。一个数据中心中的所有服务器都包含同时包含它们的本地缓存和一个缓存的分片式缓存的一个物理分片。因此,每个请求的键首先在服务器的本地缓存中查找,然后查询数据中心范围的分片缓存,最后,如果两个缓存都未命中该请求的键,则在其中一个存储节点中查找。
通过首先按范围将键的哈希划分为 1024 个逻辑分片,将键空间拆分为单独的分片。然后,这些逻辑分片再次按范围划分为物理分片。通过对服务器主机名重复相同的过程,为每台服务器分配一个物理分片。
每台服务器包含一个物理分片。一个物理分片包含一系列逻辑分片。本地分片包含通过对所有键进行哈希处理而产生的一系列有序集。
这种方法的优点是分片因子可以扩大到两倍,而无需将缓存复制到其他服务器。当以这种方式增加分片因子时,服务器将自动分配一个新的物理分片,其中包含该服务器先前物理分片所包含的键空间的子集。在这之后,它们的缓存中将包含所需缓存的超集。随着时间的推移,不再需要的键值将被逐出。
当物理分片的数量加倍时,服务器将自动获得新的物理分片,它们是先前物理分片的子集,因此缓存中仍有相关的键值。
这种方法意味着,随着 Quicksilver 中键数量的增加,可以在需要时轻松扩展分片缓存,而无需重新定位数据。此外,分片非常平衡,因为它们包含非常大的密钥空间的均匀随机子集。
向物理缓存分片添加新的键值会利用预取机制,该机制已经将所有解析的缓存未命中分发给数据中心中的所有服务器。属于特定服务器上物理分片的键空间的键,只是比不属于该物理分片的键在缓存中保留的时间更长。
分片缓存比将完整键空间分片简单的另一个原因是,可以使用缓存走捷径。例如,缓存分片不支持查找旧版本的键值(如用于多版本并发控制的那样)。正如之前的博客文章中所解释的,当服务器具有较新版本的数据库时,这对于在不同服务器上查找键值时保持一致性是必要的。在缓存分片中则不需要,因为当正确的版本不可用时,查找始终可以回退到存储节点。
代理有一个最近的键窗口,其中包含最近写入的所有键值。缓存分片仅包含其缓存的键值。存储副本包含所有键值,且除此之外,它们还包含最近写入的键值的多个版本。数据库版本为 1000 的代理,如果对 key1 有缓存未命中,可以看到缓存分片上该键的版本是在数据库版本 1002 中写入,因此这个版本太新。这意味着它与代理的数据库版本不一致。这就是为什么中继将从副本中获取该键,副本可以返回早期的一致版本。相反,key2 可以使用缓存分片上的 key2,因为它是在索引 994 写入的,远低于代理的数据库版本。
只有在一种非常特殊的情况下,缓存分片的键值对无法使用。当缓存分片中的键值写入的数据库版本比当时的代理数据库版本更新时,就会发生这种情况。这意味着,该键值对的值可能与正确版本中的值不同。因为,在一般情况下,缓存分片和代理数据库的版本彼此非常接近,而且只发生在这两个数据库版本之间写入的键值,所以这种情况很少发生。因此,延迟到存储节点的查找对缓存命中率没有明显影响。
分级存储
总之,Quicksilver V2 提供三个存储级别。
1 级:每台服务器上的本地缓存包含最近访问过的键值。
二级:数据中心范围的分片缓存,其中包含一段时间内未被访问但曾经被访问过的键值。
第 3 级:存储节点上包含完整数据集的副本,位于少数几个存储节点上,并且只会被查询冷密钥。
结果
通过添加第二个缓存层,可在数据中心内解析的密钥百分比显著提高。最差性能实例的缓存命中率高于 99.99%。所有其他实例的缓存命中率高于 99.999%。
一级和二级缓存的组合缓存命中率达到 99.99%,或者在最差的缓存实例中更高。
结语
团队花了好几年的时间才从旧的 Quicksilver V1(所有数据存储在每个服务器上)升级到分层缓存 Quicksilver V2(除了少数服务器外的所有服务器都只有缓存)。我们面临许多挑战,包括不间断地迁移数十万个实时数据库,以及每秒处理数十亿次请求。对大量代码进行了更改,使 Quicksilver 现在的架构大为不同。所有这些都是对我们的客户透明进行的。这一切都是通过迭代完成,总是从上一步中学习然后再进行下一步。并且始终确保,如果可能的话,所有更改都可以轻松地还原。这些是安全迁移复杂系统的重要策略。
如果您喜欢这些故事,请留意我们的博客上更多的开发故事。如果您热衷于解决此类问题,我们正在招聘多种类型的职位,
谢谢!
最后,非常感谢 Quicksilver 团队的其他成员,因为我们一起完成了这项工作:Aleksandr Matveev, Aleksei Surikov, Alex Dzyoba, Alexandra (Modi) Stana-Palade, Franciscois Stiennon, Geoffrey Plouviez, Ilya Polyakovskiy, Manzur Mukhitdinov, Volodymyr Dorokhov.