系统设计

给数据库加个缓存,能有多少坑?Facebook 用十年替你踩了个遍

Scaling Memcache at Facebook

缓存和数据库天生不是一条心。Facebook 那套扛住十亿 QPS 的缓存,没一个机制是「设计」出来的,全是被线上事故一次次揍出来的。读懂它,你会明白拿缓存换性能的代价究竟记在哪本账上——以及你自己的系统,该学它到哪一步、又该在哪停手。

先说一个做高并发迟早会撞上的墙。

你在 MySQL 前面糊了一层缓存,读命中率很漂亮,压测数据也好看。然后某天线上出了个诡异的 bug:用户改了昵称,刷新一下还是旧的;再刷,新的;再刷,又是旧的。顺着日志扒下去,根因是两个并发请求:一个在回填缓存,一个在删缓存。它俩到达的先后被网络重排了。于是一个早就过期的旧值,稳稳写回了缓存,还带着正常 TTL 赖着不走。

这不是「加个缓存」能解决的问题。这是「加了缓存之后」才会有的问题。

这类 bug 我见过太多次,根子都一样:缓存和数据库,天生不是一条心。你越想拿缓存换性能,就越得替这俩的貌合神离擦屁股。

那到底怎么加,才能只提速、不埋雷?是随手上个 Redis 就完事,还是真得学 Facebook 那套一看就头大的玩意儿?

这问题,Facebook 在 2013 年给过一个被十亿用户压过的答案——一篇叫 Scaling Memcache at Facebook 的论文 [1],讲他们怎么拿一堆开源 memcached,攒出一个每秒扛数十亿请求、装着数万亿条数据的庞然大物。这篇我读了很多遍,最服的一点是:里面几乎没有哪个机制是「设计」出来的——全是他们先在生产上被某个问题狠狠揍过一顿,才咬牙加上的补丁。

一、先把问题钉死:我们到底在优化什么 Pin down the problem first

先把需求圈死,别一上来就画框。

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 愿意在极短窗口里接受一点点旧数据,后面所有一致性妥协才有了立足点。谁要是上来就说「缓存必须和数据库强一致」,这题基本就答废了。

需求钉死了。现在从最朴素的一版动手,看它能撑多久。

二、第一版:look-aside,一加就翻车 v1: look-aside, and the write race

最朴素的加法,几乎人人第一反应都一样:读的时候先问缓存,没有就回源数据库、取到再塞回缓存;写的时候,改完数据库,顺手把新值也 update 进缓存。这套「缓存搁在旁边、由应用自己决定何时读它、何时填它」的模式,有个通用名字叫 look-aside(旁路缓存)——业界绝大多数缓存都这么用。这里你把缓存就当成一个笨盒子:一个 key 对一个 value,能读、能写、能删,不懂业务、也不主动干任何事,全等应用支使。还有个前提得点破:它不预热,里面每个值都是被某次读 miss「拽」出来的——用一次、填一次 [2]

应用缓存MySQL1. get kmiss2. SELECT3. set(k,v)写路径:改完 MySQL,顺手 update 缓存(最直觉的一版——下面就翻车)
图 1. look-aside 的读写路径:读 miss 时由应用回源 DB 再回填;写时改完库,最直觉的做法是顺手把新值 update 进缓存。填充逻辑全在应用侧,缓存只被动响应。

读这条路没什么可说的:命中返回,miss 回源再回填 [2]。翻车翻在写。

于是你顺手就 update 了缓存。麻烦立刻找上门:两个并发写,各带一个新值,各自改完库、再 update 缓存。可这两条 update 到缓存的先后,是网络说了算,不是「谁改得晚」说了算。万一那条旧的 update 后脚才到,缓存就卡在旧值,而库里明明是新的。再叠加重试、重发,update 还能反复把新值盖回旧值。病根只有一个:update 认的是到达顺序,而顺序你根本控制不了。

写者 A改库 a(先提交)写者 B改库 b(后提交)缓存= a(旧值,卡住)库 = b(新值,正确)① update b 先到② update a 迟到,盖掉 b库是 b、缓存是 a —— 对不上
图 2. 为什么写的时候「update 缓存」会写脏:写者 A、B 各自把库改成 a、b(库最终该听后提交的 b),再各自 update 缓存;可两条 update 到缓存的先后被网络打乱,A 那条旧的反而后到、盖掉了 b。结果库是新值 b、缓存却卡着旧值 a,长期对不上——换成 delete 就没这毛病,因为 delete 幂等。

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 幂等:不认顺序,靠下一次读自愈写者 A、写者 B各发一个 DELETE k缓存里 k 没了删几次、什么顺序都一样下次读 miss从库回填最新值 b ✓对照上一张图:update 会被乱序卡在旧值,delete 最坏也就是多一次 miss
图 3. 换成 delete 之后:两个并发写各发一个 DELETE,不管谁先谁后、删几次,结果都一样——key 没了。真正的最新值靠「下一次读」自动从库里补回来。所以 delete 不认到达顺序、天生抗重放,这正是 update 做不到的。

「delete 而非 update」是这条演化链的第一块地基。它把一个「必须保证顺序的强一致更新」问题,降级成了「随便重放都不会错的失效」问题。记住这个降级——后面失效传播能做得那么放肆(批处理、缓冲、重放),根子全在这一句幂等。

好,乱序写脏堵上了。但你有没有发现,我们刚给自己挖了个新坑:既然写是「删」,那一个被疯狂读的热 key,被删掉的那一瞬间,会发生什么?

三、第二版:delete 惹出两头怪兽 v2: two beasts, one lease

delete 把写路径的乱序治好了,可它自己又招来两头新怪兽。

头一头是 thundering herd(惊群)。设想一个热 key——某个大 V 的资料,每秒几万次读。它被删掉的那一刹那,缓存里没它了。这一瞬间涌进来的上千个读全部 miss,全部挤向那条昂贵的回源路径,同时去查数据库。库前一秒还很闲,下一秒就被上千条一模一样的查询按在地上。

另一头更阴,其实就是开头那个「旧值赖着不走」的 bug。咱们给它起个名,叫 陈旧写入(stale set):一个读 miss 了、从库里读到 v1,可还没来得及写回缓存;就在这空档,一个写把库改成 v2、又删了缓存;那个慢半拍的读这才把 v1 塞进缓存——缓存又卡回旧值。它跟第二节那个 update 乱序不是一回事:那个是两个「写」在抢,delete 已经治了;这个是「读回填」跟 delete 在抢跑,delete 根本够不着。

读者 R缓存数据库(写者 W)时间① GET 未命中② 读库,得旧值 v1③ W 把库改成 v2④ W delete 缓存⑤ R 迟到,写回 v1库 = v2(新值)v1(stale)缓存卡旧值← delete 也拦不住
图 4. stale set(陈旧写入)竞态:读者 R 读 miss、从库取到旧值 v1 后还没回填;这空档里写者 W 把库改成 v2、又删了缓存;R 姗姗来迟才把 v1 写进缓存。于是缓存卡着旧值 v1,库却是 v2——注意 W 明明用了 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已deleteMISS 全部穿透DB被打穿 17K/s拿到token客户端客户端客户端客户端缓存命中回源唯一 token 回源收 wait重试即命中回填缓存DB1.3K/s
图 5. 热 key 失效瞬间的对照:没有租约时全部客户端穿过空缓存直击数据库,把 DB 打到 17K/s;有租约时只有拿到 token 的一个客户端回源(1.3K/s),其余客户端收到 wait 短暂重试后命中被回填的缓存。

数字最有说服力。一组容易惊群的 key,采一周数据:没租约时峰值数据库查询 17K/s,上了租约峰值降到 1.3K/s [4]。数据库是按峰值容量买单的,这十几倍削峰直接变成钱。

(顺带一提,租约还派生一个可选优化 stale values:key 被删后旧值不立刻消失,进一个短暂保留区,能容忍轻微过期的读可以直接拿旧值、不必干等回源 [5]——又绕回第一节那个题眼:你愿意接受多旧,决定你能省多少等待。)

到这,单台缓存的正确性基本闭环了。可我们从头到尾说的都是「一台缓存」。真实系统里,一个前端集群是几百台。几百台机器塞在一起,会长出全新的麻烦。

四、第三版:几百台机器,开始互相踩,还会宕机 v3: pools, replication, and a dead box

把不同业务的 key 塞进同一批缓存机,就像几拨人合租一个储物柜:柜子就那么大,塞满了就得往外扔,规矩是「谁最久没被碰,就先扔谁」——这就是缓存的 LRU 淘汰。

麻烦在于,不同业务用这柜子的方式天差地别。有一类 key 特别「勤」:一直在写、一直失效、一直换新的,本身命中率就不高。它就像那个疯狂囤货又疯狂清货的室友,不停往共享柜子里塞新东西,一会儿就把柜子占满。

另一类 key 正相反:平时没人碰,可一旦 miss、回源特别贵(比如要跑一个很重的查询)。它偶尔才被用一次,于是在「谁最久没碰先扔谁」的规矩下,一次次被那个囤货室友挤下架。等你真要用它,柜子里早没了——白白吃一次昂贵回源 [6]

两拨 key 各自单住都好好的,一合租,勤快那拨就把值钱那拨的命中率活活拖下水。这就是把不同访问模式混进同一个缓存池的代价 [6]

解法直接得很:把会互相踩的 key 分开放,各用各的缓存池,谁也挤不着谁。 默认所有 key 进一个大池(叫 wildcard),只有那些「跟别人混会出事」的 key 才拎出来、单开专用池 [6]。至于每个专用池开多大,逻辑还跟直觉反着来:给「访问频繁、但 miss 了也不心疼」的 key 一个小池就够,给「访问稀少、但 miss 一次贵到肉疼」的 key 反而要开大池 [6]——大池装不下才可惜,小池偶尔 miss 无所谓。

混在一个池:互相踩HHH高流转 key 一直涌入共享缓存满了按 LRU 淘汰「最久没碰的」值钱 key:平时没人碰、miss 很贵被挤下架 → 下次 miss(贵)拆成两个池:互不打扰高流转池HHHchurn 随它造值钱 key 专用池V一直在 · 命中 ✓各占各的地盘,谁也挤不着谁
图 6. 为什么不同 key 不能混一个池:缓存空间有限、满了按 LRU(最久没碰的先扔)淘汰。左边混在一个池——高流转的 key 一直往里涌,把那个「平时没人碰、但 miss 一次很贵」的值钱 key 顶下架,等你要用它时它已经没了;右边给值钱 key 单开一个专用池,churn 再猛也挤不着它,命中率就稳住了。

分好池,下一个问题马上来:某一类 key 的请求率,超过了单台机器能扛的极限,怎么办?直觉是加机器。但加机器有两种加法,选错等于白加

算例:每请求 100 key · 总负载 1M req/s · 单机上限 500Kpartition 切一半(各存半量 key)客户端请求拆两个子请求,两台都要打1M1M机器 Akey 0–50%机器 Bkey 50–100%每台仍 1M req/s · 没降(照样打穿)replication 复制整份(各存全量 key)客户端整份请求发给任一台,各承一半500K500K机器 C全量 key机器 D全量 key每台 500K req/s · 降一半(正好命中)
图 7. 同样两台机器:左边按 key 切一半,每个请求都得拆成两个子请求分别打两台,单机负载仍是 1M req/s,照样打穿 500K 上限;右边复制整份数据,客户端把整份请求发给任一台,负载被均摊到 500K,正好压回单机上限之下——这就是为什么切分降不了载、只有复制能把负载压下来。

说白了:当瓶颈是请求率(每个包的处理开销)而不是数据量(内存)时,复制才有用、切分白费——因为批量取 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],刚好覆盖住自动修复那几分钟的空窗。

Gutter 容错客户端缓存节点已宕机原目标存活邻居节点rehash(被否决)会级联压垮邻居Gutter 池1% 闲置 · 短 TTL采用 ✓改投 Gutter 池
图 8. 当某台缓存节点宕机时,Facebook 不让客户端把请求 rehash 到存活邻居(灰虚线,被红叉否决——那会把额外负载压到邻居上引发级联雪崩),而是统一改投一个仅占约 1%、平时闲置、只设短 TTL 的 Gutter 池(实线红箭头),用小代价兜住故障流量、隔离连锁失效。

单台正确、集群扛住、单点故障兜住。现在把镜头拉远:一个数据中心里不止一个前端集群,写发生在集群 A,B 和 C 的缓存怎么知道该失效?再拉远一点——集群分布在不同大洲呢?

五、第四版:跨集群、跨大洲,失效开始追不上写 v4: invalidation across clusters and regions

先把 region(区域)定义清楚:多个前端集群(每个 = 应用 + 缓存节点)+ 一个含数据库的存储集群,合起来算一个 region [12]。多个前端集群共享同一个存储集群,故障域更小、网络更可控。

region 内:最自然的广播被否,让数据库来当广播源

最自然的想法:应用改完库,直接向本 region 所有前端集群广播 delete。Facebook 试过,然后否了,两个理由 [15]:一是应用批处理失效的效率远不如专门管道,包开销大;二是一旦出系统性问题(比如配置把 delete 路由错了),应用广播这条路没有任何补救手段——他们真为此 rolling restart 过整个缓存基础设施。

于是失效被做成一条流水线,核心是让数据库来当广播源:

MySQL+ mcsqueal从 commit log 抽 deletemcrouter前端集群 Amcrouter前端集群 B批量 delete缓存节点缓存节点缓存节点缓存节点
图 9. region 内失效传播:delete 被嵌进 SQL、随事务提交进 commit log;每台库上的 mcsqueal 抽出 delete、批量打包发给各前端集群的 mcrouter,再由 mcrouter 拆包路由到具体缓存节点。

流程是:修改权威状态的那条 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% 的功都做在了空气上。

跨 region:副本库落后,读会捡回旧值

再拉到跨大洲。一个 master region 持有 master 数据库,其余 region 全是只读副本,靠 MySQL 复制同步 [17]。整个跨 region 的一致性难题,说到底就源于一件事——副本库会落后于主库。所以定位是「尽力而为的最终一致」,性能和可用性优先。

master region 的写,只在本 region 内发失效就安全了。但副本 region 的失效不能由应用直接发——因为改动可能还没通过复制流到副本库,你这边一失效,回填的读又会从落后的副本库拿到旧值写回来,失效和复制在赛跑 [18]。所以副本 region 的失效,是随复制流一起到达副本库后,再由副本库上的 mcsqueal 广播。

最麻烦的是从非 master region 发起写——你人在副本 region 要改数据,但权威库在另一个大洲。修复叫 remote marker(rk)

Replica Region(副本区域)应用regional poolrk副本库可能陈旧Master Regionmaster 数据库1. set rk2. 写 master(含 k, rk)读 miss 见 rk → 打 master
图 10. 跨 region 的 remote marker:在副本 region 写数据时先置一个 marker rk;后续读 miss 时,看到 rk 就把查询打到 master region,绕开还没同步过来的陈旧副本。

当应用要更新影响 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]

走到这,那张最终架构图终于成型了。回头看,我们没有一步是「设计师灵光一现」——每一版都是上一版解决一个问题、又亲手捅出下一个问题,硬生生被逼出来的。

六、这条链,2026 年你该走到第几版 How far down the chain should you go?

架构讲到这儿就齐了。但你八成从头到尾憋着一个更实在的问题:我又不是 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]。架构原则十年没动,零件一直在换代。

那你到底该走到第几版?一版一版对着自己的规模看,别跳版:

Facebook 的重型机械你的平替(撞上对应的痛,再上)delete,不 update写完 DB 就 DEL key免费 · 第一天就该做对leases(租约)singleflight / SET NX EX撞上缓存击穿再上Gutter 备用池副本 + 自动 failover + 熔断加得起副本就够,别抄 1% 闲置池mcsqueal从 binlog 广播失效CDC:Debezium / Canal / Maxwell要跨服务失效 / 绕过应用改库时remote marker(跨 region)先别碰单 region 用不上,等做多活再说
图 11. 这条链,落到你手里该怎么抄:左边是 Facebook 的重型机械,右边是普通团队的平替,以及「什么时候才该上」。真相是——大多数系统一辈子停在前两档就够了,越往下越是被规模逼出来的,没撞上那个痛就别上。
一句话

别抄零件,更别跳版——你的规模在第几版,就抄到第几版。cache-aside + delete 而非 update 这一版,今天任何一个上了缓存的团队都该做对;后面每一版都是被一个具体的痛逼出来的,没撞上那个痛就上,等于拿 Facebook 的复杂度,喂你根本没有的规模。

七、这题到底在考你什么 What this problem is really testing

把这条链摊开,你会发现 Facebook 从头到尾没做过一件「炫技」的事。memcached 是开源的,UDP、TCP 是现成的,MySQL 复制是标准功能。他们做的全部工作,是在每一版暴露的裂缝上,选择「接受多少不一致」来换「多少性能和可用性」——每一步都在明码标价:

你可能已经看出来了:上面每一笔交易,卖的都是同一样东西——一点点「不一致」。 从头到尾,没有哪一版让缓存和库做到「时刻一致」;每一版都留着一个「库改了、缓存还没追上」的中间窗口。真正在变的,是这个窗口——从 v1 那种「不受控、能让旧值赖到 TTL 到期」的旧,被一版版驯化成「受控、可定价、你敢签字」的旧:v2 用租约把它收进一个你自己选的短窗口,v3 的 Gutter 只在故障那几分钟认几秒旧,v4 跨洲干脆挑明了是「尽力而为的最终一致」、remote marker 也只把读到旧值的概率压低、压不到零。所以这条链不是「一步步逼近强一致」——强一致从来不在选项里,因为在这个业务里它太贵、也不必要。这也正是开头那句题眼:这份数据,旧一点会死人吗?

如果面试里有人问你「设计一个大规模缓存」,你张口就是最终架构、一堆花名,那你背的是答案。这题真正在考的,是你能不能从最朴素的一版出发,自己沿着「问题 → 修复 → 新问题」这条链一步步走下去,并且每一步都说得清:我在卖哪一档一致性、换回什么、代价谁来付。

我自己带评审,最怕听到「我们要做到强一致」。强一致不是免费的默认档,它是账本上最贵的那一档。这篇论文最值钱的地方,不是任何一个具体机制,而是它逼你先回答一个问题:这份数据,旧一点会死人吗? 想清楚这个,前面那条演化链才不是要背的知识点,而是你手里能挑、能停的工具。这题没有标准答案,但有一条及格线——你得知道自己走到第几版、每一步在卖什么、卖了多少钱。

附:这些坑和招,论文里叫什么 A glossary

前面为了顺,我尽量用大白话。这里把用到的名字跟论文里的正经术语对齐一下——你去翻原文、或跟人讨论时好对得上词(括号是论文章节):

全部出自 Rajesh Nishtala 等《Scaling Memcache at Facebook》,NSDI ‘13 [1]

  1. Rajesh Nishtala, Hans Fugal, Steven Grimm, Marc Kwiatkowski, Herman Lee, Harry C. Li, Ryan McElroy, Mike Paleczny, Daniel Peek, Paul Saab, David Stafford, Tony Tung, Venkateshwaran Venkataramani. Scaling Memcache at Facebook. 10th USENIX Symposium on Networked Systems Design and Implementation (NSDI ‘13), 2013.
  2. 同上,Section 2「Query cache」与 Figure 1(demand-filled look-aside cache 定义、读写路径、delete 幂等性论证)。
  3. 同上,Section 2 / 引言(read-heavy 工作负载与规模描述)。
  4. 同上,Section 3.2.1「Leases」(租约机制、64 位 token、stale set 与 thundering herd、10 秒限速、17K/s→1.3K/s)。
  5. 同上,Section 3.2.1「Stale values」(被删值转入短暂保留结构的可选优化)。
  6. 同上,Section 3.2.2「Memcache Pools」与 Figure 5(负干扰、wildcard 与 dedicated 池、churn)。
  7. 同上,Section 3.2.3「Replication Within Pools」(复制 vs 切分算例,500K→1M req/s)。
  8. 同上,Section 7.2「Pool Statistics」,Table 2 / Table 3(replicated 池 710K/s、2.7 倍、0.053% miss)。
  9. 同上,Section 4.2「Regional Pools」(跨集群共享单副本的内存-带宽权衡)。
  10. 同上,Section 3.3「Handling Failures」(Gutter 约 1%、超时改投、短 TTL、拒绝 rehash、热 key 占 20%)。
  11. 同上,Section 3.3(故障率降低 99%、每日 10%–25% 转命中、4 分钟内命中率超 35%)。
  12. 同上,Section 4 与 Section 5(region = 前端集群 + 存储集群的定义)。
  13. 同上,Section 4.1「Regional Invalidations」与 Figure 6(mcsqueal 从 commit log 抽 delete 广播、应用本地失效优化)。
  14. 同上,Section 4.1「Reducing packet rates」(mcsqueal → mcrouter 批处理、每包 delete 中位数提升 18 倍)。
  15. 同上,Section 4.1「Invalidation via web servers」(否决应用广播的两个理由、失效可重放)。
  16. 同上,Section 4.1(仅 4% 的 delete 真正失效了缓存数据)。
  17. 同上,Section 5「Across Regions: Consistency」(master / read-only replica、MySQL 复制、best-effort eventual consistency)。
  18. 同上,Section 5「Writes from a master region」(副本 region 失效随复制流到达后再广播)。
  19. 同上,Section 5「Writes from a non-master region」(remote marker 三步、以 miss 延迟换降低陈旧读概率)。
  20. 同上,Section 5(marker 存于 regional pool、并发修改下可能误删 marker、只降低不消除陈旧读)。
  21. 同上,Section 5(remote marker 的运维取舍:marker 被驱逐与并发误删在实践中都很罕见,予以接受)。
  22. memcached 与 Redis 的特性对比与选型(纯缓存 vs 数据结构/持久化/pub-sub 等),综合 AWS ElastiCache 文档及主流对比资料。
  23. Meta Engineering,CacheLib: Facebook’s open source caching engine for web-scale services(engineering.fb.com,2021,70+ 系统在用);Introducing mcrouter(engineering.fb.com,2014,峰值近 50 亿 rps)。
  24. Debezium,Automating Cache Invalidation With Change Data Capture(debezium.io,2018)及基于 CDC(Debezium / Canal / Maxwell)的缓存失效实践。
#系统设计#缓存#Memcached#Redis#分布式一致性