RSS 只是总账,真正漏起来的是四本隐藏账本:协程便签墙 / 线程手指头 / FD 手环 / 外包车间——再加一辆垃圾车(GC 速率)当实锤证人。覆盖内存盘 🧵 进程资源组 4 张面板(m103 m104 m105 m126)· 仪表盘 siliang-memory。
上一课的 RSS 是"总账",它只告诉你这家店一共占了多少地方,不告诉你哪本账在漏。这家分店(worker 进程)里有四本隐藏账本:协程是服务员脑里的待办便签墙(m103,忘 cancel 越贴越多);线程是店员的手指头、FD 是游乐场手环(m104,手环发完就报 Too many open files);重活外包给车间(m105 进程池);垃圾车(m126 GC 速率)负责把不要的内存运走——它不出车,垃圾就堆成实锤。
这四本账平时都被 RSS 的总和掩盖:RSS 看着正常,FD 可能已经斜着涨了半小时。所以内存排查的规矩是:总账异常来这四张图找凶手,四张一起看。
先看便签墙。backend 的服务员是个"聪明服务员"(asyncio):给 A 桌点完单,趁等上菜的空档立刻去服务 B 桌——每接一单就往墙上贴一张便签(创建协程),菜上齐、客人买单就撕掉(await 完成或 cancel)。正常营业时墙上几十张便签起起落落。有一天出了 bug:有一类单子贴上去之后没人去撕(创建了协程却忘了 await/cancel)——便签越贴越多,墙贴满,服务员脑子(事件循环内存)被撑爆。这就是协程泄漏,m103 上是一条只涨不落的斜线。
再看手指头和手环。线程是店员的手指头:抓着活干活(Python 有 GIL,同一时刻只有一根手指能执行 Python 代码,所以 CPU 活线程多也没用)。FD 是游乐场手环:每打开一个文件、一条网络连接、一个管道,场馆就发一个手环;场馆手环总数有限(ulimit),用完必须 close 还牌。哪天有代码只领牌不还牌,手环迟早发光——届时新连接建不了,报出经典的 Too many open files,表现为莫名其妙的一批量 5xx。m104 就是数"现在手上有几根手指、几个手环"。
最后是车间和垃圾车。老板(事件循环)有条铁律:绝不亲自干重活——大文件压缩、图像处理全外包给几个专职工人(compute/batch 子进程池),不然全场客人干等。m105 数的是"车间里正在干几个活"。而垃圾车(GC)负责把没人要的对象运走还库:gen0 新人区一天扫八遍、gen2 档案室一年开一次门。m126 数"每秒出车几次"——gen2 长期不出车,加上 gen2 待回收爬升(上一课 m102),两条证据一合,大对象驻留就是实锤。
| 类比里的东西 | 系统里对应的东西 |
|---|---|
| 待办便签墙 | m103 asyncio 任务数:每协程一张便签;忘 await/cancel = 只贴不撕,只涨不落就是协程泄漏 |
| 店员的手指头 | m104 线程数:抓着活干;Python 的 GIL 让同一时刻只有一根"手指"在跑 Python 代码 |
| 游乐场手环 | m104 打开 FD:文件/连接/管道各发一个,总数有限;发光 = Too many open files |
| 外包车间 | m105 进程池在途任务:compute / batch 两个池,CPU 重活外包,前台(事件循环)不亲自干 |
| 垃圾车出车频率 | m126 GC 回收速率按代拆:gen0 最忙、gen2 应最闲——停滞 + 待回收爬升 = 大对象驻留实锤 |
| 便签墙对总账 | 协程泄漏要对照核心盘后台任务数(c7)与聊天监听数,判断"多出来的便签是谁贴的" |
Too many open files 事故。# m103 口径:每 worker 一条线,看 6h 形态 max_over_time(siliang_asyncio_tasks_active[1m])
# 所以外包车间(m105)的存在感 = 前台是否轻快 max_over_time(siliang_process_pool_active[1m]) by (pool)
# m104 双线之一:线程数(手指头) max_over_time(siliang_process_threads[1m])
Too many open files——新连接建不了,批量 5xx。# m104 双线之二:打开 FD(手环),斜着涨 = 只开不关 max_over_time(siliang_process_open_file_descriptors[1m])
# m105 口径:按 pool 拆(compute / batch) max_over_time(siliang_process_pool_active[1m]) by (pool)
# m126 口径(照抄主图谱) sum by (generation) (rate(siliang_python_gc_collections_total[5m]))
# 统一口径:Gauge 面板 = max_over_time(…[1m])
m103 / m104 / m105 全一样,m126 例外用 rate# 找出最忙的那个分店 topk(3, siliang_asyncio_tasks_active) # 前三名便签墙
它是什么:事件循环里的"待办便签"数量,每 worker 一条线。协程也会泄漏——创建后忘了 await/cancel,便签越贴越多:每个泄掉的协程都拽着自己引用的上下文内存不放,RSS 跟着被抬起来。
回答什么问题:协程有没有只创建不回收?内存爬升的凶手是不是"贴不完的便签"?
✅ 围绕业务量上下波动(锯齿):接单贴便签、干完撕便签,进出平衡
🚨 只涨不落 = 协程泄漏。动作:对照核心盘后台任务数(c7)与聊天监听数判断来源,再去代码里找忘了 await/cancel 的 create_task
# 面板 PromQL(照抄主图谱口径,每 worker 一条线) max_over_time(siliang_asyncio_tasks_active[1m])
联动:主图谱 m103 ↗ · 相关课程:后台生成任务(c7 对照来源) · Python GC 分代回收
它是什么:进程的"手指头"(线程)和"拿着的文件句柄"(FD)两本账。子进程、网络连接、临时文件都要占 FD;手环总数有限(ulimit),这是资源的硬天花板。
回答什么问题:有没有什么东西在"只开不关"?——它是子进程 / 连接 / 临时文件泄漏最灵敏的旁证,比 RSS 反应更早。
✅ 两条线长期平稳或小幅波动;线程数与常驻连接数成稳定比例
🚨 持续上涨不回落 = 泄漏旁证;涨到上限直接报 Too many open files——新连接建不了,表现为莫名的一批量 5xx。动作:按 instance 定位分店,查未 close 的连接/文件
# 面板 PromQL(照抄主图谱口径:线程 / FD 两条线) max_over_time(siliang_process_threads[1m]) max_over_time(siliang_process_open_file_descriptors[1m])
联动:主图谱 m104 ↗ · 相关课程:HTTP 健康(FD 耗尽 = 批量 5xx) · 连接池:图书馆借书
它是什么:重活"外包车间"(compute / batch 两个子进程池)当前正在干的活数。它是 CPU 密集型工作不阻塞主事件循环的关键——老板绝不当着客人的面杀鱼。
回答什么问题:外包车间忙不忙?活是不是堆在车间里没人收(CPU 排队)?
✅ 忙时高、闲时落,与业务节奏同步
🚨 持续高位不落 = 活堆在车间 = CPU 排队。注意:这不是内存泄漏——先分流(对应容器 CPU 挤占/接口变慢),别抓错凶手
# 面板 PromQL(照抄主图谱口径,按车间拆) max_over_time(siliang_process_pool_active[1m]) by (pool)
联动:主图谱 m105 ↗ · 相关课程:RSS / VMS / working_set · 容器层:盒子视角(CPU 挤占)
它是什么:垃圾车每秒出车几次,按代(generation)拆开的速率图——Counter 配 rate。gen0/1 应该频繁出车(新人区天天扫),gen2 应该最闲(档案室一年一开)。
回答什么问题:垃圾车还在正常收垃圾吗?——它是"大对象驻留"的实锤证人。
✅ gen0 出车最勤、gen1 次之、gen2 低频但偶有出车
🚨 gen2 长期不出车 + gen2 待回收爬升(上一组 m102)= 大对象驻留实锤,两张图互证。动作:转入"大对象在途"面板找谁攥着不放
# 面板 PromQL(照抄主图谱口径) sum by (generation) (rate(siliang_python_gc_collections_total[5m]))
联动:主图谱 m126 ↗ · 相关课程:Python GC 分代回收 · 内存全局水位(m102 互证)
半夜一批莫名 5xx、日志只有一句手环发完——按这个顺序回放监控。
# 第 1 步:FD 是不是在事发前就斜着涨(m104 口径) max_over_time(siliang_process_open_file_descriptors{job="siliang_backend_worker"}[1m]) # 第 2 步:按 instance 定位是哪家分店发的疯 max_over_time(siliang_process_open_file_descriptors[1m]) by (instance) # 第 3 步:确认报错时间点与 FD 顶格时刻对齐(旁证闭环) sum(rate(siliang_http_requests_total{status=~"5.."}[5m])) by (handler) # 判读:FD 爬升 → 顶格 → 5xx 爆发 = 只开不关的连接/文件;修 close,不是调 ulimit
m103 斜着涨,先别翻代码——先对照几张"贴便签的业务"定位是谁贴的。
# 第 1 步:确认形态(m103 口径) max_over_time(siliang_asyncio_tasks_active{job="siliang_backend_worker"}[1m]) # 第 2 步:对照后台生成任务数(c7 口径)——同步涨 = 生成任务在贴 sum(siliang_chat_resume_active_generation_tasks{job="siliang_backend_worker"}) # 第 3 步:对照聊天 SSE 监听(连接数持续涨不回落 = 监听泄漏) sum(siliang_chat_resume_active_listeners) # 判读:谁跟 m103 同步爬升,谁就是贴便签的人;代码侧找忘 cancel 的 create_task
单独一条证据不算数,两条合在一起才是实锤。
# 证据 A:gen2 回收速率停滞(m126 口径) sum by (generation) (rate(siliang_python_gc_collections_total[5m])) # 证据 B:gen2 待回收持续爬升(m102 口径) max_over_time(siliang_python_gc_pending_objects{generation="2"}[1m]) # 收尾:RSS 是否已被抬起来(m101 口径) max_over_time(siliang_process_resident_memory_bytes[1m]) # 判读:A 停滞 + B 爬升 = 实锤;RSS 尚平 = 早期窗口,立即查在途与哨兵
接口变慢 + 在途任务高——先分清是"车间排队"还是"内存泄漏",处置完全不同。
# 车间在途(m105 口径,按池拆) max_over_time(siliang_process_pool_active[1m]) by (pool) # 对照 RSS:车间排队时 RSS 平稳 = CPU 问题,不是内存问题 deriv(siliang_process_resident_memory_bytes[1h]) # 判读:pool_active 高 + deriv≈0 → 查 CPU/并发;pool_active 平 + deriv>5MB/h → 回内存线
把四本账钉在一张看板里,谁斜谁出局。
# 便签墙(协程) max_over_time(siliang_asyncio_tasks_active[1m]) # 手指头 + 手环(线程 / FD) max_over_time(siliang_process_threads[1m]) max_over_time(siliang_process_open_file_descriptors[1m]) # 车间(进程池) max_over_time(siliang_process_pool_active[1m]) by (pool) # 垃圾车(GC) sum by (generation) (rate(siliang_python_gc_collections_total[5m])) # 巡检标准:四条都"锯齿或平稳" = 过关;任一条只涨不落 = 当天跟进
# 错: tasks_active 高 → 结论"流量大了" # 对: 看 6h 形态:只涨不落 = 泄漏,与流量无关
# 错: FD < ulimit 就不管 # 对: deriv(open_file_descriptors[1h]) > 0 且持续 → 排查
# 错: 加线程提速 CPU 密集任务 # 对: max_over_time(siliang_process_pool_active[1m]) by (pool)
# 错: gen2 rate ≈ 0 → 泄漏实锤 # 对: gen2 rate≈0 + gen2 pending 爬升 = 实锤
# 错: pool_active 高 → 查内存泄漏 # 对: pool_active 高 + RSS 平 → 查 CPU 节流/慢查询
# 错: sum(siliang_asyncio_tasks_active) # 对: max_over_time(siliang_asyncio_tasks_active[1m]) by (instance)
# 错: rate(siliang_asyncio_tasks_active[5m]) # 对: deriv(siliang_asyncio_tasks_active[1h])
# 错: 只看 m101 定健康 # 对: m101 + m103/m104/m105/m126 四本账合审
参考答案:协程泄漏是 m103 的便签墙只涨不落——协程创建后忘了 await/cancel,每个泄掉的协程还拽着上下文内存。线程/FD 泄漏是 m104 里 FD 那条线斜着涨不回落——有东西只开不关(连接/临时文件/子进程),涨到 ulimit 上限就报 Too many open files,新连接建不了,表现为一批量 5xx。
参考答案:Python 的 GIL 让同一时刻只有一个线程能执行 Python 字节码,CPU 密集活在线程里并行不起来;只有独立子进程才能用满多核。代价是进程间传数据要序列化,所以大进大出的数据要掂量。这也解释了事件循环铁律:前台(事件循环)绝不亲自干重活,重活一律丢进程池(m105 看它在途几个活)。
参考答案:因为单独一条都有无辜解释:gen2 出车少是它的正常分工(档案室一年开一次门,闲过头单看不算病);而待回收数也可能只是正常波动。只有"垃圾车长期不去 gen2 收"加上"gen2 存量还在持续爬升"同时成立,才能断定有一批大对象赖在老年代、GC 收不走——这就是大对象驻留,下一步去"大对象在途"面板找谁攥着不放。
参考答案:第一步确认形态(拉 6h,排除正常锯齿);第二步对照核心盘后台任务数 c7 和聊天 SSE 监听数——谁与 m103 同步爬升,谁就是贴便签的业务(来源定位);第三步进代码找忘了 await/cancel 的 create_task(fire-and-forget)。顺手用 deriv 量化每小时涨几个任务,给修复验收定基线。