Python实战进阶:如何用装饰器实现函数级缓存,让重复计算快到飞起?
在 Python 编程中,有些技术看起来只是语法糖,真正理解之后却会发现,它们其实是工程效率的放大器。装饰器就是其中之一。
很多初学者第一次看到 @timer、@login_required、@lru_cache 时,会觉得它像魔法:为什么只是在函数上面加了一行代码,函数就突然拥有了计时、鉴权、缓存等能力?而当你开始写 Web 接口、数据分析脚本、自动化任务、机器学习特征处理流程时,你会越来越频繁地遇到一个问题:同样的函数、同样的参数,被反复调用,结果也完全一样,为什么还要一次次重新计算?
这就是函数级缓存发挥价值的地方。
本文将以“如何用装饰器实现函数级缓存”为主线,从 Python 装饰器基础讲起,逐步实现一个可用、可扩展、可控制过期时间的缓存装饰器。无论你正在学习 Python教程,还是已经在做 Python实战 项目,都可以把这篇文章当作一次从基础语法到工程思维的完整练习。
一、为什么函数级缓存值得掌握?
Python 自诞生以来,就以简洁、优雅、可读性强著称。它从最初的脚本语言,逐步成长为 Web 开发、自动化运维、数据科学、人工智能、物联网、测试平台等领域的重要工具。今天,Python 不只是“能快速写脚本”的语言,更是连接系统、数据、模型和业务的“胶水语言”。
在实际开发中,我们经常会遇到这些场景:
- 查询某个用户信息,多次请求结果相同;
- 计算复杂数学结果,输入相同则输出相同;
- 读取配置文件,内容不会频繁变化;
- 调用第三方接口,短时间内无需重复请求;
- 数据清洗流程中,同一批参数会反复触发相同逻辑。
如果每次都重新执行函数,不仅浪费 CPU、内存和网络资源,还会拖慢用户体验。函数级缓存的核心思想很简单:
当函数使用相同参数调用时,直接返回上一次计算结果,而不是重新执行函数体。
这听起来朴素,却是很多高性能系统的基础优化手段。
二、先从一个慢函数开始
假设我们有一个模拟耗时查询的函数:
import time
def get_user_profile(user_id):
print(f"正在查询用户 {user_id} 的信息...")
time.sleep(2)
return {
"user_id": user_id,
"name": "Alex",
"level": "vip"
}
print(get_user_profile(1001))
print(get_user_profile(1001))
print(get_user_profile(1001))
每次调用都会等待 2 秒。即使参数都是 1001,函数仍然重复执行。
这就是缓存可以优化的地方。我们希望第一次调用时正常执行,之后如果参数相同,就直接返回缓存结果。
三、装饰器基础:给函数“套一层外壳”
在 Python 中,函数是一等公民。函数可以被赋值、传参,也可以作为另一个函数的返回值。
装饰器的本质是:
函数 = 装饰器(函数)
例如:
def simple_decorator(func):
def wrapper(*args, **kwargs):
print("函数执行前")
result = func(*args, **kwargs)
print("函数执行后")
return result
return wrapper
@simple_decorator
def say_hello(name):
print(f"你好,{name}")
say_hello("Python")
这段代码等价于:
say_hello = simple_decorator(say_hello)
所以,装饰器并不神秘。它只是把原函数包起来,在执行前后增加额外逻辑。函数级缓存正适合用装饰器实现,因为缓存逻辑通常不属于业务逻辑本身,却又需要包裹函数调用过程。
四、实现第一个函数级缓存装饰器
我们先实现一个最简单版本,只支持位置参数,并假设参数都是可哈希对象。
from functools import wraps
def cache(func):
cached_data = {}
@wraps(func)
def wrapper(*args):
if args in cached_data:
print("命中缓存")
return cached_data[args]
print("未命中缓存,开始计算")
result = func(*args)
cached_data[args] = result
return result
return wrapper
使用它:
import time
@cache
def slow_add(a, b):
time.sleep(1)
return a + b
print(slow_add(3, 5))
print(slow_add(3, 5))
print(slow_add(4, 6))
print(slow_add(3, 5))
输出大致如下:
未命中缓存,开始计算
8
命中缓存
8
未命中缓存,开始计算
10
命中缓存
8
这个版本已经具备函数级缓存的基本能力。
它的工作流程可以表示为:
调用函数
↓
根据参数生成缓存 key
↓
检查 key 是否在缓存字典中
↓
存在:直接返回缓存结果
↓
不存在:执行原函数
↓
保存结果到缓存
↓
返回结果
五、为什么要使用 functools.wraps?
在装饰器中,@wraps(func) 是非常重要的最佳实践。
如果不使用 wraps,被装饰函数的名称、文档字符串等元信息会丢失:
def bad_cache(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_cache
def hello():
"""这是 hello 函数"""
return "hello"
print(hello.__name__)
print(hello.__doc__)
输出可能是:
wrapper
None
在调试、日志、单元测试、接口文档生成时,这会带来麻烦。因此,生产代码中写装饰器时,建议默认加上:
from functools import wraps
六、支持关键字参数的缓存装饰器
真实项目中,函数不仅有位置参数,也可能有关键字参数:
def search(keyword, page=1, size=10):
...
如果只用 args 作为缓存 key,就无法正确处理 kwargs。我们可以把 args 和排序后的 kwargs 一起作为 key。
from functools import wraps
def cache(func):
cached_data = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key in cached_data:
print("命中缓存")
return cached_data[key]
print("未命中缓存,开始计算")
result = func(*args, **kwargs)
cached_data[key] = result
return result
return wrapper
测试一下:
@cache
def build_url(host, path="/", https=True):
protocol = "https" if https else "http"
return f"{protocol}://{host}{path}"
print(build_url("example.com", path="/users", https=True))
print(build_url("example.com", https=True, path="/users"))
因为我们对 kwargs.items() 做了排序,所以即使关键字参数顺序不同,也能生成相同的缓存 key。
七、缓存 key 的难点:可变对象怎么办?
Python 中,字典的 key 必须是可哈希对象。整数、字符串、元组通常可以作为 key,但列表、字典、集合不可以。
例如:
@cache
def total(numbers):
return sum(numbers)
print(total([1, 2, 3]))
这会报错,因为列表不可哈希。
为了提高通用性,我们可以写一个函数,把常见可变对象转换成可哈希结构。
def make_hashable(value):
if isinstance(value, dict):
return tuple(sorted((k, make_hashable(v)) for k, v in value.items()))
if isinstance(value, list):
return tuple(make_hashable(item) for item in value)
if isinstance(value, set):
return tuple(sorted(make_hashable(item) for item in value))
return value
然后改造缓存装饰器:
from functools import wraps
def cache(func):
cached_data = {}
@wraps(func)
def wrapper(*args, **kwargs):
hashable_args = make_hashable(args)
hashable_kwargs = make_hashable(kwargs)
key = (hashable_args, hashable_kwargs)
if key in cached_data:
print("命中缓存")
return cached_data[key]
print("未命中缓存,开始计算")
result = func(*args, **kwargs)
cached_data[key] = result
return result
return wrapper
这样就可以支持列表参数了:
@cache
def total(numbers):
print("正在计算总和...")
return sum(numbers)
print(total([1, 2, 3]))
print(total([1, 2, 3]))
不过要注意,如果参数中包含自定义对象、文件句柄、数据库连接等复杂对象,缓存 key 的设计就需要更谨慎。
八、带过期时间的缓存:TTL Cache
普通缓存有一个问题:结果可能会过期。
例如用户信息、商品价格、天气数据、接口返回值,都可能在一段时间后发生变化。此时我们需要 TTL,也就是 Time To Live,缓存存活时间。
下面实现一个支持过期时间的缓存装饰器:
import time
from functools import wraps
def ttl_cache(seconds=60):
def decorator(func):
cached_data = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = (
make_hashable(args),
make_hashable(kwargs)
)
now = time.time()
if key in cached_data:
cached_time, cached_value = cached_data[key]
if now - cached_time < seconds:
print("命中缓存")
return cached_value
print("缓存已过期")
print("未命中缓存,开始计算")
result = func(*args, **kwargs)
cached_data[key] = (now, result)
return result
return wrapper
return decorator
使用方式:
@ttl_cache(seconds=3)
def get_config(name):
print(f"正在读取配置:{name}")
return {
"name": name,
"value": time.time()
}
print(get_config("database"))
print(get_config("database"))
time.sleep(4)
print(get_config("database"))
第一次调用会执行函数,第二次调用命中缓存。等待 4 秒后再次调用,缓存过期,函数重新执行。
这类 TTL 缓存非常适合短时间内稳定、但长期可能变化的数据。
九、增加缓存清理能力
在工程项目中,缓存不能只进不出。否则随着调用参数越来越多,缓存字典可能无限增长。
我们可以给包装函数挂载一个 cache_clear 方法。
from functools import wraps
def cache_with_clear(func):
cached_data = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = (
make_hashable(args),
make_hashable(kwargs)
)
if key in cached_data:
print("命中缓存")
return cached_data[key]
result = func(*args, **kwargs)
cached_data[key] = result
return result
def cache_clear():
cached_data.clear()
print("缓存已清空")
wrapper.cache_clear = cache_clear
return wrapper
使用:
@cache_with_clear
def multiply(a, b):
print("正在计算...")
return a * b
print(multiply(2, 5))
print(multiply(2, 5))
multiply.cache_clear()
print(multiply(2, 5))
这个技巧在实际项目中很有用。例如后台配置变更后,可以主动清除缓存,让系统读取最新结果。
十、限制缓存容量:避免内存失控
如果函数参数组合很多,缓存会不断膨胀。我们可以实现一个简单的最大容量控制。当缓存超过指定数量时,删除最早进入的缓存项。
Python 3.7 之后,普通字典保持插入顺序,因此可以用 next(iter(cache)) 找到最早的 key。
from functools import wraps
def limited_cache(maxsize=128):
def decorator(func):
cached_data = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = (
make_hashable(args),
make_hashable(kwargs)
)
if key in cached_data:
print("命中缓存")
return cached_data[key]
result = func(*args, **kwargs)
if len(cached_data) >= maxsize:
oldest_key = next(iter(cached_data))
cached_data.pop(oldest_key)
cached_data[key] = result
return result
return wrapper
return decorator
使用:
@limited_cache(maxsize=2)
def square(n):
print(f"计算 {n} 的平方")
return n * n
print(square(2))
print(square(3))
print(square(2))
print(square(4))
print(square(3))
这个版本是简单的 FIFO,也就是先进先出。生产场景中,更常见的是 LRU,也就是最近最少使用淘汰策略。
十一、标准库方案:functools.lru_cache
虽然自己实现缓存装饰器很适合学习原理,但在生产项目中,如果需求符合标准库能力,优先使用 Python 自带的 functools.lru_cache。
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(40))
print(fibonacci.cache_info())
如果不加缓存,递归计算斐波那契数列会产生大量重复计算。加上 lru_cache 后,相同参数的结果只会计算一次。
cache_info() 可以查看缓存状态:
CacheInfo(hits=..., misses=..., maxsize=128, currsize=...)
其中:
hits表示命中次数;misses表示未命中次数;maxsize表示最大缓存容量;currsize表示当前缓存数量。
也可以清理缓存:
fibonacci.cache_clear()
这就是 Python最佳实践 中很重要的一条:先理解原理,再优先使用成熟工具。
十二、实践案例:缓存第三方 API 查询结果
假设我们正在开发一个简单的天气查询服务。真实项目中可能会调用第三方接口,这里用 time.sleep 模拟网络请求。
需求如下:
- 同一个城市 10 秒内重复查询,直接返回缓存;
- 超过 10 秒后重新请求;
- 支持手动清理缓存;
- 保留函数元信息,便于调试和测试。
代码如下:
import time
from functools import wraps
def make_hashable(value):
if isinstance(value, dict):
return tuple(sorted((k, make_hashable(v)) for k, v in value.items()))
if isinstance(value, list):
return tuple(make_hashable(item) for item in value)
if isinstance(value, set):
return tuple(sorted(make_hashable(item) for item in value))
if isinstance(value, tuple):
return tuple(make_hashable(item) for item in value)
return value
def ttl_cache(seconds=60):
def decorator(func):
cached_data = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = (
make_hashable(args),
make_hashable(kwargs)
)
now = time.time()
if key in cached_data:
cached_time, cached_value = cached_data[key]
if now - cached_time < seconds:
print(f"[CACHE HIT] {func.__name__}")
return cached_value
print(f"[CACHE MISS] {func.__name__}")
result = func(*args, **kwargs)
cached_data[key] = (now, result)
return result
def cache_clear():
cached_data.clear()
def cache_size():
return len(cached_data)
wrapper.cache_clear = cache_clear
wrapper.cache_size = cache_size
return wrapper
return decorator
@ttl_cache(seconds=10)
def get_weather(city):
print(f"正在请求天气接口:{city}")
time.sleep(2)
return {
"city": city,
"temperature": "26°C",
"weather": "晴"
}
print(get_weather("上海"))
print(get_weather("上海"))
print("当前缓存数量:", get_weather.cache_size())
get_weather.cache_clear()
print(get_weather("上海"))
这个案例虽然简单,却已经具备很多工程思维:
业务函数
↓
缓存装饰器
↓
参数生成 key
↓
检查缓存和过期时间
↓
命中则直接返回
↓
未命中则执行请求
↓
保存结果
在 Web 应用中,这类缓存可以用于商品详情、用户权限、系统配置、热门文章列表等场景。在数据分析中,它可以用于缓存中间计算结果。在 AI 项目中,它也可以用于缓存特征提取、模型推理或数据预处理结果。
十三、缓存不是银弹:什么时候不该缓存?
缓存可以提升性能,但并不是所有函数都适合缓存。
适合缓存的函数通常有几个特点:
- 输入相同,输出稳定;
- 函数执行成本较高;
- 数据允许短时间不更新;
- 函数没有明显副作用。
不适合缓存的函数包括:
def get_current_time():
return time.time()
这个函数每次都应该返回当前时间,不适合缓存。
再比如:
def create_order(user_id, product_id):
# 写入数据库
# 扣库存
# 生成订单
...
这类函数有明显副作用,绝不能简单缓存。否则可能导致订单没有真正创建,却直接返回了旧结果。
判断一个函数能不能缓存,可以问自己一句话:
如果我跳过函数执行,直接返回上一次结果,会不会破坏业务正确性?
如果答案是“可能会”,那就不要轻易缓存。
十四、并发环境下的注意事项
在单线程脚本里,字典缓存通常没问题。但在多线程 Web 服务中,同时读写同一个缓存字典可能出现竞争问题。
简单做法是加锁:
import threading
from functools import wraps
def thread_safe_cache(func):
cached_data = {}
lock = threading.Lock()
@wraps(func)
def wrapper(*args, **kwargs):
key = (
make_hashable(args),
make_hashable(kwargs)
)
with lock:
if key in cached_data:
return cached_data[key]
result = func(*args, **kwargs)
with lock:
cached_data[key] = result
return result
return wrapper
不过,这个版本仍然可能出现多个线程同时计算同一个 key 的情况。对于高并发生产系统,可以进一步做细粒度锁,或者使用 Redis、Memcached 这类外部缓存系统。
函数级本地缓存适合轻量场景;分布式系统中的共享缓存,则应交给专业缓存中间件。
十五、性能对比:缓存前后差异有多大?
我们用一个简单实验感受缓存效果。
import time
from functools import lru_cache
def slow_fib(n):
if n <= 1:
return n
return slow_fib(n - 1) + slow_fib(n - 2)
@lru_cache(maxsize=None)
def fast_fib(n):
if n <= 1:
return n
return fast_fib(n - 1) + fast_fib(n - 2)
start = time.time()
print(fast_fib(35))
print("缓存版本耗时:", time.time() - start)
递归斐波那契是缓存教学中的经典案例,因为它存在大量重复子问题。未加缓存时,函数会重复计算同一个 fib(n);加缓存后,每个 n 基本只计算一次。
对比如下:
无缓存:
fib(35) 会触发大量重复递归调用
有缓存:
fib(0) 到 fib(35) 每个结果只需计算一次
这就是缓存的威力:它不是让单次计算变快,而是避免重复计算。
十六、和 Web 框架结合的思路
在 Flask、Django、FastAPI 等 Web 框架中,缓存装饰器非常常见。
例如伪代码:
@app.get("/products/{product_id}")
@ttl_cache(seconds=30)
def get_product(product_id: int):
return query_product_from_database(product_id)
不过要注意,真实 Web 服务中,如果函数返回的是用户相关数据,缓存 key 必须包含用户身份、权限、语言、地区等上下文信息。否则可能出现严重的数据串用问题。
例如,下面这种缓存是危险的:
@ttl_cache(seconds=60)
def get_current_user_profile():
...
因为函数没有显式参数,所有用户都可能命中同一个缓存结果。
更安全的方式是:
@ttl_cache(seconds=60)
def get_user_profile(user_id):
...
缓存优化的前提永远是业务正确性。
十七、最佳实践总结
在 Python实战 项目中使用函数级缓存时,可以遵循这些建议:
1. 优先缓存纯函数
纯函数指输入相同、输出相同、没有副作用的函数。数学计算、格式转换、配置读取、静态数据查询都比较适合。
2. 缓存 key 要设计清楚
不要只看函数名,要看参数是否完整表达了结果依赖。用户身份、语言环境、权限范围、分页参数、过滤条件都可能是 key 的一部分。
3. 设置容量或过期时间
无限缓存很危险。缓存会占用内存,也可能返回旧数据。生产系统中通常要有 maxsize、ttl 或清理机制。
4. 使用 wraps
保留函数元信息,方便调试、测试和文档生成。
5. 避免缓存有副作用的函数
写数据库、发消息、扣库存、创建订单、发送邮件,这些操作通常不适合用函数级缓存跳过执行。
6. 标准库能解决时,优先用标准库
functools.lru_cache 简洁、稳定、性能好。自己实现装饰器更适合学习、定制特殊逻辑,或处理 TTL、外部缓存等扩展需求。
十八、进阶方向:从本地缓存到分布式缓存
本文实现的是函数级本地缓存,缓存数据保存在当前 Python 进程内。它的优点是简单、快速、无额外依赖;缺点也很明显:
进程重启后缓存消失
多进程之间无法共享缓存
缓存容量受限于本机内存
不适合大规模分布式系统
如果你的应用部署在多个实例上,比如多个 Gunicorn worker、多个容器、多个服务器,那么本地缓存就无法保证全局一致。此时可以考虑:
- Redis:适合高性能键值缓存;
- Memcached:适合简单高速缓存;
- 数据库缓存表:适合需要持久化的缓存;
- CDN:适合静态资源和接口边缘缓存。
不过,无论缓存放在哪里,思想都是一样的:
先查缓存
缓存命中直接返回
缓存未命中执行计算或查询
把结果写入缓存
返回结果
掌握函数级缓存,是理解更复杂缓存架构的第一步。
十九、附录:推荐学习资料
如果你希望进一步系统学习,可以关注这些方向:
- Python 官方文档中的
functools模块; - PEP8 代码风格规范;
- 《Python编程:从入门到实践》;
- 《流畅的Python》;
- 《Effective Python》;
- Flask、Django、FastAPI 中的装饰器实践;
- Redis 缓存设计与缓存失效策略;
- Pytest 单元测试与性能测试工具。
这些内容能帮助你从“会写 Python”逐渐走向“写出可靠、清晰、可维护的 Python”。
二十、总结:缓存是一种克制的性能优化
装饰器让函数级缓存变得优雅。我们不需要把缓存逻辑散落在每个业务函数里,只需要用一个 @cache 或 @ttl_cache,就能把重复计算、接口查询、配置读取等场景优化起来。
本文从最简单的缓存字典开始,逐步实现了:
- 基础缓存装饰器;
- 支持
args和kwargs的缓存; - 支持可变参数转换的缓存 key;
- 支持 TTL 过期时间的缓存;
- 支持手动清理的缓存;
- 限制容量的缓存;
- 标准库
lru_cache的使用; - Web、API、数据处理中的实践建议。
但也请记住:缓存不是炫技,而是一种克制的性能优化。它的目标不是让代码看起来高级,而是让系统在正确的前提下更快、更稳、更节省资源。
真正优秀的 Python 开发者,不只是知道怎么写 @cache,更知道什么时候该缓存,什么时候不该缓存,缓存多久,缓存什么,以及缓存失效后如何保证业务正确。
最后留两个问题给你:
你在日常 Python 开发中,遇到过哪些重复计算或重复查询的问题?
如果让你给当前项目加一个函数级缓存,你最想优化哪个函数?
欢迎在评论区分享你的案例。也许你的一个真实问题,正是另一个开发者正在寻找的答案。


900

被折叠的 条评论
为什么被折叠?



