当前位置:首页 > Python > 正文内容

Python 中利用 functools.lru_cache 实现高效缓存:从入门到进阶

admin10小时前Python5

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 项目,找一个可以优化的函数试试吧!

相关文章

[Python 教程] OpenCV-Python 入门:图像处理基础详解

OpenCV-Python 入门:图像处理基础详解OpenCV 是一个跨平台计算机视觉库,轻量级且高效,支持 Python 接口。本文将系统介绍 OpenCV 的核心概念和基础操作。一、OpenCV...

[Python 教程] OpenCV 实战:图像与视频文件处理

OpenCV 实战:图像与视频文件处理本文详细介绍如何使用 OpenCV 处理图像和视频文件,包括读取、显示、保存等操作。一、图像文件操作1.1 读取图像import cv2 #&nb...

[Python 教程] Pandas 数据分析实战

Pandas 数据分析实战 Pandas 是 Python 数据分析的核心库,提供 DataFrame 和 Series 数据结构。本文介绍 Pandas 的实用技巧。 一、创建 DataFrame...

[Python 教程] Matplotlib 数据可视化教程

Matplotlib 数据可视化教程 Matplotlib 是 Python 最常用的绘图库。本文介绍常用图表的绘制方法。 一、基础设置 import matplotlib.pyplot as pl...

[Python 教程] Python 多线程编程指南

Python 多线程编程指南 Python 的 threading 模块提供多线程支持。本文介绍多线程编程的基础和实用技巧。 一、创建线程 import threading import time...

[Python 教程] Python 网络请求与爬虫基础

Python 网络请求与爬虫基础 requests 是 Python 最常用的 HTTP 库。本文介绍网络请求和爬虫的基础知识。 一、基础请求 import requests # GET 请求 r...

发表评论

访客

看不清,换一张

◎欢迎参与讨论,请在这里发表您的看法和观点。