采集服务内存越跑越大:一次 tracemalloc 排查实录
服务跑了三天,容器 RSS 从 300MB 涨到 2.4GB,最后被 OOM Killer 干掉。运维的标准动作是「重启一下就好了」——重启确实能撑三天,但根因没人知道。这种「重启续命」的习惯,迟早会在某次流量高峰翻车。这篇记录一次完整的排查过程:怎么确认是真泄漏、怎么用工具定位、真凶长什么样。
第一步:先区分「该涨」和「不该涨」
内存上涨本身不一定是 bug:缓存预热、连接池扩展都会让 RSS 涨到一个稳定平台。要分辨,看三样东西:
- 曲线形状:预热后走平 = 正常;阶梯式上涨 = 每次重启后涨得更快;线性上涨 = 每批请求都在漏。
- 对象数量:按类型统计的对象数——如果对象数也线性涨,基本实锤泄漏。
- 回收行为:手动 gc.collect() 后对象数不降,才是真正的泄漏。
这次是典型的线性上涨:对象数跟着请求数涨,gc.collect() 之后依然不降。
第二步:上 tracemalloc 做快照差分
单看总量没用,要知道「谁在涨」。tracemalloc 的快照对比能按调用栈行号给出增量:
import time
import tracemalloc
tracemalloc.start(25) # 25 = 保留调用栈帧数,关键
snap_before = tracemalloc.take_snapshot()
time.sleep(600) # 让服务跑 10 分钟,积累增量
snap_after = tracemalloc.take_snapshot()
for stat in snap_after.compare_to(snap_before, "lineno")[:10]:
print(stat)
输出形如:
/opt/collector/cache.py:42: size=1.4 GiB (+1.4 GiB), count=823100 (+823100)
一眼就能看出:cache.py 第 42 行涨了 823100 个对象。如果没开 start(25),你只能看到「某处涨了」,看不到调用栈。
第三步:本次的真凶
cache.py 第 42 行做的事,是我一个月前加的「响应缓存」:
from functools import lru_cache
@lru_cache(maxsize=None) # 问题在这一行
def fetch_search(q: str) -> dict:
return api_client.search(q)
动机很合理:同一个查询一天内会重复调,缓存能省钱。但 maxsize=None 意味着永不淘汰——每个不同的查询(包括带时间戳、带随机 seed 的查询)都把完整响应体(几百 KB 的 JSON)永久缓存在进程里。跑了三天,缓存占掉两个 GB。
同类问题我排查时还见过几个变体:
- 全局 seen 集合只 add 不清理,用来去重——数据量大了之后集合本身成了泄漏源
- 注册了回调或定时器却从不注销,闭包持有整个请求上下文
- 每次请求新建 Session 且从不关闭,连接对象堆积
第四步:修复
三处改动:
from cachetools import TTLCache
_cache = TTLCache(maxsize=5000, ttl=3600) # 有界 + 过期
def fetch_search(q: str) -> dict:
if q in _cache:
return _cache[q]
resp = api_client.search(q)
_cache[q] = resp
return resp
- 有界(maxsize)加过期(ttl)——两个都是必需品,缺一个都会复发
- 大对象不进缓存:完整响应体只留最近需要的一小段,长期留存的换成 id 列表
- 加内存指标上报,RSS 超过阈值告警——下次泄漏能在 OOM 前被发现
第五步:验证,而不是「感觉好了」
内存问题的验证单位是天,不是分钟。修复后跑满三天,RSS 稳定在 350MB 附近波动;同时把「长跑压测」加进发布清单,用同样的查询模式跑 6 小时做回归。
这次踩出来的坑
坑 1:用重启掩盖泄漏。重启只是重置计时器,问题还在,而且下次爆发可能就在高峰期。
坑 2:只看 RSS 就下结论。容器 RSS 包含预分配和缓冲,先看对象数和回收行为再定性。
坑 3:tracemalloc 没开调用栈。不传帧数参数,快照差分只有文件名,定位不到行。
坑 4:修复后不跑长测。十分钟内看不出来的问题,必须用小时级、天级的验证。
坑 5:缓存无淘汰策略。这是最常见、也最容易被「优化」名义引入的泄漏——加缓存的时候,淘汰策略不是可选项。
小结
一条可复用的排查线:曲线定性(该涨还是不该涨)→ tracemalloc 快照差分(谁在涨)→ 读代码找持有链(为什么涨)→ 有界缓存加长测验证(不再涨)。内存泄漏很少是玄学,大多数时候只是某处「只进不出」的数据结构——找到它,比重启一百次都有用。
请求日志里加上响应大小、耗时字段,能帮你在泄漏早期就发现异常增长,字段说明见 SerpBase 官方文档。你最近一次查内存泄漏花多久?评论区聊聊。

浙公网安备 33010602011771号