人人都能学编程
English 开始学习
专项 · 系统设计第 2 课 / 共 5 课免费阅读

缓存 · 系统设计

怎么让一个慢吞吞的数据库用起来很快?

课程目录

为什么需要它

从内存里读,比通过网络去问数据库快得多;而大多数应用会反复读取同一批热门数据。缓存把最近的答案放在手边。它是系统设计里最常见的提速手段,也是一个经典的 bug 来源:哪怕只显示过一次过期的价格,用户也会发现。

直觉理解

最常用的模式是旁路缓存(cache-aside):

  1. 先查缓存;如果有,就是命中,直接返回
  2. 没有就是未命中:去读数据库,把结果放进缓存,再返回

命中率(命中次数 / 查询次数)说明缓存帮了多大的忙。

内存是有限的,缓存满了就得踢掉一些东西。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
逐步执行
  1. L7命中:完全不用访问数据库。
  2. L9未命中:记一次访问数据库。
  3. L10把答案留着下次用。
  4. 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']
逐步执行
  1. L6a 刚被用过,所以移到最新的一端。现在的顺序是 b、a。
  2. L9超出容量:从最老的一端丢掉一个,也就是 b。
  3. 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))
  1. A
    ['x', 'z']
  2. B
    ['y', 'z']
  3. C
    ['x', 'y']
查看答案

答案:A

再次使用 x 后顺序是 y、x;加入 z 时淘汰 y。

2. 数据库里的价格变了,网站却一直显示旧价格。最可能的修复方法是什么?

  1. A每次写入价格时删除缓存里的副本
  2. B把缓存加大
  3. C从缓存里读两次
查看答案

答案:A

缓存从没失效,所以还存着旧值。

动手练习

3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。

  1. 预测输出命中与未命中

    准确写出它打印的内容。

  2. 改需求过期的价格

    set_price 更新了数据库,却把旧价格留在了缓存里,所以 get_price 一直返回旧值。让 set_price 使缓存里的副本失效。

  3. 从零写模拟 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.