一次真实的排查记录。起因是一个跑在 3.6G 内存小机器上的 Flask 应用,进程内存三天涨到 888M。
结论先放这里,省得你翻到最后:
| 之前 | 之后 | |
|---|---|---|
| Flask 主进程 RSS | 888M(三天) | 68M(稳定) |
| 一次语义检索 | 10.99s | 0.34s |
| 检索结果 | — | 逐位一致,没变 |
两件事其实没什么关系,是排查过程中前后脚撞上的。但它们合起来说明一个道理:动手优化之前先测量,你以为的瓶颈通常不是瓶颈。我在这篇里两次猜错,都写出来了。
一个 Flask 应用,功能是笔记 + 对话,带语义检索(bge 中文小模型做 embedding,BM25 做关键词兜底)。机器 3.6G 内存。
现象是主进程 RSS 一路涨:
重启后 79M
17 分钟后 662M
三天后 888M
看起来像内存泄漏。但"看起来像"不等于是。
如果真是泄漏,那一定有某个代码路径反复执行、每次留下一点。最省事的验证方式是按真实流量比例打请求,看 RSS 和对象数怎么走。
我没用 py-spy、没上 tracemalloc,就用 Flask 自带的 test_client 在独立进程里跑:
import gc, collections, time
def rss_kb():
for line in open('/proc/self/status'):
if line.startswith('VmRSS:'):
return int(line.split()[1])
def obj_profile():
c = collections.Counter()
for o in gc.get_objects():
c[type(o).__name__] += 1
return c
import app as A
client = A.app.test_client()
gc.collect()
base_rss, base_obj = rss_kb(), obj_profile()
# 1) 先测后台线程空转:什么都不请求,只等
time.sleep(45)
gc.collect()
print('空转 45 秒:', (rss_kb() - base_rss) / 1024, 'M')
# 2) 再按真实流量比例逐个端点压
for method, url in ENDPOINTS:
gc.collect(); before = rss_kb()
for _ in range(150):
client.get(url)
gc.collect(); after = rss_kb()
print(f'{url:42} {(after-before)/1024:+.1f}M')
# 3) 最后看对象类型的净增长
gc.collect()
for name, n in (obj_profile() - base_obj).most_common(12):
print(f'{name:28} +{n}')完整脚本在 code/memprobe.py。
结果:
空转 45 秒后 +0.3M
/api/notes_by_date +0.3M
/api/profile -0.3M
/api/feed +0.8M
/api/tags/list +0.0M
/api/messages +2.0M
/api/shelf +0.0M
...
1200 个请求总计 +3M
对象数增长 top:
function +23
ReferenceType +19
builtin_function_or_method +19
SplitResult +18
list +4
读接口是干净的。 1200 个请求只涨 3M,对象增长几十个而且全是模块导入的正常副产物(function、ModuleSpec、SourceFileLoader 这些是首次走到某分支时 import 带进来的,一次性)。后台线程空转也干净。
那 888M 从哪来的?
既然不是反复累积,那就是某个东西一次性吃掉一大块然后赖着不走。分层测最直接:
def rss_mb():
for line in open('/proc/self/status'):
if line.startswith('VmRSS:'):
return int(line.split()[1]) / 1024
print(f'空进程 {rss_mb():.0f}M')
import vector_search
print(f'import 检索模块 {rss_mb():.0f}M')
vector_search.search('随便查点什么', limit=3)
print(f'首次 search 之后 {rss_mb():.0f}M')输出:
空进程 11M
import 检索模块 42M
首次 search 之后 571M ← 一次 +529M
再往下拆一层,import torch 这一句就吃 163M:
import vector_search 42M
import torch 205M
真相:检索模块里的模型是 lazy load 的,
_model = None
def get_model():
global _model
if _model is None:
from sentence_transformers import SentenceTransformer
_model = SentenceTransformer(MODEL_NAME)
return _model第一次有人调 search(),torch 运行时 + 模型权重 + 分词器词典整套被拉进 Flask 主进程,然后就永远待在那儿了。
这不是 bug,_model 缓存住是对的,重复加载才有问题。问题在于它待错了地方:一个要频繁重启、要保持轻快的 Web 进程里。
顺带解释了那个"三天 888M"的曲线:571M 是一次性的台阶,之后每次检索还留 0.4M 左右(torch 的 CPU 分配器和 glibc 堆碎片,会趋于平台期,不是无限涨)。50 次检索之后 gc 干净了是 593M。台阶 + 缓慢累积,三天正好凑到 888M。
教训一:RSS 曲线像泄漏,不代表是泄漏。先分层测一次性成本,再谈累积。
摆在面前两条路:
A. 换 ONNX Runtime,把 torch 那 163M 省掉。sentence-transformers 支持 onnx 后端,推理还更快。
B. 把检索拆成独立进程,主进程通过 HTTP 调。
直觉上 A 更"根治",B 只是把 530M 从这个进程挪到那个进程,总量没变。
我选了 B。理由:
A 动的是 embedding 计算本身。 数据库里已经存着一批用 torch 算出来的向量。换成 ONNX 之后新算的向量哪怕只有极小偏差,新旧向量就不在同一个空间里了,检索质量会悄悄变差。这种问题不报错,只是结果慢慢变得不相关,等你察觉可能已经过去很久。要保险就得全库重新 embed。
A 省得也没听起来那么多。 torch 走了,onnxruntime 自己要占几十 M,模型权重照样得加载。净省估计 150 到 200M,不是 529M。
B 的风险是零。 计算逻辑一行不改,向量一个比特不变。而且它能给一个 A 给不了的东西:独立进程可以在空闲时整个退掉,那才是真省内存,而不是搬家。
两者不冲突。先做 B,将来想再压,在检索服务内部换 ONNX。那时候即使向量出问题,影响面也只在那一个服务里。
教训二:选方案要看风险面,不要只看收益数字。能回滚的小收益胜过不能回滚的大收益。
一个几十行的 Flask 服务,把需要模型的操作暴露成三个接口。完整代码见 code/vecsrv.py:
@app.route('/search', methods=['POST'])
def api_search():
_touch() # 记一下最后请求时间,给空闲判断用
d = request.get_json(force=True) or {}
import vector_search
with _lock:
hits = vector_search.search(d['query'], limit=d.get('limit', 8), ...)
return jsonify({'ok': True, 'results': [_jsonable(h) for h in hits or []]})要注意 _jsonable():检索结果里混着 sqlite3.Row 和 numpy 标量,直接 jsonify 会炸。而且 embedding 的 bytes 字段不该回传:
def _jsonable(row):
import numpy as np
out = {}
for k, v in dict(row).items():
if isinstance(v, (np.floating, np.integer)):
out[k] = v.item()
elif isinstance(v, (bytes, bytearray)):
continue # embedding blob 不回传
else:
out[k] = v
return out这是整个改动里最关键的设计。客户端提供和原模块同名的函数,需要模型的走 HTTP,纯 numpy 的本地算:
# 走 HTTP
def search(query, limit=8, **kw): ...
def encode_text(text): ...
def embed_note(note_id, content): ...
# 本地算,不引入 torch
def vec_to_blob(vec): return np.asarray(vec, dtype=np.float32).tobytes()
def blob_to_vec(blob): return np.frombuffer(blob, dtype=np.float32)
def cosine_sim(a, b): return float(np.dot(a, b))这样调用点只改 import 那一行:
- import vector_search
+ import vector_client as vector_search其余代码一个字不用动。 五个调用点全部是这样改的,改动量小到可以一眼看完。
再加一个防呆,免得有人误调:
def get_model():
raise RuntimeError('get_model() 在瘦客户端里不可用:模型只住在检索服务进程里。'
'需要向量请改用 encode_text(text)。')三个接口失败时的处理必须不一样,这点值得单独说:
def search(query, **kw):
try:
return _post('/search', {...})['results']
except Exception as e:
_log(f'search degraded ({e})')
return [] # 少一段相关记忆,功能照常,不该让整个请求失败
def embed_note(note_id, content):
try:
return _post('/embed_note', {...})['ok']
except Exception as e:
_log(f'embed_note({note_id}) failed: {e}')
return False # 后台补偿任务会重跑,静默失败可以接受
def encode_text(text):
d = _post('/encode', {'text': text})
if not d.get('ok'):
raise RuntimeError(...) # 调用方拿它的结果写库,静默返回 None 会写坏数据search 静默降级,encode_text 必须抛。判断标准是失败的后果写不写进持久化状态。
短命的定时任务(cron 里跑完就退的那些)保持直连。它们各自加载一次模型然后进程就结束了,走 HTTP 反而多此一举,还多一个失败点。
只有常驻进程需要瘦身。
拆完之后,检索服务空闲时占着 578M 明显浪费。第一版我写了软卸载:
def _unload():
import vector_search
vector_search._model = None
gc.collect()
ctypes.CDLL('libc.so.6').malloc_trim(0) # 让 glibc 把堆还给系统实测:
卸载前 578M → 卸载后 492M
只吐出来 86M。
原因是 libtorch_cpu.so 那 400 多 M 是 mmap 进来的共享库映射,把 _model 置空只释放了模型权重和推理时的缓存,那个 .so 的映射还在。malloc_trim 对 mmap 区域也无能为力。
改成让进程整个退掉,交给 systemd 拉起:
def _janitor():
while True:
time.sleep(60)
if time.time() - _last_req > IDLE_UNLOAD_SEC and _model_loaded():
log(f'空闲 {IDLE_UNLOAD_SEC}s,退出让 systemd 回收 (RSS {_rss_mb():.0f}M)')
os._exit(0)[Service]
Restart=always
RestartSec=5这样空闲后回到 52M。实测走了一遍:120 秒空闲触发退出,重新请求能正常拉起并返回结果。
代价是有一个 5 秒的重启窗口。客户端加重试兜住:
def _post(path, payload, timeout, retries=2):
last = None
for i in range(retries + 1):
try:
r = requests.post(BASE + path, json=payload, timeout=timeout)
r.raise_for_status()
return r.json()
except Exception as e:
last = e
if i < retries:
time.sleep(3) # 覆盖 RestartSec=5 的窗口
raise last教训三:卸载一个大对象和释放它占的内存是两回事。共享库映射不归 Python 的 gc 管,也不归 malloc 管。
这是整件事里最该花时间的地方。拆服务的全部价值建立在"计算结果不变"上,得拿数据说话,不能靠"应该没问题"。
import numpy as np
import vector_client as VC
import vector_search as VS
Q = '一个测试查询'
hits = VC.search(Q, limit=5, warm=False, track=False) # 走 HTTP
direct = VS.search(Q, limit=5, warm=False, track=False) # 同进程直接调
print('命中 id 序列一致:', [h['id'] for h in hits] == [d['id'] for d in direct])
print('score 完全一致: ', all(abs(h['score'] - d['score']) < 1e-9
for h, d in zip(hits, direct)))
v_http = VC.encode_text('一句用来测试的话')
v_local = VS.encode_text('一句用来测试的话')
print('最大逐元素误差:', np.abs(v_http - v_local).max())
print('余弦相似度: ', float(np.dot(v_http, v_local)))命中 id 序列一致: True
score 完全一致: True
最大逐元素误差: 0.000e+00
余弦相似度: 1.0000000000
顺便量一下 HTTP 的代价(同一批查询,各跑一轮取中位数):
同进程直接调用 中位数 10.74s
走 HTTP 中位数 11.05s
HTTP 净开销 约 +0.31s
本地回环加 JSON 序列化,三百毫秒,可以接受。
……等一下。10.74 秒?
拆服务是为了内存,性能本来不在计划里。但上面那个 benchmark 顺手暴露了一件事:一次检索要 10 秒多。
我当时的猜测是:逐条把 embedding 从数据库读出来算余弦,几千条 Python 循环肯定慢,堆成矩阵做一次乘法就能解决。
猜错了。 老老实实分段计时:
t = time.time(); query_vec = encode_text(Q); t_enc = time.time() - t
t = time.time(); rows = conn.execute(SQL1).fetchall(); t_q1 = time.time() - t
t = time.time()
for r in rows:
cosine_sim(query_vec, blob_to_vec(r['embedding']))
t_vec = time.time() - t
t = time.time(); bm25 = SimpleBM25(texts); t_build = time.time() - t
t = time.time(); scores = bm25.score(Q); t_score = time.time() - t完整脚本见 code/profile_search.py。结果:
encode_text(查询向量) 0.03s
SQL 查笔记 0.01s (497 行)
笔记逐条余弦 0.00s
SQL 查对话块 0.06s (5266 行)
对话块逐条余弦 0.01s
BM25 建索引(全库分词) 11.48s <<<
BM25 打分 0.19s
------------------------------------
合计 11.78s
BM25 两段占比 99%
向量部分 0.01 秒。我准备优化的地方,占总耗时的千分之一。
真凶是这段:
class SimpleBM25:
def __init__(self, docs, k1=1.5, b=0.75):
self.doc_tokens = [list(jieba.cut(d)) for d in docs] # ← 这里
...
# search() 里
texts = [r['text'] for r in rows]
bm25 = SimpleBM25(texts) # 每次查询都重建
bm25_scores = bm25.score(query)5266 条文档、184 万字,每次查询都重新跑一遍中文分词。jieba 是纯 Python,这个量级就是十几秒。
教训四:优化前必须 profile。凭直觉找瓶颈,大概率找错。我这次的直觉偏了三个数量级。
BM25 索引只依赖文档内容。文档没变,索引就没必要重建:
_BM25_CACHE = {'key': None, 'bm25': None}
def _get_bm25(rows):
"""按行集合缓存 BM25 索引。
key 用全部 id 的 hash:新增、删除、顺序变化都会失效,
不会和 zip(rows, bm25_scores) 的对应关系错位。
"""
key = hash(tuple(r['id'] for r in rows))
c = _BM25_CACHE
if c['key'] != key or c['bm25'] is None:
c['bm25'] = SimpleBM25([r['text'] for r in rows])
c['key'] = key
return c['bm25']调用处:
- bm25 = SimpleBM25([r['text'] for r in rows])
+ bm25 = _get_bm25(rows)最容易想到的 key 是 len(rows),或者 (len(rows), max_id)。都不够。
因为 bm25_scores 是靠 zip(rows, bm25_scores) 和行按位置对应的。只要行的顺序或组成变了而 key 没变,分数就会张冠李戴,而且不会报错,只是检索结果悄悄变得不相关。
hash(tuple(r['id'] for r in rows)) 对新增、删除、顺序变化全都敏感。遍历 5266 个 id 取 hash 大约 1 毫秒,为这个安全性完全值得。
另外注意:这个表的文本字段只在 INSERT 时写入,之后只更新别的列。如果你的文本字段会被原地修改,key 里还得带上内容的指纹。
优化前 优化后
第 1 次查询 10.85s 11.96s ← 冷缓存,要建一次索引
第 2 次 11.12s 0.35s
第 3 次 10.86s 0.32s
第 4 次 11.14s 0.33s
平均 10.99s → 0.34s 快 32 倍
结果比对(4 个查询,比 source / id / score 到小数点后 10 位):
一致 查询 1
一致 查询 2
一致 查询 3
一致 查询 4
结果全部一致: True
代价是常驻内存从 586M 涨到 650M,索引占 60M。
这个缓存只在常驻进程里有意义。原来的架构下,检索散落在各处按需加载,缓存了也活不长。
是先拆了服务,才吃得到这个收益。 两件事看起来无关,实际上第二件依赖第一件。架构调整的收益经常是这样迟到的。
诚实交代:
- 冷启动 20 秒(加载模型 8s + 建索引 11.5s)。空闲退出之后的第一个请求会撞上。想去掉得把索引 pickle 到磁盘,还没做。
- BM25 打分那 0.19 秒没优化。
score()里为每个文档重建tf_map,预计算或者上倒排索引能压到接近零。当前不是瓶颈,没动。 - 60M 索引内存没优化。存 token 列表比较费,改倒排索引结构会省一些。
三件都是想清楚了故意不做,不是忘了。够用就停手也是一种选择。
- RSS 曲线像泄漏不代表是泄漏。 先分层测一次性成本,用固定流量压读接口证伪。
- 选方案看风险面,不只看收益数字。 不改变计算结果的小优化,胜过可能悄悄污染数据的大优化。
- 卸载对象 ≠ 释放内存。 mmap 进来的共享库不归 gc 管也不归 malloc 管,要真还给系统只能让进程退掉。
- 优化前必须 profile。 我这次凭直觉找的瓶颈偏了三个数量级,profile 一跑十秒钟就水落石出。
code/vecsrv.py— 检索服务,含空闲退出code/vector_client.py— 瘦客户端,含降级和重试code/vecsrv.service— systemd 单元code/bm25_cache.py— BM25 缓存的完整实现code/memprobe.py— 内存探针,用来证伪泄漏code/profile_search.py— 分段计时,用来找真瓶颈code/verify_identical.py— 结果一致性验证
代码是从实际项目里摘出来的,命名做了通用化处理。直接抄大概率跑不起来,看思路就好。
MIT License。