为什么这个迁移值得做

这个 Flask 博客项目默认使用 SQLite,本地开发非常省心,但文章数量、评论量和后台操作都上来以后,SQLite 更适合单机轻量场景的边界就会逐渐明显:写入并发弱、备份和恢复通常依赖整库文件、线上排查时不如 MySQL 常见。对个人博客来说,真正稳妥的做法不是“数据库越重越好”,而是在出现这些信号时再升级:

  • 你已经把博客部署到长期运行的 Linux 服务器,而不是偶尔启动的本地脚本。
  • 后台发文、评论写入、统计更新开始同时发生,偶发锁等待变多。
  • 你希望把备份、监控、迁移、权限控制纳入更标准的运维流程。

当前项目本身已经给出了切换入口:config.py 会优先读取 DATABASE_URL,README 也明确给出了 mysql+pymysql://...?...charset=utf8mb4 的示例。因此这次迁移的重点不是改业务代码,而是把“备份、建库、导数、切换、回滚”这条链路走稳。

先做三件准备工作

1. 先备份 SQLite 文件和上传目录

项目里已经有现成脚本:

cd D:\project\webokelog
python scripts/backup_sqlite.py

它会把 database.dbstatic/uploads/ 一起打到 backups/。这一步不要省,因为博客迁移出问题时,最有价值的不是“理论上能重试”,而是你能立刻回到原状态。

2. 给 MySQL 准备独立库和最小权限账号

不要直接拿 root 连应用。更稳妥的方式是单独建库、单独建用户:

CREATE DATABASE blog
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

CREATE USER 'blog_user'@'127.0.0.1' IDENTIFIED BY 'replace-with-a-strong-password';
GRANT ALL PRIVILEGES ON blog.* TO 'blog_user'@'127.0.0.1';
FLUSH PRIVILEGES;

这里最关键的是 utf8mb4。博客标题、摘要、标签里常见中文、emoji 和多语言字符,如果字符集偷懒,后面排查乱码会非常痛苦。

3. 安装 MySQL 驱动,但先别急着切生产库

这个项目的 requirements.txt 默认没有 MySQL 驱动,所以要额外安装:

pip install PyMySQL

然后在一份临时环境文件里准备目标连接:

APP_ENV=production
DATABASE_URL=mysql+pymysql://blog_user:replace-with-a-strong-password@127.0.0.1:3306/blog?charset=utf8mb4

先不要覆盖正在跑的生产 .env。迁移期间最好用一份临时配置验证,确认连通、建表、导数都成功,再正式切换。

推荐的迁移顺序

第一步:让 MySQL 先生成空表结构

这个项目使用 Flask-SQLAlchemy,应用启动后会基于模型创建表。最小做法是先让目标库得到一套干净结构:

$env:DATABASE_URL="mysql+pymysql://blog_user:replace-with-a-strong-password@127.0.0.1:3306/blog?charset=utf8mb4"
python -c "from app import app, db; ctx = app.app_context(); ctx.push(); db.create_all(); print('schema ready')"

这样做的好处是:表结构来自当前代码,而不是把 SQLite 的方言细节原样“翻译”到 MySQL。对这个项目来说,articlecategorytagarticle_tag 这些核心表都更适合按模型重建。

第二步:写一个一次性导数脚本

不要把生产切换和导数混在一起。最稳妥的是从 SQLite 读取,再写入刚建好的 MySQL。下面这个简化版脚本足够覆盖当前博客最核心的数据:

import os
import sqlite3
from app import app, db, Article, Category, Tag

SQLITE_PATH = "database.db"
os.environ["DATABASE_URL"] = (
    "mysql+pymysql://blog_user:replace-with-a-strong-password@127.0.0.1:3306/blog?charset=utf8mb4"
)

con = sqlite3.connect(SQLITE_PATH)
con.row_factory = sqlite3.Row

with app.app_context():
    db.create_all()

    categories = {}
    for row in con.execute("select * from category"):
        item = Category(name=row["name"], slug=row["slug"], description=row["description"] or "")
        db.session.add(item)
        db.session.flush()
        categories[row["id"]] = item

    tags = {}
    for row in con.execute("select * from tag"):
        item = Tag(name=row["name"], slug=row["slug"])
        db.session.add(item)
        db.session.flush()
        tags[row["id"]] = item

    for row in con.execute("select * from article order by id"):
        article = Article(
            title=row["title"],
            slug=row["slug"],
            language=row["language"] or "zh",
            translation_key=row["translation_key"] or row["slug"],
            summary=row["summary"] or "",
            content_md=row["content_md"] or "",
            content_html=row["content_html"] or "",
            cover_image=row["cover_image"] or "",
            status=row["status"] or "draft",
            is_top=bool(row["is_top"]),
            view_count=row["view_count"] or 0,
            category=categories[row["category_id"]],
            author_user_id=row["author_user_id"],
            created_at=row["created_at"],
            updated_at=row["updated_at"],
            published_at=row["published_at"],
        )
        db.session.add(article)
        db.session.flush()

        for rel in con.execute("select tag_id from article_tag where article_id = ?", (row["id"],)):
            article.tags.append(tags[rel["tag_id"]])

    db.session.commit()

如果你的站点已经启用了用户投稿、评论、广告配置、站点配置,也要把 site_usercommentad_configsite_config 等表补进去。原则很简单:先迁基础表,再迁依赖外键的表。

第三步:做切换前核对

正式修改生产 .env 之前,先核对几件事:

SELECT COUNT(*) FROM article;
SELECT COUNT(*) FROM category;
SELECT COUNT(*) FROM tag;
SELECT language, COUNT(*) FROM article GROUP BY language;
SHOW VARIABLES LIKE 'character_set%';

再启动应用做一次烟雾测试:

$env:DATABASE_URL="mysql+pymysql://blog_user:replace-with-a-strong-password@127.0.0.1:3306/blog?charset=utf8mb4"
python app.py

重点检查首页、文章详情、分类页、标签页、后台文章列表和登录是否正常。博客迁移最常见的问题不是“服务起不来”,而是页面能打开,但标签关联、发布时间、多语言路由这些细节悄悄出错。

正式切换怎么做更稳

建议安排一个低流量时间窗,按下面顺序操作:

  1. 先停止写入,避免迁移过程中 SQLite 和 MySQL 同时产生新数据。
  2. 再跑一次最终备份。
  3. 执行导数脚本。
  4. 把生产 .envDATABASE_URL 改成 MySQL。
  5. 重启 Gunicorn 或应用进程,再观察日志和后台功能。

如果你是 Gunicorn + Nginx + systemd 组合,切换后至少做两件事:一是看应用日志里有没有 OperationalError、字符集或连接失败;二是确认 SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True} 仍然保留,这对长期运行的 MySQL 连接回收很有帮助。

迁移里最容易踩的坑

1. 直接把 SQLite 文件复制到服务器就当“迁移完成”

这只能算备份,不算升级。SQLite 继续跑在生产机上,锁竞争、备份粒度、运维能力都没有改善。

2. 忘记处理字符集

如果连接串里没有 charset=utf8mb4,或者库本身不是 utf8mb4,中文标题、标签和后续内容扩展都可能埋雷。

3. 先切库,再补数据

这会让线上请求直接打到空库。正确顺序一定是:先建表、先导数、先核对,再切换流量。

4. 只核对文章数量,不核对关联关系

博客系统里真正容易悄悄出错的是 article_tag、分类归属、双语 translation_key,以及 published_at 这类看起来不起眼但直接影响展示和 SEO 的字段。

5. 没有回滚方案

最简单的回滚方式就是保留原 SQLite 文件和旧 .env,如果切换后发现问题,恢复旧连接并重启服务。不要在首次切换当天顺手删掉 database.db

我的建议:什么时候应该继续用 SQLite

如果你还是单人维护、日更不高、评论不多,SQLite 依然是很好的默认选项。不要为了“看起来更专业”而提前引入复杂度。真正值得迁移的时机,是你已经开始把博客当成一个长期在线系统来经营,需要更标准的权限、备份、排障和扩展能力。

迁移完成后,也别马上把这件事当作结束。至少补上一次定时备份演练、一次从备份恢复到测试库的演练,以及一次线上慢查询和错误日志检查。数据库升级真正带来的价值,不只是“跑在 MySQL 上”,而是后续维护动作终于有了更清晰的抓手。

总结

Flask 博客从 SQLite 迁移到 MySQL,核心并不在“把连接串改掉”,而在于把数据安全、切换顺序和回滚路径设计清楚。对这个项目而言,最实用的迁移打法是:先用现有脚本备份,按模型在 MySQL 建空表,用一次性脚本导入核心数据,做数量和页面双重校验,再在低流量窗口切换 DATABASE_URL。这样做的好处是风险可控,后续也更容易接入规范备份、监控和运维流程。