缓存和数据库天生不是一条心。Facebook 那套扛住十亿 QPS 的缓存,没一个机制是「设计」出来的,全是被线上事故一次次揍出来的。读懂它,你会明白拿缓存换性能的代价究竟记在哪本账上——以及你自己的系统,该学它到哪一步、又该在哪停手。
先说一个做高并发迟早会撞上的墙。
你在 MySQL 前面糊了一层缓存,读命中率很漂亮,压测数据也好看。然后某天线上出了个诡异的 bug:用户改了昵称,刷新一下还是旧的;再刷,新的;再刷,又是旧的。顺着日志扒下去,根因是两个并发请求:一个在回填缓存,一个在删缓存。它俩到达的先后被网络重排了。于是一个早就过期的旧值,稳稳写回了缓存,还带着正常 TTL 赖着不走。
这不是「加个缓存」能解决的问题。这是「加了缓存之后」才会有的问题。
这类 bug 我见过太多次,根子都一样:缓存和数据库,天生不是一条心。你越想拿缓存换性能,就越得替这俩的貌合神离擦屁股。
那到底怎么加,才能只提速、不埋雷?是随手上个 Redis 就完事,还是真得学 Facebook 那套一看就头大的玩意儿?
这问题,Facebook 在 2013 年给过一个被十亿用户压过的答案——一篇叫 Scaling Memcache at Facebook 的论文 [1],讲他们怎么拿一堆开源 memcached,攒出一个每秒扛数十亿请求、装着数万亿条数据的庞然大物。这篇我读了很多遍,最服的一点是:里面几乎没有哪个机制是「设计」出来的——全是他们先在生产上被某个问题狠狠揍过一顿,才咬牙加上的补丁。
先把需求圈死,别一上来就画框。
Facebook 的负载是典型的读多写少(read-heavy)。页面加载会向缓存发起海量 get,写操作相比之下是零头——不同二手转述在「一个数量级」还是「两个数量级」上有分歧,但方向一致:读远远压倒写 [3]。这个特征决定了一切设计的重心:往死里优化读路径,容忍写路径稍微贵一点。
规模上,论文摘要自己说的是每秒数十亿请求、数万亿 item、十亿级用户 [1]。但更要命的不是总量,是扇出(fan-out):加载一个热门页面,平均要从缓存里取约 521 个不同的 item,一个请求常常触达上百台缓存机 [1]。说白了通信就是 all-to-all(全网互连)——任何一台机器慢一点、抖一下,都会被几百个并发请求同时踩中。
所以延迟目标不是「平均快」,而是「尾部不能崩」。论文给的生产数据:7 天里单次 get 的中位延迟 333 微秒,p75 是 475 微秒,p95 是 1.135 毫秒 [1]。整个端到端的目标是:中位压在亚毫秒,尾部也控制在毫秒级,而这还包含路由、网络、反序列化。
四条硬需求排下来:低延迟、高可用、读吞吐可水平扩展、可容忍轻微陈旧。最后一条是这道题的题眼——正因为 Facebook 愿意在极短窗口里接受一点点旧数据,后面所有一致性妥协才有了立足点。谁要是上来就说「缓存必须和数据库强一致」,这题基本就答废了。
需求钉死了。现在从最朴素的一版动手,看它能撑多久。
最朴素的加法,几乎人人第一反应都一样:读的时候先问缓存,没有就回源数据库、取到再塞回缓存;写的时候,改完数据库,顺手把新值也 update 进缓存。这套「缓存搁在旁边、由应用自己决定何时读它、何时填它」的模式,有个通用名字叫 look-aside(旁路缓存)——业界绝大多数缓存都这么用。这里你把缓存就当成一个笨盒子:一个 key 对一个 value,能读、能写、能删,不懂业务、也不主动干任何事,全等应用支使。还有个前提得点破:它不预热,里面每个值都是被某次读 miss「拽」出来的——用一次、填一次 [2]。
读这条路没什么可说的:命中返回,miss 回源再回填 [2]。翻车翻在写。
于是你顺手就 update 了缓存。麻烦立刻找上门:两个并发写,各带一个新值,各自改完库、再 update 缓存。可这两条 update 到缓存的先后,是网络说了算,不是「谁改得晚」说了算。万一那条旧的 update 后脚才到,缓存就卡在旧值,而库里明明是新的。再叠加重试、重发,update 还能反复把新值盖回旧值。病根只有一个:update 认的是到达顺序,而顺序你根本控制不了。
v1 的修复只改一个字:写的时候别 update,改成 delete。 改完库,把 key 删掉,下次读自然 miss、自然回填最新值 [2]。
// 写路径:改库在前,删缓存在后
db.execute("UPDATE users SET name=? WHERE id=?", name, uid)
cache.delete(key(uid)) // 幂等:重复/乱序都无害
为什么 delete 就对了?原因一句话,但极硬:delete 是幂等的 [2]。缓存不是权威数据源,它有权随时丢掉任何 key。所以两个 delete 不管重复几次、什么顺序到,效果都一样——都是让 key 变 miss。而 update 不行,两个乱序的 update 会把缓存写成一个谁都不认识的状态。
「delete 而非 update」是这条演化链的第一块地基。它把一个「必须保证顺序的强一致更新」问题,降级成了「随便重放都不会错的失效」问题。记住这个降级——后面失效传播能做得那么放肆(批处理、缓冲、重放),根子全在这一句幂等。
好,乱序写脏堵上了。但你有没有发现,我们刚给自己挖了个新坑:既然写是「删」,那一个被疯狂读的热 key,被删掉的那一瞬间,会发生什么?
delete 把写路径的乱序治好了,可它自己又招来两头新怪兽。
头一头是 thundering herd(惊群)。设想一个热 key——某个大 V 的资料,每秒几万次读。它被删掉的那一刹那,缓存里没它了。这一瞬间涌进来的上千个读全部 miss,全部挤向那条昂贵的回源路径,同时去查数据库。库前一秒还很闲,下一秒就被上千条一模一样的查询按在地上。
另一头更阴,其实就是开头那个「旧值赖着不走」的 bug。咱们给它起个名,叫 陈旧写入(stale set):一个读 miss 了、从库里读到 v1,可还没来得及写回缓存;就在这空档,一个写把库改成 v2、又删了缓存;那个慢半拍的读这才把 v1 塞进缓存——缓存又卡回旧值。它跟第二节那个 update 乱序不是一回事:那个是两个「写」在抢,delete 已经治了;这个是「读回填」跟 delete 在抢跑,delete 根本够不着。
这两头怪兽,Facebook 用同一个东西一次摁住——leases(租约) [4]。
解法简单到有点反高潮:让缓存在 miss 的时候多发一样东西——一张「回填许可证」。具体说,读 miss 时,缓存不只回「没有」,还塞给你一个绑定这个 key 的一次性 64 位 token;你回填时必须带上它,缓存先验 token、再决定收不收 [4]。这张许可证,咱们就叫它租约(lease)。
// 读 miss 拿 token,回填时校验
token = cache.lease_get(k) // 类比 load-link
v = db.query(k)
cache.lease_set(k, v, token) // 类比 store-conditional
// 期间任何 delete 都会作废 token,这次 set 失败
说穿了,这套机制跟 CPU 的 load-link / store-conditional 一模一样 [4]。读 miss 拿 token 相当于 load-link,回填 set 相当于 store-conditional,中间只要有人动过这个地址(delete),你的写就作废。我第一次读到这儿是真乐了——分布式系统里很多所谓「新机制」,剥开都是把单机老把戏搬到网络上。
数字最有说服力。一组容易惊群的 key,采一周数据:没租约时峰值数据库查询 17K/s,上了租约峰值降到 1.3K/s [4]。数据库是按峰值容量买单的,这十几倍削峰直接变成钱。
(顺带一提,租约还派生一个可选优化 stale values:key 被删后旧值不立刻消失,进一个短暂保留区,能容忍轻微过期的读可以直接拿旧值、不必干等回源 [5]——又绕回第一节那个题眼:你愿意接受多旧,决定你能省多少等待。)
到这,单台缓存的正确性基本闭环了。可我们从头到尾说的都是「一台缓存」。真实系统里,一个前端集群是几百台。几百台机器塞在一起,会长出全新的麻烦。
把不同业务的 key 塞进同一批缓存机,就像几拨人合租一个储物柜:柜子就那么大,塞满了就得往外扔,规矩是「谁最久没被碰,就先扔谁」——这就是缓存的 LRU 淘汰。
麻烦在于,不同业务用这柜子的方式天差地别。有一类 key 特别「勤」:一直在写、一直失效、一直换新的,本身命中率就不高。它就像那个疯狂囤货又疯狂清货的室友,不停往共享柜子里塞新东西,一会儿就把柜子占满。
另一类 key 正相反:平时没人碰,可一旦 miss、回源特别贵(比如要跑一个很重的查询)。它偶尔才被用一次,于是在「谁最久没碰先扔谁」的规矩下,一次次被那个囤货室友挤下架。等你真要用它,柜子里早没了——白白吃一次昂贵回源 [6]。
两拨 key 各自单住都好好的,一合租,勤快那拨就把值钱那拨的命中率活活拖下水。这就是把不同访问模式混进同一个缓存池的代价 [6]。
解法直接得很:把会互相踩的 key 分开放,各用各的缓存池,谁也挤不着谁。 默认所有 key 进一个大池(叫 wildcard),只有那些「跟别人混会出事」的 key 才拎出来、单开专用池 [6]。至于每个专用池开多大,逻辑还跟直觉反着来:给「访问频繁、但 miss 了也不心疼」的 key 一个小池就够,给「访问稀少、但 miss 一次贵到肉疼」的 key 反而要开大池 [6]——大池装不下才可惜,小池偶尔 miss 无所谓。
分好池,下一个问题马上来:某一类 key 的请求率,超过了单台机器能扛的极限,怎么办?直觉是加机器。但加机器有两种加法,选错等于白加:
说白了:当瓶颈是请求率(每个包的处理开销)而不是数据量(内存)时,复制才有用、切分白费——因为批量取 100 个 key 和取 1 个 key,缓存侧开销差不多,瓶颈在请求处理侧(工程上通常理解为 CPU / 网卡)不在存储侧。复制是拿内存换降载 [7]。生产里那个专做复制的池,get 速率约 710K/s、是次高池的约 2.7 倍、miss 率低到 0.053% [8]。
正确性有了、扩展性有了,还剩一个你躲不掉的问题:机器会挂。挂一台会怎样?那台上的所有 key 瞬间全部 miss——惊群的大号版,一整片 key 一起砸向数据库。
最自然的修法是「把挂掉那台的 key 重新哈希到其余存活机」。Facebook 偏不这么干,因为 key 访问极不均匀,单个热 key 能占一台机 20% 的请求 [10];你把故障机的负载摊给邻居,接手热 key 的那个邻居很可能也被压垮,然后继续传染——级联故障。
修复是一小组闲置机,叫 Gutter,大约占集群的 1% [10]。客户端 get 超时判定这台挂了,就把请求改投 Gutter;Gutter 也 miss 才查库、回填到 Gutter;Gutter 条目 TTL 很短,很快过期,于是连给它发失效都省了,代价是可能有点轻微陈旧 [10]。效果:Gutter 把客户端可见的故障率降低了 99%,每天把 10%–25% 的失败转成命中;整机故障时,Gutter 池命中率通常 4 分钟内就超过 35%、常接近 50% [11],刚好覆盖住自动修复那几分钟的空窗。
单台正确、集群扛住、单点故障兜住。现在把镜头拉远:一个数据中心里不止一个前端集群,写发生在集群 A,B 和 C 的缓存怎么知道该失效?再拉远一点——集群分布在不同大洲呢?
先把 region(区域)定义清楚:多个前端集群(每个 = 应用 + 缓存节点)+ 一个含数据库的存储集群,合起来算一个 region [12]。多个前端集群共享同一个存储集群,故障域更小、网络更可控。
最自然的想法:应用改完库,直接向本 region 所有前端集群广播 delete。Facebook 试过,然后否了,两个理由 [15]:一是应用批处理失效的效率远不如专门管道,包开销大;二是一旦出系统性问题(比如配置把 delete 路由错了),应用广播这条路没有任何补救手段——他们真为此 rolling restart 过整个缓存基础设施。
于是失效被做成一条流水线,核心是让数据库来当广播源:
流程是:修改权威状态的那条 SQL 会被顺手改写(amend)——附上事务提交后要失效的那些缓存 key。每台数据库上跑一个失效守护进程,叫 mcsqueal,它盯着自己这台库已提交的 SQL,把里面的 delete 抽出来,广播到本 region 每个前端集群 [13]。但它不直连缓存节点——许多库对许多缓存节点、还跨集群,直连的话包速率高到没法看,所以它把 delete 批量打包发给每个集群里专跑 mcrouter 的服务器,由 mcrouter 拆包再路由到正确那台缓存节点 [14]。光这个批处理,就让每个包携带的 delete 中位数提升了 18 倍 [14]。
这套最妙的地方,是把失效变成了「可重放」的:失效嵌在 SQL 里、随事务进了可靠的 commit log,所以 delete 丢了或路由错了,mcsqueal 重放就行 [15]——又回到第二节那句幂等。作为优化,发起写的应用还会额外主动失效自己本地集群,让同一个用户请求拿到写后读一致、并缩短本地陈旧窗口 [13]。
一个反直觉的生产事实:所有 delete 里,只有 4% 真正命中并失效了缓存数据 [16]。剩下 96% 都是空操作——因为这种「用一次填一次」的缓存下,大部分 key 本来就没被缓存。这解释了为什么整条失效链必须做得极其廉价:你 96% 的功都做在了空气上。
再拉到跨大洲。一个 master region 持有 master 数据库,其余 region 全是只读副本,靠 MySQL 复制同步 [17]。整个跨 region 的一致性难题,说到底就源于一件事——副本库会落后于主库。所以定位是「尽力而为的最终一致」,性能和可用性优先。
master region 的写,只在本 region 内发失效就安全了。但副本 region 的失效不能由应用直接发——因为改动可能还没通过复制流到副本库,你这边一失效,回填的读又会从落后的副本库拿到旧值写回来,失效和复制在赛跑 [18]。所以副本 region 的失效,是随复制流一起到达副本库后,再由副本库上的 mcsqueal 广播。
最麻烦的是从非 master region 发起写——你人在副本 region 要改数据,但权威库在另一个大洲。修复叫 remote marker(rk):
当应用要更新影响 key k 的数据时,它做三件事 [19]:先在本 region 置一个 remote marker rk;再把写打到 master,SQL 里同时嵌上 k 和 rk 待失效;最后 delete 掉本地集群的 k。之后再有人读 k、缓存 miss,应用会去查 rk 在不在——在,就说明本地副本可能还旧,把查询重定向到 master region;不在,才走本地。说白了就一句:在缓存 miss 时多花一点延迟(跨 region 查主库),换取读到陈旧数据的概率下降 [19]。(这个 rk 存放在一个 regional pool 里——本 region 所有前端集群共享的一个池,用单副本省内存、代价是多花点跨集群带宽 [9]。)
但 remote marker 有个概念上很微妙的软肋:它只降低、不消除陈旧读的概率。并发修改同一个 key 时,一个操作可能把另一个还在飞的操作需要的 marker 给删了 [20]——普通缓存条目你随便删都绝对安全,但 marker 的「存在本身」承载了「副本是否陈旧」这条信息,删错就等于说了谎。论文的态度很实在:marker 被驱逐和并发修改在实践中都很罕见,认了 [21]。
走到这,那张最终架构图终于成型了。回头看,我们没有一步是「设计师灵光一现」——每一版都是上一版解决一个问题、又亲手捅出下一个问题,硬生生被逼出来的。
架构讲到这儿就齐了。但你八成从头到尾憋着一个更实在的问题:我又不是 Facebook,就手上这点量级,这套东西到底该抄多少?
巧了,答案就摊在刚走过的这条路上。既然每一版都是被一个具体的规模问题逼出来的,那「该抄哪几条」说到底就是一个问题:你的规模,把你逼到了第几版?
先拆一个最常见的误会:这条链跟你用 memcached 还是 Redis 基本正交。 look-aside、delete 而非 update、从数据库广播失效、把一致性当可定价的档位——这些换成 Redis 一样成立,真正跟引擎强绑定的只有租约那种要服务端配合的机制,而那部分你有平替(下一段就说)。所以「memcached 还是 Redis」其实是道小题,凭需求选:只要一个极简、多线程、纯 volatile 的缓存角色,memcached 就够;要顺带用上数据结构、持久化、pub/sub、限流、排行榜这些「不只是缓存」的活,才上 Redis [22]。但引擎选完,上面这条链一条都不能省——那才是这篇论文真正的干货。
这套设计过时了吗?没有——Facebook 自己的答案最有说服力:mcrouter 这个路由层 2014 年公布时就说峰值近 50 亿请求每秒,至今是他们缓存基础设施的骨架;2021 年他们又开源了 CacheLib,把缓存引擎抽成可插拔的 in-process 库,用在 70 多个大规模系统里 [23]。架构原则十年没动,零件一直在换代。
那你到底该走到第几版?一版一版对着自己的规模看,别跳版:
DEL key,别 update 缓存值。一行代码躲掉一整类乱序写脏。这条没有任何借口不做。SET key val NX EX(拿个短命互斥锁当轻量租约),覆盖 90% 的击穿场景。租约是 Facebook 把它做进服务端的版本,你在客户端做一样成立。DEL 掉对应的 key,端到端延迟通常 100 到 500 毫秒 [24]。原则和 Facebook 当年一模一样:从源头发失效、幂等、可重放。别抄零件,更别跳版——你的规模在第几版,就抄到第几版。cache-aside + delete 而非 update 这一版,今天任何一个上了缓存的团队都该做对;后面每一版都是被一个具体的痛逼出来的,没撞上那个痛就上,等于拿 Facebook 的复杂度,喂你根本没有的规模。
把这条链摊开,你会发现 Facebook 从头到尾没做过一件「炫技」的事。memcached 是开源的,UDP、TCP 是现成的,MySQL 复制是标准功能。他们做的全部工作,是在每一版暴露的裂缝上,选择「接受多少不一致」来换「多少性能和可用性」——每一步都在明码标价:
你可能已经看出来了:上面每一笔交易,卖的都是同一样东西——一点点「不一致」。 从头到尾,没有哪一版让缓存和库做到「时刻一致」;每一版都留着一个「库改了、缓存还没追上」的中间窗口。真正在变的,是这个窗口——从 v1 那种「不受控、能让旧值赖到 TTL 到期」的旧,被一版版驯化成「受控、可定价、你敢签字」的旧:v2 用租约把它收进一个你自己选的短窗口,v3 的 Gutter 只在故障那几分钟认几秒旧,v4 跨洲干脆挑明了是「尽力而为的最终一致」、remote marker 也只把读到旧值的概率压低、压不到零。所以这条链不是「一步步逼近强一致」——强一致从来不在选项里,因为在这个业务里它太贵、也不必要。这也正是开头那句题眼:这份数据,旧一点会死人吗?
如果面试里有人问你「设计一个大规模缓存」,你张口就是最终架构、一堆花名,那你背的是答案。这题真正在考的,是你能不能从最朴素的一版出发,自己沿着「问题 → 修复 → 新问题」这条链一步步走下去,并且每一步都说得清:我在卖哪一档一致性、换回什么、代价谁来付。
我自己带评审,最怕听到「我们要做到强一致」。强一致不是免费的默认档,它是账本上最贵的那一档。这篇论文最值钱的地方,不是任何一个具体机制,而是它逼你先回答一个问题:这份数据,旧一点会死人吗? 想清楚这个,前面那条演化链才不是要背的知识点,而是你手里能挑、能停的工具。这题没有标准答案,但有一条及格线——你得知道自己走到第几版、每一步在卖什么、卖了多少钱。
前面为了顺,我尽量用大白话。这里把用到的名字跟论文里的正经术语对齐一下——你去翻原文、或跟人讨论时好对得上词(括号是论文章节):
全部出自 Rajesh Nishtala 等《Scaling Memcache at Facebook》,NSDI ‘13 [1]。