引言为什么 Python 的内存泄漏更隐蔽Python 有引用计数还有分代垃圾回收GC理论上不该出现内存泄漏。但在真实的生产事故里Python 服务 RSS 缓慢爬升、Pod 被 OOMKill 的案例一点都不少。原因在于Python 的泄漏绝大多数不是malloc之后忘了free而是对象在语义上已经死亡却仍然被某条引用链牵着处于可达状态。GC 只回收不可达对象对可达对象无能为力。这类问题有三个典型特征缓慢。每次请求泄漏几十 KB压测几分钟看不出来跑两天才炸。没有报错。没有异常、没有日志只有 RSS 曲线一路向上。难以复现。本地跑十分钟一切正常线上有长连接、有定时任务、有异常分支才会触发。常见的根因包括全局注册表事件总线、插件表、连接池只注册不注销functools.lru_cache装饰实例方法导致self永驻闭包捕获了self形成对象 → 闭包 → cell → 对象的环异常 traceback 持有栈帧里的self__del__让循环引用回收被推迟甚至失败。面对这些问题光看tracemalloc的分配统计不够——它告诉你内存在哪一行分配但不告诉你谁还拿着它。光看objgraph也不够——它能画出引用图但面对几十万个对象你无从下手。正确的方法是两者串联先用 tracemalloc 定位分配热点再用 objgraph 顺着引用链找到持有者。本文就按这个思路走一遍完整排查流程。需要特别强调的一点是Python 的内存泄漏和 C 语言里那种分配了没释放是两回事。CPython 的引用计数是确定性的一个对象的ob_refcnt一旦归零就立刻析构__del__同步执行内存同步归还给分配器。所以只要你在 Python 层看到 RSS 上涨几乎可以百分之百断定存在一条从 GC Root 出发、指向这批应该已经死了的对象的引用链。排查的本质就是把这条链找出来、切断它。理解了这一点后面所有工具的使用都有了统一的思路一切都是为了找到持有者。另外要提醒的是RSS 上涨并不总是等于内存泄漏。CPython 的 pymalloc 会把释放的小块内存留在 arena 里不还给操作系统obmalloc的 arena 只有在完全空闲时才munmap所以一个先涨后稳的锯齿曲线往往是正常现象。真正需要警惕的是单调上升、且与请求量正相关的曲线。这一点在第五节踩坑与优化建议里还会展开。一、核心原理从引用计数到分代 GC1.1 引用计数管不了的环CPython 的每个对象头部都有ob_refcnt。引用计数归零对象立即释放这是确定性的。但一旦出现环aNode()a.selfa# a 引用自己dela# 外部引用没了但 a.self 还在引用 arefcnt 1引用计数永远到不了 0。这就是循环垃圾回收器存在的理由它从 GC Roots模块字典、栈帧、内置对象等出发做可达性分析找出不可达但互相引用的对象集合然后打破环、释放对象。这里有一个容易被忽略的细节引用计数是即时的而循环 GC 是批量、延迟的。也就是说一个环即使已经被判定为垃圾也要等到下一次对应代际的 GC 被触发时才会真正释放。如果这个环里还挂着大对象比如几 MB 的bytearray、一整个 DataFrame那么在两次 GC 之间这块内存就一直占着。在高频创建临时对象的服务里这会表现为内存的锯齿抖动如果 GC 触发频率跟不上分配速度锯齿的上沿就会不断抬高最终看起来像泄漏。所以排查时先区分真泄漏可达和GC 滞后不可达但没回收非常重要前者要改代码后者往往调 GC 阈值就能缓解。1.2 分代回收与老对象陷阱GC 采用三代策略阈值默认(700, 10, 10)第 0 代分配数减去释放数超过 700 触发一次年轻代回收年轻代回收 10 次才扫一次第 1 代以此类推。设计动机是大部分对象朝生夕死老对象被扫描的频率极低。问题就在这里如果你的循环引用对象因为某个偶然原因活到了第 2 代比如被一个长生命周期容器短暂持有过它会变得极难被回收而每次全量 GC 的代价又极高。在 CPython 3.12 之前容器对象还要在 GC 链表里被反复遍历这既是内存问题的来源也是 CPU 抖动的来源。这里补充两个实用细节。第一可以用gc.get_threshold()读取当前阈值用gc.get_count()实时观察三代的计数判断 GC 是否跑得够勤。第二从 CPython 3.12 开始GC 的实现改成了增量式incremental把一次全量扫描拆成多个小步骤长停顿被显著缩短3.13 又进一步做了优化。如果你的服务卡顿与 GC 相关升级解释器版本往往比调参数更有效。但要注意升级 GC 实现不会修复逻辑泄漏可达对象依然可达该涨还是涨只是抖动小一点而已。还有一个经典陷阱值得单列gc.freeze()。在多进程服务如 gunicorn preload 模式、fork出来的 worker里父进程加载的所有模块、常量、缓存在 fork 之后每个子进程都会各自维护一份 GC 链表每次全量 GC 都要把这些永远不会变的对象重新扫一遍。在 fork 之前调用gc.freeze()可以把当前所有对象移入永久代后续 GC 直接跳过它们能显著降低 COW 内存复制和 GC 开销。这是生产环境里性价比极高的一招但它同样只优化性能不解决泄漏。1.3 tracemalloc 与 objgraph 的分工工具观察维度回答的问题局限tracemalloc分配点traceback“这块内存是哪一行代码分配的”看不到 C 扩展直接 malloc 的内存objgraph引用图对象关系“这个对象被谁引用着为什么活着”无法告诉你对象有多大、增长多快gc模块回收器内部状态“有没有回收不掉的循环垃圾”粒度粗DEBUG_SAVEALL有副作用tracemalloc的底层是替换了 CPython 的内存分配器钩子按 traceback 聚合统计objgraph的底层就是gc.get_referrers()/gc.get_referents()这两个原语。理解这一点你就知道它们的边界在哪。顺着这个边界再说几句。tracemalloc只统计经过 Python 分配器的内存像 NumPy 的大数组走malloc/mmap、mmap映射的文件、C 扩展自己malloc的缓冲区它都看不见。所以如果你排查的是 NumPy/Pandas 服务tracemalloc的绝对值会明显偏小别被它骗了——此时应配合psutil看 RSS、配合memory_profiler或pympler。反过来objgraph依赖gc.get_referrers()而它对不支持 GC 追踪的对象比如纯 C 类型、int、str这类非容器是无效的因为这类对象根本不在 GC 链表里。所以objgraph主要用来追容器类对象list、dict、自定义类实例这也恰好是泄漏的高发区。最后一点方法论这两个工具都应该只在排查时启用不要长期挂在生产上。tracemalloc会显著增加分配开销官方数据是 2~3 倍甚至更多objgraph的get_referrers在大对象图上更是重量级操作。正确的姿势是平时用轻量的指标RSS、对象计数采样发现异常再在灰度实例或复现环境里开启重型工具做精确定位。二、实战一个看不见的泄漏先构造一个高度还原线上的最小复现。核心是一个全局事件总线 一个只增不减的会话表配合闭包形成的环# demo_leak.pyimportgcimporttracemallocclassBus:def__init__(self):self.handlers[]# 强引用所有回调defregister(self,handler):self.handlers.append(handler)defpublish(self,event):forhinself.handlers:h(event)BUSBus()# 模块级单例天然是 GC Root_SESSIONS{}# 会话表业务上忘了清理classSession:def__init__(self,sid):self.sidsid self.payloadbytearray(4096)# 模拟真实业务负载# 闭包捕获 self形成 BUS - handler - cell - Session - BUS 的环BUS.register(lambdae:self.on_event(e))defon_event(self,event):passdef__del__(self):# 反面教材在环里定义 __del__passdefcreate_session(sid):sSession(sid)_SESSIONS[sid]sreturns这段代码里有两个独立的问题恰好对应两类典型泄漏BUS.handlers持有闭包闭包持有Session而BUS是模块级单例——Session永远可达GC 完全无能为力。这是逻辑泄漏。Session.__del__存在于循环引用中即使外部引用全部断开回收也会被推迟PEP 442 之后能回收但代价是额外的延迟和复杂度。值得把第 2 点讲透因为它是很多玄学泄漏的根源。在 Python 3.4PEP 442之前只要一个对象身处循环引用并且定义了__del__整个环就会被标记为不可回收直接扔进gc.garbage永久驻留——这是老代码里最臭名昭著的泄漏形态。PEP 442 之后GC 会先调用__del__再打破环所以对象最终能被回收但代价是__del__的执行时机变得不确定要等 GC 扫到这一代而且如果__del__里又创建了新引用可能引发二次问题。实践建议是尽量不要在可能进入循环引用的类上定义__del__如果必须做清理优先用contextlib的上下文管理器或weakref.finalize后者不依赖 GC 时机更可预测。下面开始排查。第一步用tracemalloc找分配热点。注意start(25)里的参数是保存的栈帧层数默认只有 1 层排查时必须调大# step1_tracemalloc.pyimportgcimporttracemallocfromdemo_leakimportcreate_session,_SESSIONS tracemalloc.start(25)# 保存 25 层调用栈snap1tracemalloc.take_snapshot()foriinrange(2000):screate_session(i)s.payload[0]1# 模拟业务处理dels# 业务上认为会话已结束丢弃局部引用gc.collect()snap2tracemalloc.take_snapshot()# 过滤掉解释器自身的噪声snap2snap2.filter_traces((tracemalloc.Filter(False,tracemalloc.__file__),tracemalloc.Filter(False,frozen importlib._bootstrap),tracemalloc.Filter(False,frozen importlib._bootstrap_external),))# 按 traceback 对比两次快照看谁增长了forstatinsnap2.compare_to(snap1,traceback)[:10]:print(stat)运行后你会看到类似这样的输出数值因环境而异demo_leak.py:24: size8237 KiB (8237 KiB), count2001 (2001), average4217 B这行输出的含义要拆开看很多人第一次看会读错demo_leak.py:24是分配点也就是self.payload bytearray(4096)那一行不是泄漏发生地。size8237 KiB是当前仍存活的、由这一行分配的总字节数括号里的8237 KiB是相对snap1的增量。count2001是存活的对象个数2001是新增个数。2000 次循环 1 个初始对象对上了。关键认知tracemalloc告诉你这 2000 个 4KB 的 bytearray 还活着它们是在第 24 行创建的但它不告诉你为什么它们还活着。从业务逻辑看del s之后Session就该死了可它没死。是谁在引用它tracemalloc到此为止接下来该objgraph上场。在切换工具之前先做一个低成本的交叉验证——用gc模块确认这些对象确实处于可达但应死的状态。这一步能帮你排除其实是被某个正常缓存持有的误判# step1b_gc_check.pyimportgcfromdemo_leakimportSession,create_sessionforiinrange(2000):screate_session(i)dels gc.collect()# 统计当前存活的 Session 实例数量alive[oforoingc.get_objects()ifisinstance(o,Session)]print(falive Session:{len(alive)})# 预期看到 2000而不是 0print(fcollected garbage:{len(gc.garbage)})如果alive是 2000说明gc.collect()之后它们依然可达问题定位在引用链没断而不是GC 没跑。如果alive接近 0 但 RSS 不降那更可能是分配器不归还内存或 C 扩展的问题排查方向要换。这个前置判断能省掉大量无用功。第二步用objgraph顺着引用链找持有者。先用show_backrefs画出反向引用图再用find_backref_chain找一条从 GC Root 到目标对象的最短路径# step2_objgraph.pyimportgcimportobjgraphfromdemo_leakimportSession,create_session,BUSforiinrange(2000):screate_session(i)dels gc.collect()alive[oforoingc.get_objects()ifisinstance(o,Session)]print(falive Session:{len(alive)})victimalive[0]# 随便挑一个已死的会话做样本# 1) 画出反向引用图会生成 PNG需要 graphvizobjgraph.show_backrefs([victim],max_depth6,too_many10,# 每个节点最多展开 10 条引用边防止图爆炸filenamesession_backrefs.png,)# 2) 直接打印最短引用链从某个 GC Root 到 victimchainobjgraph.find_backref_chain(victim,objgraph.is_proper_module,# 以真正的模块对象作为链的终点max_depth10,)print( - .join(type(x).__name__forxinchain))典型输出会是这样一条链module - Bus - list - function - cell - Session读法是从右往左看Session被一个cell引用闭包捕获了self这个cell属于一个function那个 lambdafunction被Bus.handlers这个list持有list属于Bus实例Bus实例挂在module上——链条闭合Session永远可达。至此泄漏的根因被精确定位而不是靠猜。show_backrefs生成的 PNG 在对象多的时候会非常壮观所以两个参数要会用max_depth控制展开深度一般 4~6 层够用too_many控制每个节点的扇出超过就折叠成...节点图上会标注N more references。这两个参数设不好图要么大到看不清要么关键信息被折叠掉。经验做法是先给一个max_depth4, too_many5的粗图看方向锁定可疑分支后再对单个对象做max_depth10的细图。第三步验证修复。找到根因后有两种改法对应两种不同的设计取舍# fix_a.py —— 方案 A注册时保存句柄销毁时注销importweakrefclassSession:def__init__(self,sid):self.sidsid self.payloadbytearray(4096)# 用 weakref.WeakMethod 包一层回调里通过弱引用取 self# 这样 BUS 不会强引用 Session环被打断refweakref.WeakMethod(self.on_event)self._handlerlambdae:(m:ref())andm(e)BUS.register(self._handler)defclose(self):BUS.handlers.remove(self._handler)# 显式注销self._handlerNonedefon_event(self,event):pass# fix_b.py —— 方案 BBUS 用弱引用集合从根上避免只注册不注销importweakrefclassBus:def__init__(self):self._handlersweakref.WeakSet()# 不阻止回调被回收defregister(self,handler):self._handlers.add(handler)defpublish(self,event):forhinlist(self._handlers):# 迭代时快照防止并发修改h(event)方案 A 保留了强引用语义靠配对register/close管理生命周期适合回调必须存活到显式销毁的场景方案 B 从根上消除了忘记注销的可能代价是回调若没有其他强引用会被意外回收——这对短命 lambda 是致命的所以 WeakSet 方案里通常要把回调存成实例属性如上面的self._handler以保证其存活。两种方案各有取舍没有绝对优劣关键是要和团队约定一致并写进代码规范。修复后再跑一遍 step1 和 step2alive Session应该回到个位数只留_SESSIONS里那些业务上确实需要的compare_to的增量也应该收敛到接近 0。验收标准一定要用数字说话跑 1 万次、10 万次迭代看增量是否随迭代线性增长。线性增长 没修好恒定 修好了。三、常见问题FAQQ1tracemalloc显示的增量明明很小但 RSS 涨了好几个 G怎么办这通常意味着内存不是 Python 层分配的。排查顺序① 用psutil.Process().memory_info().rss对比tracemalloc.get_traced_memory()的current差额大就说明有逃逸② 检查是否用了 NumPy/Pandas/OpenCV 等走 Cmalloc的库这些要换pympler、memory_profiler或valgrind③ 检查是否有mmap、共享内存、ctypes缓冲区未释放④ 检查 pymalloc 的 arena 是否因碎片化没有归还此时 RSS 高但tracemalloc的 current 是低的。Q2gc.collect()之后对象数量确实降了但 RSS 不降是泄漏吗大概率不是。CPython 的 pymalloc 会把释放的小对象留在 arena 的空闲链表里复用arena 只有在完全空闲时才munmap归还给 OS。所以对象已释放、RSS 不降是常态。真正的判据是长时间运行后 RSS 是否单调上升而不是降不降。Q3为什么objgraph.show_backrefs报错找不到 graphvizobjgraph画图依赖 graphviz 的dot命令需要单独安装系统包apt install graphviz/brew install graphviz光pip install objgraph不够。如果环境里装不了可以用objgraph.find_backref_chain纯文本打印引用链或者用gc.get_referrers()手动逐层打印效果差一些但无需外部依赖。Q4tracemalloc.start()的参数到底该给多少参数是保存的栈帧层数。默认 1 层只能看到最内层分配点几乎没用。排查时建议 25能覆盖绝大多数调用深度给到 50 也行但内存开销更大。注意一旦start之后参数不能改要改必须先stop再start。另外这个开销只影响分配路径对已分配对象无影响所以可以在怀疑的时段动态开启。Q5gc.DEBUG_SAVEALL会把垃圾存进gc.garbage用完之后怎么清理gc.garbage是一个普通 list直接gc.garbage.clear()即可。但注意DEBUG_SAVEALL会让所有被回收的循环垃圾都进gc.garbage内存会暴涨只能在极短窗口内开启用完立刻gc.set_debug(0)并清空。Q6lru_cache装饰实例方法为什么会泄漏functools.lru_cache会把self作为 key 的一部分缓存起来而缓存本身是函数对象的属性函数对象又是类属性、类又是模块属性——self因此被永久强引用。修复办法① 不要在实例方法上用lru_cache改用实例级别的dict缓存② 用weakref.WeakKeyDictionary自己实现③ 在 Python 3.8 可以用functools.cached_property它把值存在实例__dict__里随实例一起回收但要确认实例本身不会泄漏。Q7异常 traceback 为什么会泄漏sys.exc_info()和__traceback__会持有异常发生时的整个栈帧栈帧的f_locals又持有局部变量包括self。如果这个 traceback 被存进了某个长生命周期的结构比如日志队列、错误上报缓冲整个调用栈上的对象都会被钉住。修复办法捕获异常后立刻traceback.clear_frames(tb)或者只保留str(e)而不保留异常对象本身。Q8怎么判断一个泄漏是循环引用导致的还是普通强引用导致的一个快速的判别法如果gc.collect()之后对象数量下降说明存在循环垃圾之前只是没被扫到如果gc.collect()之后纹丝不动那就是普通强引用逻辑泄漏objgraph一定能顺藤摸瓜找到持有者。这个判断决定了后续是该调 GC 还是改代码。四、踩坑与优化建议踩坑 1在生产环境直接开tracemalloc。分配开销 2~3 倍接口 P99 直接翻番还可能在压测时把实例压垮。正确做法是在灰度实例、或流量低峰、或复现环境开启且开启时用tracemalloc.take_snapshot()定时采样比如每 5 分钟一次而不是一直挂着。踩坑 2只看tracemalloc的 top 10 就下结论。top 10 里往往全是解释器自身的分配比如importlib、codecs、linecache真正的泄漏藏在小增量的长尾里。一定要filter_traces过滤掉标准库和框架并且用compare_to看增量而非绝对值。踩坑 3用id()作为缓存 key。id()在对象释放后会被复用导致缓存里拿到错误的对象看起来像数据串了实际是泄漏排查里极难发现的坑。需要按对象身份缓存时用weakref.WeakKeyDictionary或weakref.ref作 key。踩坑 4忽略__slots__和__dict__的区别。带__dict__的实例可以动态挂属性排查时经常发现某个本不该存在的属性把对象钉住了比如框架往里塞的_cache、_ctx。给类加__slots__既能省内存也能让这类意外赋值直接报错是预防性的好习惯。优化建议 1把注册/注销做成上下文管理器。与其指望开发者记得close()不如提供with bus.subscribe(handler):这样的 API退出自动注销从语言层面杜绝只注册不注销。优化建议 2给关键容器加水位监控。对_SESSIONS、BUS.handlers、各种全局缓存定期上报len()指标一旦超过阈值就告警。这类泄漏的特征是单调增长一个简单的长度曲线就能提前几天发现。优化建议 3在 CI 里加一个轻量的泄漏回归测试。用tracemalloc跑固定次数的核心流程断言增量不超过阈值。这能挡住绝大多数顺手加了个全局缓存忘了清理的回归成本极低。优化建议 4合理调 GC 阈值但别乱调。把第 0 代阈值从 700 调大比如 5000能减少 GC 频率、降低 CPU但会让循环垃圾驻留更久、内存峰值更高。反过来调小能更快回收但更耗 CPU。没有普适最优值必须结合自己的分配速率和内存预算实测。在 fork 场景下优先考虑gc.freeze()。五、小结排查 Python 内存泄漏的正确姿势是先定性、再定位、后验证定性用 RSS 曲线和gc.collect()判断是真泄漏可达还是 GC 滞后不可达未回收。定位tracemalloc找分配热点哪一行objgraph找持有者谁拿着两者串联才能从现象走到根因。验证修复后用固定迭代次数跑增量线性增长就是没修好。最后记住一句话**Python 里几乎所有的泄漏都是引用没断而不是内存没还。更多硬核网安与AI工具包请扫码获取完整源码** 找到那条从 GC Root 到目标对象的引用链切断它问题就解决了。工具只是帮你更快地找到那条链而已。