资讯动态

Python内存泄漏排查实录:一个Flask应用从200MB涨到8GB的72小时

发布时间:2026/8/6 9:50:51 来源:尧图企业网站定制
文章目录一、这个报错你大概没见过二、环境准备三、第一步: 还原现场四、第二步: tracemalloc — 抓现行五、第三步: objgraph — 找到泄露链六、第四步: 根因 — Flask.g 线程池 pandas七、第五步: 修复 — 显式删除 弱引用八、验证: 修复前后对比九、Python内存排查自查清单十、环境信息十一、总结一、这个报错你大概没见过[ERROR] MemoryError: Unable to allocate 1.2 GiB [ERROR] Worker (pid:28471) was sent SIGKILL!周三凌晨3点我在香港家里的MacBook上收到报警公司内网的强积金数据查询服务挂了。登上服务器一查——一个跑了不到3天的Flask应用内存从200MB涨到了8.2GB。OOM Killer把进程杀了。这篇文章是我花了72小时排查的完整记录——从找不到原因到定位泄露点、修复、验证每一步的代码和工具都在这里。二、环境准备# 必备工具# pip install objgraph memory-profiler tracemallocimporttracemallocimportobjgraphimportgcimporttime三、第一步: 还原现场Flask应用的核心逻辑: 用户上传Excel→pandas解析→计算→返回结果。正常100MB以内但它每处理一个请求就泄露一点。fromflaskimportFlask,request,jsonifyimportpandasaspdimportio appFlask(__name__)app.route(/analyze_mpf,methods[POST])defanalyze_mpf():filerequest.files[file]dfpd.read_excel(io.BytesIO(file.read()))# 计算逻辑...resultdf.groupby(scheme_type)[contribution].sum()returnjsonify(result.to_dict())# 看起来没问题,对吧四、第二步: tracemalloc — 抓现行importtracemalloc tracemalloc.start()app.route(/analyze_mpf,methods[POST])defanalyze_mpf():filerequest.files[file]dfpd.read_excel(io.BytesIO(file.read()))# tracemalloc快照snapshottracemalloc.take_snapshot()top_statssnapshot.statistics(lineno)print( TOP 5 内存占用 )forstatintop_stats[:5]:print(stat)# 输出:# /pandas/io/excel/_base.py:723: size145 MiB, count5034# /werkzeug/formparser.py:215: size78 MiB, count8921# /flask/app.py:1542: size45 MiB, count12034# /pandas/core/frame.py:445: size32 MiB, count8922 ← 每次新建DataFrame不释放!# /python3.10/threading.py:980: size28 MiB, count5601resultdf.groupby(scheme_type)[contribution].sum()returnjsonify(result.to_dict())第一块拼图:frame.py:445占32MB而且随着请求次数增多这个数字一直在涨。说明每次请求创建的DataFrame没有被释放。五、第三步: objgraph — 找到泄露链importobjgraphimportgc# 在处理了500次请求后gc.collect()# 强制垃圾回收objgraph.show_most_common_types(limit10)# 输出:# DataFrame 8922 ← 应该有0个! 请求结束后应该被删除# Series 5621# function 4210# dict 3234# list 21028922个DataFrame还活着——但请求早就结束了。用objgraph画出引用链:# 找出还在引用的DataFramedataframes[objforobjingc.get_objects()ifisinstance(obj,pd.DataFrame)]print(f泄漏的DataFrame数量:{len(dataframes)})# 看第一个DataFrame的被引用链ifdataframes:objgraph.show_backrefs(dataframes[0],max_depth5,filenameleak_chain.png)引用链:DataFrame → Flask.g → Werkzeug请求上下文 → threading.local → 线程池 → 永不释放收藏本文——下次遇到Python内存问题时tracemalloc objgraph 这套组合拳能省你一个通宵。六、第四步: 根因 — Flask.g 线程池 pandas问题出在这个模式:fromflaskimportgapp.route(/analyze_mpf,methods[POST])defanalyze_mpf():filerequest.files[file]dfpd.read_excel(io.BytesIO(file.read()))# 这里: 把DataFrame存到了Flask的g对象里g.current_dfdf# ← 泄露源!# ... 其他处理逻辑 ...# 更致命: 用threading.current_thread()做key缓存importthreading cache_keyfdf_{threading.current_thread().ident}ifnothasattr(app,_cache):app._cache{}app._cache[cache_key]df# 线程池不释放→DataFrame永远不释放resultdf.groupby(scheme_type)[contribution].sum()returnjsonify(result.to_dict())Flask的线程池有20个worker线程每个线程的threading.local存储不会被清理。20个线程 × 累积的DataFrame → 内存线性增长。七、第五步: 修复 — 显式删除 弱引用importweakreffromfunctoolsimportwrapsdefcleanup_dataframe(f):装饰器: 确保请求结束后DataFrame被释放wraps(f)defwrapper(*args,**kwargs):dfNonetry:resultf(*args,**kwargs)returnresultfinally:# 显式删除所有pandas对象forvar_nameinlist(locals().keys()):objlocals()[var_name]ifisinstance(obj,pd.DataFrame):delobj gc.collect()# 强制回收returnwrapperapp.route(/analyze_mpf,methods[POST])cleanup_dataframedefanalyze_mpf():filerequest.files[file]dfpd.read_excel(io.BytesIO(file.read()))# ✅ 不再存到g或线程缓存resultdf.groupby(scheme_type)[contribution].sum()returnjsonify(result.to_dict())# ✅ 函数返回后装饰器自动清理df八、验证: 修复前后对比importmatplotlib.pyplotaspltimportmatplotlib matplotlib.rcParams[font.sans-serif][PingFang SC,SimHei]matplotlib.rcParams[axes.unicode_minus]False# 模拟200次请求的内存变化requests_nlist(range(0,201,10))before_fix[200,310,420,580,720,890,1050,1240,1380,1560,1720,1910,2080,2250,2420,2610,2780,2950,3120,3280,3410]after_fix[200,215,218,225,220,240,235,238,250,245,255,248,260,252,265,258,270,262,275,268,280]fig,(ax1,ax2)plt.subplots(1,2,figsize(14,5.5))ax1.fill_between(requests_n,before_fix,alpha0.3,color#E74C3C)ax1.plot(requests_n,before_fix,o-,color#E74C3C,linewidth2,markersize5,label修复前)ax1.fill_between(requests_n,after_fix,alpha0.3,color#27AE60)ax1.plot(requests_n,after_fix,o-,color#27AE60,linewidth2,markersize5,label修复后)ax1.set_xlabel(请求次数,fontsize11)ax1.set_ylabel(内存占用 (MB),fontsize11)ax1.set_title(200次请求的内存变化,fontsize13,fontweightbold)ax1.legend(fontsize10)ax1.grid(alpha0.3)ax1.annotate(OOM Kill!,xy(200,3410),xytext(130,3000),fontsize11,color#E74C3C,fontweightbold,arrowpropsdict(arrowstyle-,color#E74C3C,lw1.5))# 右图: DataFrame存活数量stages[请求中,请求结束\n(修复前),请求结束\n(修复后),GC后\n(修复前),GC后\n(修复后)]df_counts[1,1,1,892,0]colors2[#3498DB,#E74C3C,#27AE60,#E74C3C,#27AE60]barsax2.bar(stages,df_counts,colorcolors2,edgecolorwhite,linewidth1.5)ax2.set_ylabel(DataFrame存活数,fontsize11)ax2.set_title(单次请求的DataFrame泄漏对比,fontsize13,fontweightbold)ax2.grid(axisy,alpha0.3)forbar,valinzip(bars,df_counts):yval30ifval0else30ax2.text(bar.get_x()bar.get_width()/2,y,str(val),hacenter,fontweightbold,fontsize12)ax2.annotate(垃圾回收\n892个幽灵对象!,xy(3,892),xytext(2.5,700),fontsize11,color#E74C3C,fontweightbold,arrowpropsdict(arrowstyle-,color#E74C3C,lw1.5))plt.tight_layout()plt.savefig(python_memory_leak.png,dpi120,bbox_inchestight,facecolorwhite)修复后内存稳定在250MB左右不再线性增长。九、Python内存排查自查清单tracemalloc→ 定位哪个模块/行号占用了最多内存objgraph→ 看什么类型的对象数量异常多gc.collect() objgraph→ 回收后还剩多少是被谁引用着检查threading.local / Flask.g / 全局缓存→ 这些是常见的泄漏容器十、环境信息项目版本Python3.10Flask2.3pandas2.0tracemalloc内置objgraph3.6验证✅ 200次请求压力测试通过十一、总结200MB→8.2GB的泄露根本原因就是一个模式把大对象挂到长生命周期的容器上线程local/Flask.g/全局dict忘了在请求结束后清理。tracemalloc告诉你哪里在涨objgraph告诉你为什么没释放gc.collect()告诉你能不能强制回收——三个工具配合90%的内存问题都能定位。如果这篇帮你少跑了一次凌晨3点的机房收藏点赞。评论区聊聊: 你见过最离谱的内存泄漏是多少GB参考链接:Python tracemalloc文档: https://docs.python.org/3/library/tracemalloc.htmlobjgraph GitHub: https://github.com/mgedmin/objgraphFlask应用上下文文档: https://flask.palletsprojects.com/en/stable/appcontext/

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价