🔎 泄漏判定心法:锯齿 / 爬升 / 平台期

看内存图不看单点数值,看形状——形状只有三种,只有一种是病。这是内存盘所有 RSS 面板的阅读方法,也是 m101(backend RSS)/ b9(biz RSS)/ c7(后台任务哨兵) 这几张图的共同判读口径。

泄漏判定心法:内存曲线三形态——两种是脾气,一种是病 先看形状定性质,再用 deriv 算斜率定量,最后按下钻链找凶手 形态一 · 锯齿 涨涨跌跌来回抖 ✅ 健康:用完就还(常态) 忙时涨、闲时落,围绕某水平上下波动 形态二 · 线性爬升 一条斜线只涨不跌 永不回落 🚨 真泄漏:对象只进不出 去查在途与哨兵面板(按下钻链) 形态三 · 冲高后平台期 冲高后停在高位不回落 😐 allocator 留着复用,不是泄漏 Python 常态:内存留着下次用,重启才还 第二步 · 用 deriv 定量(核心告警口径) deriv(RSS[1h]) = 每小时涨多少字节(斜率) deriv > 5MB/h 且持续 = 真累积(抓锯齿盖不住的爬升) 锯齿的斜率忽正忽负会被 [1h] 窗口抹平;只有真爬升恒为正 平台期 deriv ≈ 0:横着走的线没有斜率,别误报 🚑 事故现场 · 确认真泄漏后的下钻链(主图谱 triage) RSS 形态 m101 → GC gen2 m102 → 后台任务 c7/c8 → asyncio m103 / FD m104 → 泄漏哨兵 m108 沙箱 / m124 MiniMax / m125 导演组 → 大对象在途 m106 / m107 biz 侧同款:b9(RSS 形态)→ b10(GC 速率)→ b3(并发:流量涨了?) 每一环都回答一个问题:谁在涨 → 谁拽着 → 哪次修复被回退了

💡 一句话理解

看内存图和看体检报告一样:不看单点数值,看曲线形状。形状只有三种——锯齿(涨跌交替)是健康,用完就还;线性爬升(只涨不跌)是真泄漏,马上下钻;冲高后平台期(停在高位走平)是 Python 分配器的脾气——内存留着复用不肯还给操作系统,不是病。

两个长得很像的"高位",一个是病、一个是脾气:病是斜着的(每小时还在涨),脾气是横着的( deriv 斜率 ≈ 0)。分不清就用核心告警口径 deriv(RSS[1h]) > 5MB/h 来定量。

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

锯齿 = 图书馆正常借还。程序干活要借内存,干完就还。读者借了还、还了借,图书馆的"在借数"围绕某个水平上下抖——这就是锯齿。看到锯齿你该高兴:借还机制活着,没有书被塞进床底。

线性爬升 = 借书塞床底。有人每借一本书就塞床底,从不还。床底总有塞满的一天——内存被吃爆,最后内核开枪(OOM)。泄漏的本质是"对象已经没用了,但还有引用拽着它,回收机制不敢收":注册表只加不删、任务只创建不结束、全局列表无限 append。判定特征就一条:RSS 线性爬升,永不回落。

平台期 = 餐厅高峰后不辞退服务员。生意高峰雇了 20 个服务员,高峰过了你不会当天全辞退——明天可能还忙,留着排班复用。Python 的内存分配器(pymalloc / glibc malloc)就是这个脾气:用过的内存留着下次用,不着急还给操作系统。所以大流量、大文件处理过后,RSS 常常停在高位走平台——这部分内存会被复用,下次流量来不会再涨,重启才真正清零。它是策略,不是失控。

类比里的东西系统里对应的东西
图书馆借了还、还了借锯齿形 RSS:忙时涨闲时落,用完就还,健康常态
借书塞床底,从不归还线性爬升:对象被引用拽着收不走,只进不出 = 真泄漏
餐厅高峰后不辞退服务员冲高后平台期:allocator 把内存留着复用不还给系统,重启才还
每天固定涨一斤的体重deriv(RSS[1h]) > 5MB/h:斜率持续为正 = 真累积的核心告警口径
先量体温再决定挂哪个科下钻链(主图谱 triage):RSS 形态 → GC gen2 → 后台任务 → 哨兵 → 大对象在途

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

  1. 打开内存盘「进程内存 RSS / VMS」(m101),时间窗调到 6h——每个 worker 一条 RSS 线,先遮住 VMS 只看 RSS。
  2. 给每条线归形态——锯齿?爬升?平台期?把鼠标放在最高点和最低点读差值:6 小时净变化接近 0 是前两种,持续净增是爬升。
  3. 切到核心盘「后台生成任务数」(c7)——大数字应稳态个位数~几十(<20 绿);如果 RSS 在爬、任务数也在爬,两个证据互相指认了。
  4. 怀疑爬升时用 deriv 定量——把 deriv(RSS[1h]) 画出来:恒为正且 > 5MB/h = 真累积,按下钻链去主图谱 #triage 查。

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

RSS = 体温计
物理内存真实占用,进程面板的主口径。泄漏判定永远从它开始,但定罪要看形状而不是数值高低。
# m101 主口径:max_over_time 抹掉抓取抖动
max_over_time(siliang_process_resident_memory_bytes[1m])
锯齿 = 健康
涨跌交替、围绕某个水平波动:借了还、还了借。尖峰吓人但不累积——上一波和这一波的谷底差不多高,就没有库存。
# 判锯齿:比较相邻两个波谷
# min(今天) ≈ min(昨天) → 用完就还,健康
线性爬升 = 真泄漏
一条斜线只涨不跌:对象只进不出。泄漏的本质是"没用了但还有引用拽着,GC 不敢收"。去查在途与哨兵面板。
# 判爬升:波谷都在抬高
# min 这小时 > min 上一小时,一整天如此 → 真累积
平台期 = allocator 脾气
CPython 的 pymalloc 和 glibc 的 malloc 各管一层内存池,还内存给操作系统是有条件的(碎片率、空闲比例),经常"宁可留着不还"。留下的内存会被复用,重启才真正清零——不是泄漏。
# 判平台期:冲高后水平走 + deriv ≈ 0
deriv(siliang_process_resident_memory_bytes[1h])  # ≈ 0 → 横着走
deriv 定量
斜率 = 每小时涨多少字节。这是内存盘的核心告警口径:deriv(RSS[1h]) > 5MB/h 持续 = 真累积。锯齿的忽正忽负会被 1h 窗口抹平,只有真爬升恒为正。
# 核心告警口径(内存盘 chips 原文)
deriv(siliang_process_resident_memory_bytes[1h]) > 5MB/h  # 持续 = 抓真累积
c7 = 后厨订单哨兵
后台生成任务数是历史上真实泄漏过的东西留下的哨兵。RSS 在爬而它在爬 = 两个证据互相指认;它稳态个位数~几十 = 这条嫌疑排除。
# c7 主口径:稳态 <20 绿 / 20-50 黄 / >50 红
sum(siliang_chat_resume_active_generation_tasks{job="siliang_backend_worker"})
biz 同款判定
b9 是 biz 服务的 RSS 形态面板,判读口径与 m101 完全一致:锯齿=健康、平台期=常态、爬升=真累积再配合 b10 GC 互证。
# b9 主口径:按容器拆分,每 worker 一条线
max_over_time(siliang_biz_process_resident_memory_bytes[1m])
下钻链 = 排查 SOP
确认真泄漏后不要乱翻面板,主图谱 #triage 给了固定顺序:形态 → GC → 任务 → 协程/FD → 哨兵 → 大对象在途。每一环回答一个问题。
# 主图谱「backend 内存涨 / 怀疑泄漏」一行:
# m101 → m102 → c7/c8 → m103 → m104 → m108/m124/m125 → m106/m107
哨兵 = 防回退警报器
下钻链最后一站是三个"非零即违规"的哨兵面板:沙箱台账(m108)、MiniMax H3 登记簿(m124)、导演组跟踪窗口(m125)——都是历史泄漏修复后留下的警报器,非零 = 对应修复被回退。
# 巡检固定项:三个哨兵都应恒 0
max_over_time(siliang_sandbox_tracked_threads[5m])      # 沙箱台账
max_over_time(siliang_minimax_h3_tracked_entries[10m])  # MiniMax 登记簿
max_over_time(siliang_director_tracked_messages[5m])    # 导演组台账

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

📊 内存盘 · 进程内存 RSS / VMS(m101)↗——三形态判定法的主战场。✅ 锯齿健康、😐 冲高后平台期 = allocator 不归还(常态)、🚨 线性爬升永不回落 = 真泄漏,马上下钻。

🟢 biz 盘 · 进程内存水位 RSS / VMS(b9)↗——同一套心法用在 biz 上:每台机器一张图、每 worker 一条 RSS 线(1 分钟窗口最大值)。

🔴 核心盘 · 后台生成任务数(c7)↗——内存泄漏核心监控(后厨未出餐订单)。历史教训:这些任务曾只进不出把内存吃爆。RSS 爬升时先看它有没有同涨。

📊 内存盘 · GC 各代待回收对象数(m102)↗——比 RSS 更早的预警器。以及主图谱 🚑 排查速查表 ↗ 的「backend 内存涨 / 怀疑泄漏」一行,就是本课下钻链的完整版。

🏭 生产实战 real world

场景 1 · 巡检三问:什么形状?斜着还是横着?谁在同涨?

每天两分钟,把 m101 的每条线过一遍。先定性(三形态),再定量(deriv),再找同涨的嫌疑人。

# 第一问:形态——每 worker 一条 RSS 线(m101 口径)
max_over_time(siliang_process_resident_memory_bytes{job="siliang_backend_worker"}[1m])
# 第二问:斜率——横着走还是斜着涨
deriv(siliang_process_resident_memory_bytes{job="siliang_backend_worker"}[1h])
# 判读:|deriv| 在 0 附近抖 = 锯齿或平台期(健康/常态)
#       deriv 恒为正且 > 5MB/h = 真累积,进入第三问
# 第三问:同涨嫌疑人——后台任务数(c7 口径)
sum(siliang_chat_resume_active_generation_tasks{job="siliang_backend_worker"})

场景 2 · 把判定心法变成告警

人眼盯不住半夜的斜线,让 deriv 替你值夜班——这是内存盘 5 条核心告警里最重要的一条。

groups:
  - name: siliang-memory-leak
    rules:
      - alert: BackendRSSLinearClimb
        # 线性爬升定量口径:每小时涨超 5MB 且持续
        expr: |
          deriv(siliang_process_resident_memory_bytes{job="siliang_backend_worker"}[1h])
            > 5 * 1024 * 1024
        for: 15m   # 持续窗口为通用示例,线上以内存盘口径卡(m100)为准
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.instance }} RSS 每小时涨超 5MB:线性爬升=真泄漏,按下钻链排查"

场景 3 · 平台期排除法:对时间戳找搬运工

RSS 冲高后停在高位,先别慌。确认斜率归零后,去大对象面板对时间戳——冲高那一刻谁在搬大文件,一对照便知。

# 冲高时刻 t:文件/打包在途有没有活(m106 口径,稳态应归零)
max_over_time(siliang_file_batch_archive_active[1m])
max_over_time(siliang_image_segmentation_active[1m])
# 大对象典型体重(m109 口径):解释冲高幅度合不合理
histogram_quantile(0.95,
  sum(rate(siliang_file_batch_archive_bytes_bucket[5m])) by (le))
# 判读:t 时刻在途 > 0 + 字节分布能对上冲高幅度 → 冲高合理;
#       斜率随后归零 → 平台期,重启才还,不是泄漏,结案

场景 4 · 真泄漏第一枪:后厨订单与协程

判定为爬升后,按下钻链先查最容易"只进不出"的两样:后台任务和 asyncio 协程。

# 后台任务趋势(c8 口径):只涨不跌的斜线 = 泄漏复发
sum(siliang_chat_resume_active_generation_tasks{job="siliang_backend_worker"})
# 拆到 worker 定位:哪个 instance 在堆积
sum by (instance) (siliang_chat_resume_active_generation_tasks)
# 协程便签(m103 口径):创建后忘了 cancel 越贴越多
max by (instance) (max_over_time(siliang_asyncio_tasks_active[1m]))
# 两条线与 RSS 爬升同形状同起点 → 嫌链锁定

场景 5 · biz 侧同款体检

biz 是独立进程,判定心法一字不改:先形态、再斜率、最后找同涨。

# b9:biz RSS 形态(每 worker 一条线)
max_over_time(siliang_biz_process_resident_memory_bytes[1m])
# 斜率定量:真累积的分界线同样是 5MB/h(b0 口径卡建议告警)
deriv(siliang_biz_process_resident_memory_bytes[1h])
# 互证:流量是不是涨了(b3 口径)——先排除业务增长
sum(siliang_biz_http_requests_in_progress) by (method)

场景 6 · 哨兵复核:泄漏查到最后一步是"哪次修复被回退了"

协程、任务、FD 都排除后还找不到嫌疑人?看哨兵面板——历史上修过的泄漏如果被代码回退,会在这里原样复发,非零即违规。

# 哨兵一:沙箱跟踪台账与待清理队列(m108 口径,应恒 0)
max_over_time(siliang_sandbox_tracked_threads[5m])
max_over_time(siliang_sandbox_cleanup_tasks_pending[5m])
# 哨兵二:MiniMax H3 两个登记簿(m124 口径,看是否贴近 1024/4096 上限)
max_over_time(siliang_minimax_h3_tracked_entries[10m]) by (kind)
# 哨兵三:导演组消息跟踪台账(m125 口径,只涨不回落 = N6-4 修复被回退)
max_over_time(siliang_director_tracked_messages[5m]) by (kind)
# 判读:任一哨兵非零 → 按告警规则处理,对照发布记录找回退的提交

⚠️ 常见坑 pitfalls

坑 1 · 只问"300MB 高不高",不看形状 — 症状:拿着一个瞬时数值到处问"这算泄漏吗"。原因:高低是相对的(业务量、机器配置都影响),形状才是判据。正解:先归形态(锯齿/爬升/平台期),再谈数值。
# 错: "RSS 300MB,高不高?"                    # → 单点无判据
# 对: "6h 窗口看形状:锯齿/平台/爬升?"        # → 形态定性质
坑 2 · 把平台期当泄漏,重启上瘾 — 症状:看到内存停在高位就重启,一天三次。原因:allocator 留着内存复用是 Python 常态,横着走的线不是病。正解:deriv ≈ 0 + 下次流量来不再涨 = 平台期,别动它。
# 错: RSS 高位 → 立刻重启                     # → 把脾气当病治
# 对: deriv(RSS[1h]) ≈ 0 → 平台期,观察即可   # → 横线不是斜线
坑 3 · 把锯齿尖峰当泄漏 — 症状:大文件处理时 RSS 冲到平时两倍,吓一跳。原因:处理 200MB 的视频内存必然先涨 200MB+,忙完会落回去。正解:看波谷:相邻两个波谷差不多高 = 没有库存。
# 错: 尖峰 = 泄漏 → 半夜报警                # → 被正常冲高骗了
# 对: min(今天) ≈ min(昨天) → 锯齿健康       # → 谷底不抬高就没库存
坑 4 · 裸读瞬时值被抓取抖动骗 — 症状:曲线毛刺很多,一会儿像爬升一会儿像锯齿。原因:30s 抓取间隙的采样抖动。正解:max_over_time(x[1m]) 抹平后再判形态(m101/b9 面板口径已自带)。
# 错: siliang_process_resident_memory_bytes           # → 裸值毛刺多
# 对: max_over_time(siliang_process_resident_memory_bytes[1m])  # → 干净形态
坑 5 · 只看总和,单 worker 泄漏被稀释 — 症状:sum 出来的曲线斜率很缓,看起来"还行"。原因:4 个 worker 里 1 个在漏,总和斜率只有真实值的四分之一。正解:按 instance 拆开,一条线一个 worker。
# 错: sum(siliang_process_resident_memory_bytes)              # → 稀释信号
# 对: max_over_time(…bytes[1m]) 不聚合,逐 instance 看       # → 一人一条线
坑 6 · 用太短的窗口算 deriv — 症状:deriv(x[5m]) 忽正忽负剧烈跳动。原因:锯齿周期短于窗口时,斜率跟着锯齿来回摆。正解:判累积用 [1h] 长窗口(口径卡同款)。
# 错: deriv(siliang_process_resident_memory_bytes[5m])   # → 跟着锯齿乱跳
# 对: deriv(siliang_process_resident_memory_bytes[1h])   # → 抹平锯齿见真斜率
坑 7 · 自己写查询忘了 job 过滤 — 症状:数值比面板高一截。原因:共享端口的 server job 会随机命中 worker 造成双计,所有面板已排除它,自己 ad-hoc 查询也要记得过滤。正解:永远带 job="siliang_backend_worker"。
# 错: max_over_time(siliang_process_resident_memory_bytes[1m])                     # → 可能混入 server job
# 对: max_over_time(siliang_process_resident_memory_bytes{job="siliang_backend_worker"}[1m])
坑 8 · RSS 定罪后乱翻面板 — 症状:确认真泄漏后东看一张西看一张,半小时没有进展。原因:没有 SOP。正解:按主图谱 triage 固定顺序下钻:GC → 任务 → 协程/FD → 哨兵 → 大对象在途。
# 错: 89 张图挨个点开看                # → 无 SOP,时间黑洞
# 对: m101 → m102 → c7 → m103/m104 → m108/m124/m125 → m106/m107
坑 9 · 泄漏修完就把哨兵面板当装饰 — 症状:修复上线后从来不看哨兵,直到某天内存又爬升才想起来。原因:忘了哨兵的存在意义是"防回退"——改代码的人走了、重构一下清理逻辑就没了。正解:巡检固定项:沙箱(m108)/ MiniMax H3(m124)/ 导演组(m125)三个哨兵都应恒 0,非零即违规。
# 错: 只看 RSS 曲线,从不看哨兵            # → 回退了也发现不了
# 对: max_over_time(siliang_sandbox_tracked_threads[5m]) 恒 0 才放行

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

Q1 · 内存曲线的三种形态分别是什么?各自说明什么?

参考答案:锯齿(涨跌交替)= 健康,借了还、用完就还;线性爬升(只涨不跌)= 真泄漏,对象只进不出,去查在途与哨兵面板;冲高后平台期(停在高位走平)= allocator 不归还,Python 常态,内存留着复用、重启才还,不是泄漏。三种里只有爬升是病。

Q2 · 平台期和爬升都停在高位,怎么用一条查询区分?

参考答案:看斜率。deriv(RSS[1h]):平台期是横线,deriv ≈ 0;爬升是斜线,deriv 恒为正。持续 > 5MB/h 就是内存盘核心告警口径的"真累积"。一句话:横着的和斜着的,长得像但性质完全不同。

Q3 · 为什么泄漏判定看"斜率 5MB/h"而不是"内存用了多少 G"?

参考答案:绝对值受业务量、机器、历史平台期影响,没有普适标准;斜率衡量的是"变化趋势"——真泄漏的本质是持续累积。锯齿的正常冲高斜率忽正忽负,会被 1h 窗口抹平;只有真爬升能让 deriv 持续为正。趋势不受基数干扰,所以拿趋势定罪。

Q4 · RSS 判定为线性爬升后,前三个该看的面板是什么?为什么?

参考答案:① m102 GC gen2 待回收——比 RSS 更早的预警器,确认"对象赖着"的时长;② c7 后台生成任务数——历史真实泄漏留下的哨兵,看它是否同涨(>20 黄 / >50 红);③ m103 asyncio 任务数 / m104 线程 FD——协程和句柄是常见的"只进不出"载体。这三步对应主图谱 triage 的固定下钻链。

← 上一课:Python GC 分代回收:三区垃圾站 📚 课程目录 下一课:job / instance / label:指标的身份证 →