Skip to content

Latest commit

 

History

History
253 lines (153 loc) · 22.9 KB

File metadata and controls

253 lines (153 loc) · 22.9 KB

LEC 16: Cache Consistency: Memcached at Facebook

NotebookLM 生成的双人播客

https://notebooklm.google.com/notebook/10557399-4bc3-48b3-a9ce-661ee295d4af/audio

微信链接: https://mp.weixin.qq.com/s/V_om1BT79WUKywaRqxrb0A

给我留下的较深的印象:

  1. delete 删除缓存中的内容,而非 set 更新内容;因为 delete 是幂等的,降低分布式系统的复杂性
  2. 发放 token (租约 lease),既可以控制请求数据库的频率,又可以保证缓存一致性(陈旧数据写入)

Gemini 2.5 Pro 生成的“Facebook Memcached 系统:解决超大规模缓存一致性问题”

今天我们来深入聊一聊 Facebook 是如何将一个简单的开源缓存软件 memcached,打造成一个能够支撑起全球最大社交网络、每秒处理数十亿次请求的分布式缓存系统的。

我们会沿着 Facebook 的扩展之路,从单个集群内的优化,到跨集群、跨地区的扩展,逐步揭开其架构设计的精妙之处。

为什么要用 Memcache?基础架构概览

在开始之前,我们先解决一个基本问题:Facebook 为什么不直接从数据库(比如 MySQL)读取数据,而要费这么大劲去构建一个缓存系统?

答案很简单: 。对于 Facebook 这种读多写少(read-heavy)的业务场景,用户的动态、评论、好友列表等信息被浏览的次数远超被创建的次数。如果每次读取都请求数据库,MySQL 完全扛不住如此巨大的读取压力。而 memcached 是一个完全基于内存的键值存储(key-value store),速度比基于磁盘的数据库快几个数量级。

核心架构:旁路缓存(Look-Aside Cache)

Facebook 使用 memcache 的模式非常经典,称为 旁路缓存 。流程如下:

  1. 读(Read) :Web 服务器需要数据时,首先会带着一个 keymemcache 里查找。
  • 缓存命中 (Cache Hit) :直接拿到数据,返回给用户。
  • 缓存未命中 (Cache Miss) :服务器会去后端数据库(或其他服务)查询,拿到数据后,一方面返回给用户,另一方面把这份数据用 set(key, value) 的方式写回 memcache,方便下次读取。
  1. 写(Write) :当数据发生变更时,Web 服务器会先更新数据库。为了保证缓存数据的一致性,它并 不是 去更新 memcache 里的值,而是直接向 memcache 发送一个 delete(key) 命令,将旧的缓存数据删除。

为什么写操作是 delete 而不是 update

论文中提到,因为 delete幂等 (idempotent) 的。简单说,重复执行多次 delete 和执行一次的效果是一样的,这在复杂的网络环境中能简化系统设计,避免因重试等机制导致数据错乱。而如果两个 update 请求因为网络延迟乱序到达,就可能导致缓存中存储的是一个旧版本的数据。

(上面可能面临陈旧数据写入/脏数据的问题,解决方案参考下文“减少负载:租约 (Leases) 的妙用”)

另外,需要明确两个术语:

  • memcached:指开源的、单机版的缓存软件本身。
  • memcache:在本文中,特指 Facebook 基于 memcached 构建的整个分布式缓存系统。

Memcached 与 Redis:现代缓存技术选型

我们来解决一个非常实际的工程问题:memcached 在今天用得还多吗?它和现在非常流行的 Redis 相比,我们应该如何选择?

首先,memcached 本身是一个单机的内存缓存程序。我们常说的“部署一个 memcached 集群”,实际上是指通过客户端的路由逻辑(或像 mcrouter 这样的代理),将不同的 key 分发到不同的 memcached 实例上,从而在逻辑上构成一个分布式系统。

在今天的技术选型中,Redis 因其丰富的功能和灵活性,在很多场景下已经成为首选。但 memcached 凭借其极致的简洁和性能,依然在特定领域(尤其是需要大规模、低延迟纯缓存的场景)拥有一席之地。它们的对比可以总结如下:

特性 Memcached Redis
核心定位 纯粹的内存缓存 (Cache) 内存数据结构服务器 (In-Memory Data Store)
数据类型 只支持简单的字符串 (Key-Value)。 支持字符串、列表、哈希、集合、有序集合等丰富的数据结构。
性能 多线程 架构。在简单的 get/set 场景和高并发下,性能极高,CPU 利用率更好。Facebook 还对其做了进一步的性能优化。 单线程 执行命令(I/O 多路复用)。避免了锁的开销,但CPU密集型操作会阻塞其他请求。Redis 6.0 后引入了多线程 I/O。
持久化 不支持。数据只存在于内存,服务器重启后数据全部丢失,纯粹用于缓存。 支持 。提供 RDB(快照)和 AOF(日志)两种持久化方式,可作为数据库使用。
高级功能 功能极简,只有 get, set, delete 等基本操作。 支持事务、发布/订阅 (Pub/Sub)、Lua 脚本、Streams 等高级功能。
内存管理 使用 Slab Allocator 。预先分配固定大小的内存块,可能因数据大小与块大小不匹配而造成一定的空间浪费。 内存管理更精细,支持多种淘汰策略。

选型建议:

  • 选择 Memcached :如果你的需求非常纯粹,就是一个高速、高并发、分布式的键值缓存,且不需要复杂的数据结构和持久化。它的简洁性和多线程带来的高吞吐在某些大规模场景下是巨大优势。
  • 选择 Redis :绝大多数场景下的更优选择。如果你需要使用哈希、列表等数据结构,或者需要消息队列、排行榜(有序集合)、分布式锁等功能,Redis 提供了开箱即用的解决方案。

集群之内:攻克延迟与负载

当服务器规模扩展到数千台时,即便都在一个数据中心内,也会遇到巨大的挑战。Facebook 的优化主要集中在两个方面:降低访问延迟和减少缓存未命中带来的负载。

降低延迟:解决全连接与 Incast 拥塞

在一个集群中,一台 Web 服务器为了响应单次用户请求,可能需要与成百上千台 memcached 服务器通信,这被称为“ 全连接 (all-to-all) ”的通信模式。这种模式极易引发网络拥堵,尤其是 Incast 拥塞(当大量服务器同时向一个目标发送数据时,会导致交换机缓冲区溢出而大量丢包)。

Facebook 的解决方案是从客户端和服务端两方面入手:

客户端并行与批量请求

Web 服务器的客户端库会将一次页面请求所需的所有数据依赖关系构建成一个有向无环图(DAG),从而最大限度地并行发起请求 。同时,它会将多个 key 的请求合并成一个批量请求(batched request),平均一次会打包 24 个 key

这里提到的 DAG(有向无环图),它并 不是 由缓存系统自动生成的,而是 由应用程序的业务逻辑定义的

举个例子,渲染一个用户个人主页,可能需要以下数据:

  1. 用户基本信息(user_info
  2. 用户的好友列表(friend_list
  3. 用户的最新动态(latest_posts

这三者都依赖于 user_id。一旦获取到 user_id,这三份数据之间没有依赖关系,可以并行去获取。应用框架会根据代码中声明的这种依赖关系,构建出一个 DAG,然后调度 memcache 客户端尽可能地并行执行独立的请求分支,同时将同一分支上的多个请求打包,从而最小化网络往返次数和等待时间。

协议选择与连接优化

  • 对于 get 请求,使用 UDP 协议。UDP 无连接的特性减少了建立和维护 TCP 连接的开销,从而降低了延迟。虽然 UDP 不可靠,但 Facebook 的网络设施质量很高,实践中只有约 0.25% 的 get 请求被丢弃,客户端直接将这些视为缓存未命中即可。
  • 对于 setdelete 这类必须保证可靠性的操作,则通过 TCP 完成。为了避免海量 Web 线程与 memcached 服务器建立过多 TCP 连接,他们引入了一个名为 mcrouter 的代理进程。mcrouter 部署在 Web 服务器本地,负责将来自多个线程的 TCP 请求聚合起来,再统一转发给 memcached 服务器,极大地减少了连接数和服务器资源消耗。

客户端流量控制

为了解决 Incast 拥塞,客户端实现了一个滑动窗口(sliding window)机制来控制未完成的请求数量。当窗口过小,请求串行化会增加延迟;当窗口过大,则会引发网络拥堵和丢包。通过动态调整窗口大小,可以在两者之间找到一个平衡点。

减少负载:租约 (Leases) 的妙用

缓存未命中会给后端数据库带来巨大压力。Facebook 引入了一种非常巧妙的机制—— 租约 (Leases) ,它一举解决了两个老大难问题: 陈旧数据写入(stale sets)和惊群效应(thundering herds)

问题一:陈旧数据写入 (Stale Sets)

想象这个场景:

  1. 客户端 C1 请求 key,缓存未命中。
  2. C1 去数据库查询,得到 value=1。但此时 C1 因为某些原因卡顿了一下。
  3. 在此期间,另一个用户更新了数据,数据库中 key 的值变为 2,并删除了缓存。
  4. 客户端 C2 也请求 key,缓存未命中,从数据库拿到 value=2,并成功写入缓存。
  5. 卡顿的 C1 终于恢复,将它持有的旧数据 value=1 写入了缓存,覆盖了新数据。

此时,缓存中就存在了一个脏数据。

问题二:惊群效应 (Thundering Herds)/缓存雪崩

当一个热点 key(比如首页上的热门新闻)突然失效(被更新或删除),成千上万的请求会同时穿透缓存,打到数据库上,可能瞬间压垮数据库。

租约的解决方案

租约是 memcached 在处理缓存未命中时,返回给客户端的一个 64 位 令牌 (token)

解决 Stale Sets

客户端在 set 数据时必须带上这个租约令牌。memcached 会验证令牌的有效性。在上面的例子中,当数据被更新时,memcached 会将与该 key 相关的租约(C1 持有的)标记为无效。因此,当 C1 带着过期的租约来写缓存时,请求会被直接拒绝,从而保证了缓存不会被旧数据污染。这类似于一种 乐观锁load-link/store-conditional 操作。

解决 Thundering Herds

memcached 服务器会对租约的发放进行 速率限制 ,例如,针对同一个 key,10 秒内只发放一个租约。这样,当热点 key 失效时,只有第一个请求的客户端能拿到租约并去查询数据库。其他客户端在 10 秒内再次请求时,会收到一个特殊通知,告诉它们“稍等片刻再试”。通常,第一个客户端在毫秒级时间内就能把新数据写回缓存。后续的客户端重试时,就能直接命中缓存了。通过这种方式,原本成千上万的数据库请求被缩减到只有一次,效果极其显著。

故障处理:Gutter 服务器

如果一台 memcached 服务器宕机,所有指向它的请求都会失败,这同样会形成一股流量洪峰冲击数据库。

如何处理缓存节点宕机?

一个常见的想法是将宕机节点上的 key 通过哈希算法重新分布到其他健康的节点上。但 Facebook 指出这是 危险的 ,因为如果某个 key 是热点 key,它可能会将大量请求带到新的节点上,导致该节点也被压垮,从而引发 级联故障 (cascading failures)

Facebook 的方案是设立一个专门的、小规模的服务器池,叫做 Gutter 。这些服务器平时基本是空闲的。当客户端访问一台 memcached 服务器超时后,它不会重新哈希,而是会把请求重试到 Gutter 服务器上。Gutter 作为一个临时的缓存替代品,接管了失效服务器的负载,从而保护了后端数据库。Gutter 中缓存项的过期时间很短,以避免数据一致性问题。

这引出了另一个问题:为什么 Gutter 服务器要保持空闲,而不是也用来提供普通服务?因为当一台服务器故障时,接替它的 Gutter 服务器需要拥有足够的、未被占用的处理能力来承接前者的全部负载。

区域之内:跨集群复制

随着业务增长,单个集群的规模总有上限。Facebook 将一个数据中心划分为多个 前端集群 (frontend clusters) ,它们共享一个 存储集群 (storage cluster) (即数据库)。这个整体被称为一个 区域 (region)

这种架构下,最大的挑战是如何处理跨集群的数据复制和一致性问题。

区域性失效 (Regional Invalidations)

由于每个前端集群都有自己独立的 memcache,一份数据可能被缓存在多个集群中。当数据库中的数据更新时,必须通知所有集群删除对应的旧缓存。

这个任务由一个叫 mcsqueal 的守护进程完成。它部署在数据库服务器上,通过实时监控数据库的提交日志(binlog),来捕获数据变更操作。一旦捕获到变更,mcsqueal 就会解析出需要失效的 memcache key,然后将删除请求广播给该区域内所有前端集群的 memcache

这种基于 变更数据捕获 (Change Data Capture, CDC) 的异步失效模式,比让 Web 服务器直接广播失效请求更可靠、更高效,因为它能更好地批量处理请求,并且基于可靠的数据库日志,可以重放丢失的失效消息。

区域性失效:mcsqueal 的可靠之道

mcsqueal 详细机制:基于 变更数据捕获 (Change Data Capture, CDC) 的系统是保证跨集群缓存一致性的基石,其流程远比 Web 服务器直接发 delete 请求要精妙和可靠。

详细流程如下:

  1. 修改数据库事务 :当 Web 服务器要更新数据时,它发给数据库的事务不仅仅是 UPDATEINSERT 语句。它还会附加元数据,明确指出这个数据库变更会影响到哪些 memcachekey
  2. 写入可靠日志 :数据库执行事务,并将数据变更和这些需要失效的 key 一同记录到其高可靠的提交日志中(例如 MySQL 的 binlog)。这是最关键的一步,因为日志是持久化且有序的。
  3. mcsqueal 监控与解析mcsqueal 守护进程像一个忠实的哨兵,持续不断地监控(tail)数据库的提交日志。当它读到新的变更记录时,就会从中解析出那些需要被删除的 key
  4. 广播与中继mcsqueal 将提取出的删除请求,批量打包后,广播给区域内所有前端集群。这些请求并非直接打到 memcached 服务器上,而是先发送给每个集群中专门负责中继的 mcrouter 实例。
  5. mcrouter 精准投递mcrouter 收到批量包后,将其解开,并根据每个 key 的哈希值,将删除命令精准地路由到该 key 所在的具体 memcached 服务器上。

这套机制的核心优势在于 可靠性效率 。因为失效指令记录在数据库的持久化日志里,即使中途 mcsqueal 进程崩溃或者网络中断,恢复后只需从上次中断的位置继续读取日志即可,保证了失效消息“ 不丢不重 ” 。同时,高效的批量处理也极大地降低了网络开销。

冷集群预热 (Cold Cluster Warmup)

当一个新的前端集群上线,或者一个集群因维护重启时,它的缓存是空的(称为“冷集群”),此时所有请求都会穿透到数据库,造成巨大冲击。

为了解决这个问题,Facebook 设计了 冷集群预热 机制。简单来说,就是让冷集群里的客户端在缓存未命中时,不去请求数据库,而是去请求一个缓存数据齐全的“热集群”的 memcache 。这相当于用其他集群的缓存来帮助新集群“预热”。为了处理这期间可能发生的竞争问题(例如,数据刚在热集群被更新,冷集群就来读取旧数据),他们在向冷集群发送 delete 命令时,会附加一个短暂的“ 拒绝写入 ”时间(如 2 秒)。这能有效防止脏数据被写入冷集群的缓存中。

当一个新集群(冷集群)上线时,让它从一个已有集群(热集群)的缓存中拉取数据预热,是一个非常聪明的办法。但如何处理竞争问题,以及 2 秒的“拒绝写入”时间是怎么回事?

预热机制与竞争问题

预热的基本流程是:冷集群的客户端在缓存未命中后,会向热集群发送 get 请求,拿到数据后再尝试 add 到本地缓存中。

这里的竞争在于:在冷集群客户端从“获取数据”到“写入本地缓存”的这个时间窗口内,原始数据可能已经被更新了。此时,失效消息会同时发往热集群和冷集群。如果冷集群的客户端动作不够快,就可能把已经过期的旧数据写入本地缓存。

“拒绝写入”的妙用

为了解决这个问题,Facebook 对 delete 命令做了扩展。发往冷集群的失效删除命令,会附带一个参数,即 拒绝写入期 (hold-off time) ,默认为 2 秒。

当冷集群的 memcached 服务器收到这样一条命令后,它会:

  1. 删除指定的 key
  2. 在该 key 上设置一个 临时锁 ,在接下来的 2 秒内, 拒绝 所有针对这个 keyaddset 操作。

现在我们再看预热流程:当冷集群客户端带着从热集群取回的旧数据尝试 add 到本地时,如果一个失效消息刚刚到达,这个 add 操作就会因为这个 2 秒的锁而 失败

这个失败是一个非常重要的信号,它告诉客户端:“你手上的数据已经旧了!” 客户端捕获到这个失败后,就会放弃这次预热操作,转而直接从权威数据源——数据库——去获取最新的数据。

为什么是 2 秒?如果延迟更长怎么办?

  • 为什么是 2 秒? 这是一个基于海量实践数据得出的 工程经验值 (heuristic) 。2 秒被认为足以覆盖绝大多数情况下,失效消息在数据中心内部网络中的传播和处理延迟。
  • 如果延迟超过 2 秒呢? 论文坦诚地承认,这种情况理论上是可能发生的。如果一条失效消息因为极端网络拥堵等原因,延迟超过了 2 秒才到达,那么一个 stale 的值的确可能被成功写入缓存。

但这正是 Facebook 架构哲学的一个完美体现:他们愿意接受这种极小概率的、短暂的数据不一致,来换取 巨大的运维收益 ——将一个集群的预热时间从几天缩短到几小时。这是一种在“一致性”和“可用性/性能”之间做出的清醒且务实的权衡。

跨越区域:全球一致性挑战

为了降低全球用户的访问延迟和容灾,Facebook 在世界各地部署了多个区域(数据中心)。架构上,他们设定一个 主区域 (master region) ,存放主数据库,其他区域作为 从区域 (replica regions) ,存放只读的数据库副本。数据通过 MySQL 的复制机制从主区域同步到从区域。

这里最大的挑战是 复制延迟 (replication lag) 。如果一个欧洲用户更新了他的头像(写请求被路由到美国的主区域),然后立即刷新页面(读请求在本地的欧洲从区域),他很可能因为复制延迟而看不到自己的更新。

为了解决这个问题,Facebook 使用了一种叫 远程标记 (remote marker) 的机制。 当一个写请求发生在从区域时:

  1. 客户端首先在 本地 memcache 中设置一个标记 key(如 remote_marker_for_key_X)。
  2. 然后将写请求发送到 主区域 的数据库。
  3. 同时删除 本地 memcache 中真正的 key_X

当后续的读请求来到从区域:

  1. 它首先请求 key_X,缓存未命中。
  2. 接着它会检查是否存在 remote_marker_for_key_X
  3. 如果标记存在,说明这个 key 刚刚在主区域被更新,本地数据库副本的数据可能是旧的。于是,客户端会将这次读请求 直接路由到主区域 去查询最新数据,从而避免读到旧数据。

这是一种典型的 用延迟换取一致性 的权衡。虽然跨区域读取会慢一些,但保证了更好的用户体验(直接请求美国数据比用户等待数据复制到欧洲区域更快)。

总结与启示

Facebook 的 Memcache 架构是一个在工程实践中不断演化、充满权衡的杰作。从它的演进中,我们可以学到几个核心思想,这对我们日常的系统设计和面试都大有裨益:

  1. 分离关注点 :将缓存系统和持久化存储系统分离,使得两者可以独立扩展和优化。
  2. 将复杂性推向客户端memcached 服务器本身极其简单,大部分复杂逻辑如路由、序列化、错误处理、服务发现等都放在了无状态的客户端(或 mcrouter)中。这使得系统核心组件非常稳定,而客户端逻辑可以快速迭代和部署。
  3. 为失败而设计 :从 Gutter 系统到 mcsqueal 的可靠重放,整个系统处处体现着对故障的思考,致力于避免单点故障和级联故障。
  4. 简单为王 :系统设计中充满了务实的权衡,例如容忍短暂的陈旧数据以换取极高的性能和可用性,避免引入过于复杂但收益有限的优化。

希望今天的讲解能帮助大家更深入地理解这个经典的分布式缓存系统。这其中的很多设计思想,比如租约、CDC、Gutter 模式等,都是非常值得我们在自己的项目中借鉴的。