适用场景
Stepnex 这类 Flask 独立博客在早期用 SQLite 很合理:部署简单、备份直接、少一个数据库服务要维护。真正的问题通常不是 SQLite 不能用,而是文章、分类、标签、评论和搜索记录逐渐增加后,列表页、分类页、详情页上下篇、站点地图和后台文章管理都开始依赖重复查询;如果没有基线,就很难判断一次变慢是数据量增长、模板循环、索引缺失,还是某个新功能把查询路径改坏了。
这篇文章适合已经上线、但还没有专门做查询性能治理的小型 Flask 博客。它不要求你立刻迁移到 MySQL 或 PostgreSQL,而是先把当前 SQLite 阶段的可维护边界做扎实:记录常用查询、用 SQLAlchemy 日志确认真实 SQL、用 SQLite EXPLAIN QUERY PLAN 看是否走索引,再用最小索引覆盖高频路径。站内以前写过 SQLite 博客备份怎么做才可靠 和 Flask 博客从 SQLite 迁移到 MySQL,本文补的是迁移前最容易被忽略的一环:先证明现有查询到底慢在哪里。这样后续无论继续使用 SQLite,还是迁移到其他数据库,都有同一套验收口径。
我的取舍是:不为了几十篇文章提前引入复杂监控,也不凭感觉给每个字段都加索引。对个人博客来说,最值得优先保护的是公开访问链路和后台发布链路。公开链路包括首页、分类页、文章详情页、sitemap、tag 页面和搜索页;后台链路包括文章列表、草稿筛选、发布后校验和评论审核。只要这些路径有固定命令、固定日志和固定复盘清单,SQLite 可以继续稳定承担很长一段时间。
先列出高频查询路径
第一步不是打开数据库就建索引,而是把页面和任务拆成查询清单。以当前 Flask 博客为例,首页通常会按 status='published'、当前 language、is_top、published_at 排序;分类页会再叠加 category_id;文章详情页会按 slug、language、status 查单篇;相关文章会按分类和标签做连接;sitemap 会遍历所有已发布文章;后台文章列表会按创建时间倒序分页。只看模型字段会觉得每一列都重要,真正落到路由后,优先级就清楚了。
建议先写一张轻量表格,至少记录五列:入口、SQLAlchemy 查询条件、排序字段、预期数据量、验收阈值。阈值不用复杂,初期可以用“本地 1000 篇文章下首页渲染小于 300ms”“sitemap 生成小于 1s”“后台文章列表翻到第 10 页仍小于 500ms”这类工程可感知的目标。它们不是性能承诺,而是帮你发现回归的报警线。
可以先把这些路径列成检查清单:
- 首页:
status + language过滤,is_top + published_at + created_at排序。 - 分类页:
status + language + category_id过滤,发布时间倒序。 - 文章详情:
slug + language + status精确查询。 - sitemap:全量
published文章按语言和时间输出。 - 后台列表:按
created_at倒序分页,可能叠加状态筛选。 - 搜索页:标题、摘要、正文的
LIKE查询,数据量上来后要单独评估。
这一步的避坑点是不要把搜索页和普通列表页混在一起。标题、正文 LIKE '%keyword%' 在 SQLite 里很难靠普通 B-tree 索引解决,后续可能需要 FTS5 或外部搜索;而首页、分类页、详情页是典型过滤加排序,适合先用复合索引治理。把两类问题混在一张清单里,最后往往会加很多无效索引。
打开 SQLAlchemy 日志,拿到真实 SQL
第二步是确认应用实际发出了什么 SQL。很多慢查询不是模型写错,而是模板循环触发了额外查询,或者分页总数查询比列表查询更重。开发环境可以临时打开 SQLAlchemy engine 日志,生产环境不要长期输出全量 SQL,避免日志膨胀和泄露参数。
配置思路可以这样放进本地 .env 或启动脚本:
APP_ENV=development
SQLALCHEMY_ECHO=false
QUERY_BASELINE_ENABLED=true
如果不想改应用配置,可以用一次性 Python 命令在应用上下文里跑基线查询:
python - <<'PY'
from app import app, db, Article
with app.app_context():
q = Article.query.filter_by(status="published", language="zh").order_by(
Article.is_top.desc(), Article.published_at.desc(), Article.created_at.desc()
).limit(8)
print(q.statement.compile(compile_kwargs={"literal_binds": True}))
PY
Windows PowerShell 可以改成:
@'
from app import app, Article
with app.app_context():
q = Article.query.filter_by(status="published", language="zh").order_by(
Article.is_top.desc(), Article.published_at.desc(), Article.created_at.desc()
).limit(8)
print(q.statement.compile(compile_kwargs={"literal_binds": True}))
'@ | python -
拿到 SQL 后,把它保存到 docs/query-baseline.md 或运维笔记里。日志至少保留三类信息:路由名称、SQL 摘要、执行耗时。小站不一定要接入完整 APM,但应该知道一次首页请求到底发了几条查询。你可以结合站内的 Flask 博客 Nginx 访问日志治理 一起看:Nginx 日志告诉你哪个 URL 慢,SQL 日志告诉你慢在数据库还是模板。
用 EXPLAIN QUERY PLAN 验证索引
第三步是把关键 SQL 放进 SQLite 里做验证。SQLite 的 EXPLAIN QUERY PLAN 会告诉你是 SCAN 还是 SEARCH,是否使用了某个索引。简单说,公开列表页长期出现全表 SCAN article,就需要警惕;单篇详情如果已经通过唯一 slug 命中索引,通常不是优先问题。
可以先在本地数据库执行:
sqlite3 database.db ".indexes article"
sqlite3 database.db "EXPLAIN QUERY PLAN SELECT id,title,slug,published_at FROM article WHERE status='published' AND language='zh' ORDER BY is_top DESC, published_at DESC, created_at DESC LIMIT 8;"
sqlite3 database.db "EXPLAIN QUERY PLAN SELECT id,title,slug FROM article WHERE status='published' AND language='zh' AND category_id=1 ORDER BY published_at DESC, created_at DESC LIMIT 8;"
如果你的 Windows 环境没有 sqlite3 命令,也可以用 Python 标准库:
@'
import sqlite3
con = sqlite3.connect("database.db")
sql = "SELECT id,title,slug FROM article WHERE status=? AND language=? ORDER BY published_at DESC, created_at DESC LIMIT 8"
for row in con.execute("EXPLAIN QUERY PLAN " + sql, ("published", "zh")):
print(row)
'@ | python -
验证方式要固定下来:每次新增索引前记录一次 plan,新增后再记录一次 plan,并且跑页面请求确认没有行为变化。不要只看某条 SQL 的 plan 变漂亮,还要看写入成本和数据库体积。SQLite 的索引会提升读取,也会增加写入和备份文件大小;对每天只发几篇文章的博客来说,这个成本通常可接受,但仍然要记录。
最小索引方案和配置片段
在当前模型里,slug、language、status、title 已经有单列索引,但公开列表页经常同时过滤 status、language,再按发布时间排序。单列索引未必能很好覆盖这种路径。可以考虑补两类复合索引:一类给首页和归档,一类给分类页。
SQL 片段如下,建议先在备份库或本地副本验证:
CREATE INDEX IF NOT EXISTS ix_article_public_feed
ON article (status, language, is_top, published_at, created_at);
CREATE INDEX IF NOT EXISTS ix_article_category_feed
ON article (category_id, status, language, published_at, created_at);
CREATE INDEX IF NOT EXISTS ix_article_translation_status
ON article (translation_key, status, language);
如果你使用 Flask-Migrate,应把它们写进迁移脚本,而不是只在服务器上手敲。SQLite 支持 CREATE INDEX IF NOT EXISTS,但迁移记录仍然很重要,否则下次换环境会不知道哪些索引是正式结构的一部分。迁移前先备份:
cp database.db backups/database-before-index-$(date +%Y%m%d%H%M%S).db
flask db migrate -m "add article feed indexes"
flask db upgrade
如果项目暂时没有迁移流程,也可以先写一个受控脚本:
from app import app, db
INDEX_SQL = [
"CREATE INDEX IF NOT EXISTS ix_article_public_feed ON article (status, language, is_top, published_at, created_at)",
"CREATE INDEX IF NOT EXISTS ix_article_category_feed ON article (category_id, status, language, published_at, created_at)",
"CREATE INDEX IF NOT EXISTS ix_article_translation_status ON article (translation_key, status, language)",
]
with app.app_context():
for sql in INDEX_SQL:
db.session.execute(db.text(sql))
db.session.commit()
避坑点有三个。第一,不要把正文 content_md 放进普通索引,这会拖慢写入且收益很低。第二,不要为了搜索页盲目给 summary、content_md 加普通索引,全文搜索需要单独方案。第三,复合索引的字段顺序要服务查询条件和排序,不是把所有字段随便堆进去。对这个博客来说,status 和 language 是公开访问的稳定过滤条件,published_at 和 created_at 是稳定排序字段,所以它们比 view_count 更适合作为第一批治理对象。
发布前后的验证步骤
索引治理必须有发布前后验证。发布前先在本地复制一份 database.db,跑基线 SQL,确认 plan 和耗时;发布后再抽查线上页面和日志。验证命令可以写成固定流程:
python -m compileall app.py
python scripts/baidu_union_sprint.py --submit-limit 1 --dry-run
curl -I https://stepnex.cn/
curl -I https://stepnex.cn/category/tech
curl -I https://stepnex.cn/sitemap.xml
数据库侧再跑:
sqlite3 database.db "PRAGMA integrity_check;"
sqlite3 database.db "PRAGMA index_list('article');"
sqlite3 database.db "EXPLAIN QUERY PLAN SELECT id FROM article WHERE status='published' AND language='zh' ORDER BY published_at DESC LIMIT 8;"
如果线上没有 sqlite3 命令,就用 Python 替代。日志检查要看三处:应用启动日志有没有迁移异常;Nginx access log 里首页、分类页、sitemap 是否出现 5xx;应用日志里是否出现数据库锁、迁移失败或 SQL 语法错误。索引新增通常不改业务逻辑,但它会动数据库结构,所以仍然要按一次小发布对待。发布当天还应抽查后台新建草稿、编辑旧文和评论审核,确认读写路径都没有被结构变更影响。
复盘清单
每次做完查询基线和索引治理,我建议按下面清单复盘:
- 适用范围是否清楚:这次优化的是首页、分类页、详情页还是搜索页。
- 步骤是否可重复:是否留下 SQLAlchemy 查询、
EXPLAIN QUERY PLAN输出和执行命令。 - 配置是否进入版本管理:索引是迁移脚本、受控脚本,还是只在服务器手动执行。
- 验证是否覆盖页面:至少抽查首页、技术分类页、文章详情页、sitemap 和后台文章列表。
- 日志是否能定位问题:Nginx 日志、应用日志、数据库 plan 是否能串起来。
- 避坑是否记录:全文搜索、正文索引、过度索引、生产全量 SQL 日志是否被明确排除。
- 回滚是否可行:数据库备份路径、恢复命令、迁移回退说明是否齐全。
做完这些,SQLite 阶段的博客就不是“暂时还能跑”,而是有了可复查的性能边界。以后真的迁移到 MySQL 或 PostgreSQL,这份基线也能直接变成迁移验收标准:同样的页面、同样的查询、同样的验证方式,才能判断迁移是变好了,还是只是换了一个更复杂的数据库。