有个 Python 写的后台 worker,功能很朴素:从消息队列里不停取任务、处理、写库,日复一日地跑。但它有个怪毛病——内存占用像潮水一样只涨不退,每隔大约三天就涨到把机器吃满,被系统的 OOM Killer 一枪打死,然后靠守护进程拉起来重新跑。重启后内存立刻回到几百兆的健康水位,可接下来三天又会一路爬到十几个 G。我们一开始甚至怀疑是不是机器内存太小,加了一倍内存——结果只是把"三天 OOM"拖成了"六天 OOM",该涨还是涨。
这事最迷惑人的地方在于:Python 是带垃圾回收的语言,按理说"用完的内存它会自己收掉",怎么会像 C 那样"泄漏"?可监控曲线明明白白地写着——这内存,确实有去无回。后来我用 tracemalloc 把内存增量打印出来,真相才水落石出:不是 Python 的 GC 坏了,而是我们亲手用几种经典的写法,让一批本该被回收的对象,永远有人攥着它们的引用、死活不肯松手。这篇就从这个"只涨不跌"的内存说起,把 Python 的内存管理讲透:它到底怎么决定回收一个对象、为什么"有 GC"也照样会泄漏、以及那几个几乎人人都写过的内存陷阱——可变默认参数、无限膨胀的缓存、循环引用、还有该用生成器时却把整个列表读进了内存。
先认清:Python 里"内存只涨不跌"的几个元凶
在讲机制前,先把"明明有 GC、内存却下不去"的几种典型原因摆出来。我那个 worker 同时中了好几条,它们的共同点都是"有东西一直引用着,导致对象回收不掉":
| "内存只涨不跌"的元凶 | 真相 | 后果 |
|---|---|---|
| 可变默认参数 def f(x, c=[]) | 默认值只在定义时创建一次,所有调用共享同一个对象 | 数据在函数间偷偷累积,越积越多 |
| 模块级缓存(全局 dict)无上限 | 全局对象生命周期=进程生命周期,塞进去的永不释放 | 缓存当内存泄漏用,无限膨胀 |
| 循环引用 + 自定义 __del__ | 互相引用的对象引用计数归不了零,__del__ 还可能拖住 GC | 大对象成环,迟迟收不掉 |
| 该用生成器却返回整个 list | 一次性把全部数据读进内存,峰值=全量大小 | 处理大文件/大结果集直接 OOM |
| 全局/长生命周期容器持有大对象 | 只要还有一条引用链连着,对象就回收不了 | "用完了"但没解引用,内存留着 |
这张表的共同主题是:Python 回收内存的唯一标准是"还有没有人引用它",而不是"你还用不用它"。只要存在一条从根对象出发、能走到它的引用链,这个对象就被判定为"还活着",GC 绝不会碰它——哪怕你早就不需要它了。下面先把这个判定机制讲清楚,再逐个拆解上面那几个把对象"意外留住"的陷阱。
第一件事:先搞清 Python 到底什么时候回收一个对象
Python 的内存回收靠两套机制配合:主力是引用计数——每个对象记着"有多少个引用指向我",这个数归零的瞬间,对象立即被释放;补充是分代垃圾回收器——专门处理引用计数解决不了的"循环引用"(A 引用 B、B 引用 A,两者计数都不为零,却谁也用不到了)。理解这两点,就能明白"内存为什么下不去":
看懂那条虚线没有——它就是所有"Python 内存泄漏"的本质:对象并没有真的泄漏,是你还留着一条引用链连着它,于是引用计数永远不归零,GC 也就永远不回收。所谓"内存只涨不跌",几乎都不是 Python 的 bug,而是程序里有个全局变量、一个闭包、一个默认参数,在你不知不觉间一直攥着那些对象不放。排查内存问题的第一性原理因此只有一句话:不是问"为什么不回收",而是问"到底是谁还在引用它"。下一节就讲怎么把这个"谁"揪出来。
第二件事:定位——用 tracemalloc 看清内存到底涨在哪一行
面对"内存只涨不跌",最忌讳的就是凭感觉猜。Python 自带的 tracemalloc 是最趁手的武器:它能给内存拍快照,再对比两个时间点的快照,精确告诉你哪一行代码分配的内存增长最多。我那次正是靠它,一眼就锁定了元凶:
import tracemalloc
tracemalloc.start() # 开始追踪内存分配
snapshot1 = tracemalloc.take_snapshot() # 在"健康"时拍第一张快照
# ... 让程序正常跑一段时间,处理一批任务 ...
snapshot2 = tracemalloc.take_snapshot() # 跑一阵后拍第二张
# 对比两张快照,按"内存增量"从大到小排序
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
print("内存增长 Top 5:")
for stat in top_stats[:5]:
print(stat) # 会打印出 文件:行号 size=+XXX KiB count=+N
# 例如: worker.py:42: size=+512 MiB count=+1000000
# ↑ 一眼看出 worker.py 第 42 行在疯狂累积对象
关键就是 compare_to 这个增量对比:单看一张快照只能知道"现在谁占内存多",而对比两张快照能看出"谁在持续增长"——后者才是泄漏的真正特征。当我看到打印结果里某一行的 count 随时间疯狂增加、对应着一个我以为"用完就丢"的列表时,心里就有数了。原则:排查内存别靠脑补,先用 tracemalloc 做增量对比,让数据指着那一行告诉你"就是这儿"。定位到具体行之后,接下来三节就是那几个最常见的"罪魁"长什么样。
第三件事:陷阱一——可变默认参数,数据在偷偷累积
这是 Python 最经典、也最反直觉的坑。看这个函数,它想给每次调用一个"临时"的缓存列表:
# 陷阱:默认值 [] 只在函数"定义时"创建一次,所有调用共享同一个 list
def process(item, cache=[]): # ← 这个 [] 是个长期存活的全局对象
cache.append(item) # 每次调用都往同一个 list 里塞
return do_something(cache)
# 调用方以为每次都是空 cache,实际上:
process("a") # cache = ["a"]
process("b") # cache = ["a", "b"] ← 上次的还在!
process("c") # cache = ["a", "b", "c"] ← 越积越多,永不释放
# 正解:默认值用 None,在函数体内创建新对象
def process(item, cache=None):
if cache is None:
cache = [] # 每次调用都是全新的、用完即可回收的 list
cache.append(item)
return do_something(cache)
坑的根源在于:函数的默认参数值,是在函数定义那一刻就求值并固定下来的,不是每次调用都重新创建。所以那个 [] 从程序启动到结束,自始至终是同一个对象,挂在函数对象上,生命周期等同于整个进程。每次调用往里 append,数据就永久地累积在里面,GC 当然回收不了——它确实"还被引用着"。我那个 worker 里就有一处类似写法,把每个任务的中间结果默默存进了一个共享的默认 list,三天下来攒了几百万条。铁律:默认参数永远不要用可变对象(list/dict/set),要用 None 占位、在函数体内创建。
第四件事:陷阱二——模块级缓存,被当成了内存泄漏
第二个高频元凶是"手写的全局缓存"。为了避免重复计算,很多人会在模块级别放一个 dict 当缓存——这本身没错,错在它只进不出、没有任何容量上限:
# 陷阱:全局 dict 当缓存,只往里塞、从不淘汰 → 等于慢性内存泄漏
_cache = {} # 模块级全局,生命周期=整个进程
def get_user_profile(user_id):
if user_id not in _cache:
_cache[user_id] = load_from_db(user_id) # 每见一个新 user 就多存一份
return _cache[user_id]
# 用户量一大,_cache 无限膨胀,里面的 profile 永远回收不掉
# 正解 A:用 functools.lru_cache 设容量上限,自动淘汰最久未用的
from functools import lru_cache
@lru_cache(maxsize=10000) # ← 关键:有上限,满了自动踢掉旧的
def get_user_profile(user_id):
return load_from_db(user_id)
# 正解 B:需要按时间过期,就用带 TTL 的缓存(如 cachetools.TTLCache)
from cachetools import TTLCache
_cache = TTLCache(maxsize=10000, ttl=300) # 最多1万条,每条存活5分钟
问题的本质是:全局对象的生命周期等于进程的生命周期,你往一个全局 dict 里塞东西,就等于把这些东西的"死期"推到了进程结束。缓存没有淘汰策略,就不再是缓存,而是一个伪装成缓存的内存泄漏。lru_cache(maxsize=N) 的价值就在那个 maxsize 上——它给缓存设了天花板,满了就按"最近最少使用"淘汰旧条目,内存稳稳地封在上限内。铁律:任何缓存都必须有边界——要么 maxsize 限容量,要么 ttl 限时间,绝不允许一个无上限的全局容器只进不出。
第五件事:陷阱三——循环引用,引用计数归不了零
引用计数有个天生的死角:两个对象互相引用,谁的计数都归不了零,即便外部早已没人用它们。最典型的是父子节点互相持有引用,或者把对象自己塞进自己的回调里:
# 陷阱:父子互相引用,形成环,引用计数永远不归零
class Node:
def __init__(self):
self.parent = None
self.children = []
self.data = bytearray(10 * 1024 * 1024) # 假设每个节点都拖着 10MB 数据
def build():
parent = Node()
child = Node()
parent.children.append(child) # parent → child
child.parent = parent # child → parent ← 环就此形成
# 函数返回后,parent/child 这两个局部名字消失,
# 但它俩还互相指着对方,引用计数都 >0,引用计数机制收不掉
# 正解:用 weakref 打破环 —— 子持有父用"弱引用",不增加父的引用计数
import weakref
class Node:
def __init__(self):
self._parent = None
self.children = []
@property
def parent(self):
return self._parent() if self._parent else None
@parent.setter
def parent(self, p):
self._parent = weakref.ref(p) # 弱引用:不阻止 p 被回收
好消息是,Python 的分代垃圾回收器正是为这种循环引用设计的,它会定期扫描、识别出这种"外部够不着的环"并回收掉——所以纯粹的循环引用通常不会造成永久泄漏,只是会延迟回收、给 GC 添负担。但有一种情况会让它彻底卡住:环里的对象定义了自定义的 __del__ 方法(老版本 Python 中,GC 不知道按什么顺序调用环上各对象的 __del__,干脆放弃回收,这些对象就真的永久泄漏了)。原则:数据结构里出现双向引用(父子、双链表、观察者),让其中一个方向用 weakref 弱引用来打破环;同时尽量避免给可能成环的对象写 __del__。
第六件事:陷阱四——该用生成器,却把整个列表读进了内存
最后一个、也是处理大数据时最容易 OOM 的坑:一次性把全部数据加载进内存,而不是流式地一条条处理。区别在于内存峰值——前者等于全量大小,后者只等于单条大小:
# 陷阱:一次性把整个大文件 / 大结果集读进 list,内存峰值 = 全量
def load_all(path):
result = []
for line in open(path):
result.append(parse(line)) # 千万行全堆进内存
return result # 返回一个巨大的 list
data = load_all("huge_10GB.log") # ← 还没开始处理就 OOM 了
for item in data:
handle(item)
# 正解:用生成器 yield,一次只在内存里留一条,边读边处理
def load_all(path):
with open(path) as f:
for line in f:
yield parse(line) # ← 惰性产出,不囤积
for item in load_all("huge_10GB.log"): # 内存峰值≈单行大小,稳如老狗
handle(item)
把 return result(一个装满数据的 list)换成 yield(一个惰性的生成器),内存模型就从"先囤齐再处理"变成了"来一条处理一条,处理完即可回收"。对于处理大文件、大查询结果、数据流的场景,这一个改动往往就是"OOM"和"几十兆内存稳定运行"的分界线。同理,优先用 itertools 里的惰性工具、数据库游标的流式读取、以及生成器表达式 (x for x in ...) 而非列表推导 [x for x in ...]。铁律:数据量不确定或可能很大时,默认用生成器流式处理,别张口就把整个集合 list() 出来。
把整套排查收成一棵决策树
把前面几件事串起来,下次再遇到"Python 内存只涨不跌",照着这棵树走,基本能快速锁定:
这棵树的总开关,就是开头那个第一性原理:不是问"Python 为什么不回收",而是问"到底是谁还在引用它"。顺着这条引用链找下去,所谓的"内存泄漏"几乎总会现出原形。
收口成几条 Python 内存的铁律
- 排查先 tracemalloc,别脑补:对比两个时间点的快照,看"谁在持续增长",数据会直接指到那一行。
- 默认参数绝不用可变对象:
list/dict/set一律用None占位、函数体内创建,否则数据会跨调用偷偷累积。 - 任何缓存都要有边界:用
lru_cache(maxsize=N)或带ttl的缓存,没有淘汰策略的全局容器就是伪装的内存泄漏。 - 双向引用用 weakref 打破环:父子、双链表、观察者模式里,让一个方向用弱引用;避免给可能成环的对象写
__del__。 - 大数据默认流式处理:用生成器
yield、游标、生成器表达式,别一次性list()把全量读进内存。 - 用完的大对象主动解引用:长生命周期的容器/对象,不再需要的成员及时置
None或移除,断开引用链才能让 GC 回收。 - 记住回收的标准是"有没有引用":不是"你还用不用",只要有一条可达的引用链,对象就回收不了。
几个特别容易踩的认知误区
这套经验讲给同事时,有几个误区几乎人人都有,值得专门点破。
第一个、也是最根本的:"Python 有垃圾回收,所以不会内存泄漏。" 这正是我那次走弯路的认知根源。GC 能自动回收的前提是"对象没人引用了";但只要你的代码还留着一条引用链——一个全局缓存、一个可变默认参数、一个忘了解开的回调——对象就永远"有人用",GC 一点办法没有。有 GC 不等于不会泄漏,它只是把"忘了 free"换成了"忘了解引用"。
第二个误区:"内存涨了,手动调 gc.collect() 或者 del 一下就能降回去。" del 只是删掉一个名字、把引用计数减一,如果还有别的地方引用着,对象照样不死;gc.collect() 只能回收循环引用的垃圾,对"被全局容器正当引用着"的对象毫无作用。治本永远是断开那条不该存在的引用链,而不是指望 GC 替你擦屁股。
第三个误区:"缓存嘛,存得越多命中率越高,越省事。" 没有上限的缓存,本质是拿内存换命中率,而且是个无底洞。命中率的边际收益会迅速递减,内存占用却线性甚至更快地涨。给缓存设一个合理的 maxsize,牺牲一点点命中率,换来内存的可预测和稳定,这笔账在长跑服务里永远划算。
第四个误区:"进程内存涨上去了,就一定是我的代码漏了。" 也不全是。Python 的内存分配器(pymalloc)在释放小对象后,有时并不会立刻把内存还给操作系统,而是留作下次分配复用——所以你可能看到"内存涨上去后维持在高位但不再涨"。这种"高位平台"通常不是泄漏(它不再增长);真正要警惕的是"持续单调上涨、永不回落"那条曲线,那才是引用链出了问题的信号。学会区分这两者,能省下大量瞎找的时间。
再补两招:省内存的 __slots__,和"揪出谁在引用"的利器
把那几个泄漏陷阱填平后,还有两个实战里特别有用的技巧,顺带说一说——一个用来"从源头少占内存",一个用来"在引用链上抓现行"。
第一个是 __slots__。Python 的普通对象,每个实例默认都带一个 __dict__ 字典来存属性,这个字典本身就有不小的开销。当你要创建成百上千万个同类小对象时(比如海量的数据点、节点),这点开销会被放大成惊人的内存占用。给类加上 __slots__,就能砍掉这个 __dict__,把每个实例的内存压下去一大截:
# 普通类:每个实例都有 __dict__,百万级实例时内存开销很可观
class PointSlow:
def __init__(self, x, y):
self.x = x
self.y = y
# 加 __slots__:固定属性、去掉 __dict__,单实例内存可省 40%~50%
class Point:
__slots__ = ('x', 'y') # ← 声明只有这两个属性,不再有 __dict__
def __init__(self, x, y):
self.x = x
self.y = y
# 创建一千万个时,Point 比 PointSlow 能省下数百 MB
points = [Point(i, i) for i in range(10_000_000)]
__slots__ 的代价是实例不能再随意添加声明之外的属性、也不便多继承,所以它适合那种"字段固定、数量巨大"的小对象。判断标准很简单:这个类会不会被实例化成千上万次?会,就认真考虑 __slots__;不会,就别为这点优化牺牲灵活性。
第二个利器,是当 tracemalloc 告诉你"某类对象在疯长",而你却想不通"到底是谁还引用着它们"时,用 gc 模块反查引用者:
import gc
# 已知某类对象在异常增长,反查"是谁在引用它们"
suspects = [o for o in gc.get_objects() if isinstance(o, MyLeakyClass)]
print(f"当前存活 {len(suspects)} 个 MyLeakyClass 实例")
# 对其中一个,列出所有"正引用着它"的对象 —— 顺着这条链就能找到泄漏源头
referrers = gc.get_referrers(suspects[0])
for r in referrers:
print(type(r), repr(r)[:120]) # 往往会发现是某个全局 list / dict 攥着它
gc.get_referrers 能直接回答那个第一性的问题——"到底是谁还在引用它"。顺着它列出的引用者往上找,几乎总能挖到那个不该存在的引用链根部:一个全局缓存、一个忘了清的注册表、一个闭包捕获的变量。(生产中更顺手的还有第三方库 objgraph,能把引用关系画成图。)把 tracemalloc(定位"哪行在涨")和 gc.get_referrers(定位"谁在引用")两件武器配合起来,Python 的内存泄漏基本无所遁形。
最后一句:把"内存曲线"做进监控,别等 OOM 才知道
我那个 worker 之所以拖了那么久才被发现,本质上是因为没人盯着它的内存曲线——直到 OOM Killer 动手,才被动地知道出了事。其实预防的成本极低:把进程的常驻内存(RSS)接进监控,配一条"内存持续单调上涨 N 小时"的告警就够了。
import os, psutil
def report_memory():
rss = psutil.Process(os.getpid()).memory_info().rss
metrics.gauge("worker.rss_mb", rss / 1024 / 1024) # 定期上报给监控系统
区分"健康的高位平台"和"危险的单调上涨"这件事,人眼盯曲线一目了然,而告警规则也只需盯住后者。长跑服务的内存,该像盯 CPU、盯延迟一样被持续盯着——把它从"事后被 OOM 告知"变成"事中看着曲线异常就介入",是性价比最高的一道防线。
写在最后
回到开头那个每隔三天就 OOM 的 worker。最终的修复琐碎得很:把一处可变默认参数改成了 None,给那个无限膨胀的全局缓存换上了 lru_cache(maxsize=...),再把一段一次性读全量的逻辑改成了生成器流式处理——上线之后,那条曾经一路爬到十几 G 的内存曲线,稳稳地压在了几百兆,再没触发过 OOM。改动不大,可它逼着我把"Python 到底什么时候才回收一个对象"这件事,从引用计数到分代 GC,从头到尾真正想明白了一遍。
这件事给我最深的体会是:"自动垃圾回收"给的安全感太足,足到让人以为内存这事再也不用操心——可它收的只是"没人引用的对象",而"谁在引用",百分之百是你代码的责任。所谓 Python 内存泄漏,几乎从来不是语言的锅,而是我们在某个不起眼的角落,用一个全局变量、一个默认参数、一个忘了打破的环,把本该速朽的对象,悄悄续成了和进程一样长的命。下次你的 Python 服务内存又开始那条只涨不跌的曲线时,别急着加内存——先问一句:这些对象,到底是谁还攥着不放?
—— 别看了 · 2026