那个数据处理服务已经稳稳当当跑了好几个月,我几乎忘了它的存在。直到运维群里开始零星地飘出告警:服务又被 OOM Killer 干掉了。重启,过几个小时,又被干掉。盯着内存监控曲线看了一会儿,我心里咯噔一下——那条曲线像爬楼梯一样,只涨不跌,一路向上,直到撞到内存上限被系统强行杀死,重启清零,然后又开始新一轮的攀爬。
这是教科书级别的内存泄漏症状。可问题是,这是 Python 啊——一门带垃圾回收的语言,理论上"用完的内存它会自己收走",怎么还会泄漏?我当时第一反应甚至有点不服气:难道是 Python 解释器自己的 bug?
带着这份怀疑,我一头扎进了排查。几天下来,真相一点都不玄乎,反而平淡得让人脸热:泄漏的不是解释器,正是我们自己的代码。一个图省事写下的可变默认参数、一个只进不出的全局缓存字典、再加上几处循环引用拖着对象迟迟不肯释放——三股小水流汇到一起,就把内存的池子一点点灌满了。这篇文章,就是我把那次"GC 语言也会内存泄漏"的事故彻底复盘之后,整理出的一份 Python 内存管理避坑指南。它不堆术语,只讲那些真正会让你的 Python 进程悄悄变胖的陷阱。
先纠正几个关于 Python 内存的常见误解
动手之前,先把几个我曾经深信、后来被这次 OOM 狠狠纠正的误解摆出来。如果你也这么想过,这篇文章大概率能帮你提前堵上漏洞。
| 常见误解 | 真相 |
|---|---|
| Python 有 GC,根本不会内存泄漏 | GC 只能回收"没人引用"的对象;只要还有引用挂着,它就一直占着内存,这就是泄漏 |
| 函数的默认参数每次调用都会重新创建 | 默认值只在定义时创建一次,可变默认参数(如 =[])会被所有调用共享、不断累积 |
| 对象没人用了,马上就会被回收 | 循环引用的对象要等 GC 周期性扫描才回收;若还定义了 __del__,旧版本甚至可能永远不回收 |
| 加个全局 dict 当缓存,简单又高效 | 只进不出的缓存就是内存炸弹,必须有上限(maxsize)或过期(TTL) |
| del 一个变量,它占的内存就立刻还给系统了 | del 只是减少一个引用;且 Python 释放的内存未必立刻还给操作系统 |
| 内存涨了不要紧,反正会自己降下来 | "只涨不跌"恰恰是泄漏最典型的信号,健康的服务内存应在一个区间内波动 |
第一件事:先搞懂 Python 到底怎么管内存
要理解"GC 语言为什么也会泄漏",得先看清 Python 的内存回收机制。它其实是两套机制的组合:主力是引用计数(reference counting),辅以一个处理特殊情况的分代垃圾回收器(GC)。
引用计数的逻辑非常直白:每个对象都记着"现在有多少个变量指向我";每多一个引用就 +1,每少一个就 -1;一旦这个计数归零,对象会被立刻回收。大部分内存,就是靠这个机制实时回收的。但它有个致命短板:对付不了循环引用——A 引用 B、B 又引用 A,哪怕外界再没人用它俩,它俩的引用计数也都还是 1,永远归不了零。这时就轮到分代 GC 出场,它会周期性地扫描,专门揪出这种"互相抱团、但整体已是孤岛"的对象群并回收掉。
看懂这张图,泄漏的本质就清楚了:Python 的内存泄漏,几乎都不是"GC 失灵",而是"对象明明逻辑上不该用了,却仍被某个地方实实在在地引用着",导致引用计数永远归不了零、GC 也判定它"还有人用"。那个"某个地方"在哪,就是排查泄漏要找的真凶。我那次事故的三个真凶——可变默认参数、全局缓存、循环引用——无一例外,都是在"悄悄地、持续地持有着本该被释放的对象"。下面就一个个拆开看。
第二件事:可变默认参数,那个共享了一切的"=[]"
三个真凶里最隐蔽、也最让我懊恼的,是一个可变默认参数。当时有个函数大致长这样:def collect(item, bucket=[])——本意是"调用时不传 bucket 就用个空列表",看着人畜无害。可它埋了 Python 里最经典的一颗雷:函数的默认值,只在函数定义那一刻创建一次,之后所有不传该参数的调用,共享的都是同一个列表对象。
# ❌ 反例:默认的 [] 只创建一次,被所有调用共享,数据不断累积
def collect(item, bucket=[]):
bucket.append(item) # 每次都往"同一个" list 里塞,它永远不清空
return bucket
collect("a") # ['a']
collect("b") # ['a', 'b'] ← 惊不惊喜!上次的还在
collect("c") # ['a', 'b', 'c'] ← 在长跑服务里,这个 list 会无限膨胀直到 OOM
# ✅ 正例:默认用 None,在函数体内每次新建,杜绝共享
def collect(item, bucket=None):
if bucket is None:
bucket = [] # 每次调用都是一个全新的、干净的 list
bucket.append(item)
return bucket
问题的根源在于 Python 的"定义时求值"特性:def 那行执行时,[] 就被创建好、绑定到了函数对象上,成了一个跨调用持久存在的东西。在一个跑几个月、被调用千万次的服务里,这个被共享的列表只进不出,就成了一台不知疲倦的"内存抽水机"。记牢这条铁律:默认参数永远别用可变对象(list / dict / set),要用就用 None 占位、进函数体再新建。这几乎是 Python 面试和 code review 的必查项,而它真在生产里咬你一口时,排查起来格外费劲——因为那行代码看起来实在太正常了。
第三件事:循环引用,让对象互相"抱住"赖着不走
第二个真凶是循环引用。前面说过,引用计数对付不了"A 引用 B、B 又引用 A"的情况。我们代码里就有这么一处:一个节点对象持有了它的父节点,父节点又持有子节点列表,父子互相指着对方。当这棵树本该被丢弃时,父子的引用计数都还 > 0,引用计数奈何不了它们。
# ❌ 反例:父子互相强引用,形成循环,引用计数永远归不了零
class Node:
def __init__(self):
self.parent = None
self.children = []
def add(self, child):
child.parent = self # 子 → 父(强引用)
self.children.append(child) # 父 → 子(强引用),闭环形成
# ✅ 正例:用 weakref 把其中一个方向改成弱引用,打破闭环
import weakref
class Node:
def __init__(self):
self._parent = None
self.children = []
def add(self, child):
child._parent = weakref.ref(self) # 弱引用:不增加父的引用计数
self.children.append(child)
@property
def parent(self):
return self._parent() if self._parent else None
循环引用本身不算"绝症"——分代 GC 最终能回收它们。但它有两个隐患:其一,这些对象要等 GC 周期性扫描才被回收,在那之前会一直占着内存,如果创建速度快过回收速度,内存就会持续走高;其二,在 Python 3.4 之前,带 __del__ 方法的循环引用对象 GC 甚至拒绝回收(怕析构顺序出问题),会被永久搁置。最干净的解法是用 weakref 弱引用主动打破循环:弱引用不增加目标的引用计数,对象该走时就能立刻走,根本不必劳烦 GC。父子、观察者、缓存这类"一方不该掌控另一方生死"的关系,都是弱引用的用武之地。
第四件事:只进不出的全局缓存,最得意忘形的那个内存炸弹
压垮内存的最后一根、也是最粗的一根稻草,是一个全局缓存字典。当时为了加速一个反复出现的查询,我在模块顶上随手写了 _cache = {},查到的结果就往里塞,下次直接命中。逻辑没毛病,效果也立竿见影——可我忘了给它装个出口。这个 dict 只进不出,跑得越久键越多,到最后它一个人就吃掉了大半的内存。
# ❌ 反例:全局 dict 当缓存,只塞不删,key 无限增长直到 OOM
_cache = {}
def get_user(uid):
if uid not in _cache:
_cache[uid] = query_db(uid) # 来一个新 uid 就多一条,永不释放
return _cache[uid]
# ✅ 正例之一:用 lru_cache 设上限,超出按最近最少使用淘汰
from functools import lru_cache
@lru_cache(maxsize=10000) # 最多缓存 1 万条,满了自动踢掉最久没用的
def get_user(uid):
return query_db(uid)
# ✅ 正例之二:需要"过期"语义时,用带 TTL 的缓存
from cachetools import TTLCache
_cache = TTLCache(maxsize=10000, ttl=300) # 最多 1 万条,且每条只活 5 分钟
def get_user(uid):
if uid not in _cache:
_cache[uid] = query_db(uid)
return _cache[uid]
缓存的本质是用空间换时间,但"空间"必须是有边界的,否则就是慢性自杀。判断一个缓存健不健康,就看它能不能回答两个问题:最多存多少条(maxsize)?每条活多久(TTL)?两个都答不上来的,就是一颗定时炸弹。functools.lru_cache 适合"键空间有限、结果不变"的纯函数;要"过期"语义就上 cachetools.TTLCache;再复杂些,直接把缓存外置到 Redis,让它的内存和淘汰由专门的组件去操心,你的进程反而轻装上阵。我那次的修法很简单:把 _cache = {} 换成 lru_cache(maxsize=10000),内存曲线当晚就从"爬楼梯"变成了"心电图"——在一个区间里平稳地上下波动,这才是健康服务该有的样子。
第五件事:与其靠猜,不如让 tracemalloc 把真凶指给你看
回头看,我在前期走了不少弯路——盯着代码空想"哪里会漏",效率极低。真正让排查提速的,是 Python 自带的 tracemalloc。它能给内存拍快照,还能把两个快照做 diff,直接告诉你"这段时间里,内存涨在了哪一行代码上"。真凶藏得再深,在它面前也无所遁形。
import tracemalloc
tracemalloc.start() # 开始追踪内存分配
snap1 = tracemalloc.take_snapshot() # 跑业务之前,拍第一张快照
run_business_for_a_while() # 让可疑逻辑跑一段时间
snap2 = tracemalloc.take_snapshot() # 再拍第二张
# 按"代码行"对比两张快照的内存增量,降序排列
for stat in snap2.compare_to(snap1, 'lineno')[:10]:
print(stat)
# 典型输出:.../cache.py:12: size=480 MiB (+480 MiB), count=2000000 (+2000000)
# ↑ 一眼就能看出 cache.py 第 12 行涨了 480MB、多了 200 万个对象
那一行 +480 MiB 配着两百万的对象计数,基本就是给真凶拍了张正面照。除了 tracemalloc,还有几件趁手的家伙:gc.get_objects() 能列出所有被 GC 跟踪的对象,配合 collections.Counter 按类型计数,看哪类对象在异常膨胀;objgraph.show_backrefs() 能画出"是谁还在引用这个对象"的反向引用图,顺藤摸瓜揪出那个赖着不放手的引用者;线上则可以用 memray 做更细的分配剖析。排查内存泄漏的心法,是把"我觉得"换成"数据显示"——工具一上,几天的空想往往一小时就有了着落。
第六件事:那些悄悄延长对象寿命的"隐形长线"
三个真凶之外,还有一类更隐蔽的泄漏,根子都在一句话上:有个你没意识到的引用,把对象的寿命悄悄拉长了。它们不像可变默认参数那样有明显的"案发现场",而是散落在日常代码的各个角落。我把踩过和见过的列在下面,你可以拿它当一份 code review 的清单。
| 隐形长线 | 为什么会漏 | 怎么堵 |
|---|---|---|
| 模块级全局变量持续 append | 全局对象的生命周期 = 进程生命周期,塞进去就别想出来 | 定期清理,或改用有界容器 / 外置存储 |
| 注册了监听器/回调却不注销 | 事件源一直持有回调,回调又持有它依赖的一堆对象 | 有 register 就配 unregister,或用 weakref 持有回调 |
| 闭包意外捕获大对象 | 闭包会把它引用的外层变量一并"打包"留住 | 只捕获真正需要的小值,别顺手把整个 self / 大 list 带进去 |
| 异常对象 / traceback 被长期持有 | traceback 里挂着出错那一刻整条调用栈的所有局部变量 | except 里别把 exc 存进全局;用完及时 del |
| 线程 / 协程的 thread-local 累积 | 线程不退出,thread-local 上挂的东西就一直在 | 用完显式清理,或用短生命周期的 worker |
这张表里我栽得最惨的是第二行——"注册了不注销"。曾经有个模块在初始化时往一个全局事件总线注册了回调,而对象销毁时没有对应的反注册,结果事件总线像个黑洞,把一批本该释放的对象死死攥在手里。记住一个对称性原则:凡是成对的操作——register/unregister、add_listener/remove_listener、open/close——你写了前一半,就有义务安排好后一半。实在没法保证成对调用的场景(比如回调),就让持有方用弱引用,让对象的生死回归它自己。
一张图收束:内存涨上去了,顺着它往下查
把这次排查的思路串成一条决策路径,下次再遇到"内存只涨不跌",照着走一遍,基本不会迷路。
这套打法的内核,其实就一句话:先用工具定位"内存涨在哪",再判断"是谁在持有它",最后切断那条不该存在的引用。三个真凶也好,五条隐形长线也罢,落到操作上都是这同一条路径。
七条铁律,直接抄进你的 code review 清单
最后把这次事故沉淀成七条可以直接执行的铁律。它们不深奥,但每一条背后都对应着一次实打实的内存上涨——抄下来,贴在 review 模板里,比读十篇原理文章都管用。
- 默认参数绝不用可变对象:
list / dict / set一律用None占位,进函数体再新建。 - 任何缓存都必须有边界:答得出 maxsize 和 TTL,答不出就别上线;优先
lru_cache/TTLCache/ Redis。 - 循环引用用 weakref 主动打破:父子、观察者、缓存这类"一方不该掌控另一方生死"的关系,弱引用是首选。
- 成对操作必须配齐:register/unregister、add/remove、open/close,写了上半句就安排好下半句。
- 闭包只捕获真正需要的小值:别顺手把整个
self或大对象打包进去。 - 排查靠数据不靠猜:
tracemalloc拍快照 diff、objgraph画反向引用,先定位再动手。 - 把"内存只涨不跌"当成红灯:健康服务的内存应在一个区间里波动,持续单调上涨就是泄漏在报警。
顺带说清:del、gc.collect() 和"内存为什么没还给系统"
排查过程中,我也被几个似是而非的操作绕过弯。它们常被当成"释放内存的开关",其实各有各的边界,用错了反而添乱。这里一次性说清楚。
import gc
# 1) del 只是"减少一个引用",不等于"立刻释放内存"
big = [0] * 10_000_000
ref = big
del big # big 这个名字没了,但对象还被 ref 引用着,引用计数仍 > 0
# 此刻内存一点没少;只有当 ref 也不再指向它,计数归零,才会回收
# 2) gc.collect() 只负责"循环引用"那部分,不是万能清理
gc.collect() # 手动触发一次分代回收,专门处理互相引用的孤岛
# 普通对象靠引用计数早就实时回收了,轮不到它操心
# 3) 想确认到底有没有循环引用泄漏,可以盯住这个
gc.set_debug(gc.DEBUG_SAVEALL) # 调试期:让 GC 把本该回收的对象留下来供检查
print(len(gc.garbage)) # gc.garbage 里堆着的,就是回收不掉的可疑对象
还有一个最反直觉、却让无数人误判"内存没释放"的点:Python 把内存还给了它内部的内存池,未必立刻还给操作系统。CPython 有个 pymalloc 分配器,小对象的内存释放后往往先在进程内部的池子里待命,留着给后续分配复用——所以你在系统监控里看到的 RSS(进程占用),即便对象真的都回收了,也可能不会马上跌下来。这本身是性能优化,不是泄漏。区分"真泄漏"和"内存只是没还给 OS"的关键,是看趋势:真泄漏是无论怎么跑都单调向上、永不回头;而后者会在一个稳定区间里波动、达到平衡。当年我就一度把后者错当成泄漏白忙了半天——直到看清那条曲线其实是"涨到某个台阶后就稳住了",才反应过来虚惊一场。
给长期服务装一个"内存哨兵"
修完那三个真凶,我还顺手做了件事:给这个长跑服务装了个简单的内存哨兵,让它自己盯着自己。思路很朴素——周期性记录进程 RSS,一旦发现"持续 N 个采样点只涨不跌",就主动告警,而不是等内存撞到上限被 OOM Killer 动手。被动挨打和主动预警之间,差的就是这么几十行代码。
import os, psutil, time
proc = psutil.Process(os.getpid())
history = [] # 只留最近 N 个采样,自身也别变成泄漏源
def memory_sentinel(window=12, step_mb=20):
rss = proc.memory_info().rss / 1024 / 1024
history.append(rss)
if len(history) > window:
history.pop(0) # 滑动窗口,长度恒定
# 窗口内每一步都在涨,且总涨幅超过阈值 → 大概率在泄漏
if len(history) == window and all(
history[i] < history[i + 1] for i in range(window - 1)
) and history[-1] - history[0] > step_mb:
alert(f"内存疑似泄漏:{window} 个采样持续上涨 "
f"{history[0]:.0f}MB → {history[-1]:.0f}MB")
这个哨兵帮我把"事后救火"真正变成了"事前预警"。它不取代 tracemalloc——定位真凶还得靠后者——但它能在内存刚露出"爬楼梯"苗头、离上限还远的时候就拍醒我,把排查的窗口从"凌晨被报警短信叫醒"提前到了"工作时间从容定位"。对任何一个要跑上几个月的常驻进程,我现在都建议配一个这样的哨兵:它便宜、安静,关键时刻却能替你挡下一次半夜的 OOM。
写在最后
这次事故给我最深的一课,是彻底改掉了"有 GC 就高枕无忧"的错觉。垃圾回收解决的是"没人引用的对象怎么自动清掉",它从来没承诺、也不可能替你解决"你不该再持有的对象,却仍然死死攥在手里"。后面这件事,是写代码的人的责任,不是语言的。可变默认参数、无界缓存、循环引用、没注销的监听器——这些坑的共同点,都是在你毫无察觉的地方,悄悄替对象续了命。
所以现在每写一处缓存,我都会下意识地问自己那两个问题:最多存多少?每条活多久?每用一个可变默认参数,手会先顿一下;每注册一个回调,都会顺手把注销的地方也想好。这些动作谈不上高深,但它们是把"内存"这件事从"出事后救火"前移到了"写代码时设防"。而当告警再响、内存曲线又开始爬楼梯时,我也不再慌——拍两张 tracemalloc 快照做个 diff,真凶往往一行就现了形。从"对着监控干瞪眼",到"顺着数据三步定位",这大概就是这场 OOM 事故留给我最值钱的东西。
如果你也维护着一个跑了很久、却从没认真看过内存曲线的服务,不妨今天就去翻一眼它的监控。要是那条线正不紧不慢地往上爬,别等它撞到上限——回头照着这篇里的三个真凶和五条隐形长线,挨个对照一遍。很多潜伏了几个月的泄漏,真要查起来,可能也就是一个被共享的 []、一个忘了设上限的 dict 而已。早一天发现,就少一次被半夜报警叫醒的狼狈。
—— 别看了 · 2026