Python 中利用 functools.lru_cache 实现高效缓存:从入门到进阶
Python 中利用 functools.lru_cache 实现高效缓存:从入门到进阶
在日常 Python 开发中,我们经常会遇到重复计算相同输入的问题,比如递归计算斐波那契数列、多次调用相同参数的函数处理数据、或者频繁查询数据库获取不变的数据。这些重复计算不仅浪费 CPU 资源,还会大大降低程序的运行效率。Python 标准库中的 functools.lru_cache 装饰器为我们提供了一种简单高效的解决方案,它可以自动缓存函数的计算结果,当相同输入再次调用时直接返回缓存结果,避免重复计算。
本文将从基础用法开始,逐步深入到进阶技巧,带你全面掌握 lru_cache 的使用,并通过多个原创代码示例展示它在实际开发中的应用场景。读完本文你将学会如何利用缓存优化你的 Python 程序,提升运行效率。
什么是 LRU 缓存?
LRU 是 Least Recently Used 的缩写,翻译过来就是"最近最少使用"。它是一种缓存淘汰策略,当缓存容量达到上限时,会优先删除掉最近最少使用的条目,为新的条目腾出空间。这种策略的优点是简单高效,能够保留最近经常使用的数据,淘汰很少使用的数据,非常符合实际应用场景。
functools.lru_cache 是 Python 3.2 版本引入的装饰器,它实现了 LRU 缓存算法,可以非常方便地装饰任何 Python 函数,自动为其添加缓存功能。使用它只需要一行代码,就可以让你的函数性能得到显著提升。
基础用法:快速上手
我们先从一个最简单的例子开始,看看 lru_cache 到底有多好用。假设我们需要计算斐波那契数列,递归实现非常直观,但是会有大量的重复计算:
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
当我们计算 fib(30) 的时候,这个函数需要重复计算很多次相同的值,运行时间会很长。我们来加上 lru_cache 试试:
import functools
@functools.lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
只需要添加一行 @functools.lru_cache(maxsize=None),运行速度就会得到质的提升。原来计算 fib(30) 需要几百万次函数调用,现在只需要 30 次就够了。
这里解释一下 maxsize 参数:
- 当
maxsize=None时,缓存不限制大小,所有函数调用结果都会被缓存 - 当
maxsize设置为正整数时,缓存最多保留maxsize个条目,超过后就会使用 LRU 策略淘汰最少使用的条目 - Python 3.9 开始,默认值是
128
我们再来看一个实际运行对比的例子:
import time
import functools
# 不使用缓存
def fib_no_cache(n):
if n < 2:
return n
return fib_no_cache(n - 1) + fib_no_cache(n - 2)
# 使用缓存
@functools.lru_cache(maxsize=None)
def fib_with_cache(n):
if n < 2:
return n
return fib_with_cache(n - 1) + fib_with_cache(n - 2)
# 测试性能
start = time.time()
fib_no_cache(30)
print(f"不使用缓存,计算 fib(30) 耗时: {time.time() - start:.4f} 秒")
start = time.time()
fib_with_cache(30)
print(f"使用缓存,计算 fib(30) 耗时: {time.time() - start:.4f} 秒")
运行结果大概是这样的:
不使用缓存,计算 fib(30) 耗时: 0.3624 秒 使用缓存,计算 fib(30) 耗时: 0.0001 秒
性能提升了几千倍!这就是缓存的魔力。
查看缓存信息
lru_cache 装饰后的函数提供了一个 cache_info() 方法,可以查看缓存的使用情况:
print(fib_with_cache.cache_info()) # 输出类似: CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
各个字段的含义:
hits: 缓存命中次数,也就是有多少次调用直接从缓存拿到了结果misses: 缓存未命中次数,也就是有多少次调用需要实际计算maxsize: 当前设置的最大缓存大小currsize: 当前缓存中条目数量
如果需要清空缓存,可以调用 cache_clear() 方法:
fib_with_cache.cache_clear() print(fib_with_cache.cache_info()) # 输出: CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
进阶用法:处理不同类型的参数
lru_cache 对函数参数有一个要求:参数必须是可哈希的(hashable)。因为它使用参数值作为字典的键来保存缓存结果。所以哪些参数类型可以用呢?
可以缓存的参数类型:
- 整数、浮点数、字符串、布尔值
- 元组(前提是元组里的元素也是可哈希的)
- None
- 用户自定义类的实例(默认情况下是可哈希的,基于对象的内存地址)
不能缓存的参数类型:
- 列表、字典、集合这些可变容器类型,它们不可哈希
如果你的函数需要接收列表或字典参数,该怎么办呢?这里给大家介绍几种常用的解决办法。
方法一:把列表转换成元组
import functools
@functools.lru_cache(maxsize=128)
def sum_list(items):
# items 需要是元组(可哈希)
return sum(items)
# 调用的时候把列表转成元组
my_list = [1, 2, 3, 4, 5]
result = sum_list(tuple(my_list))
print(result) # 输出 15
这种方法最简单直接,适用于列表不大的情况。
方法二:对可变参数进行手动处理
如果参数是字典,我们可以把字典转换成 sorted 的元组列表,或者转换成 frozenset:
import functools
def cached_func(config):
# config 是字典,转换成 sorted 元组列表使其可哈希
config_tuple = tuple(sorted(config.items()))
return _cached_func(config_tuple)
@functools.lru_cache(maxsize=128)
def _cached_func(config_tuple):
# 转换回字典
config = dict(config_tuple)
# 实际计算逻辑
return config.get("timeout", 30) * 1000
这种方式稍微麻烦一点,但是可以处理任意嵌套的字典结构,只要所有的键和值都是可哈希的就行。
实际应用场景
接下来我们来看几个 lru_cache 在实际开发中的应用场景,这些都是我在日常开发中经常用到的。
场景一:缓存频繁调用的计算密集型函数
假设我们正在开发一个图像处理程序,需要计算图像的直方图,而很多时候相同的图像会被多次处理。这个时候就可以用 lru_cache 缓存计算结果:
import functools
from PIL import Image
@functools.lru_cache(maxsize=32)
def calculate_histogram(image_path):
# 打开图片并计算直方图,这是一个比较耗时的操作
with Image.open(image_path) as img:
histogram = img.histogram()
return histogram
这样当我们重复处理同一个图片文件的时候,不需要每次都重新打开文件计算直方图,直接从缓存返回结果,可以大大提升处理速度。这里我们设置 maxsize=32,表示最多缓存 32 张图片的直方图,避免占用过多内存。
场景二:缓存静态数据查询
在 Web 开发中,有些数据不经常变化,但是每次请求都要查询数据库,比如网站的配置信息、分类列表等。我们可以用 lru_cache 缓存这些查询结果,减少数据库压力:
import functools
import sqlite3
@functools.lru_cache(maxsize=1)
def get_all_categories():
# 查询所有文章分类,分类不经常变化
conn = sqlite3.connect("blog.db")
cursor = conn.cursor()
cursor.execute("SELECT id, name FROM categories ORDER BY sort_order")
result = cursor.fetchall()
conn.close()
return result
因为分类列表不经常变化,所以我们只需要缓存一份就够了,设置 maxsize=1。当后台修改了分类信息后,我们可以调用 get_all_categories.cache_clear() 清空缓存,让下一次查询加载最新的数据。
场景三:递归函数优化
除了斐波那契数列,树状结构遍历、动态规划问题等都经常用到递归,这些场景非常适合用 lru_cache 优化。比如我们来计算不同面额硬币凑出指定金额最少需要多少枚硬币(经典的零钱兑换问题):
import functools
def coin_change(coins, amount):
@functools.lru_cache(maxsize=None)
def dp(n):
# 凑出金额 n 最少需要多少枚硬币
if n == 0:
return 0
if n < 0:
return -1
res = float('inf')
for coin in coins:
sub = dp(n - coin)
if sub == -1:
continue
res = min(res, 1 + sub)
return res if res != float('inf') else -1
return dp(amount)
# 测试
coins = [1, 2, 5]
amount = 11
print(coin_change(coins, amount)) # 输出 3 (5 + 5 + 1 = 11)
这个解法使用了动态规划加递归,利用 lru_cache 自动缓存已经计算过的子问题结果,代码非常简洁清晰,而且性能也很好。如果不使用缓存,同样的计算会重复很多次,对于大金额会非常慢。
LRU 缓存的淘汰机制演示
当缓存容量达到上限后,lru_cache 会淘汰最近最少使用的条目。我们来写个例子演示一下这个过程:
import functools
@functools.lru_cache(maxsize=3)
def f(x):
print(f"计算 f({x}),没有命中缓存")
return x * 2
# 开始调用
f(1) # 计算,缓存: [1]
f(2) # 计算,缓存: [1, 2]
f(3) # 计算,缓存: [1, 2, 3]
print("当前缓存信息:", f.cache_info())
# 访问 1,现在 1 变成最近使用
f(1)
# 添加 4,缓存满了,淘汰最少使用的 2
f(4)
print("添加 4 后缓存信息:", f.cache_info())
# 现在尝试访问 2,会发现缓存不命中,需要重新计算
print("访问 2:")
f(2)
运行结果:
计算 f(1),没有命中缓存 计算 f(2),没有命中缓存 计算 f(3),没有命中缓存 当前缓存信息: CacheInfo(hits=0, misses=3, maxsize=3, currsize=3) 添加 4 后缓存信息: CacheInfo(hits=1, misses=4, maxsize=3, currsize=3) 访问 2: 计算 f(2),没有命中缓存
这个例子很好地展示了 LRU 的工作过程:当缓存满了之后,最近最少使用的条目会被淘汰。在我们的例子中,添加 4 之前,2 是最近最少使用的,所以它被淘汰了,当我们再次访问 2 的时候就需要重新计算。
与其他缓存方案的对比
除了 functools.lru_cache,Python 生态中还有其他一些缓存方案,我们来简单对比一下:
lru_cache vs 手动字典缓存
手动用字典保存缓存结果需要自己处理很多逻辑,比如缓存大小限制、LRU 淘汰等。lru_cache 只需要一个装饰器就搞定了,代码更简洁,维护成本更低。除非你有非常特殊的需求,否则推荐使用 lru_cache。
lru_cache vs joblib.Memory
joblib.Memory 是把缓存结果存在磁盘上,适合缓存非常耗时的计算,即使程序重启缓存还在。lru_cache 是存在内存中,速度更快,但是程序重启就丢失了,适合缓存较小、需要快速访问的结果。
简单来说:
- 计算耗时几分钟,结果很大 → 用 joblib 磁盘缓存
- 计算耗时几毫秒,经常调用,结果不大 → 用 lru_cache 内存缓存
lru_cache vs functools.cache
Python 3.9 引入了一个新的装饰器 functools.cache,它其实就是 functools.lru_cache(maxsize=None) 的别名,写起来更短。如果你使用的 Python 版本 >= 3.9,可以直接用 @functools.cache,效果完全一样。
# Python 3.9+ 可以这么写,等价于 lru_cache(maxsize=None)
from functools import cache
@cache
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
使用注意事项
虽然 lru_cache 很好用,但是也有一些需要注意的地方:
1. 纯函数才能使用缓存
缓存适合纯函数,也就是相同输入总是得到相同输出的函数。如果函数的输出依赖于外部会变化的状态,或者函数有副作用,那么使用缓存就会导致问题,因为它会返回旧的结果。
2. 注意内存占用
所有缓存结果都存在内存中,如果缓存了太多大对象,会导致内存占用过高。这个时候合理设置 maxsize 就非常重要,不要总是用 maxsize=None。对于不常用的数据,让它被淘汰掉释放内存反而更好。
3. 线程安全
lru_cache 内部实现了锁,在多线程环境下是安全的,可以放心使用。多个线程同时调用同一个缓存函数不会出现问题。
4. 缓存失效
当底层数据发生变化后,记得调用 cache_clear() 清空缓存,否则用户会一直看到旧的数据。这一点在 Web 开发中尤其重要。
总结
functools.lru_cache 是 Python 标准库中一个非常实用的工具,它简单易用,只需要添加一个装饰器就能给你的函数带来显著的性能提升。无论是递归优化、重复计算缓存,还是静态数据查询缓存,它都能很好地胜任。
本文介绍了 LRU 缓存的基本概念、基础用法、进阶技巧,通过多个原创代码示例展示了实际应用场景,还讨论了与其他缓存方案的对比和使用注意事项。希望你读完本文后,能在自己的 Python 开发中灵活运用 lru_cache,写出更高效的代码。
如果你还没用过 lru_cache,不妨现在就打开你的 Python 项目,找一个可以优化的函数试试吧!
