速成课 · No. 15
cache 是一个又小又快的地方,用来存放你已经算出来的答案,这样下次需要它时直接读出来,而不必把活儿重做一遍。它是计算里最大的提速诀窍——同时也带来了计算里最著名的难题:判断那个记下来的答案什么时候已经过时。
20 个概念 · 每个配一幅图 · 结尾有速查表
cache 只是一个简单的想法:把答案的一份副本放在某个快的地方,这样同一个答案不用付两次代价。懂了这一点,你以后遇到的每个 cache 都是同一个把戏,只是换了个地方。
地图:七组共 20 个概念
一张地铁图:先看到所有线路的全貌,再一站一站地坐过去。
先是三个基础——cache 是什么、hit 与 miss、以及它为什么管用。然后是六组,每章一组:cache 的层级、hit ratio、新鲜度、更新与淘汰的策略、陷阱,以及如何用好它。
后面每个概念都是同一个样子:它的英文名称(要记住——这就是术语)、一幅生活里的画面、一张图,以及一张卡片,字段有问题、思路、例子、代价、何时使用和关联。
Cache — cache 记住答案
桌上放一本便笺,旁边是一个很慢的文件柜——查过一次的东西你就记下来,下次读便笺就行,不用再穿过房间。
cache 是一个又小又快的存储,保存着耗费很大的工作的结果——一次数据库查询、一个算好的页面、一个下载的文件——这样下一次请求就很便宜。昂贵的事只做一次;便宜的读取做很多次。这就是全部原理。其余都是细节:便笺放在哪里,以及什么时候该信上面写的东西。
- 问题
- 同样昂贵的工作被一遍又一遍地做。
- 思路
- 把结果存在某个快的地方,从那里读取。
- 例子
- 一个算好的页面、一次数据库查询的结果、一个下载的文件——都存在 cache 里。
- 代价
- 副本占用内存,并且有副本过时的风险。
- 关联
- 课程的其余部分讲的就是 cache 放在哪里,以及什么时候该信它们。
Cache hit 与 cache miss — 两种结果
你去拿便笺。要么它就在那里,你立刻读到——要么它是空白的,你终究还是得走到文件柜那里。
答案在 cache 里时,叫 cache hit(命中)——又快又便宜。不在时,叫 cache miss(未命中)——你要做缓慢的工作,通常还会把结果存起来,让下次命中。这两个词描述了 cache 的每一次交互。caching 这场游戏,就是把 miss 变成 hit。
- 是什么
- hit 表示答案在 cache 里。miss 表示不在。
- 如何运作
- miss 时你做缓慢的工作,并把结果放进 cache——这样下次就是 hit。
- 例子
第一次访问某个页面:一次 miss,加一次数据库查询。第二次访问:毫秒级的 hit。
- 关联
- hit 所占的比例就是 hit ratio,是 cache 的主要成绩。
Locality — 它管用是因为重复
咖啡店记住老顾客点的东西——值得这么做,只因为同样的人一次又一次点同样的东西。
当同样的答案被反复需要时,caching 才划算——而计算里几乎一切都在重复。这个性质叫 locality(局部性):访问不是随机的,而是集中在少数热门条目上。同一个热门页面、同一个用户的资料、同一次查找,被问上千次。所以,一个装着热门条目的小 cache 就能吸收极大一部分工作。没有重复,就没有理由做 cache。
- 是什么
- 同样的数据被一遍又一遍地请求。
- 为什么重要
- 一个装着热门条目的小 cache 能接住极大一部分请求。
- 例子
- 一个热门页面、同一个用户的资料、同一个搜索。
- 陷阱
- 没有重复——cache 几乎不命中,只是白白浪费内存。
- 关联
- locality 让高 hit ratio 成为可能。
cache 保存一份昂贵答案的快速副本。hit 是读便笺;miss 是走去文件柜。要做的事,就是把 miss 变成 hit。
caching 不是某个地方的某一件事——它是在用户与数据之间的每一层都重复出现的模式。副本离需要它的人越近,就越快。
Cache Hierarchy — cache 越近,hit 越快
你的用品放在桌上、房间里、楼里,以及城那头的仓库里——越近拿得越快,但能放的越少。
用户与真相之间有一级一级的 cache——一个 cache hierarchy(缓存层级):浏览器把文件存在你的设备上,CDN 把副本存在你所在城市附近,应用把热门数据放在内存里,CPU 有只隔几纳秒的微型 cache。越近的 cache 越快但越小。请求先试最近的,miss 时再向外退一层。
- 问题
- 一个 cache 不可能同时又近、又快、又大。
- 思路
- 一串 cache:近的小而快,远的大而慢一些。
- 例子
- 浏览器 → CDN → 应用 cache(Redis)→ 数据库。
- 代价
- 过时的副本多了更多可以藏身的地方。
- 关联
- 这些层是 CDN、内存 cache 和 CPU cache。
In-memory Cache (Redis) — 主力
一个职员把最常被要的文件放在桌上伸手可及的地方,而不是每次都跑到地下室档案库去取。
大多数应用会在应用与数据库之间放一个专门的内存 cache——像 Redis 或 Memcached 这样的工具。内存比磁盘快得多,所以把热门结果放在那里,就能把一次缓慢的数据库查询变成近乎瞬时的读取。人们说"加个 cache"来加速后端时,通常指的就是这个。
- 问题
- 磁盘上的数据库查询很慢,而且相同的查询会重复出现。
- 思路
- 把热门结果存在内存里,按 key 查找。
- 例子
- key
user:42存着一份资料;应用从 Redis 而不是数据库读取它。 - 代价
- 多了一个服务;内存有限;副本可能过时。
- 何时使用
- 在慢速的真相来源前面加速后端。
- 关联
- 通常用 cache-aside 模式来填充。
CDN — 缓存在用户身边的网络
热门图书摆在各地的本地图书馆里,这样谁都不必向唯一的中央档案馆邮购。
CDN(content delivery network,内容分发网络)是把 caching 用到地理上:它把你的页面、图片和文件的副本存在世界各地的服务器(edge 边缘服务器)上,让每个用户从附近的一台获得服务。这就是为什么一个全球性的网站在各处都加载很快,而不只是在离它的源(origin)服务器近的地方。还是同一个 hit/miss 的想法——只不过副本散布在地图上,以此战胜距离。
- 问题
- 服务器在某一个国家,远处的用户要为距离等待。
- 思路
- 把副本存在遍布世界的 edge 服务器上。
- 例子
- 一个网站的图片从离用户近的城市提供,而不是从 origin 提供。
- 代价
- 散布在世界各地的副本同样会过时;需要有办法刷新它们。
- 何时使用
- 面向多地用户的静态文件、页面和资源。
- 关联
- cache hierarchy 的其中一层。
caching 是在每一层重复出现的模式:浏览器、CDN、内存存储、CPU。越近越快但越小——而 miss 会落到外面的下一层。
只有当 cache 真的接住了大多数请求,它才配得上自己带来的复杂度。一个数字——hit ratio——就能告诉你,你的 cache 有没有物有所值。
Hit Ratio — 真正重要的成绩
守门员是看他扑出的射门占多大比例来评判的。扑出 95% 的守门员能改变一切;只扑出 10% 的,几乎改变不了比赛。
hit ratio(命中率)是由 cache 而不是慢速数据源来响应的请求所占的比例。95% 的 hit ratio 意味着每二十个请求里只有一个会到达数据库——负载和延迟都大幅降低。10% 的 hit ratio 则意味着这个 cache 基本上是额外开销。判断一个 cache 是否管用,靠的就是这一个数字。
- 是什么
- hits ÷ (hits + misses)——由 cache 响应的请求所占的比例。
- 例子
- 95%——每 20 个请求里 1 个到达数据库。10%——cache 几乎没帮上忙。
- 原因
- 判断 cache 是否管用的主要方法,也是你要调的主要旋钮。
- 关联
随 locality 和合理的淘汰而升高;随冷 cache 和较短的 TTL 而下降。
Cold 与 Warm Cache — 冷 cache 需要预热
一家货架空空的新店,一开始谁也服务不快——随着顾客开口要东西才慢慢进货,直到热门商品总是现成的。
刚启动之后,cache 是冷的(cold)——空的,所以每个请求都 miss,都去找慢速的数据源。随着真实流量涌入,它被热门答案填满,变得热(warm),hit ratio 随之攀升。这就是为什么刚重启或部署后性能可能看起来很差,然后又稳定下来。有些系统会有意地预热(pre-warm)cache,在用户到来之前就加载已知的热门数据。
- 是什么
- 冷——空 cache,全部 miss。热——已填满,能命中。
- 例子
- 部署之后,网站会慢几分钟,直到 cache 被填满。
- 陷阱
- 同时重启所有节点,会把全部负载压到数据库上。
- 对策
- 预热:在用户到来之前加载热门数据。
- 关联
- 解释了重启之后 hit ratio 的下跌。
Memory–Speed Trade-off — cache 用内存换速度
租一张更大的桌子,让更多文件放在伸手可及的地方——占的空间更多,但你去文件柜的次数少得多。
caching 不是免费的:你花的是有限且要花钱的内存,来省下时间。cache 越大,存得越多,命中也越频繁,但你不可能什么都缓存——所以窍门在于缓存对的东西:那些热门的、昂贵的、被反复询问的答案,它们每存一个字节换来的速度最多。cache 是一次刻意的取舍(trade-off)。
- 思路
- 用内存付费,以减少去慢速数据源的次数。
- 例子
- 给 Redis 更多内存——能放下更多 key,更多 hit。
- 代价
- 内存有限且要花钱;你不可能什么都缓存。
- 何时使用
- 缓存每字节换来速度最多的东西:热门的和昂贵的。
- 关联
- hit ratio 衡量回报;淘汰腾出空间。
hit ratio 是 cache 的成绩单。你花内存买速度——所以要缓存那些热门、昂贵、每字节换来最多 hit 的答案。
一个记下来的答案,可能在你还信着它的时候就已经过时。知道什么时候该不再相信那份副本,是 caching 中核心的、真正困难的问题。
Stale Data — caching 的代价
你记在便利贴上的那个电话号码是宝贝——直到朋友搬了家,那张便条悄悄变成了错的。
记住一个答案的另一面是:真实的答案可能变了,而你的副本没变。stale(陈旧)数据就是不再与真相相符的缓存值——旧价格、已删除的帖子、一小时前的库存数。每个 cache 都有提供陈旧数据的风险,而决定你能容忍多少陈旧,是任何 cache 的第一个真正的设计问题。
- 是什么
- 一个不再与数据源相符的缓存值。
- 例子
- 旧价格、已删除的帖子、一小时前的库存数。
- 为何发生
- 真相变了;副本没变。
- 关联
- 受 TTL 和失效约束;能容忍多少,由 staleness budget 决定。
TTL — 让它按计时器过期
牛奶上盖着保质期——过了那天,不检查就不会相信它,哪怕它看起来还好好的。
最简单的新鲜度工具是 TTL(time to live,存活时间):每个缓存条目都有一个过期时间,过了之后该条目就被当作不存在,迫使重新获取新数据。TTL 短意味着数据更新鲜但 miss 更多;TTL 长意味着 hit 更多但更陈旧。选 TTL,就是在新鲜度与速度的权衡里选出你的确切位置——按每种数据分别选,依据是它实际变化得有多快。
- 问题
- 副本可能变陈旧,而 cache 并不知道。
- 思路
- 给每个条目一个有效期;过了之后,重新获取。
- 例子
- 汇率用 60 秒的 TTL 缓存:永远不会比一分钟更旧。
- 代价
- TTL 短意味着更多 miss;TTL 长意味着更陈旧。
- 何时使用
- 几乎总是用,作为基本的安全网;按每种数据调整。
- 关联
- 一个热门 key 对所有人同时过期,会引发 cache stampede。
Cache Invalidation — 为什么它出了名的难
在世界某处某个事实变化的那一刻就知道,好在恰到好处的时机撕掉你的便条——说起来容易,做起来让人发疯。
有个流传已久的笑话说,计算机科学里只有两个难题,而 cache invalidation(缓存失效)——知道什么时候必须丢掉某个缓存值——就是其中之一。它之所以难,是因为 cache 往往不知道底层数据已经变了;变化发生在别的地方。下一章讲的就是人们用来对付这个难题的一套策略。
- 问题
- 数据在别处变了,而 cache 不知道。
- 思路
- 真相变化时,丢掉或刷新副本。
- 例子
- 价格变动之后,从 cache 里删掉该商品的 key。
- 代价
- 你得找出副本存在的每一个地方,一个都不能漏。
- 关联
策略:cache-aside、write-through、write-around;安全网:TTL。
每个缓存的答案都可能变陈旧。TTL 用计时器让它过期——而确切知道何时失效,是计算里真正困难的问题之一。
两股力量决定 cache 里有什么、它是否可信:真相变化时你如何更新它,以及它满了时你如何腾出空间。两者都有值得了解的、有名字的策略。
Cache-aside — 由应用来填充 cache
你先看便笺;如果是空白,就走到文件柜,读完文件,把答案记下来,再继续手头的事。
最常见的模式是 cache-aside(也叫 lazy loading,懒加载):读取时,应用先查 cache;miss 时,从数据源获取,存下结果并返回。cache 按需填充,存的正是实际被请求的东西。它简单而流行——它的弱点是陈旧,因为一个值会一直待在 cache 里,直到有东西让它过期或失效。
- 问题
- 你需要填充 cache,却事先不知道会被问到什么。
- 思路
- 由应用来做:查 cache,miss 时读数据库,并把结果放进 cache。
- 例子
GET user:42→ miss → 对数据库SELECT→ 带 TTL 地SET user:42。- 代价
- 一个值可能一直陈旧,直到它的 TTL 用完或有人删除它。
- 何时使用
- 数据库前面大多数 cache 的默认做法。
- 关联
- 常与 TTL 搭配;写入时用 write-through 来补充。
Write-through 与 Write-around — 让写入保持一致
一个事实变化时,你可以在更新总档案的同时更新桌上的便条——也可以跳过便条,让它下次重新获取新的。
数据被写入时,你要选择 cache 怎么跟上。Write-through 同时更新 cache 和数据源,所以 cache 永远不会陈旧(代价是写入更慢)。Write-around 只写数据源,让 cache 在 miss 时再重新加载,避免缓存没人会再读的数据。选哪个,取决于你的读写比例。
- 问题
- 写入时,cache 和数据源会各走各的。
- 思路
- Write-through——cache 和数据库一起更新。Write-around——只写数据库。
- 例子
马上就会被再次读取的资料——write-through。几乎没人读的日志——write-around。
- 代价
- Write-through——写入更慢。Write-around——写入之后紧接着一次 miss。
- 何时使用
- 取决于你的读写比例。
- 关联
- 从写入一侧做 cache invalidation 的方式。
Eviction (LRU, LFU) — 满了的时候决定丢什么
一张已经放满的小桌子——要放进新文件,就得收起一个,所以你拿走那个好久没碰过的,而不是每小时都用的。
cache 的空间有限,所以满了的时候必须淘汰(evict)一些东西。最常见的策略是 LRU(least recently used,最近最少使用):丢掉最久没被碰过的,赌的是它不会很快被需要。还有别的策略——LFU(least frequently used)丢掉使用频率最低的。淘汰让一个小 cache 能自动保留热门条目、舍弃冷门条目,而不必无限增长。
- 问题
- cache 空间用完了,而新数据还在不断到来。
- 思路
- 丢掉最不可能很快被需要的。
- 例子
- LRU——丢掉最久没碰的。LFU——丢掉用得最少的。
- 代价
- 有时会丢掉马上就要被问到的东西——多一次 miss。
- 关联
在不增加内存的情况下保持 hit ratio。和失效不是一回事:失效针对的是错误数据,淘汰针对的是空间。
失效决定一个缓存值何时是错的;淘汰决定空间用完时丢什么。cache-aside、write-through、LRU——都是这两件事的名字。
caching 的威力伴随着锋利的棱角。那些经典的故障并不稀奇——它们可预测、有名字,而了解它们就是避开它们的一半。
Cache Stampede — 热门 key 过期的时候
一件热门商品卖光了,就在那一刻,一百个顾客同时冲向柜台要它——把唯一负责补货的店员压垮了。
当一个热门缓存条目过期时,所有想要它的请求都会在同一时刻 miss,并一起冲向慢速的数据源——这叫 cache stampede(缓存踩踏,也叫 thundering herd,惊群)。被保护了几个小时的数据库,突然一下子要面对整个人群,可能会被压垮。解决办法是已知的——让一个请求去刷新而其他请求等待,或者错开过期时间——但你必须预先想到:stampede 恰恰出现在流量最高的时候。
- 问题
- 一个热门 key 过期,所有请求都 miss,同时冲向数据库。
- 例子
- 首页在流量高峰时过期——成千上万个请求同时重建它。
- 对策
- 一个请求去刷新,其余等待;或者给 TTL 加随机分散(jitter)。
- 何时要想到
- 每一个带 TTL 的热门 key。
- 关联
- TTL 用在热门数据上的副作用。
Stale Reads — 又快、又自信、还是错的
从一张旧便利贴上读出一个价格,并自信满满地报出来——数字瞬间送到,却是错的数字。
cache 会乐呵呵地又快又充满自信地提供一个陈旧的值——这是一次 stale read(陈旧读取)。通常这没问题——浏览次数稍微旧一点,不会伤到谁。但对于正确性至关重要的数据——银行余额、库存、权限——一次陈旧读取可能就是真正的 bug。关键的本事是知道哪些数据能容忍陈旧、哪些必须永远新鲜,并且不要随随便便缓存后一类。
- 问题
- cache 又快又自信地提供了一个旧值。
- 无害
- 一个晚了一分钟的浏览次数。
- 真正的 bug
- 银行余额、库存、权限。
- 对策
- 事先决定哪些数据能容忍陈旧,哪些不能。
- 关联
- stale data 是如何表现出来的;用 staleness budget 来处理。
What Not to Cache — 有些东西根本不该缓存
你不会去复印一份每分钟都在改写的文件,也不会在共用的桌子上放别人的私人信件的复印件——这份副本要么是错的,要么本就不该在那里。
不是所有东西都属于 cache。不断变化的数据,hit ratio 很差,还频繁陈旧。敏感的或按用户区分的数据,如果共享 cache 把一个用户的副本交给另一个用户,就有泄露的风险。而很少被请求的数据只会浪费空间。知道不该缓存什么,和知道该缓存什么一样重要。
- 变化快
- hit ratio 很差,而且持续陈旧。
- 敏感
- 共享 cache 里按用户区分的数据和机密数据可能到达错误的用户手里。
- 冷门
- 很少被请求——只会占空间。
- 关联
- cache target 的另一面。
caching 经典的咬人之处都有名字:热门 key 过期时的 stampede、带着虚假自信提供的 stale read,以及缓存了根本不该缓存的东西。
caching 是一件强力工具:用一点点内存换来巨大的速度,代价是有提供过去数据的风险。有意识地使用,它是计算里最好的交易之一。
Cache Target — 缓存热门、昂贵、变化慢的东西
你记住老顾客的常点,和一天要给十遍的路线——而不是一次性的请求,或者按分钟变化的东西。
理想的 cache target 是经常被读取、生成起来昂贵、很少变化的东西——这种组合以最小的陈旧风险换来最大的速度。一个由慢查询构建、一整天重算结果都一样的热门页面,是完美的。一个不断变化、很少被读取、又敏感的值,则正相反。
- 思路
- 经常被读 + 生成昂贵 + 很少变化。
- 例子
- 一个由慢查询构建、一整天都一样的热门页面。
- 反例
- 一个不断变化、很少被读取、又敏感的值。
- 关联
- what not to cache 的反面;它带来高 hit ratio。
Staleness Budget — 事先就定好
事先约定多旧是可以接受的——天气小组件可以晚几分钟;银行余额一秒都晚不得。
在缓存任何东西之前,先决定它能容忍多少陈旧——这就是它的 staleness budget(陈旧预算)。这个选择决定了其他一切——TTL、你是否需要主动失效、它到底能不能安全地缓存。要按每种数据把它明确下来。"它被允许错到什么程度,错多久?"这个问题,能把 caching 从神秘 bug 的来源,变成一笔受控的、刻意的交易。
- 问题
- 没有明确的决定,陈旧就会以神秘 bug 的形式出现。
- 思路
- 对每种数据,事先决定它可以有多旧。
- 例子
- 天气——几分钟没问题。银行余额——一秒都不行。
- 它决定什么
- TTL 的长度、是否需要失效、到底要不要缓存。
- 关联
- 应对 stale data 和 stale read 风险的答案。
这些概念如何联系起来
天平的两个托盘:一边是速度,一边是新鲜度。本课的每个概念都在移动其中一个。
整个 caching 就是速度与新鲜度之间的拉锯。左边,是抬高 hit ratio 的因素:locality、热 cache、合理的淘汰。右边,是压住 stale data 的因素:staleness budget 决定 TTL,而失效丢掉错误的数据。TTL 短,更新鲜但命中更少;在热门 key 上,同样的 TTL 可能引发 stampede。
- 数据是热门的吗——被反复请求的程度,足以带来真正的 hit ratio 吗?
- 生成起来昂贵吗,让一次 hit 确实能省下可观的工作吗?
- 它能有多陈旧——TTL 是多少,我需要主动失效吗?
- 热门条目过期时会怎样——会引发 stampede 吗?
- 缓存它安全吗——共享 cache 里没有按用户区分的机密数据吧?
- 我怎么知道它管用——我在测量 hit ratio 吗?
- 你能说出重要 cache 的 hit ratio。
- 每个被缓存的东西都有一个经过考虑的 TTL,与它实际变化的快慢相匹配。
- 没有任何按用户区分的或机密的东西意外地躺在共享 cache 里。
- 你的热门 key 在过期时不会一下子踩踏数据源。
- 你缓存的是热门、昂贵、变化慢的答案——其余的就不管。
速查表:全部 20 个概念
基础
- Cache便笺
昂贵答案的快速副本,免得把活儿做两遍。
- Cache hit / miss找到 / 没找到
答案在 cache 里——或者你得去数据源。
- Locality热门集合
同样的数据被一遍又一遍地请求——这就是 cache 管用的原因。
层级
- Cache Hierarchy多层 cache
浏览器 → CDN → 应用内存 → 数据库。越近越快,但越小。
- In-memory CacheRedis、Memcached
慢速数据库前面放在内存里的热门数据。
- CDN内容分发网络
文件和页面的副本,放在离用户近的服务器上。
命中率
- Hit Ratio命中占比
由 cache 响应的请求所占的比例。cache 的主要成绩。
- Cold / Warm Cache预热
空 cache 全部 miss;填满的就能命中。预热会提前加载它。
- Memory–Speed Trade-off拿内存换时间
付出内存,买到时间。缓存每字节换来速度最多的东西。
新鲜度
- Stale Data过时的副本
副本不再与真相相符。
- TTL存活时间
一个条目的寿命;过了之后,重新获取。
- Cache Invalidation丢掉副本
真相变化时丢掉副本。出了名的难。
策略
- Cache-asidelazy loading
应用先查 cache,miss 时读数据库,并存下结果。
- Write-through / Write-around写入策略
同时写 cache 和数据库——或者只写数据库。
- Eviction (LRU / LFU)腾出空间
满了的时候,丢掉最久没碰的或用得最少的条目。
陷阱
- Cache Stampedethundering herd
一个热门 key 过期——所有人同时冲向数据库。
- Stale Read提供过去的数据
又快、又自信、还是错的。对金钱、库存、权限很危险。
- What Not to Cache跳过清单
变化快的、敏感的、冷门的。
用好它
- Cache Target该缓存什么
经常被读、生成昂贵、很少变化。
- Staleness Budget多旧才算可以
事先决定数据可以有多旧。它决定 TTL 和失效。
caching 用一点内存换来巨大的速度,代价是有提供过去数据的风险。做得好,它就是刻意的:对的答案,配上你有意选定的 staleness budget。