一、介绍
Docstore 是 Uber 内部的分布式数据库,建立在 MySQL 之上。它存储数十PB的数据,每秒处理数千万个请求,是Uber最大的数据库引擎之一,被所有业务垂直领域的微服务所使用。自 2020 年成立以来,Docstore 用户和用例正在增长,请求量和数据占用量也在增长。业务垂直领域和产品/服务的需求不断增加,引入了复杂的微服务和依赖项调用图。因此,应用程序需要数据库的低延迟、更高的性能和可扩展性,同时会产生更高的工作负载。
1.挑战
Uber 的大多数微服务都使用基于磁盘的存储支持的数据库来持久化数据。但是,每个数据库都面临着为需要低延迟读取访问和高可伸缩性的应用程序提供服务的挑战。当一个用例需要比我们现有任何用户都高得多的读取吞吐量时将非常具有挑战性。Docstore 可以满足他们的需求,因为它由 NVMe SSD 提供支持,可提供低延迟和高吞吐量。然而,在上述场景中使用 Docstore 的成本会过高,并且需要许多扩展和运营挑战。在深入探讨挑战之前,让我们先了解一下 Docstore 的高级架构。
2.Docstore 架构
Docstore 主要分为三层:无状态查询引擎层、有状态存储引擎层和控制面。对于本博客的范围,我们将讨论其查询层和存储引擎层。无状态查询引擎层负责查询规划、路由、分片、架构管理、节点健康监控、请求解析、验证和 AuthN/AuthZ。存储引擎层负责通过 Raft、复制、事务、并发控制和负载管理来实现共识。分区通常由NVMe SSD支持的MySQL节点组成,这些节点能够处理繁重的读取和写入工作负载。此外,使用 Raft 将数据分片到包含一个主节点和两个从属节点的多个分区中进行共识。
现在,让我们看一下当服务需要大规模低延迟读取时面临的一些挑战:
从磁盘检索数据的速度有一个阈值:优化应用程序数据模型和查询以改善数据库延迟和性能的程度是有限的。
垂直扩展:分配更多资源或升级到性能更高的更好主机有其局限性,数据库引擎本身会成为瓶颈。
水平扩展:在更多分区中进一步拆分分片有助于在一定程度上解决挑战,但这样做在操作上是一个更复杂和漫长的过程。我们必须确保数据的持久性和弹性,而不会出现任何停机。此外,此解决方案并不能完全帮助解决热键/分区/分片的问题。
请求不平衡:读取请求的传入速率通常比写入请求高几个数量级。在这种情况下,底层 MySQL 节点将难以跟上繁重的工作负载并进一步影响延迟。
成本:从长远来看,通过垂直和水平扩展来改善延迟的成本很高。成本乘以 6 倍,以处理两个区域中的 3 个有状态节点中的每一个。此外,缩放并不能完全解决问题。
为了克服这个问题,微服务利用了缓存。在优步,我们提供 Redis 作为分布式缓存解决方案。微服务的典型设计模式是写入数据库和缓存,同时从缓存中读取数据,以改善延迟。但是这种方法存在以下挑战:
每个团队都必须为各自的服务预置和维护自己的 Redis 缓存
缓存失效逻辑在每个微服务中分散实现
在区域故障转移的情况下,服务要么必须保持缓存复制以保持热度,要么在缓存在其他区域预热时遭受更高的延迟
各个团队必须花费大量精力来使用数据库实现自己的自定义缓存解决方案。当务之急是找到一种更好、更高效的解决方案,不仅要以低延迟处理请求,而且要易于使用并提高开发人员的工作效率。
3.CacheFront
我们决定构建一个集成的缓存解决方案,即 CacheFront for Docstore,并牢记以下目标:
最大限度地减少垂直和/或水平扩展的需求,以支持低延迟读取请求
减少对数据库引擎层的资源分配;缓存可以从相对便宜的主机构建,因此整体成本效益得到提高
改善 P50 和 P99 延迟,并稳定微突发期间的读取延迟峰值
替换大多数由各个团队构建(或将要)构建的自定义缓存解决方案,以满足他们的需求,尤其是在缓存不是团队的核心业务或能力的情况下
通过重用现有的 Docstore 客户端使其透明,无需任何额外的处理,从而允许从缓存中受益
提高开发人员的工作效率,并允许我们向客户透明地发布新功能或替换底层缓存技术
将缓存解决方案从 Docstore 的底层分片方案中分离出来,避免热键、分片或分区引起的问题
允许我们水平横向扩展缓存层,独立于存储引擎
将维护和调用 Redis 的所有权从功能团队转移到 Docstore 团队
二、CacheFront 设计
1.Docstore 查询模式
Docstore 支持通过主键或分区键进行查询的不同方式,并可选择过滤数据。概括地说,主要可分为以下几类:
Key-type / Filter | No Filter | Filter by WHERE clause |
Rows | ReadRows | – |
Partitions | ReadPartition | QueryRows |
我们希望以增量方式构建解决方案,从最常见的查询模式开始。事实证明,超过 50% 的 Docstore 查询是 ReadRows 请求,而且由于这也恰好是最简单的用例——没有过滤器和点读取——因此很自然地从集成开始。
2.高级体系结构
由于 Docstore 的查询引擎层负责向客户端提供读取和写入服务,因此非常适合集成缓存层。它还将缓存与基于磁盘的存储分离,允许我们独立扩展其中任何一个。查询引擎层实现了一个用于存储缓存数据的 Redis 接口,以及一种使缓存条目失效的机制。高级体系结构如下所示:
Docstore 是一个高度一致的数据库。尽管集成缓存提供了更快的查询响应,但在使用缓存时,每个微服务可能无法接受有关一致性的某些语义。例如,缓存失效可能会失败或滞后于数据库写入。出于这个原因,我们将集成缓存作为一项可选功能。服务可以基于每个数据库、每个表甚至每个请求配置缓存使用情况。如果某些流需要强一致性(例如在食客购物车中获取商品),则可以绕过缓存,而其他写入吞吐量低的流(例如获取餐厅的菜单)将从缓存中受益。
3.缓存读取
CacheFront 使用缓存端策略来实现缓存读取:
查询引擎层获取再增加一行的读取请求
如果启用了缓存,请尝试从 Redis 获取行;流式传输对用户的响应
从存储引擎中检索剩余的行(如果有)
使用其余行异步填充 Redis
将剩余行流式传输给用户
三、缓存失效
“计算机科学中只有两件困难的事情:缓存失效和命名。” – Phil Karlton
尽管上一节中的缓存策略可能看起来很简单,但必须考虑许多细节以确保缓存正常工作,尤其是缓存失效。在没有任何显式缓存失效的情况下,缓存条目将与配置的 TTL 一起过期(默认为 5 分钟)。虽然这在某些情况下可能是可以的,但大多数用户希望更改的反映速度比 TTL 快。默认的 TTL 可以降低,但这会降低我们的缓存命中率,而不会有意义地提高一致性保证。
1.条件更新
Docstore 支持条件更新,其中可以根据筛选条件更新一行或多行。例如,更新指定区域中所有连锁餐厅的假期时间表。由于给定筛选器的结果可能会更改,因此在数据库引擎中更新实际行之前,我们的缓存层无法确定哪些行将受到条件更新的影响。因此,我们无法在无状态查询引擎层的写入路径中使缓存的行失效并填充条件更新。
2.利用变更数据捕获实现缓存失效
为了解决这个问题,我们利用了 Docstore 的变更数据捕获和流媒体服务 Flux。Flux 跟踪存储引擎层中每个集群的 MySQL 二进制日志事件,并将这些事件发布到消费者列表。Flux 为 Docstore CDC(变更数据捕获)、复制、物化视图、数据湖摄取以及验证集群中节点之间的数据一致性提供支持。编写了一个新的使用者,它订阅数据事件,并使 Redis 中的新行失效或更新安装。
现在,使用此失效策略,条件更新将导致受影响行的数据库更改事件,这些事件将用于使缓存中的行失效或填充。因此,我们能够在数据库更改后的几秒钟内使缓存保持一致,而不是几分钟。此外,通过使用二进制日志,我们不会冒着让未提交的事务污染缓存的风险。缓存失效的最终读取和写入路径如下所示:
3.在查询引擎和 Flux 之间删除重复缓存写入
但是,上述缓存失效策略存在缺陷。由于写入操作在读取路径和写入路径之间同时发生,因此我们可能无意中将过时的行写入缓存,从而覆盖从数据库中检索到的最新值。为了解决这个问题,我们根据 MySQL 中设置的行的时间戳来删除重复的写入,这实际上充当了它的版本。
时间戳是从 Redis 中编码的行值中解析出来的(请参阅后面有关编解码器的部分)。Redis 支持使用 EVAL 命令以原子方式执行自定义 Lua 脚本。此脚本采用与 MSET 相同的参数,但是,它还执行重复数据删除逻辑,检查已写入缓存的任何行的时间戳值,并确保要写入的值较新。通过使用 EVAL,所有这些都可以在单个请求中执行,而不需要在查询引擎层和缓存之间进行多次往返。
4.为点写入提供更强的一致性保证
虽然 Flux 允许我们比仅依靠 Redis TTL 来使缓存条目过期的速度快得多,但它仍然为我们提供了最终的一致性语义。然而,某些用例需要更强的一致性,例如读取-自己-写入,因此对于这些场景,我们在查询引擎中添加了一个专用 API,允许我们的用户在相应的写入完成后显式地使缓存的行失效。这使我们能够为点写入提供更强的一致性保证,但不能为条件更新提供一致性保证,因为条件更新仍会被 Flux 失效。
5.表架构
在详细介绍实现之前,让我们定义几个关键术语。Docstore 表具有主键和分区键。主键(通常称为行键)唯一标识 Docstore 表中的行,并强制执行唯一性约束。每个表都必须有一个主键,该主键可以由一列或多列组成。分区键是整个主键的前缀,用于确定行将位于哪个分片中。它们不是完全分开的,相反,分区键只是主键的一部分(或等于主键)。
在上面的示例中,person_id 是 person 表的主键和分区键。而对于订单,表 cust_id 是一个分区键,cust_id 和 order_id 一起构成一个主键。
6.Redis 编解码器
由于我们主要将缓存行读取,因此我们可以使用给定的行键唯一标识行值。由于 Redis 键和值存储为字符串,因此我们需要一个特殊的编解码器以 Redis 接受的格式对 MySQL 数据进行编码。选择了以下编解码器,因为它允许不同的数据库共享缓存资源,同时仍保持数据隔离。
7.特征
在完成高级设计后,我们的解决方案是有效的。现在是时候考虑规模和弹性了:
如何实时验证数据库和缓存之间的一致性
如何容忍区域/区域故障
如何容忍 Redis 故障
8.比较缓存
所有这些关于提高一致性的讨论,如果它是不可衡量的,那么它就毫无意义,因此我们添加了一种特殊模式,将读取请求隐藏到缓存中。回读时,我们会比较缓存数据和数据库数据,并验证它们是否相同。任何不匹配(缓存中存在的过时行或缓存中存在的行,但数据库中不存在)都会被记录并作为指标发出。通过使用 Flux 添加缓存失效,缓存的一致性为 99.99%。
9.缓存预热
Docstore 实例会生成两个不同的地理区域,以确保高可用性和容错能力。部署是主动-主动的,这意味着可以在任何区域中发出和处理请求,并且所有写入都可以跨区域复制。在区域故障转移的情况下,另一个区域必须能够处理所有请求。此模型对 CacheFront 提出了挑战,因为缓存应始终跨区域为暖。否则,区域故障转移将增加对数据库的请求数,因为最初在故障区域中提供的流量出现缓存未命中。这将阻止我们缩减存储引擎并回收任何容量,因为数据库负载将与没有任何缓存时一样高。
冷缓存问题可以通过跨区域 Redis 复制来解决,但它会带来一个问题。Docstore 有自己的跨区域复制机制。如果我们使用 Redis 跨区域复制来复制缓存内容,我们将有两种独立的复制机制,这可能会导致缓存与存储引擎不一致。为了避免 CacheFront 出现缓存不一致问题,我们通过添加新的缓存预热模式来增强 Redis 跨区域复制组件。为了确保缓存始终是热缓存,我们跟踪 Redis 写入流并将密钥复制到远程区域。在远程区域,读取请求不是直接更新远程缓存,而是向查询引擎层发出,在缓存未命中时,该层从数据库读取并写入缓存,如设计的“缓存读取”部分所述。
通过仅在缓存未命中时发出读取请求,我们还避免了存储引擎不必要的过载。来自查询引擎层的读取行的响应流被简单地丢弃,因为我们对结果并不真正感兴趣。通过复制键而不是值,我们始终确保缓存中的数据与其各自区域中的数据库一致,并且我们在两个区域的 Redis 中保持相同的缓存行工作集,同时还限制了使用的跨区域带宽量。
10.负缓存
在许多读取针对不存在的行的情况下,最好缓存负结果,而不是每次都缓存未命中并查询数据库。为了实现这一点,我们在 Cachefront 中内置了负缓存。与常规缓存填充策略类似,从数据库返回的所有行都写入缓存,我们还跟踪已查询但未从数据库读取的任何行。这些不存在的行使用特殊标志写入缓存,在将来的读取中,如果找到该标志,我们在查询数据库时会忽略该行,并且也不会将该行的任何数据返回给用户。
虽然 Redis 没有受到热分区问题的严重影响,但 Docstore 的一些大客户会产生非常多的读写请求,这在单个 Redis 集群中缓存是具有挑战性的,通常限制在它可以拥有的最大节点数上。为了缓解这种情况,我们允许单个 Docstore 实例映射到多个 Redis 集群。这还可以避免数据库完全崩溃,在单个 Redis 集群中的多个节点关闭并且缓存不适用于某些范围的密钥的情况下,可以对其发出大量请求。
但是,即使数据跨多个 Redis 集群进行分片,单个 Redis 集群关闭也可能会在数据库上产生热分片问题。为了缓解这种情况,我们决定通过分区键对 Redis 集群进行分片,这与 Docstore 中的数据库分片方案不同。现在,我们可以避免在单个 Redis 集群出现故障时使单个数据库分片过载。来自失败的 Redis 分片的所有请求都将分布在所有数据库分片中,如下所示:
11.断路器
如果 Redis 节点出现故障,我们希望能够将对该节点的请求短路,以避免 Redis 获取/设置请求的不必要的延迟损失,我们非常有信心它会失败。为此,我们使用滑动窗口断路器。我们计算每个时间存储桶上每个节点上的错误数,并计算滑动窗口宽度中的错误数。
断路器配置为将一小部分请求短路到该节点,与错误计数成正比。一旦达到允许的最大错误计数,断路器就会跳闸,在滑动窗口通过之前,不能再向节点发出请求。
12.自适应超时
我们意识到,有时很难为 Redis 操作设置正确的超时时间。超时时间过短会导致 Redis 请求过早失败,从而浪费 Redis 资源并给数据库引擎带来额外的负载。超时时间过长会影响 P99.9 和 P99.99 延迟,在最坏的情况下,请求可能会耗尽查询中传递的整个超时。
虽然可以通过配置任意较低的默认超时来缓解这些问题,但我们可能会将超时设置得太低,因为许多请求绕过缓存并转到数据库,或者将超时设置得太高,这会导致我们回到原始问题。我们需要自动动态地调整请求超时,以便对 Redis 的请求的 P99 在分配的超时内成功,同时完全减少延迟的长尾。
配置自适应超时意味着允许动态调整 Redis 获取/设置超时值。通过允许自适应超时,我们可以设置一个相当于缓存请求的 P99.99 延迟的超时,从而让 99.99% 的请求以快速响应进入缓存。剩余的 0.01% 的请求本来会花费太长时间,但可以更快地取消并从数据库中提供服务。启用自适应超时后,我们不再需要手动调整超时以匹配所需的 P99 延迟,而只能设置最大可接受的超时限制,超过该限制,框架就不允许超过该限制(因为最大超时无论如何都是由客户端请求设置的)。
四、结果
那么我们成功了吗?我们最初打算构建一个对用户透明的集成缓存。我们希望我们的解决方案能够帮助改善延迟,易于扩展,帮助控制存储引擎的负载和成本,同时具有良好的一致性保证。
集成缓存的请求延迟明显更好。如上所示,P75 延迟下降了 75%,P99.9 延迟下降了 67% 以上,同时还限制了延迟峰值。
使用 Flux 和 Compare 缓存模式的缓存失效有助于我们确保良好的一致性。
由于它位于我们现有的 API 后面,因此它对用户是透明的,可以在内部进行管理,同时仍然通过基于标头的选项为用户提供灵活性。
分片和缓存预热使其具有可扩展性和容错性。事实上,我们最大的初始用例之一以 99% 的缓存命中率驱动超过 6M RPS,并成功进行了故障转移,所有流量都重定向到远程区域。
同样的用例最初需要大约 60K 个 CPU 内核才能直接从存储引擎提供 6M RPS。借助 CacheFront,我们仅使用 3K Redis 内核即可提供大约 99.9% 的缓存命中率,从而可以减少容量。
如今,CacheFront 在生产环境中的所有 Docstore 实例中每秒支持超过 40M 个请求,而且这个数字还在不断增长。
作者丨小漫