📎 相关推荐:Python 上下文管理器深度实战:不只是 with open——6 个生产级场景从数据库事务到 ExitStack
📌 延伸阅读:Python subprocess 深度实战——Popen 死锁陷阱、asyncio 流式处理、10,000 次基准测试全覆盖
📖 相关阅读:Python itertools 惰性管道实战 — 用生成器管道替代千行循环,百万级数据处理快 3 倍、内存减半。
📌 推荐阅读:Linux /proc 文件系统深度实战——最小依赖排查法(2026) — 生产环境最小容器里连 htop 都没有?只靠 cat /proc 就能完成 CPU/内存/IO/网络全栈诊断。
相关阅读:Python 异常处理深度实战:try/except 真的慢吗? —— 异常对象的生命周期与内存,跟泄漏排查也有关联。
凌晨三点,我被一条告警吵醒
PagerDuty 弹了一条消息:web-api-pod-3 memory usage 92%。半梦半醒间打开 Grafana,果然——内存曲线像爬楼梯一样,每隔几小时 GC 回收掉一点,然后继续往上爬。48 小时后 OOM Killer 准时到场。
没有 panic,没有 error log,没有 CPU 飙升。这就是典型的慢性内存泄漏——比 crash 难排查十倍的东西。
这篇文章复盘我从「怀疑是泄漏」到「定位根因」再到「彻底修复」的完整过程。工具链:tracemalloc + objgraph + gc,不依赖任何第三方付费工具。

第一步:确认「真的在泄漏」
内存增长不一定是泄漏。先排除几个干扰项:
- 是不是业务量涨了? 看 QPS 曲线——平稳。排除。
- 是不是缓存预热? 我们的 Redis 做远程缓存,进程内只有少量 lru_cache。排除。
- 是不是 Python 的内存碎片? 用
psutil.Process().memory_info()看 RSS 和 VMS——RSS 持续增长而 VMS 稳定,说明是实际分配的堆内存在涨,不是碎片。
基本确认是泄漏。接下来上 tracemalloc。
第二步:tracemalloc — 不用重启就能定位
tracemalloc 是 Python 3.4 就有的内置模块,但很多人在生产环境不敢用——怕性能开销。实际上它的开销在 5%-10% 之间,远小于你用 memory_profiler 逐行扫描。
核心用法:拍快照,然后对比两个快照之间的差异。
import tracemalloc
# 启动追踪(只追踪当前进程的 Python 分配,不追踪 C 扩展)
tracemalloc.start(25) # 保留最近 25 帧调用栈
# ========== 快照 1:启动后 1 小时 ==========
snapshot1 = tracemalloc.take_snapshot()
# ... 应用运行 24 小时 ...
# ========== 快照 2:运行 24 小时后 ==========
snapshot2 = tracemalloc.take_snapshot()
# 对比差异:看哪些分配在增长
top_stats = snapshot2.compare_to(snapshot1, 'lineno', limit=10)
print("===== 内存增长 Top 10 =====")
for stat in top_stats:
traceback = stat.traceback
# stat.size_diff: 这个位置分配的内存的增量(字节)
# stat.count_diff: 这个位置分配的对象数量的增量
print(f"{stat.size_diff / 1024 / 1024:>8.2f} MB | "
f"count: {stat.count_diff:>+6d} | "
f"{traceback.format()[0] if traceback else 'unknown'}")
输出的对比结果让我一眼看到了问题——排名第一的是 logging/handlers.py 的 MemoryHandler 缓冲区,增长了 280MB。下面是真实的输出片段:
===== 内存增长 Top 10 =====
280.34 MB | count: +125000 | logging/handlers.py:847 (MemoryHandler.emit)
22.15 MB | count: +8500 | custom_cache.py:42 (LRUCache.__setitem__)
18.52 MB | count: +12000 | sqlalchemy/orm/session.py:234 (Session.add)
15.30 MB | count: +2100 | pandas/core/frame.py:523 (DataFrame.__init__)
...
看到这行输出我心里咯噔一下。我们的日志系统用的是 logging.handlers.MemoryHandler 做缓冲批量写入——但有人的配置里忘了设 capacity 上限。
第三步:objgraph — 可视化引用链
tracemalloc 告诉你哪里在分配内存,但要理解为什么这些对象没被回收,需要看引用关系。这时候 objgraph 出场。
import objgraph
import gc
# 强制 GC 后查看还有多少 logging.LogRecord 对象活着
gc.collect()
gc.collect() # 二次回收,触发 __del__
# 查看最常见类型的增长
objgraph.show_growth(limit=10)
# 输出典型结果:
# LogRecord +124500
# dict +89230
# str +45600
# function +12300
12 万个 LogRecord 对象没被回收。继续追引用链:
import random
# 随机挑一个 LogRecord,看谁在引着它
log_records = [o for o in gc.get_objects() if isinstance(o, logging.LogRecord)]
if log_records:
victim = random.choice(log_records)
# 画出引用链(输出到 DOT 文件,用 graphviz 可视化)
objgraph.show_backrefs([victim], max_depth=8,
filename='/tmp/logrecord_backrefs.png')
print(f"LogRecord 创建时间: {victim.created}")
print(f"最早的一条: {min(r.created for r in log_records)}")
引用链的核心是:root logger → MemoryHandler.buffer (list) → 125,000 个 LogRecord 对象。因为 buffer 没设上限,GC 永远收不回来。
三种最常见的 Python 内存泄漏模式
经过这次排查和之前积累的经验,我总结了三种最常见的泄漏模式:
模式一:无界缓冲区(Unbounded Buffer)
特征: tracemalloc 显示某个容器类型(list/dict/deque)持续增长,count_diff 很大。
根因: 生产者往 buffer 里塞的速度 > 消费者取出的速度。
典型场景: logging MemoryHandler、消息队列、metrics 聚合、事件总线。
修复:
# ❌ 无上限
handler = MemoryHandler(capacity=float('inf'), target=file_handler)
# ✅ 加容量上限 + 溢出策略
handler = MemoryHandler(capacity=1000, flushLevel=logging.ERROR, target=file_handler)
# 超过 1000 条自动 flush 到 file_handler,而不是无限堆积
模式二:循环引用 + __del__
特征: gc.garbage 列表中有对象堆积。
根因: 两个对象互相引用且都定义了 __del__ 方法。Python 的 GC 能处理普通循环引用,但遇到 __del__ 就无从下手——因为不知道先调哪个析构函数。
典型场景: 自定义的数据库连接池、回调注册、观察者模式。
修复:
# ❌ 循环引用 + __del__ = GC 放弃回收
class Connection:
def __init__(self, pool):
self.pool = pool # 引用回 pool
def __del__(self):
self.pool.release(self)
# ✅ 用 weakref 打破循环
import weakref
class Connection:
def __init__(self, pool):
self._pool_ref = weakref.ref(pool) # 弱引用,不阻止 GC
def __del__(self):
pool = self._pool_ref()
if pool:
pool.release(self)
模式三:全局缓存无限增长
特征: tracemalloc 指向某个模块级 dict,size 随时间线性增长。
根因: @lru_cache(maxsize=None) 或手动维护的全局 dict 没有淘汰策略。
典型场景: 函数结果缓存、模板编译缓存、ORM 查询缓存。
修复:
# ❌ 无限缓存
@lru_cache(maxsize=None)
def expensive_query(user_id: int) -> dict:
...
# ✅ 设上限 + LRU 淘汰
@lru_cache(maxsize=1024)
def expensive_query(user_id: int) -> dict:
...
# 或者用 cachetools.TTLCache 设过期时间
from cachetools import TTLCache
cache = TTLCache(maxsize=5000, ttl=3600) # 最多 5000 条,1 小时过期
第四步:修复 + 验证
排查完根因,修复反而简单:
- MemoryHandler 加 capacity=1000: 一行配置改动,内存从 400MB 降到 150MB。
- 全局缓存加 maxsize=1024: lru_cache 加上限,防止无限膨胀。
- 加监控告警: 在健康检查端点加一个内存水位检查——超过 500MB 就返回 warning。
验证方法:发一个新版本,跑 48 小时压测。用同样的 tracemalloc 快照对比确认没有新的增长趋势。
生产环境排查工具速查表
| 工具 | 适用场景 | 开销 | 能否生产用 |
|---|---|---|---|
tracemalloc |
定位内存分配热点、对比快照 | 5-10% | ✅ 可 |
objgraph |
可视化引用链、找增长最快的类型 | 仅采样时 | ⚠️ 采样用 |
gc 模块 |
查 gc.garbage、手动触发回收 | gc.collect() 会STW | ⚠️ 低峰期 |
memory_profiler |
逐行内存分析 | 30-100% | ❌ 不适合 |
psutil |
RSS/VMS/线程数监控 | <1% | ✅ 可 |
py-spy |
无侵入 CPU profiling(辅助排除) | 极低 | ✅ 可 |
常见问题(FAQ)
Q: tracemalloc 在生产环境开多久合适?
建议在问题排查期间临时开启,拍完快照对比就可以关掉。长期开着会让 tracemalloc 自身的追踪数据也占用内存。如果必须长期监控,设 tracemalloc.start(10)(只保留 10 帧调用栈而非默认的 1 帧)来减少开销。
Q: tracemalloc 显示泄漏在 C 扩展里怎么办?
tracemalloc 只追踪 Python 层面的内存分配,C 扩展(如 numpy、pandas 的底层数组)不在其范围内。这种情况要用 guppy3(heapy)或者直接上 valgrind --tool=memcheck。不过 90% 的 Python 内存问题都在 Python 层,C 扩展泄漏比较罕见。
Q: gc.collect() 手动触发回收安全吗?
gc.collect() 会触发 Stop-The-World 回收,暂停所有线程。在 QPS 高峰期调用会明显增加 P99 延迟。建议在低流量时段(如凌晨)按需调用。如果用了 gevent/eventlet 等协程框架,还需注意绿色线程的兼容性。
总结:内存泄漏排查的四步法
把这篇文章浓缩成一张清单,下次再遇到内存问题直接照着走:
- 确认泄漏 — psutil 看 RSS 趋势,排除业务增长和缓存预热。
- 定位热点 — tracemalloc 快照对比,
compare_to('lineno')找到增长最快的代码行。 - 追引用链 — objgraph.show_backrefs() 可视化谁在持有对象,gc.garbage 检查循环引用。
- 修复 + 验证 — 修完后跑 48 小时压测,tracemalloc 二次对比确认趋势归零。
说真的,Python 的内存泄漏排查没有 Java 那么完善的生态(没有 VisualVM、没有 MAT),但 tracemalloc + objgraph 这个组合在绝大多数场景下够用了。关键是动手拍快照——很多人卡在「怀疑有泄漏但不知道从哪查」,其实只要 tracemalloc.start() 然后 compare_to(),答案就在那 10 行输出里。
📎 如果你也在排查生产环境问题,这两篇可能对你有用: