
做 Python 服务维护的这几年我最怕听到的一句话不是“挂了”而是“内存又涨了”。内存泄漏不像崩溃那样会立刻触发报警它更像慢性病——刚上线时一切正常跑十几个小时后内存缓慢攀升等到 OOM 的时候现场基本已经没救了。更让人头大的是Python 的内存管理机制让它比 C/C 更容易掩盖真相你用top看到 RSS 涨了对象也确实删了可内存就是不降下来。这篇文章是我在实际排障过程中反复验证过的一套方法论核心就两点用tracemalloc把泄漏点从分配栈里挖出来再用对象生命周期治理的思路阻断泄漏源头。如果你正被“内存缓慢增长、怎么都定位不到归属”的问题折磨这篇文章可以直接当成排查清单来对照。1. 为什么说 Python 的内存泄漏比 C/C 更难定位先说一个容易被误解的前提Python 默认使用引用计数管理对象理论上一旦一个对象没有引用它就会被立即回收。很多朋友就是被“引用计数”这四个字误导认为 Python 不可能泄漏内存涨一定属于正常扩张。实际上CPython 的内存管理分两层这两层加在一起产生了至少三种让你误判的情况。1.1 引用计数回收的直观误区引用计数确实能在多数情况下自动释放对象但它解决不了循环引用。A 持有 BB 又持有 AA 和 B 的引用计数都不为 0垃圾回收器如果不介入这对对象就永远留在内存里。GC 能做回收但两次回收之间有一个周期默认阈值是每分配 700 个对象触发一次“代际检查”也就是说即使存在循环引用也不是立刻被回收的会有一个滞后窗口。生产环境中常见的现象是内存呈锯齿状波动一个高峰期过去后应该下降却没能完全降下来反复几次之后基线整体抬高。更麻烦的是循环引用和__del__一起出现时GC 会把它们视为不可回收对象直接丢进gc.garbage列表也就是说对象虽然在逻辑上不可达了但内存依然被占着而且 GC 不会再碰它了。这个问题在老代码里很常见比如旧式的异步任务里两个管理器互相持有对方只要其中一方定义了__del__就基本宣告泄漏。1.2 内存“不归还”不等于泄漏RSS 的两种状态第二个误判来自 CPython 的分配器。CPython 从操作系统申请内存之后会维护一组 arena 缓存用于小块对象的内存分配。当一个对象被释放时内存并不会马上归还给操作系统而是留在这些缓存里供后续新对象复用。这意味着你用top看到 RSS 不降不代表对象泄漏了同理RSS 上涨也不代表一定有 Python 对象变多。我自己第一次排查时犯过这个错某个服务 RSS 涨到 4GB 不下来我以为是请求上下文没释放结果用gc.get_objects()一统计活跃对象根本没增长后来才发现是某个第三方库把大量 Numpy 数组缓存住了。这里有个比较实用的判定方式先看gc.get_objects()返回的对象数量增长曲线再看 tracemalloc 统计的当前分配量两个指标都不涨而 RSS 在涨那基本可以判定问题出在 Python 对象分配器之外比如原生扩展模块直接申请的内存或者 pymalloc 的 arena 缓存堆积。1.3 “泄漏”的真正判定标准那么到底什么才算 Python 内存泄漏结合我的经验判定标准应该是这样的经过了一段时间和若干轮业务请求之后存活对象数量或 tracemalloc 统计的现存量持续增大并且垃圾回收之后仍无法回落到初始水平。这里的“初始水平”应该是你做了gc.collect()、tracemalloc.reset_peak()之后获得的一个稳定基线而不是进程刚启动时的瞬间值。我平时会用一个很小的观测脚本记录三类数据RSS、当前分配量、可收集对象计数即gc.get_objects()的个数。把它们画成曲线如果“当前分配量”这条线呈现单调上升而不是锯齿状回归就可以正式进入泄漏排查流程了。现象可能结论下一步动作RSS 上涨Python 对象数同步上涨Python 对象在持续积累用 tracemalloc 快照对比定位分配栈RSS 上涨Python 对象数不变外部原生模块内存占用用系统级工具排查原生内存RSS 下跌后仍高于基线arena 缓存尚未释放关注对象数是否回到基线可暂时观察tracemalloc 现存量上涨且多次 GC 后不回真泄漏需要定位到具体代码路径2. 泄漏根因分类不只是循环引用很多教程一提到内存泄漏就上来讲循环引用这其实把问题带偏了。从我见过的线上案例来看循环引用占比没有想象中那么高更多泄漏出在“生命周期边界没划清楚”上。下面我把常见根因按频率和数据特征拆开说。2.1 循环引用经典但未必是最常见的循环引用最典型的代码长这样class Manager: def __init__(self): self.worker Worker(self) class Worker: def __init__(self, manager): self.manager manager这种互相持有如果发生在短命对象之间还不太要紧因为 GC 每隔若干次分配就会扫描一次能把它回收掉。但如果这些对象的数量很大比如你每秒创建上千个这样的双向持有对象GC 的扫描成本会跟着上涨回收滞后窗口也会被放大。真正的隐患还是那个组合——循环引用加__del__class A: def __init__(self, b): self.b b def __del__(self): # 有些资源清理逻辑 pass class B: def __init__(self, a): self.a a a A(None) b B(a) a.b b # a 和 b 形成循环引用且 A 定义了 __del__GC 无法处理 del a, b # 它们会留在 gc.garbage 中再也不被清理对于已经上线的老代码遇到这种问题没有捷径要么重建引用方向要么把__del__干掉换成显式清理方法。但在新代码里原则很简单尽量别定义__del__如果一定要做资源清理用weakref.finalize或上下文管理器都比它可靠。2.2 隐式全局引用模块缓存与单例这是我现实中见得最多的一种。最常见的场景是模块里有一个全局字典或lru_cache它本意是缓存计算结果但缓存键是用户 ID、订单号、批次号这类“取值空间无限”的数据。随着业务运行缓存条目只增不减内存就一路涨上去。# 问题代码全局缓存无上限 _result_cache {} def get_user_profile(user_id): key fprofile_{user_id} if key not in _result_cache: _result_cache[key] query_db(user_id) return _result_cache[key]稍微包装一下问题就更隐蔽了比如缓存里存的不只是标量而是一整个响应对象、一帧渲染结果、一个大 DataFrame。你以为只有几十个缓存条目实际上每个条目背后拖着几十 MB 的数据。这类泄漏的特征是内存增长量和业务请求量线性相关清理缓存后内存立刻回落。所以排查时如果发现跟请求量强相关先查所有全局可变容器。2.3 回调、闭包和事件监听失控的生命周期第三类泄漏在“半异步半同步”的代码里特别多。你注册了一个回调函数回调函数本身不大但它作为闭包把外围的一堆变量一起捕获了。比如下面这种def on_event(event): # 事件处理逻辑 pass # 某个长生命周期管理器持有回调 manager.add_listener(on_event) # 事件处理函数可能捕获了请求上下文或其他临时对象事件监听器的生命周期往往比业务对象长得多对象已经处理完、理论上应该被回收但监听器还挂在管理器上它的生命周期被无限拉长。还有一种变体是线程把每请求产生的临时线程放进全局 list 里的陷阱并不少见list 一直引用着已经结束的线程对象线程内部持有的大块数据也就一直留在内存里。这类泄漏用 tracemalloc 排查时有个典型特征——增长集中在“注册回调”“append 线程”那几行代码上。2.4 快速定位根因的对照表根因典型数据特征初检手段治理方向循环引用 __del__gc.garbage非空对象数锯齿上升过滤gc.garbage统计类型消除__del__显式清理无界缓存内存与请求量/数据量线性相关审查模块级全局字典设置容量上限用弱引用回调/闭包持有tracemalloc 中大量分配集中在调用注册处快照对比看回调链路取消订阅、弱引用线程/连接池失控活跃线程数或连接数同步上涨打印threading.enumerate()显式生命周期管理3. tracemalloc 实战从分配总量到调用栈定位tracemalloc 是 CPython 官方自带的追踪模块它最大的价值不是告诉你“内存用了多少”而是能告诉你“这些内存是在哪个调用栈上分配的”。这一个能力基本就能把排查时间从几天压缩到几小时。3.1 基础用法启动跟踪与看总量先把最基础的两行代码记住import tracemalloc tracemalloc.start(25) print(tracemalloc.get_traced_memory())start(25)里的 25 是帧层数。默认是 1也就是只记录分配代码所在的当前函数你最好把它设得大一点否则看到的分配栈太短不好定位到业务入口。get_traced_memory()返回一个元组第一项是当前分配的内存总量第二项是运行以来的峰值。启动之后程序里所有 Python 对象的分配都会被记录。前提是 tracemalloc 必须在内存增长之前启动。如果你的进程已经跑了一星期才想起开 tracemalloc那是拿不到历史数据的只能重新部署、从启动阶段开始跟踪。所以在排查泄漏时最标准的做法是发布一个启动时自动开启 tracemalloc 的临时版本运行观测周期 24 到 48 小时采集首尾两个快照。3.2 快照对比找到两个时间点之间增长的那部分真正定位泄漏靠的是快照对比不是看总量。具体做法是# 运行开始一段时间后记录基准快照 snapshot_before tracemalloc.take_snapshot() # ... 持续运行业务逻辑 ... # 结束阶段记录最新快照 snapshot_after tracemalloc.take_snapshot() # 对比两个快照按代码行号聚合 top_stats snapshot_after.compare_to(snapshot_before, lineno) print( 增长最多的 20 处分配 ) for stat in list(top_stats)[:20]: if stat.size_diff 0: print(f{stat.traceback.format()[0]}) print(f 内存增长: {stat.size_diff / 1024:.1f} KiB, 分配次数增长: {stat.count_diff})这里有一个很关键的小细节compare_to的第二个参数我常用lineno它会把分配栈按文件和行号聚合方便看到增长集中在哪个函数。如果你想看看某个具体文件名下面的所有分配可以用filter_traces先过滤from tracemalloc import Filter snapshot_after snapshot_after.filter_traces( filters(Filter(True, *my_service*),), )Filter(True, pattern)表示保留匹配的文件路径Filter(False, pattern)表示排除。还可以直接对整个快照做snapshot.statistics(lineno)拿到当前存量而不是增量这在你怀疑某个模块是“无界缓存”时很有用先看排名前几的分配点再顺着代码找到缓存容器。3.3 线上使用 tracemalloc 的三个注意点tracemalloc 不是零成本的。start()之后每一次分配都要记录调用栈会带来明显的 CPU 和内存开销官方文档给的经验数据是内存开销大约 1 到 2 倍。所以生产环境不要长期开着建议走临时观测版本观测完就关。第二个注意点是线程。tracemalloc 会记录所有 Python 线程的分配但不会记录非 Python 原生内存的分配特别是 C 扩展直接通过malloc申请的内存。你如果怀疑原生扩展泄漏它帮不上忙得用valgrind或者heaptrack这类系统级工具配合排查。这一点对用 Numpy、PyTorch 这类库的场景尤其重要之前一个案例里Python 侧内存完全没有增长实际上泄漏的是某个 C 扩展里缓存下来的原生内存tracemalloc 一点都看不到。第三个注意点跟快照数据量有关。在大型服务上24 小时里产生的分配栈可能有上百万条直接compare_to全部打印会让终端崩溃。建议先启动后在前几分钟采集一个短周期快照确认服务本身能承受跟踪开销再拉长观测窗口。如果快照太大用上面说到的filter_traces把无关模块划掉只保留自己服务的路径。3.4 配合 GC 模块和 objgraph 一起看有时候 tracemalloc 已经把范围缩小到一个函数了但函数内部的缓存字典是哪个对象还需要看引用关系。我的习惯是先用gc.get_objects()拿到全部存活对象再按类型做计数import gc from collections import Counter objects gc.get_objects() counter Counter(type(obj).__name__ for obj in objects) print(counter.most_common(20))如果某个自定义类的实例数量异常多说明它们被某些长周期的容器引用着。此时配合objgraph第三方库能画出引用链pip install objgraphimport objgraph import my_suspect_class objgraph.show_backrefs( objgraph.find_backref_chain(my_suspect_class.Suspect(), max_depth8), filenamebackrefs.png )max_depth控制往上追溯的层数引用链越深越可疑。看到引用图之后基本就能判断是哪个全局容器“扣住”了对象不放。4. 对象生命周期治理让该释放的早些释放定位到泄漏点只是治标真正治本的是要让对象的生命周期“与业务需求一致”该释放时就释放。我总结下来治理方案可以归成四类。4.1 用弱引用解除长生命周期容器的“控制”弱引用是治理缓存和回调类泄漏最核心的手段。weakref模块的WeakValueDictionary、WeakSet这类容器存放其中的对象不增加引用计数当业务不再持有该对象时即使它还在缓存字典里也会被正常回收对应的键会从字典里自动移除。上面那个全局缓存的例子改成弱引用版import weakref # 注意值必须是可以弱引用的对象普通 str、int、部分内建容器不行 _profile_cache weakref.WeakValueDictionary() def get_user_profile(user_id): key fprofile_{user_id} profile _profile_cache.get(key) if profile is None: profile query_db(user_id) _profile_cache[key] profile return profile注意两个限制WeakValueDictionary的值必须是可以弱引用的对象普通str、int、list的某些内建类型不行另外当对象在字典里“活着”时它会回调通知字典移除条目这会有一点性能开销。对性能敏感的核心链路设计缓存时可以做成“有界 LRU 定期清理”本质上还是把缓存条目的生命周期限制在可控范围内。对于事件订阅和监听器也是一样的逻辑能挂弱引用就尽量挂或者至少在对象销毁时显式取消订阅。很多框架的disconnect方法就是为这个而生的只是开发者常常忘了调用。4.2 上下文管理器比__del__可靠得多我前面反复提到别用__del__这里展开说一下。__del__的调用时机模糊它由对象的即时释放或 GC 触发可能在任意时刻、任意线程里执行一旦对象处于循环引用中它甚至可能永远不被调用。更麻烦的是解释器关闭过程中__del__还可能访问到已经不存在的模块状态直接抛异常。with上下文管理器把“创建—使用—释放”这个生命周期显式化是治理资源泄漏最好的工具。如果业务对象的生命周期是明确的比如一次会话、一次请求、一次文件处理就把它设计成可上下文管理class RequestScope: def __init__(self, request_id): self.request_id request_id self.buffer [] def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.buffer.clear() # 再加上显式的资源回收/反注册逻辑 release(self.request_id) # 使用处 with RequestScope(req_id) as scope: pass写成这样之后生命周期边界在代码层面看得清清楚楚出了with块要么正常清理要么异常清理没有第三种可能。对于实在需要用回调方式做清理的场景weakref.finalize比__del__靠谱它会把清理动作注册成可调用的对象在对象被回收时执行。4.3 关键节点引入 GC 观测别让 GC 全自动运行很多团队上线后根本不看 GC 的运作情况等到出问题才手忙脚乱。这套机制其实是可以观测、可以调试的import gc # 打开调试开关输出 GC 统计保存可回收对象并打印不可回收对象细节 gc.set_debug(gc.DEBUG_LEAK) # 手动执行一次完整回收并打印各级回收数量 print(gc.collect())DEBUG_LEAK实际是DEBUG_STATS、DEBUG_SAVEALL、DEBUG_OBJECTS和DEBUG_UNCOLLECTABLE的组合把所有可回收对象保存到gc.garbage同时输出每一代的统计信息。日志里如果反复出现大量相同类型的对象大概率就是泄漏。日志本身会比较吵定位结束后记得用gc.set_debug(0)关掉。我常用的另一个稳定化手段是gc.freeze()把程序启动阶段创建的、确定不会释放的基础对象“冻结”掉之后的 GC 扫描就不需要反复检查它们能显著减少 GC 开销也能让gc.get_objects()这类统计更聚焦于业务阶段创建的对象。注意freeze只对调用之后的 GC 生效而且被冻结对象不会被回收所以只适合冻结真正的纯启动期对象。4.4 代码层面的生命周期约定积累了多个案例之后我在团队里定了几条不成文的约定在这里分享给你模块级只能放“配置型”不可变对象禁止出现无上限的模块级缓存字典缓存必须显式指定容量上限和淘汰策略。所有一次性任务、请求、会话对象实现上下文管理器或明确提供close()并在使用处收尾禁止只靠 GC 兜底。回调注册时先问一句监听器本身的生命周期是否小于被监听对象拿不准就改用弱引用。大对象图片、DataFrame、响应内容不做短期缓存即使做也要等业务结束后立即释放引用不要让它被全局容器顺手接住。5. 一次完整的泄漏排查实录从现象到修复前面讲了很多理论和方法最后用一个我自己做过的完整案例串起来方便你照着这个思路动手。5.1 现象与初判RSS 天天涨Python 内部却没明显异常背景是个内部任务调度服务每处理一批任务要调用外部接口接口返回体比较大大概几百 KB。上线后第一周正常第二周开始内存每天涨 1GB 多到了第七天直接 OOM 重启。起初我怀疑是任务队列积压但查了队列长度、活跃线程数都很平稳。用gc.get_objects()看对象总数虽然有所增长但幅度很小跟上千 MB 的 RSS 增量完全对不上。这个细节很关键说明多出来的内存很可能不是“大量小对象”而是“少数大对象”在不断积累。由于大对象通常是字符串、字节串、容器类对象于是我把怀疑目标锁定到外部接口的响应体缓存上。5.2 用 tracemalloc 定位增长集中在全局响应缓存我部署了一个临时版本启动时加上tracemalloc.start(30)跑了一天取两次快照对比很快看到增长贡献最大的前几名.../http_client.py:258 size1.21 GB count43000 .../task_worker.py:114 size180.5 MB count500第一处调用栈指向http_client.py里的一个_response_cache字典赋值语句每次把response.text写进去。顺藤摸瓜找到调用方原来是去年某个中间版本为了给重复请求做缓存把“响应文本按请求参数拼成 key 存进模块级字典”但线上参数几乎每个任务都不一样缓存没有任何命中率反而成了不断吞内存的黑洞。5.3 修复方案与效果验证弱引用替代全局强引用修复不复杂改两处一是把_response_cache从普通 dict 改成weakref.WeakValueDictionary()当任务执行完、响应对象不再被业务变量引用时缓存条目自动失效二是给真正需要缓存的核心接口确实会重复调到的那几个单独加一层有容量上限的 LRU 缓存。改动上线后再观察 72 小时内存曲线从单调上升变成了扁平化的锯齿状GC 后能稳定回到基线。第三天做了一次压测确认峰值内存只有原来的三分之一左右。这里有个容易踩的坑需要单独提醒WeakValueDictionary 本身不是银弹如果业务代码里对同一个响应对象已经有全局强引用换成弱引用也白搭。修复时先看清楚引用是“谁持有”再决定是否只用弱引用缓解。5.4 复盘三个环节堵住同类问题事后我拉了个简单的复盘把问题拆成三个环节监控环节内存类指标不能只看 RSS还要有存活对象数和 tracemalloc 当前分配量的趋势曲线让“内存涨了”变成一个可定位的问题描述。代码环节全局缓存的创建必须走统一封装不允许直接在业务模块里手写全局字典缓存条目的生命周期必须能回答“它什么时候被淘汰”。发布环节做性能测试时除了耗时压测还要加 24 小时以上的长期运行测试用 tracemalloc 做首尾快照对比专门观察内存增量。这一条尤其适合跑批类服务很多泄漏都是长期运行测试才暴露的。最后讲点我个人实践里的体会。排查 Python 内存泄漏技术手段其实很固定先看现象再确认是不是真泄漏然后开着 tracemalloc 跑一轮观测期对比快照拿到分配栈最后顺着引用链找到持有者。真正让排查变难受的往往不是工具不会用而是对“对象生命周期”没有概念——不知道谁会持有它、它该在什么时候消失。把治理思路从“等 GC 帮我收”调整成“我明确控制它的生命周期”之后这类问题会少掉一大半。你现在服务里的那个全局缓存或者那个事件监听器可能就是下一个泄漏点趁它还早按上面的方法自查一遍比等 OOM 时再抢救成本低得多。