这台跑博客的服务器是 2C2G 的小机器,最近总感觉内存吃紧:可用内存只剩 600MB 出头,swap 长期占着 666MB。趁周末把整条链路梳理了一遍,从进程结构、搜索方案到数据库参数做了几轮优化,最终把整机真实内存占用从 1.7GB 左右压到 870MB,swap 基本清零。这篇文章记录完整的定位和优化过程,都是生产环境实测数据。
1. 问题:内存到底去哪了
先看优化前的主机状态:
total used free shared buff/cache available
Mem: 1837 1038 93 0 705 611
Swap: 2047 666 1381
2GB 的机器,可用内存 611MB,swap 已经吃掉 666MB。负载倒是不高,但任何内存波动都会触发换页,博客响应时间跟着抖动。
容器一共 5 个:nginx、web(Django)、db(MySQL 5.7)、redis,外加一个 Nginx Proxy Manager。docker stats 一看,最大的 web 容器 504MB,看起来"还好"。
2. 定位:docker stats 的假象
真正的坑在于:504MB 是假象。按进程看 RSS,web 容器里跑着 5 个 Python 进程,每个都完整加载了 Django(30 来个 INSTALLED_APPS):
gunicorn master ~5MB
gunicorn worker 241MB <- 跑了 3 天,无回收机制
celery worker 主进程 73MB
celery worker 子进程 x2 80 + 102MB <- prefork 默认并发 = CPU 核数 = 2
celery beat 71MB
celery 的 prefork 池默认按 CPU 核数开子进程,2 核机器直接开出 2 个 worker,每个都完整 import 一遍 Django,加上 beat 又是一个完整进程。所谓 504MB,是内核把冷页面换到 swap 之后剩下的常驻部分——swap 里那 666MB 的大头其实就是这几个进程。
结论:问题不在"容器多",而在单个容器里跑了 5 个完整 Django 进程,而且所有容器都没有内存上限约束。
3. 第一步:把进程数降下来
gunicorn 加回收参数(supervisord.conf):
[program:gunicorn-django]
command=gunicorn izone.wsgi -b 0.0.0.0:8000 --workers 1 --max-requests 1000 --max-requests-jitter 100
--max-requests 让 worker 处理满 1000 个请求后自动重启,防止 Django 加 MySQL 长连接的缓慢泄漏,那个 241MB 的 worker 不会再无限涨下去。
celery 降低并发:
command=celery -A izone worker -l info --concurrency=1
博客的定时任务全是轻量 IO(缓存刷新、友链检查、推送、统计),2 个 prefork 子进程纯属浪费,砍掉一个直接省 100MB 左右。
这一步零代码改动,只改 supervisord.conf 重建镜像。生效后 swap 从 666MB 降到 146MB,不再有任何 Python 进程被换出。
4. 第二步:合并 worker 与 beat,加内存上限
beat 和 worker 都是完整 Django 进程,各占 190MB 左右,但 beat 只负责定时派发任务。单机部署完全可以用嵌入式 beat 合并:
[program:celery-worker]
command=celery -A izone worker -l info -B --pool=solo
-B 启用嵌入式 beat(Celery 会拉一个子进程跑调度),--pool=solo 让任务在主进程内执行,不再开 prefork 子进程。容器内从 5 个 Django 进程降到 4 个。
然后给所有容器加内存上限,防止单个容器失控拖垮整机:
services:
web:
mem_limit: 1024m
db:
mem_limit: 512m
redis:
mem_limit: 256m
nginx:
mem_limit: 128m
这里有个坑:docker-compose v1 在 version: "3" 的文件里不认 mem_limit(v3 把它挪到了 deploy.resources 下,而 v1 的 up 并不应用 deploy 配置)。把 schema 改成 version: "2.4",新旧两套 compose CLI 都兼容。
5. 第三步:搜索重构,卸掉最重的依赖
梳理依赖时发现了更大的问题:搜索用的是 django-haystack + Whoosh + jieba。Whoosh 是纯 Python 的全文索引引擎,2016 年就停更了;jieba 词典每个进程加载一遍;更麻烦的是 Whoosh 索引要靠 rebuild_index 手动维护——同一个关键词,旧的 Whoosh 索引只能搜出 6 篇文章,直接查库实际有 28 篇,索引早就和数据脱节了。
个人博客的搜索量,根本不需要独立索引引擎。MySQL 5.7 的 InnoDB 自带 FULLTEXT 加 ngram 分词,直接对标题、摘要、正文建索引:
# 迁移:给文章表建 ngram 全文索引
migrations.RunSQL(
sql="ALTER TABLE blog_article ADD FULLTEXT INDEX article_ft_fulltext "
"(title, body, summary) WITH PARSER ngram",
reverse_sql="ALTER TABLE blog_article DROP INDEX article_ft_fulltext",
)
搜索视图从 haystack 的 SearchQuerySet 换成普通 ListView:
term = re.sub(r'[+\-<>\\(\\)~*"@\\]', ' ', query) # 剥离布尔语法操作符
queryset = queryset.extra(
where=["MATCH(title, body, summary) AGAINST (%s IN BOOLEAN MODE)"],
params=[term],
)
# n-gram 对英文会产生宽泛命中,用子串过滤收紧
for word in term.split():
queryset = queryset.filter(
Q(title__icontains=word) | Q(body__icontains=word) | Q(summary__icontains=word)
)
顺带的收获:搜索结果永远和数据库一致,不用再手动重建索引。jieba 因为词云工具还在用所以保留,但改成函数内延迟导入,进程启动时不再加载词典。
haystack、Whoosh 两个依赖整体移除,每个 Django 进程省下几十 MB,4 个进程累计约 200MB。
6. 第四步:MySQL 内存调优
搜索重构之后又盯上了 MySQL。izone_db 容器的内存一路涨到 286MB,而 my.cnf 里只有字符集配置,其余全是 5.7 的默认值——默认值是按通用服务器设计的,放在 2G 的小机器上并不合适。
先看两个关键事实:
-- 业务库(数据+索引)总共只有 30.1MB
SELECT ROUND(SUM(data_length+index_length)/1024/1024, 1)
FROM information_schema.tables
WHERE table_schema = 'izone';
-- performance_schema 默认开启,这一项就要吃 100MB 以上
SHOW VARIABLES LIKE 'performance_schema'; -- ON
SHOW VARIABLES LIKE 'innodb_buffer_pool_size'; -- 128M 默认值
给一个 30MB 的库配 128M 缓冲池纯属浪费。调整 my.cnf:
[mysqld]
# 关闭 performance_schema(5.7 默认开启,占用 100MB+,博客无需)
performance_schema=OFF
# 缓冲池:数据+索引共约 30MB,64M 足够(默认 128M)
innodb_buffer_pool_size=64M
# 连接与缓存按博客流量收敛
max_connections=60
table_open_cache=400
thread_cache_size=8
tmp_table_size=16M
max_heap_table_size=16M
innodb_log_buffer_size=8M
sort_buffer_size=256K
join_buffer_size=256K
改完 docker compose restart db,几秒钟生效。max_connections=60 看着激进,其实重启前实测只有 2 个连接,博客的并发水平离 60 还很远。
结果:izone_db 从 286MB 直接降到 52MB,比预估的还好。
7. 优化效果汇总
| 阶段 | 变化最大的容器 | 主机可用 | 主机 swap |
|---|---|---|---|
| 优化前 | web 504MB(真实约 700MB) | 438MB | 666MB |
| 进程调优 | web 约 600MB | 338MB | 146MB |
| 合并 beat 加 mem_limit | web 398MB | 614MB | 150MB |
| 搜索重构 | web 212MB | 750MB | 150MB |
| MySQL 调优 | db 286MB 降到 52MB | 928MB | 151MB |
第二行 web 容器数值"变高"是假象:优化前的 504MB 里有 200 多 MB 被换到了 swap,docker stats 只统计常驻部分;进程调优后所有页面回到内存,数字才显示真实大小。
看整机总账更直观:容器总内存从约 780MB 降到约 377MB,整机真实占用(used 加 swap)从约 1.7GB 降到约 870MB,全程网站正常响应,用户无感知。
8. 几点体会
- 内存优化的第一刀应该砍向"跑了几个进程",而不是"哪个参数没调"。celery 默认并发按核数开子进程、独立 beat 进程,这些默认值是按多核服务器设计的,小机器上全是浪费。
docker stats的数字要结合 swap 看,被换出的内存不在统计里,否则容易被"504MB 还好"误导。- 依赖一个停更的索引引擎,不如用数据库自带的能力。MySQL FULLTEXT 加 ngram 对中文博客完全够用,还省掉了索引同步的心智负担。
- 数据库的默认参数也是按通用服务器设计的。5.7 的 performance_schema 一项就能吃掉 100MB 以上,对个人博客毫无用处;缓冲池大小要按真实数据量配,不是越大越好。
- 给每个容器设 mem_limit 是廉价的保险,任何一个容器泄漏都不会拖垮整机。
版权声明:如无特殊说明,文章均为本站原创,转载请注明出处
本文链接:https://tendcode.com/subject/article/django-blog-memory-optimization/
许可协议:署名-非商业性使用 4.0 国际许可协议