
如何通过缓存技术提升 Python 性能
高级但开箱即用的 Python 缓存装饰器
一旦你跨越Python程序员新手期,就该开始探索 Python 中的内置功能了。Python 中会有许多令人惊讶的开箱即用的内置功能。今天,我将在这篇文章中介绍其中之一。
你是否曾遇到过需要多次执行某个函数的场景,并且某些结果可以被重用?在下面的第一个示例中,你将看到缓存机制使递归函数的性能提高了 120 倍。因此,在这篇文章中,我将介绍 functools 模块中的 lru_cache 装饰器,该装饰器自 Python 3.2 版本以来就已内置。
在 functools 模块中,还有另一个名为 cache 的装饰器,它更为简单。然而,易于使用有时意味着灵活性较低。因此,我将首先介绍 cache 装饰器及其局限性。接着,我将重点介绍 lru_cache 如何为我们提供更多的灵活性。
1. @cache 装饰器

接下来,我使用一个斐波那契递归函数来展示缓存机制的强大。
以下是示例。让我们首先编写一个斐波那契递归函数。
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
与大多数其他编程语言一样,Python 在递归函数中也需要构建一个“栈”,并在每个栈上计算值。
然而,使用“cache”装饰器可以显著提高性能。而且,这并不难做到。我们只需从 functools 模块导入它,然后将装饰器添加到函数上即可。
from functools import cache
@cache
def fibonacci_cached(n):
if n < 2:
return n
return fibonacci_cached(n-1) + fibonacci_cached(n-2)
以下是运行结果和性能对比。

结果显示,比没有缓存的版本快了大约 120 倍!
使用 lru_cache 重写函数
实际上,我们并不需要“重写”任何东西。要使用 lru_cache 装饰器,我们可以像使用 cache 装饰器一样简单。因此,只需更改装饰器的名称即可。
from functools import lru_cache
@lru_cache
def fibonacci_cached(n):
if n < 2:
return n
return fibonacci_cached(n-1) + fibonacci_cached(n-2)
可以看到,同样性能提升了。

现在,你可能会问为什么我们需要 lru_cache,以及它与 cache 装饰器之间有什么区别。接下来的部分将对此进行解答。
2. @cache装饰器的局限性?

在开始介绍 lru_cache 装饰器之前,我们需要了解 cache 装饰器的缺陷。实际上,就是内存问题。
让我模拟一个使用案例。假设我们正在开发一个从数据库获取用户详细信息的 API 端点。简单起见,我将跳过所有步骤,仅定义一个函数来随机返回一个用户。因此,你可以复制粘贴我的代码立即尝试。
import random
import string
import tracemalloc
from functools import cache
@cache
def get_user_data(user_id):
return {
"user_id": user_id,
"name": "User " + str(user_id),
"email": f"user{user_id}@example.com",
"age": random.randint(18, 60),
"self-introduction": ''.join(random.choices(string.ascii_letters, k=1000))
}
在上述代码中,使用了 random 模块来生成用户的年龄和自我介绍。自我介绍只是一个长度为 1000 的随机字符字符串,用于模拟。
现在,让我们编写一个模拟函数来调用 get_user_data() 函数。为了观察内存使用情况,我们需要使用 Python 内置的 tracemalloc 模块
def simulate(n):
tracemalloc.start()
_ = [get_user_data(i) for i in range(n)]
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/(1024**2):.3f} MB")
tracemalloc.stop()
所以,我们运行 get_user_data() 函数 n 次。同时,我们使用 tracemalloc 模块来跟踪内存使用情况。
这里是 10,000 次模拟的内存使用情况,内存使用约为 14MB。

以下是 100,000 次模拟的内存使用情况,内存使用约为 143MB。

以下是 1,000,000 次模拟的内存使用情况,内存使用约为 1421MB。

更重要的是,缓存所使用的内存在进程停止之前是不会被释放的。此外,在现实场景中,我们可能不知道需要多少内存来进行缓存。这将导致意想不到的后果。
我想你已经猜到了,这正是我们需要使用 lru_cache 的原因。
为什么叫“lru”?
lru_cache 装饰器中的“lru”一词代表“Least Recently Used”(最近最少使用)。它表示缓存机制使用的算法是通过保留频繁访问的数据并丢弃那些最少使用的数据来管理内存。
所以,这表示我们可以为缓存的数据设置上限。这正是 lru_cache 能够为我们提供的灵活性。
3. 控制最大内存大小

现在,让我们看看如何控制最大内存大小。
我们可以重用之前的 get_user_data() 函数进行比较。当然,我们需要对之前的示例进行一些更改以便于演示。
首先,我们需要导入 lru_cache,并在函数上使用这个装饰器。
import random
import string
import tracemalloc
from functools import lru_cache
@lru_cache(maxsize=1000)
def get_user_data(user_id):
return {
"user_id": user_id,
"name": "User " + str(user_id),
"email": f"user{user_id}@example.com",
"age": random.randint(18, 60),
"self-introduction": ''.join(random.choices(string.ascii_letters, k=1000))
}
为了演示,我将设置 maxsize=1000,这样我们可以将内存使用情况与没有内存控制的 cache 装饰器进行比较。
然后,我们还需要修改模拟函数。这次,让我们每 100 次运行输出一次内存使用情况。
def simulate(n):
tracemalloc.start()
for i in range(n):
_ = get_user_data(i)
if i % 100 == 0:
current, peak = tracemalloc.get_traced_memory()
print(f"Iteration {i}: Current memory usage: {current/(1024**2):.3f} MB")
tracemalloc.stop()
然后,模拟 2,000 次。

显然,前 1000 次运行显示内存使用量在不断增加。之后,在 1001 到 2000 次运行中,内存使用量保持稳定,没有显著增加。下面的图表显示了增长的趋势。

因此,当我们设置 lru_cache 的 maxsize 时,在达到允许缓存的对象上限之前,它的工作方式与 cache 装饰器相同。当达到上限时,它将开始从最近最少使用的缓存对象中删除。
这就是 lru_cache 更加灵活的原因,因为它缓解了缓存的内存问题。
4. 管理缓存的工具

在 Python 中使用 cache 和 lru_cache 就是这么简单。问题来了,我们能否管理我们缓存的内容?设置最大大小绝对是一个好方法,但还有其他方法吗?接下来,让我们看看缓存的两个控制函数。
使用 cache_info() 监控性能
第一个有用的函数是 cache_info() 函数。它可以显示当前缓存的大小、缓存数据被利用的次数(hits)以及从未被使用的次数(misses)。
让我们再次使用简单的斐波那契函数。
from functools import lru_cache
@lru_cache(maxsize=32)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
运行这个函数并查看缓存信息。
print(fibonacci(30))
print(fibonacci.cache_info())

可以看到,maxsize 显示 lru_cache 的限制设置为最多 32 个对象。已经使用了 31 个(cached)。在这 31 个缓存值中,有 28 次命中。同时也有 31 次未命中,这意味着函数必须运行其逻辑来计算值,因为没有缓存的值。看起来我们的缓存策略在这个函数中非常成功。
cache_info() 函数为我们提供了验证缓存功能是否有效的标准。例如,如果命中次数很少而未命中次数很多,这可能意味着这个函数不值得被缓存。
使用 cache_clear() 重置缓存
顾名思义,一旦我们运行它,它将清除所有缓存的结果。让我们再次使用斐波那契的例子。
from functools import lru_cache
@lru_cache(maxsize=32)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
# Run the function and check the info
print(fibonacci(30))
print(fibonacci.cache_info())
# Clear the cache and check the info again
fibonacci.cache_clear()
print(fibonacci.cache_info())
在上面的代码中,我们再次运行斐波那契函数。然后,通过检查状态,它显示的统计信息与之前完全相同,我们会运行 cache_clear() 函数来清除所有缓存的结果。现在,让我们再次检查信息,应该什么都不会剩下。

通过了解上述两个函数,我们将能够更好地管理我们的缓存。缓存的一个主要问题是内存不会被释放,但如果我们能够定期或动态地清除缓存,这个问题可以得到解决。
总结

在这篇文章中,我首先介绍了 functools 的 cache 装饰器,这是一个 Python 内置模块。虽然它更易于使用,但存在一些缺陷,例如缺乏内存控制。相反,lru_cache 将为我们提供灵活性,以确保在更复杂的情况下不会出现内存泄漏。
除此之外,通过使用缓存管理函数,我们可以实现自定义的行为,以避免错误并更有效地利用内存。希望这篇文章对你有所帮助!