Python 服务内存只涨不跌:从一次 OOM 揪出几个经典内存陷阱

有个 Python 后台 worker 功能很朴素:从队列取任务、处理、写库。可它内存像潮水一样只涨不退,每隔三天就被 OOM Killer 打死,重启又从几百兆开始爬到十几个 G。加了一倍内存,只是把三天 OOM 拖成了六天。最迷惑人的是:Python 明明有垃圾回收,怎么会像 C 那样泄漏?用 tracemalloc 打出增量后真相大白:不是 GC 坏了,而是我们用几种经典写法让本该回收的对象一直被引用着。这篇就从这条只涨不跌的曲线讲起,把 Python 内存管理讲透:引用计数与分代 GC 何时回收对象、为什么有 GC 也会泄漏、以及那几个人人写过的陷阱——可变默认参数、无上限的全局缓存、循环引用与 __del__、该用生成器却把整个 list 读进内存,再到 __slots__、gc.get_referrers 与内存监控。

有个 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 内存的铁律

  1. 排查先 tracemalloc,别脑补:对比两个时间点的快照,看"谁在持续增长",数据会直接指到那一行。
  2. 默认参数绝不用可变对象:list/dict/set 一律用 None 占位、函数体内创建,否则数据会跨调用偷偷累积。
  3. 任何缓存都要有边界:用 lru_cache(maxsize=N) 或带 ttl 的缓存,没有淘汰策略的全局容器就是伪装的内存泄漏。
  4. 双向引用用 weakref 打破环:父子、双链表、观察者模式里,让一个方向用弱引用;避免给可能成环的对象写 __del__
  5. 大数据默认流式处理:用生成器 yield、游标、生成器表达式,别一次性 list() 把全量读进内存。
  6. 用完的大对象主动解引用:长生命周期的容器/对象,不再需要的成员及时置 None 或移除,断开引用链才能让 GC 回收。
  7. 记住回收的标准是"有没有引用":不是"你还用不用",只要有一条可达的引用链,对象就回收不了。

几个特别容易踩的认知误区

这套经验讲给同事时,有几个误区几乎人人都有,值得专门点破。

第一个、也是最根本的:"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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

AI Agent 失控实录:一个停不下来的工具循环如何烧光预算

2026-5-29 21:41:35

技术教程

页面为什么会卡死:从一个转不起来的 loading 说透 JS 事件循环

2026-5-29 21:54:08

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索