快速的代码是让你乐于运行和让你畏惧运行的脚本之间的区别。没有人喜欢等待慢响应,无论是加载的网页、训练的模型,还是重新计算它一秒前刚产生过的答案的函数。加速 Python 最省钱的方法之一是缓存:保留昂贵工作的结果,以便下次需要时可以立即交还。

本指南涵盖什么是缓存以及它真正有帮助的时机,然后按顺序介绍实用工具:你自己编写的手动字典缓存、内置的 functools.lru_cache@cache 装饰器、用 cachetools 实现基于时间的缓存,最后是缓存 HTTP 响应以避免爬虫两次获取同一页面。每个代码片段都是真实且可直接复制使用的。

你将构建什么

完成后你将拥有一个可以放入任何项目的小型缓存模式工具箱。每种模式针对不同形态的重复工作。

  • 手动记忆化装饰器。一个字典支持的包装器,以函数参数为键存储结果。
  • lru_cache 和 @cache。一行代码即可获得有界或无界缓存的标准库装饰器。
  • TTL 缓存。一个 cachetools 缓存,其条目在设定秒数后过期,适用于会过时的数据。
  • 带缓存的抓取器。一个缓存 HTTP 响应的函数,使对同一 URL 的重复请求立即返回而无需访问网络。

什么是缓存,它在何时有帮助?

缓存是用于存储生成或获取代价高昂的数据的临时存储。与其每次都重新计算结果或从数据库或 API 重新请求,不如在某个快速的地方(通常是内存)保留一份副本,并在下次调用时直接读取。第一次调用支付全部成本;之后每次重复调用几乎是免费的。

缓存在三种反复出现的情况下有回报:

  • 更快的访问。从内存缓存读取远比重新计算结果或从磁盘、数据库或远程 API 等较慢来源获取快得多。
  • 减少负载。每次缓存命中就是对数据库少一次查询或对外部服务少一次请求,减轻了这些系统的压力,并有助于避免瓶颈和速率限制。
  • 更好的体验。更低的延迟意味着更灵敏的页面和更流畅的交互,这对于用户直接等待的任何内容最为重要。

值得精确说明缓存何时是错误的工具。缓存只有在相同输入重复出现且底层结果在调用之间不变(或变化足够缓慢,一点点陈旧数据可以接受)时才有帮助。每次调用参数都不同的函数没有任何好处,只有内存开销。永不过期的缓存可能永远提供过时数据,这就是为什么一旦源数据可能变化,过期机制就很重要。

关于驱逐策略的简短说明

缓存不能无限增长,所以当它满了必须决定丢弃什么。这个决定就是驱逐策略,正确的策略取决于数据的访问方式。

  • FIFO(先进先出)。最旧的条目首先被驱逐,无论使用频率如何。简单,当访问顺序大致与有用性匹配时是合理的默认值。
  • LRU(最近最少使用)。最长时间未被读取的条目首先被驱逐。这是 Web 和数据库缓存的主力,因为最近使用的数据往往很快再次被使用。
  • LFU(最少频繁使用)。命中次数最少的条目首先被驱逐,这有利于始终受欢迎的数据,而非曾经激增过一次的数据。
  • TTL(生存时间)。每个条目携带一个过期时间,无论缓存是否满,过期条目都会在固定秒数后被丢弃。这是当源数据随时间变化时的选择。

标准库提供开箱即用的 LRU。对于 TTL 和 LFU,你需要一个小型第三方库,我们在下面进行介绍。

前置条件

基本 Python 技能。你应该能够编写函数、运行脚本,并用 pip 安装包。熟悉装饰器有帮助但不是必需的,因为第一部分会从头构建一个。

Python 3.9 或更高版本。python --version 检查你的版本。下面使用的 @cache 装饰器是在 Python 3.9 中添加的;其他所有内容在 3.2 及以上版本都可以工作。如果需要安装 Python,从 python.org 获取。

用装饰器进行手动缓存

理解缓存最清晰的方式是自己编写一个。装饰器只是一个包装另一个函数的函数,而缓存装饰器将每个结果存储在以调用参数为键的字典中。如果相同的参数再次出现,它返回存储的值而不是运行被包装的函数。

python
import requests

def memoize(func):
    cache = {}

    def wrapper(*args):
        if args in cache:
            return cache[args]
        result = func(*args)
        cache[args] = result
        return result

    return wrapper

@memoize
def get_html(url):
    # Only the first call for a given URL hits the network
    response = requests.get(url, timeout=10)
    return response.text

print(get_html("https://example.com"))
print(get_html("https://example.com"))  # served from cache, no request

第一次调用 get_html 时获取页面并将正文存储在其 URL 下。第二次用相同 URL 调用时,在 cache 中找到条目并返回,无需访问网络。键是 args 元组,这就是为什么这种模式只适用于可哈希的位置参数:列表和字典不能作为字典键,关键字参数在这里被忽略。这个限制正是标准库版本存在的原因,这也是我们接下来要看的。

缓存结果,而非副作用

记忆化假设被包装的函数是纯粹的查找:相同输入,相同输出,没有重要的副作用。缓存一个写入文件或改变全局状态的函数将在重复调用时跳过该工作,这通常是一个 bug。只缓存计算或获取值并返回它的函数。

functools.lru_cache 和 @cache

Python 在 functools 模块中内置了一个生产就绪的缓存装饰器,因此你很少需要自己编写。lru_cache 缓存结果,当缓存达到大小限制时,驱逐最近最少使用的条目腾出空间。你用 maxsize 设置上限。

python
from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(50))
print(fib.cache_info())

没有缓存时,朴素递归的 fib(50) 会数十亿次重复计算相同的子问题,耗时极长。有了 lru_cache,每个 n 的值只计算一次并被复用,将指数级函数变成线性的。cache_info() 方法是个小奖励:它报告命中次数、未命中次数和当前大小,让你可以确认缓存在正常工作。以下是你可以运行并验证的第一个结果。

json
12586269025
CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)

如果你根本不需要大小限制,@cache(Python 3.9 中添加)是名称更短的 lru_cache(maxsize=None)。它永不驱逐,所以只有当可能的输入集合有界且小到足以保存在内存中时才使用它。

python
from functools import cache

@cache
def expensive_computation(x, y):
    return x * y

print(expensive_computation(5, 6))

两个装饰器都以函数接收的每个参数(位置和关键字)为键,且都要求这些参数可哈希。如果你需要缓存类上的方法或接受不可哈希输入的函数,你必须先调整输入(例如将列表转换为元组),然后再将其传递给缓存调用。

用 cachetools 实现基于时间的缓存

标准库装饰器永远不会自行过期条目;LRU 条目只有在缓存满且有更新内容将其挤出时才会离开。这对于纯计算来说很好,但对于会过时的数据来说是错误的,例如价格、汇率或全天更新的 API 响应。对于这些情况,你需要 TTL 缓存,每个条目在设定秒数后过期。cachetools 库提供了这个功能,以及 LFU 和其他策略。

bash
pip install cachetools

你用 @cached 装饰器应用它,并传入一个同时设置最大大小和生存时间(秒)的 TTLCache 实例。

python
from cachetools import cached, TTLCache
import requests

# Up to 100 entries, each valid for 300 seconds
cache = TTLCache(maxsize=100, ttl=300)

@cached(cache)
def get_rate(symbol):
    response = requests.get(f"https://api.example.com/rate/{symbol}", timeout=10)
    return response.json()["price"]

print(get_rate("BTC"))  # fetches and caches
print(get_rate("BTC"))  # cached for up to 5 minutes

在接下来的五分钟内,对同一符号的重复调用返回缓存价格,无需 HTTP 请求。TTL 过期后,条目失效,下次调用获取新值。这让你在保持数据合理最新的同时获得大部分缓存速度优势,这对任何随时间变化的内容来说都是正确的平衡。如果你需要基于频率的策略,cachetools 也提供具有相同装饰器接口的 LFUCache

为爬虫缓存 HTTP 响应

当昂贵的操作是网络请求时,缓存最有价值。遍历列表、跟随链接或重试失败页面的爬虫,在一次运行中往往会多次请求同一 URL,每次重复获取都花费时间并给目标增加负载。响应缓存解决了这个问题:每个 URL 只获取一次,存储正文,并从内存提供重复请求。

该模式将 TTL 缓存与简单的获取函数结合。以 URL 为键意味着同一页面在 TTL 窗口内只会被下载一次。

python
from cachetools import cached, TTLCache
import requests

page_cache = TTLCache(maxsize=500, ttl=3600)
headers = {"User-Agent": "Mozilla/5.0 (cache tutorial)"}

@cached(page_cache)
def fetch(url):
    response = requests.get(url, headers=headers, timeout=10)
    response.raise_for_status()
    return response.text

urls = [
    "https://quotes.toscrape.com/page/1/",
    "https://quotes.toscrape.com/page/1/",  # duplicate, served from cache
    "https://quotes.toscrape.com/page/2/",
]

for url in urls:
    html = fetch(url)
    print(f"{len(html)} chars from {url}")

第二次对第 1 页的请求从不离开你的机器;它返回第一次调用时存储的正文。有了一小时 TTL,在该窗口内重新运行爬虫时重用缓存页面而不是重新下载,这在你迭代解析代码而不想每次测试都轰炸网站时非常有帮助。关于在获取的 HTML 之上构建解析器,请参阅如何用 Python 爬取网站

Crawlbase Crawling API

响应缓存避免了重复获取,但每个页面的第一次获取仍然必须成功,在真实目标上这意味着渲染 JavaScript 并绕过机器人防御。Crawling API 处理渲染并在服务端轮换住宅 IP,返回完成的 HTML,你可以直接放入上面的缓存 fetch 函数,这样你缓存的是真实页面而非被封锁的响应。在自己搭建无头集群和代理池之前,先在免费层级试试。

缓存响应也是对你抓取的网站的一种礼貌。每个从缓存提供的页面都是目标少处理的一次请求,这使你的访问规模保持小巧,让你的爬虫不太可能触发速率限制。将缓存与合理的节奏配合使用,是成为表现良好的客户端的最简单方式之一,这个主题在如何在爬取网站时不被封锁中有深入介绍。

选择正确的缓存

有四种工具可选,选择通常归结为两个问题:数据是否会变化,以及你是否需要对内存设置界限?

  • 永不变化的纯计算。输入空间小时使用 @cache 作为无界缓存,或者当输入空间大且你希望对内存设置上限时使用 lru_cache(maxsize=N)
  • 会过时的数据。使用 cachetoolsTTLCache,使条目过期并定期重新获取。
  • 小型自定义策略。当你需要库不提供的行为时,编写手动字典装饰器,但当标准工具适用时优先使用它们。

关于从缓存转移到完整提取管道时搭配使用的库的更广泛了解,请参阅最佳 Python 网络爬取库

回顾

核心要点

  • 缓存用内存换速度。存储昂贵工作的结果并在重复调用时交还,使只有第一次调用支付全部成本。
  • 首先使用标准库。functools.lru_cache 一行代码提供有界 LRU 缓存,@cache 是无界版本。
  • 对会变化的数据使用 TTL 缓存。cachetools.TTLCache 在设定秒数后过期条目,避免永远提供过时值。
  • 缓存 HTTP 响应以避免重复获取。以 URL 为键的获取函数将重复请求变成即时缓存命中,减轻目标的负担。
  • 只缓存纯粹的可重复工作。有副作用或参数总是唯一的函数没有任何收益,还可能以微妙的方式出错。

常见问题

Python 中的缓存是什么?

Python 中的缓存是存储昂贵函数调用或数据获取结果的做法,使后续对相同输入的请求从快速临时存储返回,而不是重新做这项工作。第一次调用计算或获取值并保存;之后每次匹配的调用读取保存的副本,这降低了延迟并减少了产生该值的系统的负载。

何时应该使用 lru_cache 而非 @cache?

当可能输入的集合很大且你希望限制缓存使用的内存时,使用 lru_cache(maxsize=N);一旦满了,最近最少使用的条目会被驱逐。当输入空间小且有界,并且你愿意永远保留每个结果时,使用 @cache,因为它从不驱逐。除大小限制外,两者的行为完全相同。

如何缓存一段时间后过期的数据?

使用 cachetools 库中的 TTL 缓存。创建一个 TTLCache(maxsize=N, ttl=seconds) 并用 @cached 装饰器应用它。每个条目在你设定的秒数内有效;之后它过期,下次调用获取新值。这是价格、汇率和随时间更新的 API 响应的正确工具。

我可以在爬虫中缓存 HTTP 响应吗?

可以,这是缓存价值最高的用途之一。将获取函数包装在以 URL 为键的缓存中,这样每个页面在缓存窗口内只下载一次。重复请求从内存提供,这加快了运行速度并减少了对目标网站的请求。TTL 缓存在这里效果很好,缓存页面在合理间隔后刷新。

何时缓存是错误的选择?

只有当相同输入重复出现且结果足够稳定,存储的副本仍然正确时,缓存才有帮助。如果函数每次调用都接收不同的参数,缓存只会增加内存开销而没有命中。如果函数有重要的副作用,缓存将在重复调用时跳过它们,这通常是一个 bug。如果源数据不断变化,使用短 TTL 或跳过缓存,而不是提供过时值。

lru_cache 适用于不可哈希的参数吗?

不适用。标准库装饰器从参数构建缓存键,因此这些参数必须可哈希。列表、字典和集合不能直接使用。如果你需要缓存接受其中之一的调用,在将其传递给缓存函数之前先将其转换为可哈希形式,例如将列表转换为元组。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量