适用场景

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'、当前 languageis_toppublished_at 排序;分类页会再叠加 category_id;文章详情页会按 sluglanguagestatus 查单篇;相关文章会按分类和标签做连接;sitemap 会遍历所有已发布文章;后台文章列表会按创建时间倒序分页。只看模型字段会觉得每一列都重要,真正落到路由后,优先级就清楚了。

建议先写一张轻量表格,至少记录五列:入口、SQLAlchemy 查询条件、排序字段、预期数据量、验收阈值。阈值不用复杂,初期可以用“本地 1000 篇文章下首页渲染小于 300ms”“sitemap 生成小于 1s”“后台文章列表翻到第 10 页仍小于 500ms”这类工程可感知的目标。它们不是性能承诺,而是帮你发现回归的报警线。

可以先把这些路径列成检查清单:

  1. 首页:status + language 过滤,is_top + published_at + created_at 排序。
  2. 分类页:status + language + category_id 过滤,发布时间倒序。
  3. 文章详情:slug + language + status 精确查询。
  4. sitemap:全量 published 文章按语言和时间输出。
  5. 后台列表:按 created_at 倒序分页,可能叠加状态筛选。
  6. 搜索页:标题、摘要、正文的 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 的索引会提升读取,也会增加写入和备份文件大小;对每天只发几篇文章的博客来说,这个成本通常可接受,但仍然要记录。

最小索引方案和配置片段

在当前模型里,sluglanguagestatustitle 已经有单列索引,但公开列表页经常同时过滤 statuslanguage,再按发布时间排序。单列索引未必能很好覆盖这种路径。可以考虑补两类复合索引:一类给首页和归档,一类给分类页。

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 放进普通索引,这会拖慢写入且收益很低。第二,不要为了搜索页盲目给 summarycontent_md 加普通索引,全文搜索需要单独方案。第三,复合索引的字段顺序要服务查询条件和排序,不是把所有字段随便堆进去。对这个博客来说,statuslanguage 是公开访问的稳定过滤条件,published_atcreated_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,这份基线也能直接变成迁移验收标准:同样的页面、同样的查询、同样的验证方式,才能判断迁移是变好了,还是只是换了一个更复杂的数据库。