免责声明:
本文翻译自ByteByteAI.com

当你打开 Uber 应用请求一次行程、查看行程历史,或者浏览司机信息时,你期望得到的是即时响应
而在这种流畅体验的背后,运行着一套高度复杂的缓存系统。

Uber 的 CacheFront 在保持强一致性保证的前提下,每秒可以处理 超过 1.5 亿次数据库读取请求

在本文中,我们将拆解 Uber 是如何构建这套系统的、他们遇到了哪些挑战,以及为此设计了哪些创新性的解决方案。


为什么缓存如此重要

每当用户与 Uber 平台交互时,系统都需要获取各种数据,例如用户资料、行程详情、司机位置以及定价信息。

如果每一个请求都直接访问数据库:

  • 会引入明显的延迟
  • 会给数据库服务器带来巨大的压力

当你面对的是 数百万用户、每天数十亿次请求 时,传统数据库根本无法承受这样的负载。


缓存 通过将高频访问的数据存储在更快的存储系统中来解决这个问题。

应用在读取数据时:

  1. 首先查询缓存
  2. 如果缓存中存在数据(缓存命中,cache hit),立即返回
  3. 如果不存在(缓存未命中,cache miss),则查询数据库,并将结果写入缓存,供后续请求使用

如下图所示:

在这里插入图片描述


Uber 使用 Redis 作为缓存层。
Redis 是一种内存型数据存储,相比数据库查询的毫秒级延迟,它可以在微秒级返回数据。

(来源:[Coderabbit])


架构:三个协同工作的层次

Uber 的存储系统称为 Docstore,由三个主要组件组成:

  • Query Engine(查询引擎层)
    无状态,负责处理来自 Uber 各个服务的所有请求

  • Storage Engine(存储引擎层)
    数据的真实存储位置,由多个 MySQL 节点组成

  • CacheFront
    缓存逻辑,实现在查询引擎层内部,位于应用请求与数据库之间


在这里插入图片描述

读路径(Read Path)

当一个读请求进入系统时:

  1. CacheFront 首先查询 Redis
  2. 如果 Redis 中存在数据,立即返回给客户端

在许多使用场景下,Uber 的缓存命中率超过 99.9%,这意味着只有极少量请求需要访问数据库。
在这里插入图片描述


如果 Redis 中不存在对应数据:

  1. CacheFront 从 MySQL 中读取数据
  2. 将结果写入 Redis
  3. 将数据返回给客户端

系统还支持部分缓存未命中的情况。
例如,一个请求需要读取 10 行数据,其中 7 行已存在于缓存中,那么系统只会从数据库中查询缺失的 3 行。


写路径(Write Path)

写操作 会给任何缓存系统带来显著复杂性。

当数据库中的数据发生变化时,缓存中的副本就会变成过期数据(stale data)
如果系统返回了过期数据,将会破坏业务逻辑并导致糟糕的用户体验。

例如:
你在 Uber 应用中更新了目的地,但系统仍然显示旧目的地,因为它从缓存中读到了过期数据。


缓存刷新(invalidate)面临的核心挑战在于:
如何确定在一次写操作发生后,哪些缓存条目需要被失效。

Uber 支持两种写操作类型,它们需要不同的处理方式。


点写(Point Writes)

点写操作比较简单。

这是指在 SQL 中明确指定具体行的 INSERT、UPDATE 或 DELETE 操作。
例如:根据 user_id 更新某个用户的资料。

由于行的主键在查询中是已知的,因此可以准确地定位并失效对应的缓存条目。


条件更新(Conditional Updates)

条件更新要复杂得多。

这是指带有 WHERE 条件的 UPDATE 或 DELETE 语句。
例如:
将所有行程时间超过 60 分钟的订单标记为已完成。

在查询执行之前,你并不知道哪些行会匹配这个条件,因此:

  • 无法提前确定哪些缓存条目会受到影响
  • 也就无法在写入时同步失效缓存

正是这种不确定性,使得 Uber 在最初无法在写路径中同步执行缓存失效。


最初的解决方案

Uber 最初采用了一个名为 Flux 的系统,它基于 变更数据捕获(Change Data Capture,CDC)

Flux 会监听 MySQL 的 binlog(二进制日志),该日志记录了数据库中的所有变更。

写事务提交后:

  1. MySQL 将变更写入 binlog在这里插入图片描述

  2. Flux 通过持续读取 binlog,识别哪些行发生了变化

  3. 对 Redis 中对应的缓存条目进行失效或更新

如下图所示:

(图略)


这种方式在功能上是可行的,但存在一个致命限制:

Flux 是异步的。

这意味着数据库写入与缓存更新之间存在延迟。
虽然通常是亚秒级,但在系统重启、部署或拓扑变化时,这个延迟可能会明显变长。


异步失效会带来一致性问题。

如果用户刚刚写入数据,随后立即读取,可能会因为缓存尚未失效而读到旧数据。
这违反了**“读你所写(read-your-own-writes)一致性”**,而这是大多数应用的基本预期。


系统还依赖 TTL(Time-To-Live,存活时间) 机制。

每个缓存条目都有一个 TTL,决定它在缓存中保留多久。
Uber 的默认推荐值是 5 分钟,但可以根据业务需求调整。

TTL 作为兜底机制,确保即使缓存失效失败,过期数据最终也会被清除。


然而,仅靠 TTL 并不能满足很多使用场景。

服务负责人希望获得更高的缓存命中率,于是倾向于设置更长的 TTL。
但 TTL 越长:

  • 命中率越高
  • 提供过期数据的时间窗口也越大

一致性问题的来源

随着 CacheFront 的规模不断扩大,Uber 发现了三种主要的不一致来源:

  1. Flux 失效延迟
    导致写后立即读可能返回旧数据

  2. 缓存失效失败
    当 Redis 节点短暂不可用时,缓存条目可能一直保留到 TTL 到期

  3. 从延迟的 MySQL 从库回填缓存
    如果从库尚未同步最新写入,就可能将旧数据重新写入缓存


超越 TTL 的陈旧问题

还有一个更加隐蔽的一致性问题,与“缓存数据到底能有多旧”有关。

很多工程师认为:

如果 TTL 设置为 5 分钟,那么最多只会返回 5 分钟的旧数据。

这个认知是错误的。


考虑以下场景:

  • 某一行数据在 一年前 被写入数据库,之后从未被访问
  • 在今天时间点 T,一个读请求到来
  • 缓存中没有该数据,于是从数据库中读取并写入缓存
  • 此时,缓存中的数据本身已经是一年前的内容

随后不久:

  • 一个写请求更新了数据库中的这行数据
  • Flux 尝试失效缓存,但由于 Redis 的临时问题失败了

此时:

  • 缓存仍然保存着一年前的数据
  • 数据库中是最新数据

在接下来的一小时内(假设 TTL 为 1 小时),
所有读请求都会返回这一年前的数据。

也就是说:

数据的陈旧程度并不受 TTL 的上限约束。

TTL 只控制缓存条目存在多久,
并不限制缓存中数据本身有多旧。


这个问题在 TTL 设置较长时尤为严重。

如果 TTL 设置为 24 小时,一旦失效失败,就可能在整整一天内持续返回极度过期的数据。


突破:让条件更新可追踪

同步缓存失效的根本障碍在于:
无法在条件更新中知道哪些行发生了变化。

Uber 为此对存储引擎做了两项关键改造:


改造一:软删除

所有 DELETE 操作都改为软删除
即设置一个 tombstone(墓碑)标志位,而不是真正删除行。


改造二:严格单调递增的时间戳

系统引入了微秒级、严格单调递增的时间戳
确保每个事务都有唯一可识别的提交时间。


有了这两个保证,系统现在可以准确确定哪些行被修改。

当行被更新时,其时间戳字段会被设置为该事务的时间戳。

在提交事务之前,系统会执行一个轻量级查询,
选出时间戳落在该事务时间窗口内的所有行主键。

这个查询非常快,因为:

  • 数据已经在 MySQL 存储引擎的缓存中
  • 时间戳字段是有索引的

重构后的写路径

当写请求进入查询引擎时:

  • 系统注册一个回调
  • 当存储引擎返回时,该回调会被执行

返回信息包括:

  • 写操作是否成功
  • 受影响的行主键集合
  • 事务的提交时间戳

回调函数使用这些信息,对 Redis 中对应的缓存条目进行失效。

缓存失效可以选择:

  • 同步执行
    在请求上下文中完成,增加写延迟,但提供最强一致性

  • 异步执行
    放入队列中处理,避免增加延迟,但一致性略弱

如下图所示:

在这里插入图片描述


需要强调的是:

即使缓存失效失败,写请求也不会失败。

缓存问题不会影响写入成功,从而保证系统的可用性。


三重防御策略

目前,Uber 同时运行三套缓存一致性机制:

  1. TTL 到期自动清除(默认 5 分钟)
  2. Flux 异步 CDC 失效
  3. 写路径同步失效机制

三种机制并行运行,
相比依赖单一方案,效果要好得多。


Cache Inspector

为了验证改进效果并量化一致性水平,Uber 构建了 Cache Inspector

该工具使用与 Flux 相同的 CDC 管道,但人为引入 1 分钟延迟

它不会进行缓存失效,而是:

  • 将 binlog 中的变更与 Redis 中的数据进行对比
  • 统计发现的过期条目数量
  • 记录数据陈旧的持续时间

结果非常积极。

对于 TTL 设置为 24 小时的表:

  • Cache Inspector 在连续一周的观测中几乎没有发现过期数据
  • 缓存命中率仍然超过 99.9%
    在这里插入图片描述

这让 Uber 能够有信心地为合适的场景提高 TTL,
在不牺牲一致性的前提下显著提升性能。


其他优化

除了核心的失效机制改进外,Uber 还实现了大量工程优化,包括:

  • 基于负载的自适应超时
  • 对不存在数据的负缓存(negative caching)
  • 使用流水线(pipeline)批量读取
  • 针对不健康节点的熔断器
  • 连接速率限制
  • 数据压缩以降低内存和带宽开销

总结

如今,CacheFront 在高峰期每秒可以处理 超过 1.5 亿行读取请求

在许多场景下,缓存命中率超过 99.9%

系统规模相比最初增长了近 4 倍
同时一致性保障反而更强。


通过 写路径同步失效 + 异步 CDC + TTL 兜底 的组合方案,
Uber 在超大规模下实现了高性能与强一致性的平衡


参考资料

  • How Uber Serves over 150 Million Reads per Second from Integrated Cache with Stronger Consistency Guarantees
  • How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐