<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Objgraph on Zero Day Notes</title>
        <link>https://blog.5772447.xyz/tags/objgraph/</link>
        <description>Recent content in Objgraph on Zero Day Notes</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh</language>
        <lastBuildDate>Mon, 19 Oct 2026 09:00:00 +0800</lastBuildDate><atom:link href="https://blog.5772447.xyz/tags/objgraph/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>图表对象未释放：报表服务内存只涨不降</title>
            <link>https://blog.5772447.xyz/posts/e4d28a85/</link>
            <pubDate>Mon, 19 Oct 2026 09:00:00 +0800</pubDate>
            <guid>https://blog.5772447.xyz/posts/e4d28a85/</guid>
            <description>&lt;h2 id=&#34;问题背景&#34;&gt;&lt;a href=&#34;#%e9%97%ae%e9%a2%98%e8%83%8c%e6%99%af&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;问题背景&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;数据平台有一台报表生成服务器（Linux，8GB 内存），上面跑着一个 Python worker：从消息队列取任务，把业务数据渲染成图表、打包成日报和周报 PDF，每天生成约 3000 张图。服务由 systemd 托管，进程挂了会自动拉起，长期以来存在感很低。&lt;/p&gt;&#xA;&lt;p&gt;两个月前，值班同事开始注意到&amp;quot;报表生成越来越慢&amp;quot;；随后问题升级——进程每三四天就会被内核 OOM killer 杀掉一次，正在处理的任务丢失，当天的报表延迟。运维的临时对策是加了一个每天凌晨定时重启的脚本：只要重启，世界就正常——直到业务量再涨了一截，重启间隔从三四天缩短到两天，这条路也快走到头了。&lt;/p&gt;&#xA;&lt;h2 id=&#34;故障现象&#34;&gt;&lt;a href=&#34;#%e6%95%85%e9%9a%9c%e7%8e%b0%e8%b1%a1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;故障现象&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;进程 RSS 呈标准的锯齿形：启动时几百 MB，每天上涨 1.5~2GB，三四天后触发 OOM killer，进程被杀后由守护拉起，曲线重置。&lt;/li&gt;&#xA;&lt;li&gt;重启后生成速度立刻恢复，但任务一多又开始变慢。&lt;/li&gt;&#xA;&lt;li&gt;机器整体内存并不紧张（其他服务加起来不到 1GB），不是&amp;quot;机器不够用&amp;quot;。&lt;/li&gt;&#xA;&lt;li&gt;图表生成耗时从每张 0.1 秒退化到 0.5 秒以上；Python 的循环垃圾回收越来越频繁，但每次回收几乎不释放内存。&lt;/li&gt;&#xA;&lt;li&gt;业务侧没有任何缓存逻辑，worker 是无状态的——&amp;ldquo;缓存写大了&amp;quot;这个常见解释在这里不成立。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;排查过程&#34;&gt;&lt;a href=&#34;#%e6%8e%92%e6%9f%a5%e8%bf%87%e7%a8%8b&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;排查过程&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;第一步，确认是不是真的泄漏。&lt;/strong&gt; RSS 曲线只涨不降、重启才回落、无状态服务——三个特征把&amp;quot;缓存无界&amp;quot;和&amp;quot;负载上涨&amp;quot;都排除了。期间也有人怀疑内存碎片，或者建议调大 Python 的堆上限；但碎片不会让 RSS 随生成量线性增长，调大堆只是把 OOM 推迟几天，两条路都被曲线本身否掉了。问题指向进程内部有对象被持续持有。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;第二步，做对象层面的取证。&lt;/strong&gt; 用 &lt;code&gt;gc&lt;/code&gt; 和 &lt;code&gt;objgraph&lt;/code&gt; 对进程定期采样：统计对象数量与类型分布。结果非常醒目——&lt;code&gt;Figure&lt;/code&gt;、&lt;code&gt;Axes&lt;/code&gt;、&lt;code&gt;Line2D&lt;/code&gt; 的数量与&amp;quot;已经生成的图片张数&amp;quot;几乎相等，而且只增不减。换句话说：每生成一张图，就有一个完整的图表对象留在内存里。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;第三步，找到&amp;quot;谁在持有它&amp;rdquo;。&lt;/strong&gt; 用 &lt;code&gt;objgraph.show_backrefs&lt;/code&gt; 追其中一个 &lt;code&gt;Figure&lt;/code&gt; 的引用链，终点指向 &lt;code&gt;matplotlib._pylab_helpers.Gcf&lt;/code&gt;——matplotlib 的全局图管理器。机制清楚了：&lt;code&gt;pyplot&lt;/code&gt; 为了支持 &lt;code&gt;plt.show()&lt;/code&gt; 和交互式操作，会把通过 pyplot 接口创建的 Figure 全部注册进全局管理器并保持强引用。只要不显式 &lt;code&gt;plt.close(fig)&lt;/code&gt;，即使代码里的变量已经出了作用域，Figure 连同它持有的大型 numpy 数组、图像缓冲区都不会被回收——Python 的垃圾回收看到&amp;quot;还有引用&amp;quot;，自然无能为力。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;第四步，验证假设。&lt;/strong&gt; 在测试环境写了个最小复现：循环创建 5000 个 Figure 且不关闭，RSS 一路稳定上涨；在循环里补上 &lt;code&gt;plt.close(fig)&lt;/code&gt; 后，RSS 曲线走平。差异非常明确。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;第五步，回溯代码。&lt;/strong&gt; worker 里的图表函数是&amp;quot;每请求创建一个 Figure，渲染后返回 PNG 字节流&amp;quot;，全程没有 close，异常路径更是直接漏掉。这个函数是半年前上线的，上线初期业务量只有现在的三分之一，泄漏速度被低负载掩盖了——缓慢泄漏类问题最典型的时间差就藏在这里：代码没变，量变把它从&amp;quot;可忽略&amp;quot;熬成了&amp;quot;致命&amp;quot;。&lt;/p&gt;&#xA;&lt;h2 id=&#34;解决方案&#34;&gt;&lt;a href=&#34;#%e8%a7%a3%e5%86%b3%e6%96%b9%e6%a1%88&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;解决方案&#xD;&#xA;&lt;/h2&gt;&lt;ol&gt;&#xA;&lt;li&gt;&lt;strong&gt;立即修复&lt;/strong&gt;：在图表函数的 &lt;code&gt;finally&lt;/code&gt; 分支统一执行 &lt;code&gt;plt.close(fig)&lt;/code&gt;，确保正常和异常路径都会释放；灰度发布后 RSS 曲线立即走平，日增长归零。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;规范渲染方式&lt;/strong&gt;：服务端固定使用 Agg 后端（无显示环境必须）；图表渲染收敛到一个统一封装函数，内部负责创建、渲染、关闭，业务代码不再直接碰 pyplot 全局接口。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;撤掉临时措施&lt;/strong&gt;：删除每日凌晨定时重启的脚本——治标的窗户纸正式撕掉。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;补监控&lt;/strong&gt;：RSS、进程内对象数（Top N 类型）纳入监控，设置&amp;quot;内存日增长超过 500MB&amp;quot;告警。&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;h2 id=&#34;根因分析&#34;&gt;&lt;a href=&#34;#%e6%a0%b9%e5%9b%a0%e5%88%86%e6%9e%90&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;根因分析&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;直接根因是&lt;strong&gt;每次生成图表后未关闭 Figure，pyplot 的全局图管理器持续持有强引用，Figure 及其携带的大型数据对象无法被回收&lt;/strong&gt;，RSS 随生成量单调增长，最终触发 OOM killer。&lt;/p&gt;&#xA;&lt;p&gt;深层根因有两点：一是对 Python 垃圾回收的误解——&amp;ldquo;变量出了作用域就会释放&amp;quot;只在没有外部强引用时成立，而框架的全局注册表正是这样一个隐藏的引用源；二是监控只看了&amp;quot;内存使用率&amp;rdquo;，没看&amp;quot;内存增长趋势&amp;quot;，缓慢泄漏在正常的波动噪声里潜伏了两个月，直到以 OOM 的形式暴露。&lt;/p&gt;&#xA;&lt;h2 id=&#34;预防措施&#34;&gt;&lt;a href=&#34;#%e9%a2%84%e9%98%b2%e6%8e%aa%e6%96%bd&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;预防措施&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;资源创建与释放成对设计：create/close、open/close，异常路径同样覆盖（&lt;code&gt;finally&lt;/code&gt; 或上下文管理器）；&lt;/li&gt;&#xA;&lt;li&gt;matplotlib 在服务端固定 Agg 后端，渲染逻辑统一封装，内部负责关闭对象；&lt;/li&gt;&#xA;&lt;li&gt;关键服务监控内存&amp;quot;趋势&amp;quot;而不只是&amp;quot;水位&amp;quot;：RSS 日增长、对象数 Top N，超阈值告警；&lt;/li&gt;&#xA;&lt;li&gt;压测按真实峰值而非均值跑满 24 小时以上，观察内存曲线是否收敛；&lt;/li&gt;&#xA;&lt;li&gt;值班手册收录&amp;quot;锯齿状内存曲线&amp;quot;的排查路径：RSS 曲线 → 对象统计 → 引用链 → 定位未释放点。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;总结&#34;&gt;&lt;a href=&#34;#%e6%80%bb%e7%bb%93&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;总结&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;内存泄漏最怕的不是&amp;quot;漏&amp;quot;，而是&amp;quot;漏得慢&amp;quot;——每天 1.5GB，单看每小时都像正常波动，熬到第三天才会以 OOM 的形式现身；而一旦 OOM，运维的直觉往往是&amp;quot;加内存&amp;quot;或&amp;quot;定时重启&amp;quot;，恰好把根因养得更深。这次的关键转折是把&amp;quot;重启&amp;quot;换成了&amp;quot;看清&amp;quot;：对象计数与引用链一画出来，泄漏点就无处可藏。Python 的自动回收保护不了被框架全局持有的对象——资源管理，最终还是要靠代码和人自己。&lt;/p&gt;&#xA;&lt;h2 id=&#34;延伸阅读&#34;&gt;&lt;a href=&#34;#%e5%bb%b6%e4%bc%b8%e9%98%85%e8%af%bb&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;延伸阅读&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/5255de1b/&#34; &gt;asyncio 接口集体超时：同步调用阻塞事件循环&lt;/a&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/d5be3b26/&#34; &gt;requests 连接池耗尽致间歇性 ConnectionError&lt;/a&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/def9cf20/&#34; &gt;Docker 容器 OOM，服务雪崩是怎么炸开的？&lt;/a&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</description>
        </item></channel>
</rss>
