3. 内存性能分析:内存使用分析、内存泄漏检测、垃圾回收调优

内存问题,是后端服务里最让人头疼的隐形杀手之一。我见过太多线上事故,CPU飙高还能靠扩容顶一顶,内存泄漏了,那是真的会「慢性死亡」——服务跑着跑着就OOM了,然后被K8s杀掉重启,循环往复。

说白了,Python的内存管理不像C/C++那样需要你亲手malloc/free,但正因如此,很多人反而忽略了它。你想想看,一个请求进来,分配了几个对象,请求结束了,这些对象真的被回收了吗?不一定。

3.1 内存使用分析:先搞清楚谁在吃内存

遇到内存问题,第一步不是调优,而是「看」。你得先知道内存被谁吃了。

3.1.1 用 tracemalloc 追踪内存分配

Python 3.4 之后自带的 tracemalloc 模块,是我个人最常用的工具。它可以直接告诉你:哪一行代码分配了多少内存。

import tracemalloc

tracemalloc.start()

# 模拟一段有问题的代码
data = [dict(id=i, name='x' * 1000) for i in range(100000)]

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

for stat in top_stats[:5]:
    print(stat)

输出结果会显示类似这样的信息:

/app/main.py:5: size=78.5 MiB, count=100000, average=823 B

嗯,这里要注意。如果你发现某个模块的内存占用异常高,别急着改代码。先确认一下是不是缓存策略导致的。我在项目中遇到过,一个同事把用户会话数据全存在了全局字典里,结果用户量一上来,内存直接爆了。

核心思路:先定位,再优化。不要凭感觉猜。

3.1.2 用 objgraph 可视化对象引用

tracemalloc 告诉你「谁分配了内存」,但有时候你还需要知道「谁还持有这个对象的引用」。这时候 objgraph 就派上用场了。

import objgraph

# 查看当前内存中某个类的实例数量
objgraph.show_most_common_types(limit=10)

# 查看某个对象的引用链
objgraph.show_backrefs([my_object], filename='backrefs.png')

我曾经用这个工具抓过一个经典的「循环引用导致内存泄漏」的案例。一个回调函数里引用了外部变量,外部变量又引用了回调函数,形成了一个闭环。GC虽然能处理循环引用,但如果对象还定义了 __del__ 方法,那就另说了。

避坑指南:我曾经在线上排查过一个诡异的内存泄漏,用tracemalloc查了半天没发现问题。后来用objgraph一看,发现是某个第三方库的全局缓存没有清理机制。所以,别只盯着自己的代码,第三方库也可能是元凶。

3.2 内存泄漏检测:揪出那些「不放手」的对象

内存泄漏在Python里,说白了就是「该回收的对象没被回收」。为什么会这样?因为还有引用指向它。

3.2.1 常见的泄漏场景

  • 全局缓存无限增长:比如用 lru_cache 但没设置 maxsize,或者自己写了个字典当缓存,只增不减。
  • 闭包中的意外引用:函数内部定义了另一个函数,内部函数引用了外部函数的变量,导致外部函数的局部变量无法释放。
  • 类变量 vs 实例变量混淆:把可变对象(如列表、字典)定义在类上,而不是实例上。
  • 线程或协程未正确清理:线程池里的线程持有对象引用,任务结束了但线程没销毁。

3.2.2 用 gc 模块手动排查

Python的 gc 模块可以让你直接跟垃圾回收器对话。

import gc

# 关闭自动回收,方便手动控制
gc.disable()

# 手动触发一次回收
gc.collect()

# 查看无法回收的对象
gc.garbage  # 这个列表里存的是定义了 __del__ 且形成循环引用的对象

你想想看,如果一个对象定义了 __del__ 方法,同时又形成了循环引用,GC就没办法确定该先调用谁的 __del__。于是这些对象就永远留在内存里了。这就是经典的「循环引用 + __del__ 陷阱」。

个人建议:尽量避免在类中定义 __del__ 方法。如果实在需要,可以用 weakref 来打破循环引用。

3.3 垃圾回收调优:让GC为你服务,而不是拖累你

Python的垃圾回收机制,很多人只知道「引用计数为主,标记清除和分代回收为辅」。但实际调优时,你需要理解它的工作方式。

3.3.1 分代回收的工作原理

Python把对象分为三代:第0代(年轻代)、第1代、第2代(老年代)。新创建的对象都在第0代。每代有一个阈值,当该代的对象数量超过阈值时,就会触发一次回收。

默认阈值 回收频率 特点
第0代 700 最高 对象存活率低,回收快
第1代 10 中等 经过一次回收仍存活的对象
第2代 10 最低 长期存活的对象,回收成本高

注意,第1代和第2代的阈值是「触发次数」,不是对象数量。意思是:第0代每回收10次,才触发一次第1代回收;第1代每回收10次,才触发一次第2代回收。

3.3.2 调优策略

我个人习惯,在写高并发服务时,会做两件事:

  1. 调整阈值:如果服务创建了大量临时对象(比如Web请求处理),可以适当提高第0代的阈值,减少GC触发频率。
  2. 分代回收的时机:在请求处理完毕后,手动触发一次第0代回收,避免垃圾堆积到第1代甚至第2代。
import gc

# 提高第0代阈值,减少GC频率
gc.set_threshold(2000, 10, 10)

# 在请求结束后手动回收
def after_request():
    gc.collect(0)  # 只回收第0代,成本很低

注意:不要频繁调用 gc.collect(),尤其是全量回收(不传参数或传2)。全量回收会扫描所有存活对象,在内存占用大的服务中,可能导致明显的卡顿。

3.3.3 什么时候该关掉GC?

这个问题我经常被问到。答案是:极少数场景下可以关掉。

比如你写一个批处理脚本,创建大量对象,然后脚本结束就退出了。这时候关掉GC可以提升性能,因为反正进程要结束了,内存会被操作系统回收。

但如果是长期运行的服务,千万别关。你想想看,一旦关掉GC,循环引用导致的内存泄漏会直接让服务在几分钟内OOM。

# 批处理场景下可以这样
gc.disable()
# ... 执行大量计算 ...
# 进程结束,内存自动释放

3.4 实战:一个内存泄漏的排查过程

最后,我分享一个真实的案例。有一次,我们的API服务在压测时,内存以每分钟200MB的速度增长。我用 tracemalloc 定位到是某个数据处理函数的问题。

# 问题代码简化版
class DataProcessor:
    def __init__(self):
        self.cache = {}  # 全局缓存
    
    def process(self, data_id):
        if data_id not in self.cache:
            result = self._expensive_computation(data_id)
            self.cache[data_id] = result
        return self.cache[data_id]

表面上看,这是一个缓存。但问题是,data_id 是不断增长的,而且缓存没有过期策略。结果就是,缓存越来越大,直到撑爆内存。

解决方案很简单:加上 maxsize 限制,或者用 lru_cache 替代。

from functools import lru_cache

@lru_cache(maxsize=1000)
def expensive_computation(data_id):
    # 原来的计算逻辑
    pass

嗯,这个案例告诉我们:缓存虽好,但一定要有上限。没有上限的缓存,就是内存泄漏的温床。

总结一下:内存性能分析,核心就是三件事——看谁在吃内存、揪出谁不放手、调好GC的节奏。工具只是辅助,关键是理解背后的原理。你想想看,如果你知道对象什么时候该被回收,什么时候不该被回收,那内存问题就难不倒你了。