Docstore是 Uber 内部基于 MySQL® 构建的分布式数据库。它存储了数十 PB 的数据,每秒处理数千万个请求,是 Uber 最大的数据库引擎之一,被所有垂直行业的微服务所使用。自 2020 年成立以来,Docstore 的用户和用例不断增长,请求量和数据占用空间也在不断增加。
业务垂直领域和产品的需求不断增长,引入了复杂的微服务和依赖调用图。因此,应用程序要求数据库具有低延迟、更高性能和可扩展性,同时产生更高的工作负载。
挑战Uber 的大多数微服务都使用基于磁盘存储的数据库来保存数据。然而,每个数据库都面临着为需要低延迟读取访问和高可扩展性的应用程序提供服务的挑战。
当一个用例需要比我们任何现有用户更高的读取吞吐量时,这种情况就达到了沸点。Docstore 可以满足他们的需求,因为它由 NVMe SSD 支持,可提供低延迟和高吞吐量。然而,在上述情况下使用 Docstore 成本过高,并且需要面临许多扩展和运营挑战。
在深入探讨挑战之前,让我们先了解 Docstore 的高级架构。
Docstore 架构Docstore 主要分为三层:无状态查询引擎层、有状态存储引擎层和控制平面。出于本博客范围的考虑,我们将讨论其查询和存储引擎层。
无状态查询引擎层负责查询规划、路由、分片、模式管理、节点健康监控、请求解析、验证和 AuthN/AuthZ。
存储引擎层负责通过 Raft 达成共识、复制、事务、并发控制和负载管理。分区通常由 NVMe SSD 支持的 MySQL 节点组成,这些节点能够处理繁重的读写工作负载。此外,数据使用 Raft 跨多个分区进行分片,每个分区包含一个领导者节点和两个追随者节点,以实现共识。
图 1:Docstore 架构。
现在让我们看一下服务需要大规模低延迟读取时面临的一些挑战:
从磁盘检索数据的速度有一个阈值:优化应用程序数据模型和查询以改善数据库延迟和性能的程度是有限的。超过这个极限,就不可能再提高性能。垂直扩展:分配更多资源或升级到性能更高的更好主机有其局限性,数据库引擎本身成为瓶颈。水平扩展:将分片进一步拆分到更多分区有助于在一定程度上解决挑战,但这样做在操作上更复杂且耗时。我们必须确保数据的持久性和弹性,并且不会造成任何停机。此外,此解决方案并不能完全解决热键/分区/分片的问题。请求不平衡:通常,读取请求的传入速率比写入请求高出几个数量级。在这种情况下,底层 MySQL 节点将难以跟上繁重的工作负载,并进一步影响延迟。成本:通过垂直和水平扩展来改善延迟从长远来看成本高昂。处理两个区域的 3 个有状态节点中的每一个节点,成本都会增加 6 倍。此外,扩展并不能完全解决问题。为了克服这个问题,微服务利用了缓存。在 Uber,我们提供 Redis™ 作为分布式缓存解决方案。微服务的典型设计模式是写入数据库和缓存,同时从缓存中读取数据以改善延迟。然而,这种方法有以下挑战:
每个团队必须为各自的服务提供并维护自己的 Redis 缓存缓存失效逻辑在每个微服务中分散实现如果发生区域故障转移,服务要么必须维护缓存复制以保持热度,要么在其他区域的缓存升温时遭受更高的延迟各个团队必须花费大量精力来实现他们自己的数据库缓存解决方案。必须找到一个更好、更高效的解决方案,它不仅可以低延迟地处理请求,而且易于使用并提高开发人员的工作效率。
缓存前端我们决定构建一个集成的缓存解决方案 CacheFront for Docstore,并牢记以下目标:
尽量减少垂直和/或水平扩展的需求,以支持低延迟读取请求减少对数据库引擎层的资源分配;可以从相对便宜的主机构建缓存,从而提高整体成本效率改善 P50 和 P99 延迟,并稳定微突发期间的读取延迟峰值替换大多数由各个团队为满足其需求而构建的(或将要构建的)定制缓存解决方案,特别是在缓存不是团队的核心业务或能力的情况下通过重复使用现有的 Docstore 客户端使其透明,无需任何额外的样板即可从缓存中获益提高开发人员的工作效率,并允许我们向客户透明地发布新功能或替换底层缓存技术将缓存解决方案从 Docstore 的底层分片方案中分离出来,以避免由热键、分片或分区引起的问题允许我们独立于存储引擎水平扩展缓存层将维护和调用 Redis 的所有权从功能团队转移到 Docstore 团队CacheFront 设计Docstore 查询模式Docstore 支持通过主键或分区键进行不同方式的查询,并可选择过滤数据。从高层次上讲,它主要可以分为以下几种:
钥匙型/过滤器
无过滤器
按 WHERE 子句过滤
行
读取行
–
分区
读取分区
查询行
我们希望逐步构建解决方案,从最常见的查询模式开始。事实证明,Docstore 收到的查询中有超过 50% 是 ReadRows 请求,而且由于这恰好是最简单的用例(没有过滤器和点读取),因此自然而然地从这里开始集成。
高级架构由于 Docstore 的查询引擎层负责为客户端提供读写服务,因此它非常适合集成缓存层。它还将缓存与基于磁盘的存储分离,使我们能够独立扩展它们。查询引擎层实现了 Redis 接口,用于存储缓存数据以及使缓存条目无效的机制。高级架构如下所示:
图 2:CacheFront 设计。
Docstore 是一种高度一致的数据库。尽管集成缓存可提供更快的查询响应,但使用缓存时,某些与一致性有关的语义可能并不适用于每个微服务。例如,缓存失效可能会失败或落后于数据库写入。因此,我们将集成缓存设为可选功能。服务可以根据每个数据库、每个表甚至每个请求配置缓存使用情况。
如果某些流程需要强一致性(例如将物品放入食客的购物车中),则可以绕过缓存,而其他具有低写入吞吐量的流程(例如获取餐厅的菜单)将受益于缓存。
缓存读取CacheFront 使用缓存留出策略来实现缓存读取:
查询引擎层收到多行读取请求如果启用了缓存,则尝试从 Redis 获取行;将响应流发送给用户从存储引擎检索剩余的行(如果有)使用剩余行异步填充 Redis将剩余行流式传输给用户图3:CacheFront读取路径。
缓存失效“计算机科学中只有两件难事:缓存失效和命名事物。”
– 菲尔·卡尔顿
尽管上一节中的缓存策略看似简单,但为了确保缓存正常工作,必须考虑许多细节,尤其是缓存失效。如果没有任何明确的缓存失效,缓存条目将以配置的 TTL(默认情况下为 5 分钟)过期。虽然在某些情况下这可能是可以接受的,但大多数用户希望更改的反映速度比 TTL 更快。可以降低默认 TTL,但这会降低我们的缓存命中率,而不会显著提高一致性保证。
有条件更新Docstore 支持条件更新,即可以根据筛选条件更新一行或多行。例如,更新指定区域内所有连锁餐厅的假期安排。由于给定筛选的结果可能会发生变化,因此我们的缓存层无法确定哪些行会受到条件更新的影响,直到数据库引擎中实际的行更新为止。因此,我们无法在无状态查询引擎层的写入路径中使缓存行失效并填充条件更新。
利用变更数据捕获实现缓存失效为了解决这个问题,我们利用了 Docstore 的变更数据捕获和流式传输服务 Flux。Flux 跟踪存储引擎层中每个集群的MySQL binlog事件,并将事件发布给消费者列表。Flux 为 Docstore CDC(变更数据捕获)、复制、物化视图、数据湖提取以及验证集群中节点之间的数据一致性提供支持。
编写了一个新的消费者,它订阅数据事件并让 Redis 中的新行失效或更新插入。现在,有了这个失效策略,条件更新将导致受影响行的数据库更改事件,这些事件将用于让缓存中的行失效或填充行。因此,我们能够在数据库更改后的几秒钟内(而不是几分钟内)使缓存保持一致。此外,通过使用 binlog,我们不会冒让未提交的事务污染缓存的风险。
最终的带有缓存失效的读写路径如下所示:
图 4:CacheFront 的无效读写路径。
在查询引擎和 Flux 之间删除重复的缓存写入但是,上述缓存失效策略存在缺陷。由于写入操作在读取路径和写入路径之间同时发生,因此我们可能会无意中将过时的行写入缓存,从而覆盖从数据库检索到的最新值。为了解决这个问题,我们根据 MySQL 中行集的时间戳(实际上充当其版本)对写入进行重复数据删除。时间戳是从 Redis 中的编码行值解析出来的(请参阅后面的编解码器部分)。
Redis 支持使用EVAL命令原子执行自定义 Lua 脚本。此脚本采用与MSET相同的参数,但它还执行重复数据删除逻辑,检查已写入缓存的任何行的时间戳值并确保要写入的值较新。通过使用 EVAL,所有这些操作都可以在单个请求中执行,而无需在查询引擎层和缓存之间进行多次往返。
为点写入提供更强的一致性保证虽然 Flux 允许我们以比仅依赖 Redis TTL 来使缓存条目过期更快的速度使缓存失效,但它仍然为我们提供了最终一致性语义。然而,有些用例需要更强的一致性,例如读取-拥有-写入,因此对于这些场景,我们在查询引擎中添加了一个专用 API,让我们的用户在相应的写入完成后明确使缓存行失效。这使我们能够为点写入提供更强的一致性保证,但不能为条件更新提供更强的一致性保证,因为条件更新仍需由 Flux 使其失效。
表模式在深入了解实施细节之前,让我们先定义几个关键术语。Docstore 表有一个主键和分区键。
主键(通常称为行键)唯一地标识 Docstore 表中的一行并强制执行唯一性约束。每个表都必须有一个主键,它可以由一列或多列组成。
分区键是整个主键的前缀,决定了该行将位于哪个分片中。它们并不是完全独立的——相反,分区键只是主键的一部分(或等于主键)。
图 5:示例 Docstore 模式和数据建模。
在上面的例子中,person_id既是person表的主键,也是分区键。而对于orders表,cust_id是分区键,cust_id和order_id一起构成主键。
Redis 编解码器由于我们主要将缓存行读取,因此我们可以使用给定的行键唯一地标识行值。由于 Redis 键和值存储为字符串,因此我们需要一个特殊的编解码器以 Redis 接受的格式对 MySQL 数据进行编码。
选择以下编解码器是因为它允许不同的数据库共享缓存资源,同时仍保持数据隔离。
图6:CacheFront Redis 编解码器。
特征完成高层设计后,我们的解决方案就可以使用了。现在是时候考虑规模和弹性了:
如何实时验证数据库与缓存的一致性如何容忍区域/地区故障如何容忍 Redis 故障比较缓存如果一致性无法衡量,那么所有关于提高一致性的讨论都毫无意义,因此我们添加了一种特殊模式,可将读取请求影子化到缓存中。读回时,我们会比较缓存数据和数据库数据,并验证它们是否相同。任何不匹配(缓存中存在的过时行或缓存中存在但数据库中不存在的行)都会被记录并作为指标发出。通过使用 Flux 添加缓存失效功能,缓存的一致性达到 99.99%。
图7:比较缓存设计。
缓存预热Docstore 实例会生成两个不同的地理区域,以确保高可用性和容错能力。部署是主动-主动的,这意味着可以在任何区域发出和处理请求,并且所有写入都会跨区域复制。如果发生区域故障转移,另一个区域必须能够处理所有请求。
这种模式对 CacheFront 提出了挑战,因为缓存应该始终跨区域保持热状态。如果不是这样,区域故障转移将增加对数据库的请求数量,因为最初在故障区域中提供的流量会导致缓存未命中。这将阻止我们缩减存储引擎并回收任何容量,因为数据库负载将与没有任何缓存时一样高。
冷缓存问题可以通过跨区域 Redis 复制来解决,但这也带来了一个问题。Docstore 有自己的跨区域复制机制。如果我们使用 Redis 跨区域复制来复制缓存内容,我们将拥有两种独立的复制机制,这可能会导致缓存与存储引擎不一致。为了避免 CacheFront 出现这种缓存不一致问题,我们通过添加新的缓存预热模式增强了 Redis 跨区域复制组件。
为了确保缓存始终处于热状态,我们跟踪 Redis 写入流并将键复制到远程区域。在远程区域中,我们不会直接更新远程缓存,而是将读取请求发送到查询引擎层,当缓存未命中时,查询引擎层会从数据库读取并写入缓存,如设计的“缓存读取”部分所述。通过仅在缓存未命中时发出读取请求,我们还避免了不必要地使存储引擎过载。由于我们对结果并不真正感兴趣,因此查询引擎层的读取行响应流将被丢弃。
通过复制键而不是值,我们始终确保缓存中的数据与其各自区域中的数据库一致,并且我们在两个区域的 Redis 中保留相同的缓存行工作集,同时还限制了使用的跨区域带宽量。
图8:缓存预热设计。
负面缓存在很多读取操作针对的是不存在的行的情况下,最好缓存负面结果,而不是每次都发生缓存未命中并查询数据库。为了实现这一点,我们在 Cachefront 中构建了负面缓存。与常规缓存填充策略(即从数据库返回的所有行都写入缓存)类似,我们还会跟踪任何已查询但未从数据库读取的行。这些不存在的行使用特殊标志写入缓存,在将来的读取中,如果发现该标志,我们在查询数据库时会忽略该行,也不会向用户返回该行的任何数据。
分片尽管 Redis 不会受到热分区问题的严重影响,但 Docstore 的一些大客户会生成大量读写请求,这在单个 Redis 集群中进行缓存会非常困难,因为集群通常受限于其最大节点数。为了缓解这种情况,我们允许单个 Docstore 实例映射到多个 Redis 集群。如果单个 Redis 集群中的多个节点发生故障,并且某些范围的键无法缓存,这还可以避免数据库完全崩溃,因为可能会向其发出大量请求。
但是,即使数据分片到多个 Redis 集群,单个 Redis 集群发生故障也可能导致数据库出现热分片问题。为了缓解这种情况,我们决定按分区键对 Redis 集群进行分片,这与 Docstore 中的数据库分片方案不同。现在,我们可以避免单个 Redis 集群发生故障时单个数据库分片过载。来自故障 Redis 分片的所有请求将分布在所有数据库分片中,如下所示:
图 9:Redis 分片请求流程。
断路器如果 Redis 节点发生故障,我们希望能够缩短对该节点的请求,以避免 Redis get/set 请求不必要的延迟损失,因为我们有很高的把握该请求会失败。为了实现这一点,我们使用滑动窗口断路器。我们计算每个时间段内每个节点的错误数量,并计算滑动窗口宽度中的错误数量。
图 10:滑动窗口设计。
断路器配置为根据错误计数的比例,切断发往该节点的部分请求。一旦达到允许的最大错误计数,断路器就会跳闸,并且直到滑动窗口通过之前,节点都无法再收到任何请求。
自适应超时我们意识到,有时很难为 Redis 操作设置正确的超时。超时太短会导致 Redis 请求过早失败,浪费 Redis 资源并给数据库引擎增加额外负载。超时太长会影响 P99.9 和 P99.99 延迟,在最坏的情况下,请求可能会耗尽查询中传递的整个超时。虽然可以通过配置任意低的默认超时来缓解这些问题,但我们可能会将超时设置得太低,导致许多请求绕过缓存并进入数据库,或者将超时设置得太高,这又会让我们回到最初的问题。
我们需要自动动态地调整请求超时,以便对 Redis 的 P99 请求在分配的超时内成功,同时完全减少延迟的长尾。配置自适应超时意味着允许动态调整 Redis 获取/设置超时值。通过允许自适应超时,我们可以设置一个相当于缓存请求 P99.99 延迟的超时,从而让 99.99% 的请求快速响应进入缓存。剩余的 0.01% 的请求(本来需要很长时间)可以更快地取消并从数据库提供服务。
启用自适应超时后,我们不再需要手动调整超时以匹配所需的 P99 延迟,而是只能设置最大可接受超时限制,超过此限制框架不允许超出(因为最大超时无论如何都是由客户端请求设置的)。
图 11:自适应超时延迟改进。
结果那么我们成功了吗?我们最初打算构建一个对用户透明的集成缓存。我们希望我们的解决方案能够帮助改善延迟、易于扩展、帮助控制存储引擎的负载和成本,同时具有良好的一致性保证。
图 12:缓存与存储引擎延迟比较。
集成缓存的请求延迟明显更佳。如上所示,P75 延迟下降了 75%,P99.9 延迟下降了 67% 以上,同时还限制了延迟峰值。使用 Flux 和 Compare 缓存模式的缓存失效帮助我们确保良好的一致性。由于它位于我们现有 API 后面,因此对用户来说是透明的,并且可以在内部进行管理,同时仍然通过基于标头的选项为用户提供灵活性。分片和缓存预热使其具有可扩展性和容错性。事实上,我们最大的初始用例之一驱动超过 6M RPS,缓存命中率为 99%,并且经过验证的故障转移成功,所有流量都被重定向到远程区域。同样的用例原本需要大约 60K CPU 核心才能直接从存储引擎提供 6M RPS。使用 CacheFront,我们仅用 3K Redis 核心就能提供大约 99.9% 的缓存命中率,从而减少容量。目前,CacheFront 支持生产中所有 Docstore 实例每秒超过 40M 个请求,而且这个数字还在增长。
图 13:所有实例的总缓存读取量。
我们通过 CacheFront 解决了扩展 Docstore 读取工作负载的核心挑战之一。它不仅使我们能够支持需要高吞吐量和低延迟读取的大规模用例,还帮助我们减轻了存储引擎的负载并节省了资源,改善了存储的总体成本,并让开发人员能够专注于构建产品而不是管理基础设施。
Oracle、Java、MySQL 和 NetSuite 是 Oracle 和/或其附属公司的注册商标。其他名称可能是其各自所有者的商标。
Redis 是 Redis Labs Ltd 的商标。其中的任何权利均归 Redis Labs Ltd 所有。此处的任何使用仅供参考,并不表示 Redis 与 Uber 之间存在任何赞助、认可或关联关系。
作者:
Preetham Narayanareddy
Preetham Narayanareddy is a Senior Software Engineer on the Core Storage team at Uber. He has worked on the design and implementation of many Docstore and CacheFront features and is focused on creating customer-centric products that enhance user experiences. In his free time he enjoys solving Rubik's cubes, cycling, bouldering, and sharing his passions through writing and videos.
Eli Pozniansky
Eli Pozniansky is a Sr Staff Engineer on the Core Storage team at Uber. He is the tech lead and one of the main developers of both CacheFront and Flux, responsible for leading the design, development and rollout to production of both projects from their inception to serving 10s of millions of cache hits per second.
Zurab Kutsia
Zurab Kutsia is a Staff Engineer, TLM on the Core Storage team at Uber. He is one of the main authors of Docstore and has designed and implemented multiple critical components of the database since its inception. Zurab now leads and manages the query engine layer of the Docstore ecosystem.
Afshin Salek
Afshin Salek is a Staff Engineer on the Core Storage team at Uber leading the Redis team. The Redis team at Uber manages tens of thousands of Redis nodes serving 100s of TiBs of capacity and 100s of millions of requests-per-second. Redis is used by 100s of microservices and virtually all the critical ones providing super low latency access to required data. In his free time, he enjoys traveling, photography, reading books and building Legos.
Piyush Patel
Piyush Patel is a Sr. Engineering Manager on the Core Storage Platform team at Uber. The team provides a world-class platform that powers all the critical functions and lines of business at Uber. The Core Storage Platform serves tens of millions of QPS with an availability of 99.99% or more and stores tens of Petabytes of operational data. His interests include large scale distributed systems, data storage and retrieval, and cloud computing.
出处:https://www.uber.com/en-JP/blog/how-uber-serves-over-40-million-reads-per-second-using-an-integrated-cache/