垃圾不是"扫"出来的,是"没人指"出来的:引用计数当场收走绝大多数对象,分代 GC 只兜底收循环引用。看懂 内存盘 2 张面板(GC 待回收 m102 / GC 回收速率 m126) + biz 盘 1 张(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 上涨更早出现的预警器 |
# Python 视角:离开作用域 / del → 计数归零 → 当场回收 # 这一步不产生任何"待回收",也不会出现在 GC 面板里
a.foo = b; b.bar = a 之后 a、b 都没人用了,但互相指着计数仍是 1——引用计数永远收不走。这是分代 GC 存在的唯一理由:可达性大扫除。 # 循环引用:refcount 收不走,分代 GC 兜底 a.foo = b; b.bar = a # 互指 → 计数永远 >= 1
# 面板里用 generation 标签区分三个区 siliang_python_gc_pending_objects{generation="2"} # gen2 = 老物件区
# m102 主口径:max_over_time 抹掉 30s 抓取间隙的抖动 max_over_time(siliang_python_gc_pending_objects{generation="2"}[1m])
# m126 主口径:垃圾车每秒出车几次 sum by (generation) (rate(siliang_python_gc_collections_total[5m]))
# 判罪模板:m102 爬升 + m126 停滞 → 下钻大对象在途与哨兵 # m102: max_over_time(…pending_objects{generation="2"}[1m]) 斜着涨 # m126: sum by (generation)(rate(…collections_total[5m])) gen2 贴 0
# b10:biz 盘 GC 各代回收速率 sum by (generation) (rate(siliang_biz_python_gc_collections_total[5m]))
max_over_time(…[1m]) 的原因。 # 抓取间隙的尖刺 vs 真趋势 max_over_time(siliang_python_gc_pending_objects[1m]) # 一分钟内最大值
📊 内存盘 · 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 告诉你贼进到哪一步了。
GC 预警器的价值在"早"。巡检时不要逐个 worker 盯,直接把 gen2 的存量趋势拉出来看形状。
# gen2 待回收 30 分钟趋势(每个 worker 一条线) max by (instance) ( max_over_time(siliang_python_gc_pending_objects{generation="2"}[30m]) ) # 判读:各线围绕自身水平小幅波动 = 健康; # 某条线单调向上 = 该 worker 有大对象驻留的早期信号
看到哪条线斜着涨,记下它的 instance——后面所有排查都围绕这个 worker 展开。
待回收爬升只是"嫌疑",还要看出车频率——档案室要是根本没人去扫,垃圾堆着不奇怪;扫得很勤还堆着,才是真有人赖着不走。
# GC 回收速率按代拆开(m126 口径) sum by (generation) (rate(siliang_python_gc_collections_total[5m])) # 健康形态:gen0 出车最勤(线最高)、gen1 次之、gen2 偶尔出车 # 危险形态:gen2 速率长期 >= 0 但贴近 0(停滞) # 同时场景 1 里 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 待回收持续爬升:大对象驻留早期信号"
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 # 重合的那台,就是凶手藏身的房间
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 停滞 → 按内存盘同一条下钻链排查
# 错: 告警 siliang_python_gc_pending_objects{generation="0"} > 0 # → gen0 永远有垃圾,天天误报 # 对: max_over_time(siliang_python_gc_pending_objects{generation="2"}[1m]) # → 只看老物件区趋势
# 错: pending{generation="2"} 爬升 = 泄漏 # → 一条证据就定罪 # 对: pending 爬升 + rate(collections_total) gen2 贴 0 # → 两条证据合在一起才定罪
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]) # → 抹抖动读趋势
# 错: "gen2 是 3000,太高了,重启!" # → 单点定罪 # 对: 看 6h 窗口形状:走平=健康,斜涨=排查 # → 形态才是判据
# 错: sum(siliang_python_gc_pending_objects) # → 三代混一起 # 对: sum by (generation) (siliang_python_gc_pending_objects) # → 分区看,盯 gen2
# 错: kubectl/重启 → 恢复 → 结案 # → 毁灭现场,两周后复发 # 对: 记录 instance + 时间窗 → 在途/哨兵下钻 → 修复后复盘 # → 抓凶手
# 错: 只看 siliang_python_gc_collections_total # → 只盯 backend # 对: 加看 siliang_biz_python_gc_collections_total # → biz 同款互证
# 错: siliang_python_gc_pending_objects{generation="2"} # → 裸读瞬时值,尖刺刺眼 # 对: max_over_time(siliang_python_gc_pending_objects{generation="2"}[1m]) # → 干净趋势线
参考答案:引用计数有个死角——循环引用。两个对象互相指着(a.foo=b; b.bar=a),哪怕外面没人要它们了,各自计数仍是 1,永远收不走。分代 GC 定期从"根"出发做可达性大扫除:能摸到的算活的,摸不到的一律收走——正好补上这个死角。
参考答案:gen2 存量持续爬升说明"存活对象总量"在涨——有一批大对象被引用拽着不走。因为对象还活着的时候 RSS 才开始涨,所以 gen2 信号比 RSS 上涨出现得更早。它是预警器(贼还没进门),不是事后验尸(家已经被搬空)。
参考答案:不能。它只是嫌疑:也可能只是恰好存活了一批长命对象。实锤需要两条证据合在一起——gen2 待回收爬升 + gen2 回收速率长期停滞(m102 与 m126 两图互证),然后再去大对象在途、泄漏哨兵、RSS 斜率面板找"谁拽着它"。
参考答案:不要。gen0 是新生区,扫描最勤、周转最快,待回收上下锯齿波动恰恰说明回收机制在正常干活。要盯的是 gen2:正常应该走平,持续爬升才需要排查。