Python进程内存持续上涨的排查方法(tracemalloc定位分配点、objgraph看对象引用与RSS口径差异)

容器里跑的Python服务,RSS从启动时的180M一路涨到1.9G,几天后被OOMKiller干掉,退出码137。重启之后又开始同样的曲线。这种事排查起来容易走弯路,因为看到的数字和真正泄漏的地方往往对不上。

之前接过一个同样症状的工单,tracemalloc报出来的Python对象总共才600M,可容器在2G就被杀了。两边都没错,只是口径不同:tracemalloc统计的是Python层申请过的字节,容器看的是进程实际占的页。中间那部分来自解释器自身、C扩展的malloc、线程栈和分配器的碎片。

先把三个口径分清楚

RSS是当前驻留在物理内存里的页,包含共享库那部分,top和ps显示的就是它。PSS把共享页按进程数摊分,多进程部署时用它累加才接近真实占用。USS只算私有页,是杀掉这个进程能立刻收回来的量。三者永远是USS小于等于PSS,PSS小于等于RSS。

$ grep -E '^(Rss|Pss|Private_Clean|Private_Dirty):' /proc/18342/smaps_rollup
Rss:    1960248 kB
Pss:    1421330 kB
Private_Clean:     4096 kB
Private_Dirty:   1387540 kB

smaps_rollup是内核4.14才加的,比逐段解析/proc/PID/smaps快得多,监控脚本里优先用它。另外resource.getrusage取到的ru_maxrss是个高点水位,涨上去就不会回落,它适合回答离限额还有多远,不适合回答改完是否见效。

还有一个跨平台坑:ru_maxrss在Linux上单位是KB,在macOS上是字节。写脚本时按sys.platform区分,否则两边差1024倍,而且错的方向看起来还挺合理。

先排除假泄漏:内存未必真的没了

CPython的小对象走pymalloc,它按arena向系统申请、再切成pool,对象释放后arena不一定归还给操作系统。表现就是RSS上去了不下来,但进程内部其实没泄漏。这种时候sys._debugmallocstats()能打出arena和pool的使用情况,如果空闲块很多而RSS居高不下,多半是碎片或者arena没释放。

$ MALLOC_ARENA_MAX=2 python app.py
$ python -c "import sys; sys._debugmallocstats()"

glibc那一层也有类似行为,多线程程序默认会开很多arena,数量大致跟核数走的。设MALLOC_ARENA_MAX=2能明显压住RSS,代价是多线程分配时的锁竞争变多。这个改动实测过一次,RSS从2.1G降到1.3G,请求耗时没变化,属于划算的买卖。

用tracemalloc定位分配点

tracemalloc从Python 3.4开始进标准库,它记录的是每一块内存的分配位置,可以取两次快照做差。要拿到完整的调用栈,得把它提前开起来并把栈深度设大,只调tracemalloc.start()的话默认只留最近一帧,看不出是谁调用的。

$ PYTHONTRACEMALLOC=25 python app.py
# 或者 python -X tracemalloc=25 app.py
import tracemalloc

tracemalloc.start(25)
snapshot1 = tracemalloc.take_snapshot()

# ... 跑一段业务 ...

snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, 'lineno')[:10]:
    print(stat)

输出里每一行是文件名、行号、当前大小和增量,按增量排序。增长最猛的那一行就是分配点。想看完整调用链就把'lineno'换成'traceback',再把stat.traceback.format()打出来。快照也能用dump()落盘,事后再load()回来分析,省得在生产进程里做重活。

它有个明确的边界:只统计经过CPython分配器的内存。numpy的大数组、PIL的图像缓冲、各种C扩展里直接malloc的那部分,tracemalloc是看不到的。所以如果tracemalloc的top10加起来才几十M而RSS涨了几百M,问题就在C扩展那边,这时候该换memray或者直接看/proc/PID/maps里匿名映射段的增长。

对象没被释放,得看谁在引用它

确认了分配点之后,更常见的情况是对象其实没人用了但就是回收不掉,因为还有引用挂着。objgraph专干这个,show_growth()在前后各调一次就能看出哪类对象在涨,show_backrefs()画出是谁引用着它。

import objgraph

objgraph.show_most_common_types(limit=20)
# ... 跑一段业务 ...
objgraph.show_growth(limit=10)

objs = objgraph.by_type('MyRequest')[:3]
objgraph.show_backrefs(objs, max_depth=8, filename='backrefs.png')

objgraph 3.5.0、3.6.0、3.6.1、3.6.2这几代接口没变,画图依赖graphviz的dot命令,机器上没装的话只会抛错不出图,装完graphviz再跑。看到图里某条链路指向一个全局列表或者模块级字典,那基本就是元凶。

gc那一侧也要看一眼

循环引用里带__del__的对象,在Python 3.4之前是gc收不走的,那之后PEP 442解决了这个问题,现在这类对象也能被回收,只是回收时机被推迟。真遇到对象堆积,可以用gc.get_objects()统计一下各类对象数量,配合gc.get_referrers()反查。

import gc, collections

gc.collect()
cnt = collections.Counter(type(o).__name__ for o in gc.get_objects())
print(cnt.most_common(15))
print('uncollectable:', gc.garbage)

gc.freeze()从Python 3.7起可用,把启动阶段已经存在的对象挪到永久代,之后的分代收集不再扫描它们,长驻服务的GC停顿会明显变短。

反复出现的那几个泄漏点

logging的handler被重复添加是最容易中招的一个。logging.getLogger(name)返回的是同一个单例,setup函数在循环里或者被反复调用的模块里执行,每跑一次就多挂一个handler,日志条数翻倍的同时这些handler还会持有各自的缓冲和格式化器。加之前判断一下if not logger.handlers就够了;用basicConfig的话注意它在root已有handler时是静默无效的,Python 3.8之后要force=True才强制重配。

functools.lru_cache给了maxsize才有界,写成@lru_cache(maxsize=None)就变成了无限增长的字典,key还是每次请求都不同的用户ID或者查询串,那涨起来非常快。同理,模块级的list或dict当缓存用、却没有淘汰逻辑,也是同一类问题。

把异常对象存进长生命周期的结构也是个隐蔽的坑。异常对象上的__traceback__指向栈帧,栈帧又持有局部变量,一圈下来可能把整个请求上下文都吊住了。要留痕就只存类型名、消息和格式化后的字符串,别存原始异常实例。

现场还见过一个默认参数导致的:def record(item, history=[])这种写法,默认列表在函数定义时创建一次,之后每次调用都往同一个列表里追加,既不报错也不释放。改成history=None再在函数内初始化就行。

容器里的限额怎么看

cgroup v2下直接读memory.current和memory.peak,后者能拿到历史最高值,比轮询采样靠谱。被OOMKiller干掉时kubectl describe里的退出码是137,同时Last State会写OOMKilled。这里要把它和Python自己抛MemoryError区分开:前者是撞到了cgroup限额被内核杀,后者是分配时系统已经给不出内存,处置方向完全不同。

回头看开头那个服务:tracemalloc的增量集中在日志格式化那几行,show_growth显示FileHandler和LogRecord一直在涨,最后是setup_logging()被每个worker进程反复调用,handler挂了十几层。改成进程启动时配一次,RSS稳在210M,跑一周没有变化。

文中版本号与接口行为核对于2026年10月,运行环境为Python 3.11、3.12、3.13、3.14。