2020 年,Uber 推出了名为 Docstore 的自研分布式数据库。
它构建在 MySQL 之上,能够存储数十 PB 的数据,同时每秒服务数千万次请求。
多年来,Docstore 被 Uber 所有业务垂直部门采用来构建他们的服务。大多数这些应用需要数据库具有低延迟、更高性能和可扩展性,同时支持更高的工作负载。
每个数据库在面对需要低延迟读取访问和高度可扩展设计的应用时都面临挑战。
其中一些挑战如下:
- 从磁盘检索数据的速度有阈值。超过这个阈值,你无法通过优化应用的数据模型和查询来提高延迟。
- 垂直扩展可以带你走得很远,但通过升级到更好的主机分配更多资源有限制。最终,数据库引擎变成瓶颈。
- 通过将数据库分割成多个分区进行水平扩展是一种有前途的方法。然而,随着时间的推移,它在操作上变得更加复杂,并且不能消除热分区等问题。
- 垂直和水平扩展策略从长期来看成本都很高。作为参考,跨两个区域处理三个有状态节点的成本会增加 6 倍。
为了克服这些挑战,微服务通常使用缓存。
Uber 开始为各种团队提供 Redis 作为分布式缓存解决方案。他们遵循典型的缓存设计模式,服务写入数据库和缓存,同时直接从缓存提供读取。
下图显示了这个模式:

然而,服务负责管理缓存的正常缓存模式在 Uber 的规模上有一些问题。
- 每个团队必须管理自己的 Redis 缓存集群
- 缓存失效逻辑在多个微服务中重复,存在偏差的可能性
- 服务必须维护缓存复制以在区域故障转移时保持热备
关键是每个需要缓存的团队都必须花费大量精力来构建和维护自定义缓存解决方案。
为了避免这种情况,Uber 决定构建一个名为 CacheFront 的集成缓存解决方案。
CacheFront 设计目标
在构建 CacheFront 时,Uber 有几个重要的设计目标:
- 减少支持低延迟读取请求所需的垂直或水平扩展
- 改善 P50 和 P99 延迟并稳定延迟尖峰
- 降低数据库引擎层的资源分配
- 替换各个团队为其需求创建的大量自定义构建缓存解决方案。相反,将维护和支持 Redis 的所有权移交给 Docstore 团队
- 从服务的角度使缓存透明,允许团队只关注业务逻辑
- 将缓存解决方案与 Docstore 的分区模式解耦以避免热分区
- 支持缓存层的水平可扩展性,使用廉价主机,使整个操作具有成本效益
为了支持这些设计目标,Uber 创建了与 Docstore 绑定的集成缓存解决方案。
下图显示了 Docstore 与 CacheFront 的高级架构:

如你所见,Docstore 的查询引擎充当服务的入口点,负责向客户端提供读取和写入。
因此,它是集成缓存层的理想位置,允许缓存与基于磁盘的存储解耦。查询引擎实现了一个接口到 Redis 来存储缓存数据,以及使缓存条目失效的机制。
读取流程
CacheFront 在读取方面使用缓存模式或旁路模式。
以下步骤解释了它的工作原理:
- 查询引擎层接收一个或多个行的读取请求
- 查询引擎尝试从 Redis 获取行并将响应流式传输给用户
- 接下来,它从数据库检索剩余的行(如果需要)
- 查询引擎异步地用缓存中未找到的行填充 Redis
- 将剩余的行流式传输给用户
参考下图,更清楚地解释这个过程:

缓存失效
正如你现在可能已经听过无数次的那样,缓存失效是计算机科学中的两大难题之一。
最简单的缓存失效策略之一是配置 TTL(生存时间)并让缓存条目在超过 TTL 后过期。虽然这适用于许多情况,但大多数用户期望更改比 TTL 更快地反映。然而,将默认 TTL 降低到非常小的值会降低缓存命中率并降低其有效性。
为了使缓存失效更相关,Uber 利用了 Flux,Docstore 的变更数据捕获和流式服务。Flux 通过跟踪每个数据库集群的 MySQL binlog 事件工作,并将事件发布到消费者列表。它为复制、物化视图、数据湖摄入和各节点之间的数据一致性验证提供支持。
对于缓存失效,创建了一个新的消费者,订阅数据事件并使 Redis 中的新行失效/插入。
下图显示了带有缓存失效的读取和写入路径:

这种方法有一些关键优势:
- 他们可以在数据库更改后几秒内使缓存与数据库一致,而不是几分钟(取决于 TTL)
- 此外,使用 binlog 确保未提交的事务不会污染缓存
然而,也有一些必须解决的问题。
由于在读取和写入路径之间同时发生对缓存的写入,因此可能通过覆盖最新值将过时的行写入缓存。
为了防止这种情况,他们基于 MySQL 中设置的行时间戳去重写入。
这个时间戳充当版本号,并使用 EVAL 命令从 Redis 中的编码行值中解析。
即使使用 Flux 进行 CDC 的缓存失效比依赖 TTL 更快,它仍然提供最终一致性。
然而,一些用例需要更强的一致性保证,如读取自己写入的保证。
对于这种情况,他们为查询引擎创建了一个专用 API,允许用户在相应写入完成后显式使缓存的行失效。通过这样做,他们不必等待 CDC 过程完成以使缓存一致。
规模化和弹性
CacheFront 的基本要求在他们开始支持读取和缓存失效后就准备好了。
然而,Uber 也希望这个解决方案能够在他们的规模上工作。他们对整个平台也有关键的弹性需求。
为了实现 CacheFront 的规模和弹性,他们利用了多种策略。
测量
测量是证明系统按预期工作的关键。CacheFront 也是如此。
他们向 CacheFront 添加了一个特殊模式,将读取请求阴影到缓存,允许他们运行缓存和数据库之间数据的比较,以验证两者是否同步。任何不匹配(如过时的行)都作为指标记录以进行进一步分析。
下图显示了 Compare cache 系统的高级设计:

基于这个系统的结果,Uber 发现缓存有 99.99% 的一致性。
跨区域缓存预热
在多区域环境中,只有当缓存始终保持热备时才有效。如果不是这样,区域故障转移可能导致缓存未命中,并急剧增加数据库的请求数量。
由于 Docstore 实例在两个不同的地理区域以主动 - 主动部署生成,冷缓存意味着你无法缩减存储引擎以节省成本,因为在故障转移的情况下有高概率的重数据库负载。
为了解决这个问题,Uber 工程团队使用了跨区域 Redis 复制。
然而,Docstore 也有自己的跨区域复制。由于同时操作两个复制设置可能导致缓存和数据库之间的数据一致,他们通过添加新的缓存预热模式增强了 Redis 跨区域复制。
以下是缓存预热模式的工作原理:
- 他们跟踪 Redis 写入流以将键复制到远程区域
- 在远程区域,他们不直接更新缓存。相反,他们对复制的键向查询引擎层发出读取请求
- 在缓存未命中的情况下,查询引擎层从数据库读取数据并写入缓存。响应流被丢弃
下图详细显示了这个方法:

复制键而不是值确保缓存中的数据在其各自区域与数据库一致。此外,它确保两组缓存的行在两个区域中都存在,从而在故障转移时保持缓存热备。
多 Redis 集群
Docstore 在 Uber 内的一些大客户可以生成非常多的读写请求。在一个受最大节点数限制的单个 Redis 集群中缓存所有这些是具有挑战性的。
为了缓解这个问题,他们允许单个 Docstore 实例映射到多个 Redis 集群。这有助于避免在单个 Redis 集群中的多个节点宕机的情况下对数据库的请求大量激增。
然而,仍然有一个情况,单个 Redis 集群宕机可能在数据库上创建热分片。为了防止这种情况,他们使用与数据库分片方案不同的方案对 Redis 集群进行分片。这确保来自单个 Redis 集群宕机的负载分布在多个数据库分片之间。
下图更详细地解释了这个场景:

滑动窗口断路器
当 Redis 节点宕机时,对该节点的 get/set 请求会产生不必要的延迟惩罚。
为了避免这种惩罚,Uber 实现了滑动窗口断路器来短路这些请求。他们计算每个节点在特定时间桶内的错误数量,并计算滑动窗口宽度内的错误数量。
查看下图以了解滑动窗口方法:

断路器配置为根据错误计数短路一部分对节点的请求。一旦达到阈值,断路器就会跳闸,在滑动窗口过去之前不能再向节点发出请求。
结果
Uber 实现与 Docstore 集成的 Redis 缓存的项目非常成功。
他们创建了一个透明的缓存解决方案,具有可扩展性,并成功改善延迟、减少负载并降低成本。
以下是一些显示结果的统计:
- P75 延迟下降了 75%,P99.9 延迟下降了超过 67%,同时也限制了延迟尖峰
- 使用 flux 和 compare cache 进行缓存失效提供了 99.9% 的缓存一致性
- 分片和缓存预热使设置具有可扩展性和容错性,允许每秒超过 600 万次读取、99% 缓存命中率的用例成功故障转移到远程区域
- 成本大幅下降,因为每秒 600 万次读取的相同用例大约需要 6 万个 CPU 核心用于存储引擎。使用 CacheFront,他们仅用 3000 个 Redis 核心就实现了相同的结果
- 目前,CacheFront 在生产中支持超过每秒 4000 万次请求,并且这个数字每天都在增长
参考
- How Uber Serves Over 40 million requests per second using integrated cache?
- An explanation of Redis EVAL function
- Uber’s CacheFront
- Redis Cache Aside Simplified
本文为学习目的的个人翻译,译文仅供参考。
原文链接:How Uber Uses Integrated Redis Cache to Serve 40M Reads/Second?。
版权归原作者或原刊登方所有。本文为非官方译本;如有不妥,请联系删除。