🗑️ Python GC 分代回收:三区垃圾站

垃圾不是"扫"出来的,是"没人指"出来的:引用计数当场收走绝大多数对象,分代 GC 只兜底收循环引用。看懂 内存盘 2 张面板(GC 待回收 m102 / GC 回收速率 m126) + biz 盘 1 张(b10),你就提前拿到了内存泄漏的预警器。

循环引用收不走 → 转交 新对象入住 gen0 晋升:熬过一轮扫描 晋升:再熬一轮 出车最勤 出车中等 几乎不出车 Python GC 机制图:引用计数为主,分代回收兜底(三区垃圾站) 经验定律:对象越年轻越早死 → 按工龄分三区,扫最勤的新生区,很少惊动老干部区 引用计数(主力保洁) 对象头部计数归零 → 当场收走 大部分对象活不到 GC 扫描 分代 GC(兜底大扫除) 从根出发做可达性分析 专收引用计数搞不定的循环引用 gen0 新生区 新对象先住这 扫描最勤(婴儿房天天消毒) 待回收:正常波动 gen1 中学区 熬过 gen0 一轮 扫描中等(成人区每周打扫) 待回收:正常波动 gen2 老物件区 熬过两轮的老干部 很少扫(档案室一年一开门) 泄漏信号就看它 ✅ 健康回路:死得快,收得勤 待回收锯齿波动 · 大部分对象走引用计数当场归还 🚨 gen2 赖着不走 待回收爬升 + 回收停滞 🚨 事故现场:大对象驻留实锤(预警器,比 RSS 上涨更早) gen2 待回收持续爬升 + gen2 回收速率长期停滞 = 两条证据合在一起才定罪 此时 RSS 可能还没动——这正是它作为预警器的价值 📌 监控落点 内存盘:GC 各代待回收对象数(m102)× GC 各代回收速率(m126) biz 盘:GC 各代回收速率(b10)同款互证

💡 一句话理解

Python 收垃圾靠两套人马:引用计数是主力保洁——每个对象头上挂个计数器,"有几个人指着我",人数归零当场收走;分代 GC 是兜底大扫除——专收那些"你指着我、我指着你"(循环引用)导致计数永远不归零的死角垃圾。

分代 GC 又把对象按"工龄"分进三个区:gen0 新生区(扫描最勤)、gen1 中学区、gen2 老物件区(很少扫)。因为经验定律说"对象越年轻越早死",把力气花在新生区最划算。监控里我们要盯的,就是老物件区的垃圾存量——它持续爬升,说明有大对象赖着不走,是泄漏的早期预警,比 RSS 上涨出现得更早。

🧩 费曼拆解 讲给完全没接触过的小白

把 Python 进程想象成一家三区垃圾站。新垃圾(新创建的对象)一律先扔进 gen0 新生区——就像婴儿房,保洁员一天消毒好几遍,因为经验证明:绝大多数垃圾在这里就该被清走(一个请求里临时创建的字符串、列表,用完就没人指了,引用计数直接归零,根本活不到大扫除)。

总有少数对象熬过一轮扫描还活着——它被晋升到 gen1 中学区(成人区,每周打扫)。再熬过一轮,晋升到 gen2 老物件区(档案室,一年开一次门)。这么设计省力气:能住进档案室的东西,大概率还要活很久,天天去扫纯属浪费。但副作用也在这:档案室里如果躺了一个大垃圾,可能很久都没人去扫它。

而引用计数有个天生的死角:a.foo = b、b.bar = a,两个对象互相指着,各自计数永远是 1——哪怕外面已经没人要它们了。这就是分代 GC 存在的理由:定期从"根"出发做一次可达性大扫除,摸不到的(哪怕计数不是 0)一律收走。监控里的两张图,一张看各区垃圾存量(待回收对象数),一张看垃圾车出车频率(回收速率)。gen0/1 的存量上下波动是正常的——垃圾总在产生;但 gen2 存量持续爬升 + gen2 垃圾车长期不出车,两条证据合在一起,就是大对象驻留的实锤。

类比里的东西系统里对应的东西
座位计数器(归零即收拾)引用计数:每个对象头部计数归零当场回收,Python 收垃圾的主力,大部分对象根本活不到扫描
婴儿房(天天消毒)gen0 新生区:新对象入住,扫描最勤,待回收数正常波动
档案室(一年一开门)gen2 老物件区:很少扫,躺着一个大垃圾很久都不会被发现——所以泄漏信号要看这里
互相称"同事"的两个失业者循环引用:互相指着计数永不归零,引用计数收不走,只能靠分代 GC 大扫除
垃圾车出车记录GC 回收速率(m126 / b10):每秒出车几次,按代拆开;gen2 停滞 + 待回收爬升互证成实锤
档案室垃圾越堆越多gen2 待回收持续爬升:大对象赖在老年代不走——比 RSS 上涨更早出现的预警器

🛠 动手验证(3~4 步,在 Grafana 里亲手做一遍)

  1. 打开内存盘「GC 各代待回收对象数」(m102),时间窗调到 6h——会看到三条线(generation 0/1/2)。
  2. 观察三条线的性格——gen0/1 上下锯齿波动(正常),gen2 应该基本走平;记下 gen2 当前水平。
  3. 切到「GC 各代回收速率」(m126)——预期 gen0 出车最频繁、gen2 贴地慢速,这是健康的分工。
  4. 在 Explore 里只留 gen2 一条线看一周——走平=健康;斜着涨=立刻去泄漏判定课按下钻链排查。

🧠 必知必会 看懂本课全部面板的地基

引用计数(主力)
每个对象头部有个 refcount,被引用 +1、解除 -1,减到 0 当场释放。所以大部分对象"死得又快又准时",根本轮不到 GC 扫描出手。
# Python 视角:离开作用域 / del → 计数归零 → 当场回收
# 这一步不产生任何"待回收",也不会出现在 GC 面板里
循环引用(死角)
a.foo = b; b.bar = a 之后 a、b 都没人用了,但互相指着计数仍是 1——引用计数永远收不走。这是分代 GC 存在的唯一理由:可达性大扫除。
# 循环引用:refcount 收不走,分代 GC 兜底
a.foo = b; b.bar = a   # 互指 → 计数永远 >= 1
分代 gen0 / gen1 / gen2
对象按"工龄"住三区:新对象进 gen0;熬过 gen0 一轮扫描还活着升 gen1;再熬过升 gen2。扫描频率 gen0 ≫ gen1 ≫ gen2(CPython 常见默认约 700:10:1 的量级)。
# 面板里用 generation 标签区分三个区
siliang_python_gc_pending_objects{generation="2"}   # gen2 = 老物件区
gen2 待回收 = 预警器
gen0/1 待回收波动是正常的(垃圾总在产生);gen2 的存量应该基本稳定。它持续爬升 = 存活对象总量在涨 = 有大对象被引用拽着不走——这比 RSS 上涨出现得更早,因为对象还活着的阶段 RSS 才开始涨。
# m102 主口径:max_over_time 抹掉 30s 抓取间隙的抖动
max_over_time(siliang_python_gc_pending_objects{generation="2"}[1m])
GC 回收速率 = 出车频率
Counter 配 rate:每秒 GC 跑了几轮,按代拆开。gen0 应该最忙,gen2 应该最闲——闲过头也是病:档案室从不打扫,里面的大垃圾永远不会被发现。
# m126 主口径:垃圾车每秒出车几次
sum by (generation) (rate(siliang_python_gc_collections_total[5m]))
两图互证才定罪
单独一条证据都不算实锤:gen2 待回收爬升可能是刚好活了批长命对象;gen2 停滞可能只是没活干。待回收爬升 + 回收速率长期停滞合在一起,才是大对象驻留的基本实锤,再去 RSS/在途面板找凶手。
# 判罪模板:m102 爬升 + m126 停滞 → 下钻大对象在途与哨兵
# m102: max_over_time(…pending_objects{generation="2"}[1m])  斜着涨
# m126: sum by (generation)(rate(…collections_total[5m]))    gen2 贴 0
biz 同款
biz 服务(gunicorn 4 worker)也有同款回收速率面板(b10)。gen2 长期停滞而 gen0/1 活跃 = 有大对象驻留——与 biz RSS 的平台期形态互证。
# b10:biz 盘 GC 各代回收速率
sum by (generation) (rate(siliang_biz_python_gc_collections_total[5m]))
max_over_time 抹抖动
内存盘 30s 刷新抓取,瞬时值有尖刺;窗口 1m 取最大值让曲线干净,看趋势不误判。这是 m102/m101 曲线都带 max_over_time(…[1m]) 的原因。
# 抓取间隙的尖刺 vs 真趋势
max_over_time(siliang_python_gc_pending_objects[1m])  # 一分钟内最大值

🔗 在四两监控里哪里用到 理论落回你的 89 张图

📊 内存盘 · GC 各代待回收对象数(m102)↗——三区垃圾站的"各区存量"实时读数。gen0/1 周转快属正常波动;gen2 待回收持续爬升 = 泄漏早期信号,比 RSS 更早报警。

📊 内存盘 · GC 各代回收速率(m126)↗——垃圾车出车频率(按代拆)。gen0/1 应频繁出车;gen2 长期不出车 + m102 的 gen2 爬升 = 大对象驻留实锤,两张图互证。

🟢 biz 盘 · GC 各代回收速率(b10)↗——biz 服务的同款出车频率。gen2 长期停滞而 gen0/1 活跃 = 有大对象驻留,与 biz RSS 的平台期形态互证。

📊 内存盘 · 进程内存 RSS / VMS(m101)↗——gen2 预警器响过之后,RSS 曲线形态是"第二现场":预警器说你家要进贼了,RSS 告诉你贼进到哪一步了。

🏭 生产实战 real world

场景 1 · 每日巡检:先瞄一眼 gen2 有没有斜着涨

GC 预警器的价值在"早"。巡检时不要逐个 worker 盯,直接把 gen2 的存量趋势拉出来看形状。

# gen2 待回收 30 分钟趋势(每个 worker 一条线)
max by (instance) (
  max_over_time(siliang_python_gc_pending_objects{generation="2"}[30m])
)
# 判读:各线围绕自身水平小幅波动 = 健康;
#       某条线单调向上 = 该 worker 有大对象驻留的早期信号

看到哪条线斜着涨,记下它的 instance——后面所有排查都围绕这个 worker 展开。

场景 2 · 互证:gen2 的垃圾车是不是真的不出车了

待回收爬升只是"嫌疑",还要看出车频率——档案室要是根本没人去扫,垃圾堆着不奇怪;扫得很勤还堆着,才是真有人赖着不走。

# GC 回收速率按代拆开(m126 口径)
sum by (generation) (rate(siliang_python_gc_collections_total[5m]))
# 健康形态:gen0 出车最勤(线最高)、gen1 次之、gen2 偶尔出车
# 危险形态:gen2 速率长期 >= 0 但贴近 0(停滞)
#          同时场景 1 里 gen2 待回收在爬升 → 互证成实锤

场景 3 · 把预警器变成告警:gen2 相对自比

不写死"多少个算超标"(高位平稳不一定是病),让 gen2 跟两小时前的自己比——现在比过去高就是爬升。

groups:
  - name: siliang-gc-gen2-early-warning
    rules:
      - alert: BackendGCGen2PendingClimbing
        # 相对自比:现在 gen2 待回收 > 2 小时前的自己 = 在爬升
        # 不设绝对阈值,避免把"高位平稳"误报成泄漏
        expr: |
          max by (instance) (max_over_time(
            siliang_python_gc_pending_objects{job="siliang_backend_worker", generation="2"}[30m]))
            > max by (instance) (max_over_time(
            siliang_python_gc_pending_objects{job="siliang_backend_worker", generation="2"}[30m] offset 2h))
        for: 15m   # 持续窗口为通用示例,线上以内存盘口径卡(m100)为准
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.instance }} GC gen2 待回收持续爬升:大对象驻留早期信号"

场景 4 · 预警之后:用 RSS 斜率确认烧到第几步

gen2 爬升说明对象赖着,RSS 斜率说明内存真的在涨。核心告警口径 deriv(RSS[1h]) > 5MB/h 是"真累积"的分界线。

# RSS 每小时涨多少字节(每 worker 一条线)
deriv(siliang_process_resident_memory_bytes{job="siliang_backend_worker"}[1h])
# > 5MB(5 * 1024 * 1024 字节)且持续 = 真累积,核心告警口径
# > 0 但 < 5MB/h:锯齿的正常上沿,配合形态三分法判读
# 两条证据链对齐:gen2 爬升的 worker × RSS 斜率为正的 worker
# 重合的那台,就是凶手藏身的房间

场景 5 · biz 侧体检:别只盯着 backend

biz 是独立进程,有自己的 GC 和 RSS。泄漏判定课的思路在这里一字不改地复用。

# biz GC 出车频率(b10 口径)
sum by (generation) (rate(siliang_biz_python_gc_collections_total[5m]))
# gen2 长期停滞而 gen0/1 活跃 = 有大对象驻留
# 与 biz RSS 形态互证(b9):
deriv(siliang_biz_process_resident_memory_bytes[1h])
# 斜率持续为正 + gen2 停滞 → 按内存盘同一条下钻链排查

⚠️ 常见坑 pitfalls

坑 1 · 把 gen0/1 的波动当成泄漏 — 症状:看到 gen0 待回收上蹿下跳就喊泄漏。原因:gen0/1 是"必死区",垃圾总在产生、总被清走,波动恰恰说明回收机制活着。正解:只盯 generation="2" 这条线的趋势。
# 错: 告警 siliang_python_gc_pending_objects{generation="0"} > 0   # → gen0 永远有垃圾,天天误报
# 对: max_over_time(siliang_python_gc_pending_objects{generation="2"}[1m])  # → 只看老物件区趋势
坑 2 · 单证据定罪 — 症状:gen2 待回收涨了一点就宣布"实锤泄漏"。原因:可能只是恰好存活了一批长命对象,回收也还没到周期。正解:必须与 GC 回收速率(gen2 停滞)互证,再下钻 RSS/在途。
# 错: pending{generation="2"} 爬升 = 泄漏        # → 一条证据就定罪
# 对: pending 爬升 + rate(collections_total) gen2 贴 0  # → 两条证据合在一起才定罪
坑 3 · 对待回收数用 rate() — 症状:曲线乱跳毫无意义。原因:pending_objects 是 Gauge(温度计),对温度计求速度没有物理含义。正解:Gauge 看 max_over_time(读数)/ deriv(斜率),rate 只留给 Counter。
# 错: rate(siliang_python_gc_pending_objects[5m])      # → Gauge 求 rate,无意义
# 对: max_over_time(siliang_python_gc_pending_objects[1m])  # → 抹抖动读趋势
坑 4 · 把"高位"当"事故" — 症状:gen2 待回收比别家高就慌。原因:高位平稳可能只是业务天然有长命对象;要警惕的是爬升(斜率)。正解:看趋势形状,不看单点数值。
# 错: "gen2 是 3000,太高了,重启!"             # → 单点定罪
# 对: 看 6h 窗口形状:走平=健康,斜涨=排查     # → 形态才是判据
坑 5 · 忘了 by (generation),三代糊成一条 — 症状:sum 之后曲线只剩 gen0 的锯齿,gen2 的缓慢爬升完全被淹没。原因:gen0 数量级远大于 gen2,求和后 gen2 的信号被稀释。正解:按 generation 拆开看。
# 错: sum(siliang_python_gc_pending_objects)                    # → 三代混一起
# 对: sum by (generation) (siliang_python_gc_pending_objects)   # → 分区看,盯 gen2
坑 6 · gen2 一爬升就重启了事 — 症状:重启后一切正常,两周后复发。原因:重启清掉了现场,凶手没抓到;重启只是止损不是修复。正解:先固定证据(截图/记录 instance 与时间窗),按下钻链找到"谁拽着大对象",修完代码再用面板观察回退。
# 错: kubectl/重启 → 恢复 → 结案              # → 毁灭现场,两周后复发
# 对: 记录 instance + 时间窗 → 在途/哨兵下钻 → 修复后复盘   # → 抓凶手
坑 7 · 只看 backend 忘了 biz — 症状:backend 一直健康,用户却报 biz 接口越来越慢、机器越来越胖。原因:biz 是独立进程,有自己独立的 GC 和内存。正解:biz 盘 b10(GC 速率)+ b9(RSS)同款体检。
# 错: 只看 siliang_python_gc_collections_total        # → 只盯 backend
# 对: 加看 siliang_biz_python_gc_collections_total    # → biz 同款互证
坑 8 · 被抓取抖动骗出"假爬升" — 症状:瞬时值每隔 30s 上蹿下跳,看着像在涨。原因:抓取间隙的采样尖刺 + GC 执行时刻的瞬时堆积。正解:max_over_time(x[1m]) 抹平后再看趋势。
# 错: siliang_python_gc_pending_objects{generation="2"}   # → 裸读瞬时值,尖刺刺眼
# 对: max_over_time(siliang_python_gc_pending_objects{generation="2"}[1m])  # → 干净趋势线

🎓 费曼自测 合上书,能讲出来才算懂

Q1 · Python 已经有引用计数了,为什么还需要分代 GC?

参考答案:引用计数有个死角——循环引用。两个对象互相指着(a.foo=b; b.bar=a),哪怕外面没人要它们了,各自计数仍是 1,永远收不走。分代 GC 定期从"根"出发做可达性大扫除:能摸到的算活的,摸不到的一律收走——正好补上这个死角。

Q2 · 为什么 gen2 待回收爬升被称为"预警器",它比什么预警得更早?

参考答案:gen2 存量持续爬升说明"存活对象总量"在涨——有一批大对象被引用拽着不走。因为对象还活着的时候 RSS 才开始涨,所以 gen2 信号比 RSS 上涨出现得更早。它是预警器(贼还没进门),不是事后验尸(家已经被搬空)。

Q3 · 单看「gen2 待回收爬升」能不能宣布泄漏实锤?还差什么?

参考答案:不能。它只是嫌疑:也可能只是恰好存活了一批长命对象。实锤需要两条证据合在一起——gen2 待回收爬升 + gen2 回收速率长期停滞(m102 与 m126 两图互证),然后再去大对象在途、泄漏哨兵、RSS 斜率面板找"谁拽着它"。

Q4 · gen0 待回收数一直上下波动,要不要半夜起来处理?

参考答案:不要。gen0 是新生区,扫描最勤、周转最快,待回收上下锯齿波动恰恰说明回收机制在正常干活。要盯的是 gen2:正常应该走平,持续爬升才需要排查。

← 上一课:OOM kill:内存超限直接枪毙 📚 课程目录 下一课:泄漏判定心法:锯齿 / 爬升 / 平台期 →