问题背景
数据平台有一台报表生成服务器(Linux,8GB 内存),上面跑着一个 Python worker:从消息队列取任务,把业务数据渲染成图表、打包成日报和周报 PDF,每天生成约 3000 张图。服务由 systemd 托管,进程挂了会自动拉起,长期以来存在感很低。
两个月前,值班同事开始注意到"报表生成越来越慢";随后问题升级——进程每三四天就会被内核 OOM killer 杀掉一次,正在处理的任务丢失,当天的报表延迟。运维的临时对策是加了一个每天凌晨定时重启的脚本:只要重启,世界就正常——直到业务量再涨了一截,重启间隔从三四天缩短到两天,这条路也快走到头了。
故障现象
- 进程 RSS 呈标准的锯齿形:启动时几百 MB,每天上涨 1.5~2GB,三四天后触发 OOM killer,进程被杀后由守护拉起,曲线重置。
- 重启后生成速度立刻恢复,但任务一多又开始变慢。
- 机器整体内存并不紧张(其他服务加起来不到 1GB),不是"机器不够用"。
- 图表生成耗时从每张 0.1 秒退化到 0.5 秒以上;Python 的循环垃圾回收越来越频繁,但每次回收几乎不释放内存。
- 业务侧没有任何缓存逻辑,worker 是无状态的——“缓存写大了"这个常见解释在这里不成立。
排查过程
第一步,确认是不是真的泄漏。 RSS 曲线只涨不降、重启才回落、无状态服务——三个特征把"缓存无界"和"负载上涨"都排除了。期间也有人怀疑内存碎片,或者建议调大 Python 的堆上限;但碎片不会让 RSS 随生成量线性增长,调大堆只是把 OOM 推迟几天,两条路都被曲线本身否掉了。问题指向进程内部有对象被持续持有。
第二步,做对象层面的取证。 用 gc 和 objgraph 对进程定期采样:统计对象数量与类型分布。结果非常醒目——Figure、Axes、Line2D 的数量与"已经生成的图片张数"几乎相等,而且只增不减。换句话说:每生成一张图,就有一个完整的图表对象留在内存里。
第三步,找到"谁在持有它”。 用 objgraph.show_backrefs 追其中一个 Figure 的引用链,终点指向 matplotlib._pylab_helpers.Gcf——matplotlib 的全局图管理器。机制清楚了:pyplot 为了支持 plt.show() 和交互式操作,会把通过 pyplot 接口创建的 Figure 全部注册进全局管理器并保持强引用。只要不显式 plt.close(fig),即使代码里的变量已经出了作用域,Figure 连同它持有的大型 numpy 数组、图像缓冲区都不会被回收——Python 的垃圾回收看到"还有引用",自然无能为力。
第四步,验证假设。 在测试环境写了个最小复现:循环创建 5000 个 Figure 且不关闭,RSS 一路稳定上涨;在循环里补上 plt.close(fig) 后,RSS 曲线走平。差异非常明确。
第五步,回溯代码。 worker 里的图表函数是"每请求创建一个 Figure,渲染后返回 PNG 字节流",全程没有 close,异常路径更是直接漏掉。这个函数是半年前上线的,上线初期业务量只有现在的三分之一,泄漏速度被低负载掩盖了——缓慢泄漏类问题最典型的时间差就藏在这里:代码没变,量变把它从"可忽略"熬成了"致命"。
解决方案
- 立即修复:在图表函数的
finally分支统一执行plt.close(fig),确保正常和异常路径都会释放;灰度发布后 RSS 曲线立即走平,日增长归零。 - 规范渲染方式:服务端固定使用 Agg 后端(无显示环境必须);图表渲染收敛到一个统一封装函数,内部负责创建、渲染、关闭,业务代码不再直接碰 pyplot 全局接口。
- 撤掉临时措施:删除每日凌晨定时重启的脚本——治标的窗户纸正式撕掉。
- 补监控:RSS、进程内对象数(Top N 类型)纳入监控,设置"内存日增长超过 500MB"告警。
根因分析
直接根因是每次生成图表后未关闭 Figure,pyplot 的全局图管理器持续持有强引用,Figure 及其携带的大型数据对象无法被回收,RSS 随生成量单调增长,最终触发 OOM killer。
深层根因有两点:一是对 Python 垃圾回收的误解——“变量出了作用域就会释放"只在没有外部强引用时成立,而框架的全局注册表正是这样一个隐藏的引用源;二是监控只看了"内存使用率”,没看"内存增长趋势",缓慢泄漏在正常的波动噪声里潜伏了两个月,直到以 OOM 的形式暴露。
预防措施
- 资源创建与释放成对设计:create/close、open/close,异常路径同样覆盖(
finally或上下文管理器); - matplotlib 在服务端固定 Agg 后端,渲染逻辑统一封装,内部负责关闭对象;
- 关键服务监控内存"趋势"而不只是"水位":RSS 日增长、对象数 Top N,超阈值告警;
- 压测按真实峰值而非均值跑满 24 小时以上,观察内存曲线是否收敛;
- 值班手册收录"锯齿状内存曲线"的排查路径:RSS 曲线 → 对象统计 → 引用链 → 定位未释放点。
总结
内存泄漏最怕的不是"漏",而是"漏得慢"——每天 1.5GB,单看每小时都像正常波动,熬到第三天才会以 OOM 的形式现身;而一旦 OOM,运维的直觉往往是"加内存"或"定时重启",恰好把根因养得更深。这次的关键转折是把"重启"换成了"看清":对象计数与引用链一画出来,泄漏点就无处可藏。Python 的自动回收保护不了被框架全局持有的对象——资源管理,最终还是要靠代码和人自己。