缓存 · 系统设计
怎么让一个慢吞吞的数据库用起来很快?
课程目录为什么需要它
从内存里读,比通过网络去问数据库快得多;而大多数应用会反复读取同一批热门数据。缓存把最近的答案放在手边。它是系统设计里最常见的提速手段,也是一个经典的 bug 来源:哪怕只显示过一次过期的价格,用户也会发现。
直觉理解
最常用的模式是旁路缓存(cache-aside):
- 先查缓存;如果有,就是命中,直接返回
- 没有就是未命中:去读数据库,把结果放进缓存,再返回
命中率(命中次数 / 查询次数)说明缓存帮了多大的忙。
内存是有限的,缓存满了就得踢掉一些东西。LRU(最近最少使用)淘汰最久没被用过的那一项,赌的是最近用过的还会再被用到。在 Python 里,OrderedDict 很适合做这个:每次使用时 move_to_end(key),用 popitem(last=False) 丢掉最老的。
数据会变,所以缓存可能会过期(变旧):
- 写入时,删除缓存里的副本,让下一次读取拿到新值
- 再给每一项设一个 TTL(存活时间),这样你漏掉的也会自己过期
定义
缓存
A fast, small store of recent answers placed in front of a slower one.放在慢速存储前面的一个快速、小巧的存储,保存最近的答案。
命中率
The share of lookups answered by the cache.由缓存直接回答的查询所占的比例。
LRU 淘汰
When full, remove the item used least recently.缓存满时,移除最久没被使用的那一项。
失效
Removing or updating cached data after the source changes.源数据变化后,删除或更新缓存里的数据。
示例
旁路缓存
动手试试五次查询,只去了两次数据库。
db = {1: 20, 2: 35}
cache = {}
db_reads = 0
def get_price(item):
global db_reads
if item in cache:
return cache[item]
db_reads += 1
cache[item] = db[item]
return cache[item]
for item in [1, 1, 2, 1, 2]:
get_price(item)
print("database reads:", db_reads)
print("hit rate:", (5 - db_reads) / 5)database reads: 2 hit rate: 0.6
- L7命中:完全不用访问数据库。
- L9未命中:记一次访问数据库。
- L10把答案留着下次用。
- L165 次查询里有 3 次命中。
hit rate: 0.6
用 OrderedDict 实现 LRU
动手试试容量为 2:被用到的键会受到保护;最久没用的被淘汰。
from collections import OrderedDict
cache = OrderedDict()
cache["a"] = 1
cache["b"] = 2
cache.move_to_end("a")
cache["c"] = 3
if len(cache) > 2:
cache.popitem(last=False)
print(list(cache))['a', 'c']
- L6a 刚被用过,所以移到最新的一端。现在的顺序是 b、a。
- L9超出容量:从最老的一端丢掉一个,也就是 b。
- L10a 因为最近被用过而保留了下来。
['a', 'c']
常见错误
从不让缓存失效
如果写入不碰缓存,读的人在重启前一直看到旧数据。每次写入都要删除缓存里的副本。
什么都永久缓存
没有大小限制和 TTL,缓存会一直长到内存耗尽。给它设上限,让条目会过期。
热门键的惊群
一个热门条目过期时,成千上万的请求同时未命中,全部打到数据库上。让一个请求去重新填充,其他请求等待或者先用旧值。
小测验
1. 这段代码会输出什么?
from collections import OrderedDict
cache = OrderedDict()
for key in ["x", "y", "x", "z"]:
if key in cache:
cache.move_to_end(key)
else:
cache[key] = True
if len(cache) > 2:
cache.popitem(last=False)
print(list(cache))查看答案
答案:A
再次使用 x 后顺序是 y、x;加入 z 时淘汰 y。
2. 数据库里的价格变了,网站却一直显示旧价格。最可能的修复方法是什么?
查看答案
答案:A
缓存从没失效,所以还存着旧值。
动手练习
3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。
- 预测输出命中与未命中
准确写出它打印的内容。
- 改需求过期的价格
set_price更新了数据库,却把旧价格留在了缓存里,所以get_price一直返回旧值。让set_price使缓存里的副本失效。 - 从零写模拟 LRU 缓存
写一个
lru_hits(capacity, requests):对于一个能放capacity个键、一开始为空的 LRU 缓存,返回requests里有多少次命中。lru_hits(2, ["a", "b", "a", "c", "b", "a"]) # 1 lru_hits(3, ["a", "b", "a", "c", "b", "a"]) # 3
本课术语
| 术语 | 中文 | 英文定义 |
|---|---|---|
| Cache | 缓存 | A fast store of recent answers in front of a slower one. |
| Hit rate | 命中率 | Hits divided by lookups. |
| LRU | 最近最少使用 | Evict the least recently used item first. |
| TTL | 存活时间 | How long a cached entry stays valid. |