Python 内存泄漏定位实战:tracemalloc + objgraph 8 小时找到 FastAPI 服务 32GB 内存被吃满的根因

分享一次 FastAPI 推荐服务内存从 1.2GB 缓慢吃到 32GB 触发 OOMKilled 的真实排查:用 tracemalloc 定位分配热点 + objgraph 追引用链,8 小时找到三个独立泄漏点(无 bound 缓存、调试代码持引用、fire-and-forget task 抓大对象),附完整工具链对比和预防机制。

2025 年春节后第一周回到公司,SRE 群里就开始有人转发监控截图:一个跑了快两年的 FastAPI 推荐服务,内存从平时稳定的 1.2GB 开始缓慢增长,每小时大约多 80MB,撑过 30 多个小时之后达到容器 limit 32GB 被 OOMKilled,然后 Kubernetes 重启又开始新一轮缓慢吞噬。运维同学的临时解决方案是每天凌晨主动重启这个服务一次,但 product 同学很不满意因为重启时段有用户请求被打断。我接手这个排查任务时被告知"已经查了一周没找到根因",结果用 tracemalloc + objgraph 组合拳,8 小时内定位到三个独立的泄漏点全部修复。这篇把整个排查过程、用到的工具、踩过的坑、最终修复方案完整记录一遍。

服务背景和现象

这个服务是我们 ML 平台的实时推荐 API,FastAPI 0.110 + Python 3.11 + uvicorn 部署在 Kubernetes,8 个副本,每个副本 4 vCPU + 8GB 内存(后来扩到 32GB 也压不住)。QPS 不算高,大约每秒 300 个请求,主要逻辑是:接收用户 ID → 查 Redis 拿用户特征 → 查向量数据库召回 100 个候选 → 调本地的 XGBoost 模型打分 → 返回 top 20。功能上没有问题,延迟也稳定在 50ms 内,就是内存一直涨。

时刻 事件
D+0 00:00 新版本上线 (升级了几个依赖, 加了一个新的 feature)
D+1 SRE 发现某副本内存 2.1GB, 高于平均
D+2 多个副本 5-8GB, 开始怀疑泄漏
D+3 OOMKilled 第一次出现, k8s 自动重启
D+5 团队尝试回滚到旧版本, 内存仍涨 (说明不是新版本问题)
D+7 临时方案: 每天凌晨主动重启
D+10 容器 limit 扩到 32GB, 缓解但未根治
D+14 我接手, 开始用 tracemalloc 排查
D+14 +8h 定位到三个独立泄漏点, 全部修复, 内存稳定

第一步:确认是真泄漏还是 fragmentation

Python 内存"看起来涨"未必都是真泄漏,有几种常见误诊。第一种是Python 内存分配器的内部 fragmentation,Python 用 pymalloc 管理小对象,即使对象被回收,内存可能不会还给 OS,但下次再分配会复用,这种情况内存会涨但有上限。第二种是缓存膨胀,各种 LRU cache、模型推理 cache,看起来涨但是有 bound 的。第三种才是真正的泄漏,某些对象一直被引用从而无法回收,这种内存会无限增长直到 OOM。

# 第一步: 用一个长跑监控脚本判断内存是否有上限
import psutil
import time
import gc

def monitor_memory(duration_hours=24, interval_sec=60):
    """监控自己进程的内存, 输出时间序列"""
    process = psutil.Process()
    start = time.time()
    samples = []

    while time.time() - start < duration_hours * 3600:
        # 强制 GC 排除浮动垃圾
        gc.collect()
        rss = process.memory_info().rss / 1024 / 1024  # MB
        vms = process.memory_info().vms / 1024 / 1024
        samples.append({
            'ts': time.time() - start,
            'rss_mb': rss,
            'vms_mb': vms,
            'gc_count': gc.get_count(),
        })
        time.sleep(interval_sec)

    # 判断是否有上限
    last_hour_avg = sum(s['rss_mb'] for s in samples[-60:]) / 60
    first_hour_avg = sum(s['rss_mb'] for s in samples[:60]) / 60
    growth_rate = (last_hour_avg - first_hour_avg) / (duration_hours)
    print(f'平均增长: {growth_rate:.1f} MB/hour')
    if growth_rate > 50:
        print('⚠️ 确认是真泄漏')
    return samples

在我们的服务跑这个监控 4 小时,确认平均每小时增长 78MB,曲线是线性的没有拐点,确定是真泄漏。这一步看似多余,但能避免后面浪费精力查那些其实是缓存机制的"假泄漏"。我们之前有同事查过一个"泄漏"查了三天,最后发现是 LRU cache 的 maxsize 设大了,根本不是泄漏。

第二步:tracemalloc 上线观察分配热点

tracemalloc 是 Python 3.4+ 自带的内存分配追踪工具,可以记录每次内存分配的调用栈,然后做快照对比看哪些位置的分配增长最多。直接在生产服务里开启会有 10-20% 的性能 overhead,我们的做法是临时挑一个副本开 tracemalloc,跑两小时打快照对比,定位嫌疑代码位置。

import tracemalloc
import asyncio
from fastapi import FastAPI

app = FastAPI()

@app.on_event('startup')
async def startup_tracing():
    # 只在调试副本启用, 通过环境变量控制
    if os.environ.get('ENABLE_TRACEMALLOC') == '1':
        tracemalloc.start(25)  # 保留 25 层调用栈
        asyncio.create_task(periodic_snapshot())

async def periodic_snapshot():
    """每 30 分钟打一次快照, 和上一次比较"""
    snapshots = []
    while True:
        await asyncio.sleep(1800)
        snap = tracemalloc.take_snapshot()
        snapshots.append(snap)

        if len(snapshots) >= 2:
            top_diff = snap.compare_to(snapshots[-2], 'lineno')
            # 输出增长最多的 30 个位置
            logger.warning('=== Memory diff (top 30) ===')
            for stat in top_diff[:30]:
                logger.warning(str(stat))

        # 同时输出当前总分配 top
        top_total = snap.statistics('lineno')[:20]
        logger.warning('=== Memory total (top 20) ===')
        for stat in top_total:
            logger.warning(str(stat))

跑了 2 小时之后,日志输出了类似下面的结果:

=== Memory diff (top 30) ===
/app/recommender/feature_cache.py:42: size=+412 MiB (+412 MiB), count=+38400 (+38400)
/app/recommender/model_loader.py:78: size=+87 MiB (+87 MiB), count=+1200
/app/lib/redis_client.py:156: size=+34 MiB (+34 MiB), count=+8200
/usr/lib/python3.11/asyncio/queues.py:120: size=+12 MiB (+12 MiB), count=+3400
... (其他都很小)

结果非常清楚:feature_cache.py:42 是最大的泄漏点,每两小时增长 412MB,占了 80% 以上。这一行直接定位到代码里看到了真凶。

第一个泄漏:feature_cache 用了无 bound 的 dict

# 出问题的代码
class FeatureCache:
    def __init__(self):
        self._cache = {}  # 普通 dict, 无 bound, 无过期

    async def get_or_compute(self, user_id: str):
        if user_id in self._cache:
            return self._cache[user_id]
        # 计算很重的用户特征 (几十个 numpy 数组)
        features = await self._compute_features(user_id)
        self._cache[user_id] = features  # ← 永远不会清理
        return features

feature_cache = FeatureCache()

问题非常典型:开发者想做"用户特征缓存"提高性能,但用了普通 dict 没有任何 bound 或过期机制。我们的用户量有几千万,每天访问几百万,每个用户特征大约 30KB,几天就吃满几十 GB。修法很简单,改成 LRU 加 TTL:

from cachetools import TTLCache
from cachetools.func import ttl_cache
import asyncio

class FeatureCache:
    def __init__(self):
        # maxsize=10000, TTL=3600s (1 小时)
        self._cache = TTLCache(maxsize=10000, ttl=3600)
        self._lock = asyncio.Lock()

    async def get_or_compute(self, user_id: str):
        if user_id in self._cache:
            return self._cache[user_id]
        async with self._lock:  # 防止 thundering herd 重复计算
            if user_id in self._cache:
                return self._cache[user_id]
            features = await self._compute_features(user_id)
            self._cache[user_id] = features
            return features

    def stats(self):
        return {
            'size': len(self._cache),
            'maxsize': self._cache.maxsize,
        }

这个修复让 80% 的泄漏立刻消失。但还有 20% 的小泄漏继续存在,需要继续查。这里有个值得说的细节:用 TTLCache 而不是 functools.lru_cache,因为 lru_cache 不支持 async function 而且没有过期机制;cachetools 的 TTLCache 对 async 更友好,而且 TTL 比单纯 LRU 更适合特征数据(用户特征会变,不能无限期缓存)。

第二步:objgraph 追踪剩余泄漏的引用链

修了第一个之后剩下的泄漏不再那么集中,tracemalloc 看不出明显热点,这时候需要换工具:objgraph。objgraph 可以统计当前进程里每种 type 的对象数量,生成对象引用图,定位"为什么这个对象没被回收"。

import objgraph
import gc

@app.get('/__debug__/objects')
async def debug_objects():
    """临时调试接口: 看哪种对象数量最多"""
    gc.collect()
    top = objgraph.most_common_types(limit=30)
    return {'top_types': top}

@app.get('/__debug__/leak/{type_name}')
async def debug_leak(type_name: str):
    """看某种 type 的对象样本被谁引用"""
    gc.collect()
    objs = objgraph.by_type(type_name)
    if not objs:
        return {'count': 0}
    # 取一个样本对象, 看它的反向引用链 (谁还引用着它)
    sample = objs[-1]
    objgraph.show_backrefs(
        [sample],
        max_depth=8,
        filename=f'/tmp/leak_{type_name}.png',
    )
    return {
        'count': len(objs),
        'graph_saved': f'/tmp/leak_{type_name}.png',
    }

跑了一次 /__debug__/objects 看到结果:

[
  ['function', 187234],
  ['dict', 156789],
  ['tuple', 98456],
  ['_RedisConnection', 8234],  ← 这个不对!
  ['Future', 4567],            ← 这个也不对
  ['BackgroundTask', 3210],
  ...
]

_RedisConnection 居然有 8234 个对象,我们用的是连接池,应该最多 50 个才对。Future 4567 个也很可疑,正常状态下不应该有这么多。继续用 show_backrefs 看引用链:

# 看 _RedisConnection 是谁引用的
import objgraph
conns = objgraph.by_type('_RedisConnection')
objgraph.show_backrefs([conns[0]], max_depth=8, filename='redis_backref.png')

# 生成的图显示: 这些连接被一个全局 dict 引用着 (跟踪用)
# 在 redis_client.py:156 行

第二个泄漏:Redis 客户端的连接跟踪 dict

# 出问题的代码 (在我们自己写的 Redis 包装层)
class RedisClient:
    def __init__(self):
        self._pool = ConnectionPool(max_connections=50)
        self._active_conns = {}  # 跟踪活跃连接, 调试用 ← 泄漏源!

    async def execute(self, cmd, *args):
        conn = await self._pool.get_connection()
        conn_id = id(conn)
        self._active_conns[conn_id] = {
            'cmd': cmd,
            'started_at': time.time(),
            'conn': conn,  # ← 持有了连接的引用, 连接池无法回收
        }
        try:
            result = await conn.execute(cmd, *args)
            return result
        finally:
            # 关键 bug: 这里只 release 连接, 没从 _active_conns 删
            await self._pool.release(conn)

这是个典型的"调试代码忘了清理"的 bug。原作者为了方便排查添加了 _active_conns 跟踪当前活跃的命令,但忘了在 finally 里清理,导致每次 execute 都往 dict 里塞一项,而且持有连接的引用阻止 GC。修法是 finally 里 pop 掉对应项:

class RedisClient:
    def __init__(self):
        self._pool = ConnectionPool(max_connections=50)
        self._active_conns = {}

    async def execute(self, cmd, *args):
        conn = await self._pool.get_connection()
        conn_id = id(conn)
        self._active_conns[conn_id] = {
            'cmd': cmd,
            'started_at': time.time(),
            # 不要存 conn 本身, 存个弱引用或者只存 id
        }
        try:
            return await conn.execute(cmd, *args)
        finally:
            self._active_conns.pop(conn_id, None)  # ← 关键修复
            await self._pool.release(conn)

修了这个之后 _RedisConnection 数量稳定在 50 以内,Future 数量也降到了几十,符合预期。但内存还是缓慢增长,虽然只有 5MB/hour,还得继续查。

第三个泄漏:asyncio task 没被 reference 导致警告但未真正完成

剩下的小泄漏更隐蔽。继续看 objgraph 输出,发现 Future 之外还有大量 Task 对象。深挖下去发现是有一段代码用了 asyncio.create_task() 但没保存 task 引用:

# 出问题的代码
async def log_request_async(req_info):
    """异步把请求日志写到 Kafka, 不阻塞主流程"""
    await kafka_producer.send('request_logs', req_info)

@app.middleware('http')
async def request_logger(request, call_next):
    response = await call_next(request)
    req_info = {
        'path': request.url.path,
        'status': response.status_code,
        'large_payload': request.state.body,  # ← 这里抓了完整请求体
    }
    # 异步打日志, 不等
    asyncio.create_task(log_request_async(req_info))  # ← 没保存 task
    return response

这段代码有两个问题。第一,asyncio.create_task() 返回的 task 没有保存引用,Python 文档明确说 task 会被弱引用持有,可能被 GC 提前回收导致 task 实际上没执行完,而且会有 RuntimeWarning。第二,req_info 里抓了完整 request body,有些请求体几 MB,即使 task 最后执行了,在执行之前会一直占着内存。修法:

BACKGROUND_TASKS = set()  # 全局保存活跃 task

async def log_request_async(req_info):
    try:
        await kafka_producer.send('request_logs', req_info)
    except Exception as e:
        logger.exception(f'log failed: {e}')

@app.middleware('http')
async def request_logger(request, call_next):
    response = await call_next(request)
    # 只抓必要字段, 不抓完整 body
    req_info = {
        'path': request.url.path,
        'status': response.status_code,
        'body_size': len(request.state.body) if hasattr(request.state, 'body') else 0,
    }
    task = asyncio.create_task(log_request_async(req_info))
    # 保存引用, 防止 GC; 完成后自动移除
    BACKGROUND_TASKS.add(task)
    task.add_done_callback(BACKGROUND_TASKS.discard)
    return response

第三个泄漏修复后,内存终于稳定在 1.2GB 不再涨,跑了一周仍然稳定。三个独立的泄漏点全部根治,服务再也不需要每天主动重启。

三个泄漏点的根因模式

排查工具横向对比

工具 能干什么 性能开销 什么时候用
tracemalloc 追踪每次分配的调用栈, 快照对比 10-20% 定位"哪行代码分配最多"
objgraph 统计对象 type 数量, 生成引用图 低 (按需) 定位"对象为什么没回收"
memray 详细的 flame graph, 比 tracemalloc 更精细 离线分析复杂 case
pympler 对象大小测量, summary 报告 评估缓存合理性
gc 模块 看不可回收对象 (循环引用) 极低 排查循环引用
guppy/heapy 类似 objgraph 但更老 对老项目兼容

我的实战经验是:tracemalloc + objgraph 是最佳组合,前者定位"分配在哪",后者定位"为什么没回收"。memray 在离线分析很强但生产用 overhead 太高;pympler 适合做"对象大小统计"但定位能力一般;gc 模块只解决循环引用一种特定问题。能解决 90% 内存问题的就是 tracemalloc + objgraph 的组合,熟练掌握这两个工具足够应付绝大多数 Python 内存泄漏排查。

预防机制:让泄漏暴露在最早阶段

修完事故只是开始,真正的工程能力是建立预防机制。我们后来在公司内部推了几个标准:第一,所有缓存类对象必须有 maxsize 和 TTL,code review 不通过禁止合并;第二,asyncio.create_task() 必须保存引用,违反者 ruff 规则报错;第三,服务上线必须经过 24 小时的 soak test,内存增长超过阈值不允许发布;第四,生产环境每个副本必须暴露 /__debug__/objects 端点(带 auth),方便随时排查。

# 用 ruff 自定义规则禁止裸 create_task
# ruff.toml
[tool.ruff.lint]
select = ['ASYNC', 'B', 'E', 'F', 'W']

[tool.ruff.lint.per-file-ignores]
# 业务代码必须用我们的包装函数
'app/**/*.py' = []

# 自定义检查 (用 pre-commit hook)
# scripts/check_create_task.py
import ast, sys

class CreateTaskChecker(ast.NodeVisitor):
    def __init__(self):
        self.violations = []

    def visit_Expr(self, node):
        # 检查 asyncio.create_task(...) 作为 Expr 出现 (没赋值)
        if (isinstance(node.value, ast.Call)
            and isinstance(node.value.func, ast.Attribute)
            and node.value.func.attr == 'create_task'):
            self.violations.append(
                f'L{node.lineno}: 裸 create_task, 必须保存引用'
            )
        self.generic_visit(node)

for f in sys.argv[1:]:
    tree = ast.parse(open(f).read())
    checker = CreateTaskChecker()
    checker.visit(tree)
    if checker.violations:
        print(f'{f}:')
        for v in checker.violations:
            print(f'  {v}')
        sys.exit(1)

soak test 的自动化

soak test 是检测泄漏的最有效手段,但很多团队没有做。我们的做法是在 CI 里跑一个简化版:服务启动后用 locust 模拟 1 小时的真实流量(从生产采样的请求 trace),期间每 5 分钟打一次内存快照,最后看是否线性增长。如果 1 小时增长超过 100MB,CI 失败阻止合并。

# .github/workflows/soak-test.yml
name: Soak Test
on:
  pull_request:
    paths: ['app/**', 'requirements.txt']
jobs:
  soak:
    runs-on: ubuntu-latest
    timeout-minutes: 80
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install -r requirements.txt locust psutil
      - name: Start service
        run: |
          ENABLE_TRACEMALLOC=1 python -m uvicorn app.main:app --port 8000 &
          sleep 10
      - name: Run 60min load test
        run: locust -f tests/locustfile.py --headless -u 50 -r 5 -t 60m --host http://localhost:8000
      - name: Memory check
        run: |
          python scripts/check_memory_growth.py --threshold-mb-per-hour 100

这个 soak test 在 CI 里跑要 60+ 分钟,我们一开始担心太慢,但实际效果远超预期:每周大约能挡住 1-2 个会引入泄漏的 PR,大幅减少线上事故。当然不是每个 PR 都跑,只有改了 app/ 目录或 requirements.txt 的才跑,普通文档 PR 不跑,平衡了速度和覆盖。

numpy / pandas / torch 的特殊性

上面提到 numpy / pandas / torch 的内存是 C 层面分配的,Python GC 看不见。这类对象在 Python 代码里看起来是个普通对象,但实际占的内存可能是 Python 引用计数显示的几十倍。我们的推荐服务里用 numpy 存特征向量,每个向量 768 维 float32 大约 3KB,但加上 numpy 的 metadata 和 view 关系,实际占用可能 5KB+。一千万用户的特征就是 50GB,即使你 dict 里只有 100 万 key 也可能很大。

import numpy as np
import sys
import gc

# 看似合理的代码: 缓存 numpy 特征
class FeatureStore:
    def __init__(self):
        self._features = {}

    def put(self, user_id, vec):
        # vec 是 np.ndarray
        self._features[user_id] = vec

    def memory_used(self):
        # 注意 sys.getsizeof 不能正确统计 numpy 实际内存
        total = 0
        for vec in self._features.values():
            total += vec.nbytes  # numpy 实际字节数
        return total

# 真实占用要看 vec.nbytes, 不是 sys.getsizeof()
arr = np.zeros(1024, dtype=np.float32)
print(sys.getsizeof(arr))  # 大约 128 (只是 Python 对象头)
print(arr.nbytes)           # 4096 (真实内存)

# 重要: numpy view 会持有原数组引用, 即使你只用了一小段
big = np.zeros(1_000_000_000, dtype=np.uint8)  # 1GB
small = big[:10]  # 看起来很小, 但持有 big 的引用
del big           # del 也没用, small 持有引用
# 正确做法: 复制出来
small = big[:10].copy()
del big           # 现在 big 才能真正释放

numpy view 持有原数组引用是个非常隐蔽的坑。我们项目里有段代码做特征切片,看似处理的是几 KB 数据,实际持有了几百 MB 的原数组,导致 GC 后内存还是降不下来。修法是切片后立刻 .copy() 一次,断开和原数组的关联。这种细节是 numpy 工程师的必备知识,不熟悉的人很容易掉坑。

pytorch 模型推理的内存陷阱

另一个陷阱是 torch 的autograd graph 累积。我们的推理服务用了 torch 的部分代码做 GPU 加速,有人忘了用 torch.no_grad() 包裹推理调用,导致每次推理都构建反向传播图却从不清理,GPU 显存几小时就满了。这个 bug 我们也踩过,修法很简单:

import torch

# 错误写法: 推理时也累积 autograd graph
def predict_wrong(model, input):
    output = model(input)  # 默认 requires_grad=True, 累积图
    return output.cpu().numpy()

# 正确写法: 推理时关闭 autograd
def predict_right(model, input):
    with torch.no_grad():  # ← 关键
        output = model(input)
        return output.cpu().numpy()

# 更进一步: 用 inference_mode 比 no_grad 更高效
def predict_better(model, input):
    with torch.inference_mode():  # PyTorch 1.9+ 推荐
        output = model(input)
        return output.cpu().numpy()

torch.no_grad() 关闭梯度追踪,torch.inference_mode() 进一步关闭 version counter,推理性能更好。我们换成 inference_mode() 之后,GPU 显存从无限增长变成稳定在几 GB,顺便延迟还降了 8%。这种"一个 with 语句解决问题"的修复非常爽,代价是要养成"推理必须包 no_grad"的肌肉记忆。

团队立的 7 条 Python 内存纪律

  1. 所有缓存类对象必须有 maxsize 和 TTL,禁止裸 dict 当缓存。
  2. asyncio.create_task() 必须保存引用,用全局 set + done_callback 模式。
  3. 调试代码必须有清理机制,任何全局 dict 都要在 finally 里 pop。
  4. request body / 大对象不进 log,只记录 size 和摘要。
  5. fire-and-forget 任务必须有超时和异常捕获,防止僵尸 task。
  6. 生产服务必须有 /__debug__/objects 端点,带 auth 保护。
  7. 新版本上线前必须过 1 小时 soak test,CI 强制。

常见误诊和坑

排查内存问题的过程中我们也踩了几个误诊的坑,分享出来给同行参考。第一个误诊是把容器内存(cgroup)和进程内存搞混。容器里 free 命令显示的是宿主机内存,完全不能用;必须看 /proc/self/status 的 VmRSS 或者 cgroup 的 memory.usage_in_bytes 才是这个容器自己的实际内存。我们第一天因为用 free 看了 30GB 内存,以为是宿主机问题,白白浪费了几小时。

第二个误诊是误以为 GC 没在工作。我们看到内存涨,第一反应是手动调 gc.collect() 看内存能不能降下来。结果调了之后没变化,以为 GC 失效,差点去翻 CPython 源码。后来才意识到,gc.collect() 只能回收 Python 层面的对象,而 numpy / pandas / torch 的内存是 C 层面分配的,Python GC 看不见,自然回收不了。解决这类内存问题要看 malloc_trim 或者直接 restart。

第三个误诊是怀疑第三方库的 bug。我们一开始怀疑 FastAPI 0.110 有内存泄漏,降级到 0.108 看是否复现,结果还是涨,白白浪费了一天部署测试。其实 Python 生态的主流库(FastAPI / aiohttp / SQLAlchemy 等)经过大量生产验证,内存泄漏基本都是自己代码的问题,先怀疑自己再怀疑库,这个顺序千万别搞反。

第四个误诊是看错了 tracemalloc 的输出。tracemalloc 的 statistics() 默认按 size 排序,但有时候你需要看 count(分配次数),才能定位"频繁小分配"类型的问题。我们一开始只看 size top,以为没有热点,后来切到按 count 排序才发现某个 hot path 在分配大量小字典对象,加起来才显著。tracemalloc 的多个 group_by 选项(filename / lineno / traceback)各有用处,要灵活切换。

循环引用的特殊情况

除了上面三类常见泄漏,还有一类比较隐蔽的:循环引用导致的延迟回收。Python 的引用计数 GC 不能处理循环引用,需要靠分代 GC 的标记清除,如果对象之间形成大的引用环,GC 周期可能赶不上对象产生速度,表现为内存缓慢增长。我们项目里有一次因为某个 ORM 对象和它的 children 互相引用,加上 children 被加到了一个全局列表里,形成了大环,导致 GC 极慢。

import gc
import weakref

# 排查循环引用的方法
gc.set_debug(gc.DEBUG_SAVEALL)
gc.collect()
print(f'Unreachable objects: {len(gc.garbage)}')
for obj in gc.garbage[:20]:
    print(type(obj), id(obj))

# 修法: 在容易形成环的地方用 weakref
class Node:
    def __init__(self, name):
        self.name = name
        self._children = []
        self._parent_ref = None  # 用 weakref 而不是直接引用

    def add_child(self, child):
        self._children.append(child)
        child._parent_ref = weakref.ref(self)  # ← 弱引用避免环

    @property
    def parent(self):
        return self._parent_ref() if self._parent_ref else None

循环引用的解决思路是用 weakref 打破环。父子关系里,子持有父的 weakref,父持有子的强引用,GC 时父被回收子也能被回收。这种设计在 ORM、UI 树、图结构里非常常见,Python 工程师应该把 weakref 列为基础工具,不会用就吃亏。

memray 在复杂 case 里的应用

tracemalloc + objgraph 能解决 90% 的问题,剩下 10% 的复杂 case 我们会用 memray。memray 是 Bloomberg 开源的内存分析工具,优势是能生成 flame graph,可视化每个调用栈的内存占用,适合定位"分配链路很深"的问题。我们用 memray 排查过一个 ORM lazy loading 引起的内存涨,普通工具看不出来,memray 的 flame graph 一目了然。

# 安装
pip install memray

# 在生产服务上跑一段时间, 收集数据
memray run --output /tmp/mem.bin -m uvicorn app.main:app --port 8000

# 或者 attach 到已运行的进程 (Linux only)
memray attach  --output /tmp/mem.bin

# 等服务跑几小时后, 停止收集, 生成 flame graph
memray flamegraph /tmp/mem.bin --output /tmp/mem.html
# 打开 mem.html 在浏览器看, 横轴是分配大小, 纵轴是调用栈

# 也可以生成 table 报告
memray table /tmp/mem.bin

# 看 leaks (整个跑完没释放的)
memray flamegraph --leaks /tmp/mem.bin --output /tmp/leaks.html

memray 的缺点是 overhead 大(30-50%),不适合长期在生产开启,只能短期诊断用。但它的可视化非常强,我们后来形成了一个工作流:tracemalloc/objgraph 看初步线索,memray 做深度分析,两者配合能搞定几乎所有内存问题。

排查工作流总结

给同行的建议

第一条建议是不要怕用 tracemalloc 和 objgraph。很多 Python 工程师遇到内存问题第一反应是"换个语言吧",其实 Python 的内存工具链已经很成熟,会用就能解决 95% 的问题。tracemalloc 是标准库,objgraph 一行 pip install,学习成本不到一天,带来的能力提升巨大。

第二条建议是预防比修复重要十倍。建立缓存规范、CI soak test、ruff 规则这些预防机制虽然麻烦,但能让团队避免大量重复踩坑。一次线上 OOM 事故的损失(用户体验、SRE 加班、业务影响)远超 soak test 的 CI 时间成本,这笔账要算清楚。

第三条建议是异步代码要格外小心内存问题。async/await 让代码看起来简单,但 task 生命周期、引用关系、异常传播比同步代码复杂得多。asyncio.create_task、background task、middleware 这些地方都是内存问题的高发区,review 时要重点看。

第四条建议是生产服务一定要有内存观测。Prometheus + Grafana 的 process_resident_memory_bytes 是最基础的,看 RSS 趋势就能发现泄漏苗头。再加上每个副本的 /__debug__/objects 端点,排查时不用打扰运维,自助就能看现场状态。这种"可观测性优先"的工程文化是高质量服务的基础。

当内存治理遇到老项目

上面讲的工程纪律对新项目容易落地,但老项目代码量大、改造成本高,推这些规范阻力很大。我们的策略是分级分阶段:核心 P0 服务必须三个月内完成所有改造,P1 服务半年内完成,P2 服务允许保留但要加监控。这种分级让团队心理负担小很多,大家更愿意配合。改造过程中我们也总结出几个老项目特别容易出问题的模式:全局 dict 当配置缓存、连接池跟踪、装饰器闭包持有引用、信号处理器持有大对象。这些模式扫一遍就能识别出大部分泄漏点,效率比挨个 audit 高得多。

具体的扫描手段是用 ast 写一个简单的 linter,识别"模块级 dict 且无 maxsize"、"装饰器内闭包持有大对象"等模式,挂到 CI 上跑一遍,几小时就能扫完几十万行代码。我们扫第一遍时找出了 47 个嫌疑点,人工 review 后确认 31 个是真问题,陆续修掉。这种"工具驱动"的治理比"人肉 audit"快得多,也更彻底,推荐每个团队做老项目治理时都用这套办法。

事故复盘后的内存治理体系

这次事故之后,我们正式把"内存治理"列为一项独立工程,搭建了一套完整的内存治理体系。第一层是编码规范:缓存必须有 bound、async task 必须保存引用、推理必须 no_grad,这些规则通过 ruff 自定义规则和 code review checklist 强制执行。第二层是CI 检查:每个 PR 跑 soak test,内存增长超标阻断合并。第三层是生产监控:每个服务的 RSS 趋势曲线接 Prometheus,涨超阈值告警。第四层是定期巡检:每月一次内存"巡逻",看哪些服务有缓慢上涨苗头,提前治理。

层级 手段 覆盖范围 响应时间
编码规范 ruff + code review 新代码 开发期
CI 检查 soak test 合并前 1 小时
生产监控 Prometheus + 告警 所有线上服务 实时
定期巡检 每月分析 历史服务 30 天

这套体系上线半年后,内存类事故从平均每月 2 起降到 0 起,整个团队对 Python 内存的把控能力上了一个台阶。这种"分层防御"的思路其实可以推广到很多工程问题:不指望单一手段万能,而是通过多层独立机制叠加,大幅降低问题溢出到生产的概率。

把 tracemalloc 用在生产的取舍

有同行问我,生产环境能不能常开 tracemalloc?这是个好问题,我的答案是有条件常开。tracemalloc 的 overhead 主要来自记录调用栈,如果只记录 1 层(tracemalloc.start(1))overhead 不到 5%,代价是定位精度下降;记录 25 层精度很高但 overhead 15-20%。我们的做法是在非主力副本上常开 25 层,主力副本不开,通过流量调度让那个副本接 10% 流量做样本观测。这样既有高精度数据,又不影响整体性能。

# 生产配置: 通过 K8s 环境变量控制
# deployment.yaml
# env:
#   - name: ENABLE_TRACEMALLOC
#     value: "1"  # 只在 canary 副本设
#   - name: TRACEMALLOC_FRAMES
#     value: "25"

@app.on_event('startup')
async def conditional_tracemalloc():
    if os.environ.get('ENABLE_TRACEMALLOC') == '1':
        frames = int(os.environ.get('TRACEMALLOC_FRAMES', '25'))
        tracemalloc.start(frames)
        logger.info(f'tracemalloc enabled with {frames} frames')

        # 提供一个 endpoint 拉快照
        @app.get('/__debug__/tracemalloc/top')
        async def get_top(n: int = 30, auth: str = Depends(admin_auth)):
            snap = tracemalloc.take_snapshot()
            stats = snap.statistics('lineno')[:n]
            return [
                {'file': s.traceback[0].filename,
                 'line': s.traceback[0].lineno,
                 'size_mb': s.size / 1024 / 1024,
                 'count': s.count}
                for s in stats
            ]

这种"canary 副本观测"的模式我们也用在其他诊断场景,比如 cProfile、line_profiler 等。专门设一个低流量副本接观测工具,定期拉数据分析,既不影响生产又能持续获取洞察,是个非常实用的工程模式。

跨语言对比的一点感想

这次排查让我对 Python 内存管理的特点有了更深理解。和 Go / Rust 相比,Python 的内存管理对开发者来说更"宽松",引用计数 + GC 让大部分代码不用关心内存,但代价就是容易因为微小的失误产生泄漏。Go 有自带的 pprof,Rust 的所有权模型从编译期就避免大部分泄漏,这些都是 Python 没有的。但 Python 的生态、表达力、开发效率有自己不可替代的优势,关键是工程师要补齐内存管理的短板。

我个人的看法是,每个 Python 工程师都应该把内存治理当成基本功,而不是出问题再学。就像 Java 工程师必懂 JVM 调优、Go 工程师必懂 GC tuning 一样,Python 工程师不懂内存就是不完整。tracemalloc、objgraph、memray 这套工具链花一周时间熟练掌握,能解决你职业生涯里 95% 的内存问题,这个投资回报率非常高。

总结

这次 8 小时排查的核心收获是:Python 内存泄漏的根因常常出在最简单的地方——无 bound 的 dict、忘了清理的调试代码、没保存引用的 task。这些都不是高深技术,而是工程纪律问题。用 tracemalloc 定位分配热点、用 objgraph 看引用链,这套组合拳能解决绝大多数泄漏问题。和团队里其他同学交流后,大家普遍反馈这次排查最值得学习的不是技术细节,而是系统化的排查流程:先确认真泄漏(避免误诊)→ 用 tracemalloc 定位热点 → 用 objgraph 看引用链 → 修复后用 soak test 验证。这套流程可以套用到几乎所有 Python 内存问题,新人按部就班就能解决问题,不用靠"经验"或"灵感"。

更重要的是,这次事故让我们建立起"从修复到预防"的工程文化:不是单纯修了三个 bug 就结束,而是借这个机会推动了缓存规范、CI soak test、ruff 检查等一系列预防机制。半年下来,内存类事故从平均每月 2 起降到 0 起,团队对 Python 内存的理解也大幅提升。这种"借事故推进改革"的做事方式,是工程团队成长的重要路径。希望这篇分享的工具链和方法论,能帮到正在被 Python 内存泄漏困扰的同行,少走我们走过的弯路。最后想说的是,内存治理和性能优化、并发安全、可观测性一样,都属于"基础工程能力",越早投资收益越大,等出事故再补永远来不及。每个 Python 工程师都应该把这套技能纳入自己的技术雷达,在日常开发里主动应用,在关键时刻才能化险为夷。

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

LangGraph 多 Agent 协作:从 3 Agent 互相调死循环到稳态 ReAct 链的 8 周复盘

2026-5-25 17:06:57

技术教程

Node.js Event Loop 阻塞导致 23 分钟雪崩的完整复盘:从 1.4 秒同步 JSON.parse 到 8 条工程纪律

2026-5-25 17:25:17

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