记一次服务器 OOM 被杀事故:Python 后端内存优化实战

2026年6月18日 7 分钟阅读 9 次阅读
📖 文章摘要

记录一次博客后端 OOM 被系统杀掉的事故排查全过程,分析内存暴涨根因并给出 SQL 聚合优化方案,附经验总结。

事情经过

今天下午 6 点左右,我用手机打开自己的博客,发现页面加载不出来。一开始以为是手机网络问题,没太在意。直到晚上 8 点多,服务器突然报错——blog-api 进程被系统 OOM Killer 强制杀掉了。

什么是 OOM Killer?简单说就是 Linux 系统发现内存不够用了,于是随机挑一个最耗内存的进程直接干掉,腾出空间。我的服务器只有 1.6GB 内存,没有 Swap 分区,所以一旦内存爆了就直接崩。

好在 systemd 配置了自动重启,服务几秒钟后就恢复了。但这件事让我意识到:我的后端代码存在严重的内存问题。

排查过程

第一步:看 Nginx 日志

登录服务器,先看 Nginx 访问日志,确认 18:00 左右服务器有没有响应:

grep '11/Jul/2026:18:' /var/log/nginx/access.log | head -20

结果发现 18:00 有正常的 200 响应,说明服务器当时是活着的。那手机打不开大概率是网络问题,不是服务器挂了。

第二步:看服务状态

systemctl status blog-api

输出显示:

blog-api.service: A process of this unit has been killed by the OOM killer.
blog-api.service: Failed with result 'oom-kill'.

确认是 OOM 杀的。

第三步:查系统日志

dmesg | grep -i oom

看到关键信息:

Out of memory: Killed process 219345 (uvicorn) total-vm:2023728kB, anon-rss:1000228kB

uvicorn 进程吃掉了约 1GB 内存,而服务器总共才 1.6GB。

第四步:分析代码

为什么一个 Python 后端会吃 1GB 内存?我逐行审查代码,发现了两个罪魁祸首。

根因分析

问题1:网站统计接口(每次访问都触发)

我博客首页底部有一个「网站统计」卡片,显示文章总数、总字数等。对应的接口是 /api/stats

原来的代码是这样的:

@app.get("/api/stats")
def stats(db: Session = Depends(get_db)):
    # 把所有文章一次性加载到内存
    articles = db.query(Article).filter(Article.is_deleted == False).all()
    total = len(articles)
    total_views = sum(a.view_count or 0 for a in articles)
    total_words = sum(len(a.content_md or "") for a in articles)
    ...

问题在哪?content_md 是文章正文,类型是 Text,无长度限制。博客有上百篇文章,总字符数达上千万。这行代码会把所有文章正文一次性加载到 Python 内存里,然后遍历算字数。

更可怕的是,Python 的内存分配器有个特点:吃进去的内存很难吐出来。即使这次请求处理完了,内存也不会归还给操作系统,而是被 Python 进程继续持有。所以来几次请求,内存就涨上去了。

问题2:清理孤儿图片接口

后台有一个「清理孤儿图片」功能,会删除上传目录里没有被任何文章引用的图片。

原来的代码:

# 把所有文章内容拼成一个大字符串
articles = db.query(Article.content_md).all()
all_content = " ".join([a.content_md or "" for a in articles])
# 然后检查每个文件名是否在这个大字符串里
for f in dirpath.iterdir():
    if f.name not in all_content:
        os.remove(f)

上百篇文章 × 平均数万字符 = 数千万字符的字符串,加上 Python 对象开销,轻松占用几百 MB。

两个接口叠加,再加上 Python 内存不释放的特性,内存慢慢涨到 1GB,最终触发 OOM。

修复方案

修复1:用 SQL 聚合代替加载全文

from sqlalchemy import func

@app.get("/api/stats")
def stats(db: Session = Depends(get_db)):
    cond = Article.is_deleted == False
    total = db.query(func.count(Article.id)).filter(cond).scalar()
    total_views = db.query(func.coalesce(func.sum(Article.view_count), 0)).filter(cond).scalar()
    total_words = db.query(func.coalesce(func.sum(func.length(Article.content_md)), 0)).filter(cond).scalar()
    first_date = db.query(func.min(Article.created_at)).filter(cond).scalar()
    ...

不再加载文章内容到 Python,直接在数据库里统计。内存占用从"所有文章总大小"降到几乎为零。

修复2:逐个文件查询代替拼接

for f in dirpath.iterdir():
    if f.is_file():
        referenced = db.query(Article.id).filter(
            Article.content_md.like(f'%{f.name}%')
        ).first()
        if not referenced:
            os.remove(f)

不再拼接所有文章内容,改成逐个文件去数据库查。每次只处理一个文件名,内存占用极小。

经验教训

  1. Text/Blob 大字段不要 .all() 一次性加载:用 SQL 聚合函数在数据库里算,别搬到 Python 里
  2. Python 内存不会轻易释放:临时峰值也会变永久占用,要从源头控制
  3. 小内存服务器要加 Swap:1.6GB 没有 Swap 太危险了,建议加 1-2GB Swap 作为缓冲
  4. 定期监控内存:写个简单的 cron 脚本,内存超过 80% 时发告警

这次事故虽然影响不大(自动恢复了),但暴露了代码里的内存隐患。如果你也在用 Python 做后端,记得检查一下有没有类似的"加载全文到内存"的操作。

文章创建于:2026年6月18日CC BY-NC-SA 4.0

评论

暂无评论,来写第一条吧

别老叽霸扫描爆破后台了,个人博客能存啥有价值的东西,有这时间不如去扫俩放片的网站