Sentry 维护者、开发者、Sentry AI debugger 三者同时修复同一个内存泄漏却使用了相同的错误方案,根本原因是弱引用无法指向 ValueError 的类型限制被忽视。
DedupeIntegration 会记住上一次看到的异常,以便丢弃重复报告。它存储的是一个弱引用,因为持有异常对象就会持有其 traceback,而 traceback 持有每一帧,每一帧又持有其中的所有局部变量。
# we can only weakref non builtin types
try:
integration._last_seen.set(weakref.ref(exc))
except TypeError:
integration._last_seen.set(exc)
你无法对 ValueError 建立弱引用。所以降级逻辑执行了,而它恰恰做了弱引用本来要防止的事。这段代码存在一个 ContextVar 中,所以在 asyncio 下每个 task 都保留自己的副本:一份被保留的异常、一份 traceback、一套完整的帧局部变量。
在 200 个长期会话中测量,每个会话保留 1MB:共保留 205 MB,gc.collect() 之后全部 200 个仍可达。
这部分我在别处有完整记录。本文讲的是三次修复尝试。
在 fork 的分支列表中翻找,我发现了两个 2025 年 9 月的废弃分支,都未合并。第一个用 (type_module, type_name, id(exc_value)) 的 SHA-256 指纹替代存储的异常对象。
这是一个合理的直觉:存储一个廉价值,而不是对象本身。问题出在 id() 上。
不保留异常对象恰恰意味着释放了它的地址,而 CPython 会立即复用已释放的地址。2000 个按顺序分配的、不同 ValueError 的情况:
distinct exceptions created : 2000
distinct fingerprints : 2
address-reuse collisions : 1998
两千个不同的异常,只有两个指纹。每一个碰撞都是一个真实错误被静默丢弃为重复。这个修复用丢失几乎所有上报错误的代价换掉了内存泄漏,这比泄漏本身更糟。
我一直没看到那个分支,这很走运,因为我独立犯了同一类错误。
我的想法是一个不含 id() 的值指纹:异常类型、消息、以及 traceback 中的起始帧。全是不可变原始值。不保留任何东西。我足够自信,在 issue 里公开贴出了方案。
SDK 自己的测试套件在运行大约一分钟后从两个不同角度把它否掉了。
test_breadcrumbs 捕获了两个不同的、未抛出的 ValueError() 实例。没有 traceback,没有消息,所以两个指纹相同,第二个事件被丢弃了。
test_option_before_breadcrumb 更糟。它调用同一个函数三次,每次从同一行抛出一个不同的 ValueError("aha!")。类型相同、消息相同、起始帧相同。三个完全相同的指纹,两个事件被错误地去重了。
缺陷不在我选的字段上。是结构性的:
值指纹无法区分"同一个异常对象被捕获两次"和"同一行代码抛出同样的错误两次"。
前者必须去重。后者不能。通过值来看,它们是一样的。增加再多字段也修复不了,因为所问的问题根本不是值的属性。
我回到 issue 上承认自己错了。
后来我把 Sentry 自己的 AI 调试器 Seer 指向了我 Sentry 项目中的这个问题。
它的诊断确实非常出色,超出我的预期。它穿过我的应用代码定位到了一个第三方 SDK,识别出 ContextVar 的强引用,识别出 weakref.ref 对内置异常类型会抛出 TypeError,还识别出 ContextVars 在 asyncio 下是按 task 独立的。最后这个细节我花了一整轮调试才自己弄清楚。它引用了 dedupe.py 的行号作为证据,说明它去读了源码而不是从堆栈猜测。
然后它提出了修复方案:
存储一个可哈希的身份元组,如 (type(exc), id(exc))
又是 id()。我跑了它那个确切的元组:
distinct errors raised : 2000
events wrongly dropped as dupe : 1999 (100.0%)
三个独立方。两个人类和一个模型。两种不同的错误答案,本质上是同一个错误答案。
我们每个人都试图用值来表示身份。
对象的身份不是一个能从它身上读出来的属性。它是对象存在这一事实——区别于其他一切对象,只要它活着。指纹由类型和消息组成,描述的是异常"像什么"。地址描述的是它当前坐在哪里。两者都不能在"使身份有用"这件事上存活——即两个看起来一样的对象仍然是两个对象。
一旦我能说出那句话,修复方案就显而易见了,而且根本不是指纹:
class _DedupeToken:
__slots__ = ("__weakref__",)
给异常附加一个 token,然后弱引用这个 token。每个异常对象有且只有一个 token,token 的生命周期与异常完全一致,所以比较 token 就是比较身份。ContextVar 什么都不持有,不会让 traceback 保持存活,去重行为完全不改变。
同样的 200 个会话,修复后:0.7 MB,没有任何对象被钉住。
有一段尾声,是我在回看一段屏幕录制时才发现的,这是我这周学到的最有趣的东西。
Seer 不只提出了一个计划。它还打开了一个包含生成代码的 pull request。代码和计划说的不一样。
try:
integration._last_seen.set(weakref.ref(exc))
except TypeError:
pass
没有任何身份元组。它直接停止存储内置类型的异常。这确实消除了泄漏,也没有地址复用的问题,但它静默地禁用了对最常见错误类型的去重。而且这恰恰是维护者在 issue 上几个月前就已经拒绝过的那个修复方案。
所以计划和补丁不一致,而且它们以两种不同的方式出错。
我要公平地说,因为诊断是困难的部分,它做到了,而且是从证据出发,没有我的引导。但如果你让 agent 打开 pull request:看 diff,不是 summary。它们不要求一致,这里就没有一致。
注释 # we can only weakref non builtin types 就直接位于导致泄漏的那一行之上,存在了多年。它准确地描述了危险,然后下一行就踩了进去。降级逻辑仍然是代码路径,而且它对绝大多数真实异常都会执行。
更有用的教训是:当几个有能力的人独立得出同一个错误答案时,这就是信息。通常意味着问题的显而易见框架本身就是错的,不是人。我们三个人都想到了指纹,因为"存储小东西而不是对象"是一个好习惯。习惯没问题。问题是问法错了。
所有内容均可复现,包括三次失败的修复,位于 JonathanSolvesProblems/sentry-dedupe-leak-repro。id_reuse_hazard.py 运行维护者的方案和 Seer 的方案,并列输出每个会丢失多少错误。