Python lru_cache 返回值复用:缓存命中之后,列表到底属于谁

10-01 4阅读

两次拿到相同内容,可能也是同一个对象

页面需要一组标签,于是给生成标签的函数加上缓存。第一位调用者追加一个临时标签,第二位调用者随后竟然也看到了它。函数体看起来每次都会创建列表,但命中缓存时,函数体根本不会重新执行;先前保存的返回对象会被直接复用。问题发生在返回值的修改权没有说清楚。

这里要同时核对两件事:参数是否指向同一项缓存,以及调用者是否允许修改取得的对象。内容相等只说明值一致,对象身份相同才说明两处引用通向同一份列表。把缓存理解成自动复制器,就会把本来局限于一次调用的追加、排序或删除传播给后续调用者。

先复现污染,再把修改权交给外层

下面只用标准库,可保存为 Python 文件运行。第一部分故意缓存可变列表,用身份断言证明污染来源;第二部分让内部函数返回只含字符串的元组,再由未加缓存的外层函数为每次请求建立新列表。所有变化都留在内存中,两个缓存互不影响。

Python lru_cache 返回值复用:缓存命中之后,列表到底属于谁

AI生成概念示意图,非真实界面

from functools import lru_cache

@lru_cache(maxsize=4)
def shared_labels(group):
    return [group, "base"]

first = shared_labels("books")
first.append("temporary")
second = shared_labels("books")
assert first is second
assert second == ["books", "base", "temporary"]
bad_info = shared_labels.cache_info()
assert (bad_info.hits, bad_info.misses, bad_info.currsize) == (1, 1, 1)
print("shared:", second)
print("shared stats:", bad_info)

@lru_cache(maxsize=4)
def labels_snapshot(group, revision):
    return (group, f"revision-{revision}")

def labels_for_user(group, revision):
    return list(labels_snapshot(group, revision))

alice = labels_for_user("books", 1)
alice.append("private-note")
bob = labels_for_user("books", 1)
new_version = labels_for_user("books", 2)
assert alice is not bob
assert bob == ["books", "revision-1"]
assert new_version == ["books", "revision-2"]
good_info = labels_snapshot.cache_info()
assert (good_info.hits, good_info.misses, good_info.maxsize,
        good_info.currsize) == (1, 2, 4, 2)
print("independent:", bob, new_version)
print("snapshot stats:", good_info)

第一行会显示共享列表已经带有 temporary,随后统计为一次命中、一次未命中。安全版本中,甲追加的 private-note 不会进入乙的列表;第二个版本号又产生一次未命中,因此统计应为一次命中、两次未命中、两项缓存。若把复制也放进被装饰函数,命中时仍会复用那一份复制品。

不可变快照需要覆盖到数据内部

示例的元组里只有字符串,调用者不能通过其中的元素修改快照。若元组里放的是字典或列表,外层容器不能增删,并不代表内部对象也不能变。浅复制只重建最外一层,嵌套对象仍可能共享。要么把内部结构也设计成不可变值,要么在交付边界按数据模型复制需要独立修改的部分。

复制策略应由接口约定决定。只读消费者可以直接取得不可变快照;编辑表单通常需要独立工作副本;很大的对象则要衡量复制成本,并限制可修改范围。不要为了消除一个共享列表问题,盲目深复制带有连接、文件句柄或自定义状态的对象,这些对象未必适合复制或缓存。

参数决定复用范围,时间不会自动刷新内容

缓存参数必须可哈希。示例将标签组和修订号一起作为参数,所以同组同版本复用,不同版本重新计算。真实数据源发生变化时,需要可靠地更新修订号或主动清空缓存;装饰器不会观察数据库、配置文件或全局变量,也没有自动过期时间。最大容量限制的是条目数量,并不是存活秒数。

调用形式也需要稳定。官方文档提醒,关键字参数顺序不同可能形成不同条目,因此业务上等价的输入最好先在外层规范化,再用固定形式调用内部函数。规范化必须符合业务含义,不能随意把大小写或类型区别抹掉,否则节省计算的同时也会错误合并请求。

验收缓存时,先清空或创建独立实例,再固定调用次数检查命中统计,避免调试时多调用一次改变证据。命中率高只说明重复请求多,还要核对结果新鲜度、对象所有权和占用内存。缓存会保留参数与返回值的引用,直到被淘汰或清除;有副作用的动作也不宜靠缓存控制执行次数。

最后把并发边界写进说明:缓存内部结构可以在多线程下保持一致,但首个结果尚未写入时,另一个线程仍可能再次执行原函数。因此它既不保证同一任务恰好执行一次,也不替代业务锁;本例只验证顺序调用下的复用和复制约定。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。