Python functools.lru_cache 实战:一行加缓存、可变参数坑与手动失效
有个函数算得慢,或者要反复调远程接口,你想「同样的入参别重复算」。手写一个 dict 当缓存?几行就能踩三个坑:线程安全、缓存无上限撑爆内存、失效逻辑一堆。其实标准库的 functools.lru_cache 一行装饰器就搞定了大半——但它也有几个不看文档必踩的坑。这篇讲清楚怎么用、什么时候会翻车、怎么手动控制。
从手写缓存的痛点说起
先看没缓存的递归斐波那契,指数级重复计算:
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
# fib(35) 要算好几秒,因为同一个 fib(k) 被重复算了成千上万次
手写缓存版:
_cache = {}
def fib(n):
if n in _cache:
return _cache[n]
result = n if n < 2 else fib(n - 1) + fib(n - 2)
_cache[n] = result
return result
能用,但问题一堆:_cache 是个全局变量污染命名空间、无上限会一直涨、多线程下 in 判断和写入之间有竞态、想清空还得手动 _cache.clear()。这些 lru_cache 都替你处理好了。
一行搞定
from functools import lru_cache
@lru_cache(maxsize=None) # None 表示不限容量
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # 瞬间出结果,每个 fib(k) 只算一次
lru_cache 帮你做了:自动用参数当 key 缓存返回值、LRU(最近最少使用)淘汰、线程安全的读写。名字里的 LRU 指的是——当缓存条数超过 maxsize,自动淘汰最久没被访问的那条。
maxsize 怎么选:
- 参数取值有限、想全缓存(如斐波那契、配置解析):
maxsize=None。 - 参数空间大、怕内存涨:给个上限如
maxsize=1024,超了自动淘汰旧的。 - Python 3.9+ 如果就是想「无限缓存」,直接用
@cache更语义化(等价于lru_cache(maxsize=None))。
坑一:参数必须可哈希,list/dict 直接报错
lru_cache 拿参数当字典的 key,所以参数必须可哈希(hashable)。传 list、dict、set 会当场炸:
@lru_cache
def process(items):
return sum(items)
process([1, 2, 3]) # TypeError: unhashable type: 'list'
解法是把可变参数换成不可变的:
@lru_cache
def process(items): # items 现在期望是 tuple
return sum(items)
process((1, 2, 3)) # 传 tuple,OK
如果调用方手上是 list,在调用前转一下:process(tuple(my_list))。要缓存「基于 dict 配置」的函数,可以把 dict 转成排序后的 tuple(sorted(d.items())) 再传。
坑二:关键字参数和位置参数算不同的 key
lru_cache 区分「参数是位置传的还是关键字传的」。同样的逻辑入参,写法不同会被当成两次不同调用,各缓存一份:
@lru_cache
def add(a, b):
print(f"计算 {a}+{b}")
return a + b
add(1, 2) # 打印"计算 1+2",算一次
add(1, 2) # 命中缓存,不打印
add(a=1, b=2) # 又打印"计算 1+2"!因为 key 和位置传参不同
后果是缓存命中率下降、缓存里存了重复内容。实战建议:对要缓存的函数,团队约定统一调用风格(要么都位置传、要么都关键字传),或者在函数签名里用 / 把参数限制为仅位置参数,从源头消除歧义。
坑三:别缓存「有副作用」或「结果会变」的函数
lru_cache 的前提是纯函数——同样的入参永远返回同样的结果,且没有副作用。违背这个前提就会出诡异 bug:
@lru_cache
def get_user_config(user_id):
# 危险:数据库里的配置会变,但缓存永远返回第一次读到的值
return db.query("SELECT * FROM config WHERE user_id=?", user_id)
用户在数据库里改了配置,你的函数还在返回旧值,而且你可能查半天都找不到原因。规则:结果会随时间/外部状态变化的函数,不要无脑套 lru_cache;要缓存也得配一个明确的失效策略。
同理,别缓存返回可变对象的函数——调用方拿到缓存的 list 后改了它,会污染缓存里的那份:
@lru_cache
def get_defaults():
return ["a", "b"] # 返回的是同一个 list 对象
d = get_defaults()
d.append("c") # 改的是缓存里那个 list!
print(get_defaults()) # ['a', 'b', 'c'] —— 被污染了
要么返回不可变的 tuple,要么在调用处 copy 一份。
手动查缓存状态与失效
lru_cache 装饰后的函数带两个实用方法:
@lru_cache(maxsize=128)
def slow(n):
return n * n
slow(2); slow(3); slow(2)
# 查命中情况:hits=1 misses=2 maxsize=128 currsize=2
print(slow.cache_info())
# 手动清空整个缓存(比如配置变更后)
slow.cache_clear()
print(slow.cache_info()) # 清空后 currsize=0
cache_info() 的 hits/misses 能帮你评估缓存到底有没有用——如果 hits 长期接近 0,说明这个函数根本没有重复调用,加缓存纯属浪费内存,该撤掉。
cache_clear() 是唯一的失效手段,但它是全量清空,没法只删某一个 key。如果你需要「按 key 精细失效」,lru_cache 就不够用了,得换成手写 dict 或专门的缓存库(如 cachetools,它支持 TTL 过期和单 key 删除)。
一个务实的选择清单
- 纯函数、想加缓存、参数可哈希 →
@cache/@lru_cache(maxsize=N),一行搞定。 - 需要过期时间(TTL) →
lru_cache不支持,用cachetools.TTLCache。 - 需要按 key 失效 →
lru_cache只能全清,用cachetools或自己管 dict。 - 结果会变 / 有副作用 → 别缓存,或想清楚失效策略再缓存。
小结
lru_cache一行给纯函数加线程安全的 LRU 缓存,maxsize=None(或 3.9+ 的@cache)不限容量,给数字就自动淘汰旧条目。- 三个坑:参数必须可哈希(list/dict 要转 tuple)、位置传参和关键字传参算不同 key(统一调用风格)、别缓存结果会变或返回可变对象的函数。
cache_info()看命中率判断缓存值不值得,cache_clear()全量失效——需要 TTL 或按 key 失效就上cachetools。
记忆点:lru_cache 只配得上「同样输入永远同样输出、参数可哈希」的纯函数;越界的场景,换缓存库或手写。

1348

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



