AI Coding 实录:博客从文件存储迁移到 MySQL

半天闭环的工程复盘 · 设计 / 编码 / 压测 / 测试 / 部署全由 AI 完成 —— dreamyouxi.com · 2026-06-21

这篇文章记录把个人站博客模块从「JSON 文件 + HTML 文件」存储迁移到 MySQL 的完整工程过程:数据建模、连接池抽象、读路径的「写时聚合固化」优化、中文全文搜索、访问统计的方案选型,以及一组真实压测数字。文中每段代码、每个 SQL、每条索引都来自仓库实际代码,性能数字来自实测基线文档。

关于本次迁移  AI Coding
从方案设计、数据建模、编码实现,到压测调优、自动化测试、生产部署、本文撰写,整个过程由 AI 编程助手(Claude Code)独立完成,全流程闭环耗时约半天(0.5 天)。其中性能优化的每一步都由 ApacheBench 内网单核压测量化驱动(见第 9 节优化阶梯,从 63 RPS 逐档调到 340),代码通过全套 341 项自动化测试(pytest)后才部署。本文亦由 AI 对自己提交的代码逐行核查后写成 —— 所有代码片段、SQL、索引、行号均可在仓库对应位置验证。

1. 为什么迁:动机与边界

迁移前,博客的存储是「元数据 JSON + 正文 HTML 文件」:文章元数据放 data/blogs/meta.json,正文按 id 散落在 data/blogs/posts/{id}.html。这套方案在文章量小的时候没问题,但随着文章累积到近 600 篇,两个硬需求暴露出来:

  • 数据量与查询:列表分页、分类筛选、统计聚合,全靠把整份 meta 读进内存再用 Python 过滤/排序/切片。
  • 中文全文搜索:文件方案无法做高效全文检索,只能全量扫描字符串匹配。

这两条正是关系型数据库 + 全文索引的强项,因此博客迁 MySQL。但迁移有明确边界:同一个站点的卡片(data/cards.json,约 19 KB)、作品存档(data/archive.json,约 8 KB)、站点计数、鉴权 token 不迁 —— 它们数据量极小、无搜索需求,继续用 JSON 内存态读写(纳秒级)反而比走数据库快。该用 DB 的才迁

迁移前 · 文件系统存储 data/blogs/ ├─ meta.json 596 篇元数据全挤一文件 └─ posts/ 1.html … 596.html 正文散落 596 文件 组织特点 • 元数据与正文分离,无索引、无关系 • 聚合(每类篇数 / 总数)无处固化 • 全文检索只能逐文件扫字符串 读一次列表的代价 ① 读整份 meta.json 入内存 ② Python 在内存 filter+sort+COUNT+切片 ③ 按 id 读 N 个 html 文件 渲染 ▶ 每个请求都是 O(全部文章):全量读 + 重复做聚合,文章越多越慢

图 1 · 文件系统下的组织与「读一次列表」路径

2. 整体架构:两层抽象

迁移后博客完全脱离文件 IO,读写全部走 MySQL,分两层:

文件职责
通用数据库层app/core/db.pyasyncmy 连接池 + execute / fetch_all / fetch_one 原语 + 启动 fail-fast / 测试降级
博客业务层app/services/blog.py持有 5 张表的 DDL 与全部 SQL,纯查库;不碰任何文件路径

一个关键约束:服务以 单 worker 运行(内存态架构的硬前提),所以连接池开得很小(见第 4 节)。运行时博客的正文、元数据、分类、统计、访问量五类数据全在 MySQL 库 dreamyouxi_blog;原 data/blogs/ 目录与迁移脚本在确认稳定后已彻底删除,回滚只靠 mysqldump 备份。

迁移后 · MySQL 库 dreamyouxi_blog HTTP 请求 app/services/blog.py 纯查库,不碰文件 app/core/db.py asyncmy 连接池 · minsize 1 / maxsize 5 SQL MySQL 5 表 · InnoDB · utf8mb4_0900_ai_ci posts 正文+元数据 · FULLTEXT(title,body_text) ngram · idx_sort(sticky,order) categories 含 post_count 写时固化列 post_categories 多对多 PK blog_kv TEXT 配置(page_size/next_id) blog_stats BIGINT 统计(visits/total_posts_all) 绿底 = 写时聚合固化的结构化字段(读路径直接取,零 COUNT/SUM) 读一次列表的代价 ① 一条 SQL:WHERE + ORDER BY idx_sort + LIMIT/OFFSET(total 读固化列,零 COUNT) 渲染 ▶ 每请求 O(当前页)

图 2 · MySQL 下的两层架构、5 张表与「读一次列表」路径

3. 数据模型:5 张表

建表 DDL 集中在 app/services/blog.py:71-118,统一 ENGINE=InnoDBCHARSET=utf8mb4COLLATE=utf8mb4_0900_ai_ci

3.1 posts —— 正文与元数据合表

CREATE TABLE IF NOT EXISTS posts (
    id          INT          NOT NULL PRIMARY KEY,
    title       VARCHAR(255) NOT NULL,
    summary     VARCHAR(512) NOT NULL DEFAULT '',
    cover       VARCHAR(1024) DEFAULT NULL,
    `date`      VARCHAR(32)  DEFAULT NULL,
    word_count  INT          NOT NULL DEFAULT 0,
    source      VARCHAR(16)  NOT NULL DEFAULT 'html',
    private     TINYINT      NOT NULL DEFAULT 0,
    sticky      TINYINT      NOT NULL DEFAULT 0,
    `order`     INT          NOT NULL DEFAULT 0,
    created_at  VARCHAR(32)  DEFAULT NULL,
    push_wechat_at     VARCHAR(32) DEFAULT NULL,
    push_wechat_status VARCHAR(16) DEFAULT NULL,
    body_html   MEDIUMTEXT,
    body_md     MEDIUMTEXT,
    body_text   MEDIUMTEXT,
    FULLTEXT KEY ft_search (title, body_text) WITH PARSER ngram,
    KEY idx_sort (sticky, `order`),
    CONSTRAINT chk_body_size CHECK (body_html IS NULL OR LENGTH(body_html) <= 5242880)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

几个设计点:

  • 三套正文列body_html(渲染用)、body_md(markdown 源,html 文章为 NULL)、body_text(纯文本,喂全文索引)。
  • FULLTEXT ft_search(title, body_text) WITH PARSER ngram:ngram 分词器是 MySQL 对中文全文检索的支撑(默认分词器对中文无效)。
  • KEY idx_sort (sticky, order):列表页排序键,下文分页查询直接吃这个索引。
  • CHECK (LENGTH(body_html) <= 5242880):5 MB 正文上限,数据库层兜底。

3.2 categories —— 含写时聚合列

CREATE TABLE IF NOT EXISTS categories (
    id          INT          NOT NULL PRIMARY KEY,
    slug        VARCHAR(128) NOT NULL UNIQUE,
    name        VARCHAR(128) NOT NULL,
    description VARCHAR(512) DEFAULT NULL,
    `order`     INT          NOT NULL DEFAULT 0,
    post_count  INT          NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

注意 post_count INT —— 这不是规范化设计,而是刻意的反范式:把「每个分类有多少篇可见文章」这个聚合值固化成一列,写时维护、读时直接取。理由见第 5 节。

3.3 关联表与 KV 表

-- 文章↔分类 多对多
CREATE TABLE IF NOT EXISTS post_categories (
    post_id     INT NOT NULL,
    category_id INT NOT NULL,
    PRIMARY KEY (post_id, category_id),
    KEY idx_cat (category_id)
) ENGINE=InnoDB ...;

-- 字符串配置(如 page_size、next_id)
CREATE TABLE IF NOT EXISTS blog_kv (
    k VARCHAR(64) NOT NULL PRIMARY KEY,
    v TEXT
) ENGINE=InnoDB ...;

-- 数字统计(visits / total_posts_all / total_words_sum)
CREATE TABLE IF NOT EXISTS blog_stats (
    k VARCHAR(64) NOT NULL PRIMARY KEY,
    v BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB ...;

blog_kvv TEXT)与 blog_statsv BIGINT刻意分成两张表:字符串配置和数字计数分开,数字直接走 BIGINT 列,避免把数字塞进 TEXT 再解析。

3.4 幂等建表

建表流程对新库、旧库都安全(app/services/blog.py:172-182):

async def init_schema(self) -> None:
    for ddl in _SCHEMA_DDL:
        await db.execute(ddl)                       # 5 张表 IF NOT EXISTS
    # 旧库迁移:categories.post_count 列(新库 DDL 已含,旧库幂等补列)
    col = await db.fetch_one(
        "SELECT 1 AS x FROM information_schema.columns WHERE table_schema = DATABASE() "
        "AND table_name = 'categories' AND column_name = 'post_count'")
    if not col:
        await db.execute("ALTER TABLE categories ADD COLUMN post_count INT NOT NULL DEFAULT 0")

所有 CREATE TABLEIF NOT EXISTSpost_count 列是后加的,所以用 information_schema 查在不在、不在才 ALTER —— 这样在迭代过程中重启任意次都不会出错。

4. 连接池与查询原语

通用层 app/core/db.py 用 asyncmy 建池(db.py:69-79):

_pool = await asyncmy.create_pool(
    host=cfg["host"], port=int(cfg["port"]),
    user=cfg["user"], password=cfg["password"], db=cfg["db"],
    minsize=1, maxsize=5,
    autocommit=True,
    charset="utf8mb4",
)

minsize=1 / maxsize=5 —— 因为是单 worker,连接需求很低;autocommit=True —— 博客读多写少,写也都是单语句 upsert,不需要显式事务包裹。

三个查询原语都从池里 acquire 连接、用字典游标返回(db.py:120-156):

async def execute(sql, args=None) -> int:        # 写:返回 affected rows
    async with pool.acquire() as conn:
        async with conn.cursor() as cur:
            await cur.execute(sql, args or ()); return cur.rowcount

async def fetch_all(sql, args=None) -> list[dict]:   # 多行
    async with conn.cursor(cursor=DictCursor) as cur:
        await cur.execute(sql, args or ()); return list(await cur.fetchall())

async def fetch_one(sql, args=None) -> Optional[dict]:  # 单行 or None
    ...

4.1 启动 fail-fast 与测试降级

连不上数据库时,生产环境直接退出、让进程守护拉起重试;但跑测试时不能因为没有数据库就崩 —— 于是有了「测试降级」(db.py:66, 80-90):

_testing = "pytest" in sys.modules or bool(os.environ.get("PYTEST_CURRENT_TEST"))
try:
    _pool = await asyncmy.create_pool(...)
except Exception as e:
    if _testing:
        log.warning("MySQL 不可达,测试环境降级(blog 功能 skip): %r", e)
        _pool = None; return
    print(f"[FATAL] MySQL 启动失败: {e!r} ...", file=sys.stderr)
    sys.exit(1)

配套一个 is_available() 就是 return _pool is not None,生命周期编排据它决定要不要初始化博客(第 8 节)。这样没有数据库的测试环境里,博客相关测试自动 skip,其余测试照常跑。

5. 读路径优化:写时算、读时取

这是整个迁移最值得讲的一节。迁完之后第一版反而比文件版还慢 —— 裸渲染从文件版的 535 RPS 掉到 63 RPS。原因是第一版照搬了文件版的思路:每个请求把相关数据查出来,在 Python 里 COUNT、SUM、过滤、排序、分页。换了存储,但没换数据访问模式。

博客是典型的读多写少:发文章 / 改文章 / 增删分类是低频写,浏览列表 / 看详情是高频读。于是优化方向明确 —— 把聚合搬到写时算一次并固化,读时直接取

读一次博客列表 · 两种路径对比 文件版 读整份 meta.json (全部 596 篇) Python:filter+sort +COUNT+slice(全在内存) 读 N 个 html 渲染 复杂度 O(全部文章) 裸渲染 63 RPS MySQL 版 一条 SQL:idx_sort + LIMIT N total / 分类数 = 读写时固化列(零 COUNT) 渲染 复杂度 O(当前页) 340 RPS

图 3 · 核心区别:聚合从「每请求重复算」变为「写时算一次、读时直接取」

5.1 写时:一次重算,固化结构化字段

四个写操作 —— upload_post(:485)、update_post_meta(:551)、reupload_post(:589)、delete_post(:598) —— 完成后都调用 _recompute_aggregates():244-269):

async def _recompute_aggregates(self) -> None:
    # 分类可见篇数 → categories.post_count(一次 UPDATE JOIN 维护结构化列)
    await db.execute(
        "UPDATE categories c "
        "LEFT JOIN (SELECT pc.category_id AS cid, COUNT(*) AS cnt FROM post_categories pc "
        "           JOIN posts p ON p.id = pc.post_id WHERE p.private = 0 GROUP BY pc.category_id) t "
        "ON t.cid = c.id "
        "SET c.post_count = COALESCE(t.cnt, 0)")
    # 总篇数 / 总字数 → blog_stats(BIGINT)
    tot = await db.fetch_one(
        "SELECT COUNT(*) AS n, COALESCE(SUM(word_count), 0) AS w FROM posts WHERE private = 0")
    for k, v in (("total_posts_all", int(tot["n"]) if tot else 0),
                 ("total_words_sum", int(tot["w"]) if tot else 0)):
        await db.execute(
            "INSERT INTO blog_stats (k, v) VALUES (%s, %s) AS new "
            "ON DUPLICATE KEY UPDATE v=new.v", (k, v))

一次 UPDATE ... LEFT JOIN 把每个分类的可见文章数写进 categories.post_count;一次 COUNT/SUM 把总篇数、总字数 upsert 进 blog_stats这些聚合一辈子只在写时算,高频的读路径再也不碰 GROUP BY / COUNT / SUM。

为什么是结构化列而不是一个 JSON? 中间一版曾把分类计数序列化成 JSON 存进 blog_kv,但读出来还要解析。最终改成 categories.post_count 整数列 —— 读路径直接 SELECT post_count 拿到 int,零解析。数字就该用数字列存。

5.2 读时:侧边栏零聚合

列表页侧边栏(分类列表 + 总数)原本要 GROUP BY 算每个分类篇数,现在只是两条朴素 SELECT(:271-289):

cats  = await db.fetch_all(
    "SELECT id, slug, name, description, `order`, post_count "
    "FROM categories ORDER BY `order` ASC, id ASC")   # 直接取 post_count 列
stats = await db.fetch_all("SELECT k, v FROM blog_stats")  # 无 COUNT/SUM

5.3 读时:分页吃索引,总数走固化值

分页查询(:291-331)把过滤、排序、分页全交给 MySQL 用索引完成,不再 COUNT —— 总页数所需的 total 由调用方传入:

SELECT p.id, p.title, p.summary, p.cover, p.`date`, p.word_count, p.source,
       p.private, p.sticky, p.`order`, p.created_at
FROM posts p {join}
WHERE p.private = 0 [AND pcf.category_id = %s] [AND p.id IN (...)]
ORDER BY p.sticky DESC, p.`order` DESC
LIMIT %s OFFSET %s

而 total 从哪来?看 get_list_context:333-357)—— 三种场景都用固化值,零 COUNT:

if restrict_ids is not None:                        # 搜索:命中数
    total = len(restrict_ids)
elif current_category is not None:                  # 分类:直接读 post_count 列
    total = int(current_category.get("post_count") or 0)
else:                                               # 全局:直接读 blog_stats
    total = sidebar["total_posts_all"]

5.4 读时:详情走主键

文章详情就是主键查 + 一条关联查(:359-415):

row = await db.fetch_one(
    "SELECT id, title, summary, cover, `date`, word_count, source, private, sticky, "
    "`order`, created_at, body_html FROM posts WHERE id=%s", (post_id,))
pc  = await db.fetch_all(
    "SELECT category_id FROM post_categories WHERE post_id=%s", (post_id,))

5.5 优化阶梯

从「迁完即退化」到「跑赢预期」,是一步步量出来的(裸渲染 RPS,列表 / 详情):

阶段列表详情关键改动
迁移第一版(查全表)6358照搬文件版思路,每请求 COUNT/SUM/过滤
SQL 精准查195221WHERE+ORDER BY+LIMIT/OFFSET 交给索引
写时 JSON 固化323333聚合搬到写时,读时取(JSON 版)
结构化 post_count 列340345JSON → 整数列,读路径零解析

合计 5.4–5.9×。详见第 9 节。

全文搜索是迁 MySQL 的核心动机之一。实现走 FULLTEXT + 兜底 LIKE(:219-233):

async def _fulltext_search_ids(self, q: str) -> set:
    rows = await db.fetch_all(
        "SELECT id FROM posts "
        "WHERE MATCH(title, body_text) AGAINST (%s IN NATURAL LANGUAGE MODE)", (q,))
    ids = {r["id"] for r in rows}
    if not ids:                                    # ngram 召回为空时兜底
        like = f"%{q}%"
        rows2 = await db.fetch_all(
            "SELECT id FROM posts WHERE title LIKE %s OR summary LIKE %s", (like, like))
        ids = {r["id"] for r in rows2}
    return ids

主力是 MATCH(title, body_text) AGAINST(? IN NATURAL LANGUAGE MODE),吃的是 posts 表上的 FULLTEXT ft_search ... WITH PARSER ngram。当 ngram 对极短查询词召回为空时,退化成 LIKE 标题/摘要匹配,保证结果不空。搜索返回的 id 集合,最终也是喂给第 5.3 节的分页查询(WHERE p.id IN (...)),total 直接用命中数。

7. 访问统计:方案 B

博客每篇浏览都要 +1 访问量。如果每次访问都写一次数据库,热路径就被一条无谓的 DB 写拖累。所以访问统计走「内存计数器 + 定期落库」(代码注释里称方案 B,app/core/stats.py):

_blog_visits_mem: int = 0          # 内存计数器
_blog_visits_dirty: bool = False   # 脏标记

def increment_blog_visits() -> int:        # 热路径:只动内存,零 DB IO
    global _blog_visits_mem, _blog_visits_dirty
    with _stats_bump_lock:
        _blog_visits_mem += 1
        _blog_visits_dirty = True
        return _blog_visits_mem

def _read_blog_visits() -> int:            # 读:直接返回内存值
    return _blog_visits_mem

真正的落库由后台协程每 10 秒做一次,且只在脏的时候写(stats.py:104-118):

async def flush_blog_visits() -> None:
    if not _blog_visits_dirty:
        return
    await db.execute(
        "INSERT INTO blog_stats (k, v) VALUES ('visits', %s) AS new "
        "ON DUPLICATE KEY UPDATE v=new.v", (_blog_visits_mem,))
    _blog_visits_dirty = False              # 成功后才清脏;失败下轮重试

进程启动时再从库里把上次的值读回内存(stats.py:96-101),保证重启不丢计数:

async def init_blog_visits() -> None:
    global _blog_visits_mem
    row = await db.fetch_one("SELECT v FROM blog_stats WHERE k='visits'")
    _blog_visits_mem = int(row["v"]) if row and row.get("v") is not None else 0
真相源教训:迁移脚本第一版把 visits 读错了源 —— 读了主元数据文件里一个早已停更的同名旧字段,而不是热路径实际写入的计数文件,导致历史访问量少了 11 万。结论:迁移计数类字段,必须先确认哪个文件是热路径真正在写的真相源,迁完还要拿数值和线上实际对一遍,不能只验行数。

8. 生命周期编排

启动顺序在 FastAPI lifespan 里(server.py:127-133),严格有序:

await db.startup()                              # 1. 建连接池(生产 fail-fast / 测试降级)
if db.is_available():                           # 2. 池就绪才初始化博客
    from app.services.blog import blog_service
    await blog_service.init_schema()            # 3. 建 5 张表(幂等)
    await blog_service._recompute_aggregates()  # 4. 启动固化一次聚合(幂等)
    await init_blog_visits()                    # 5. 从库恢复 visits 到内存
    _blog_visits_task = asyncio.create_task(_blog_visits_flusher())  # 6. 起 10s flusher
else:
    logging.getLogger("dreamyouxi").warning("MySQL 不可用,跳过 blog 初始化(测试降级)")

第 4 步在启动时也重算一次聚合,是为了「幂等自愈」—— 即便某次写时固化因故没成功,重启后聚合也会回到正确值。后台 flusher 是个朴素的 10 秒循环,异常只记日志不中断(server.py:99-111):

async def _blog_visits_flusher():
    while True:
        try:
            await asyncio.sleep(10)
        except asyncio.CancelledError:
            raise
        try:
            await flush_blog_visits()
        except Exception as e:
            logging.getLogger("dreamyouxi").warning(
                "flush_blog_visits failed, retry next tick: %r", e)

关闭时只 cancel 这个 task,不做最后一轮 flush —— 数据安全交给 10 秒周期,关闭路径保持简单。

9. 性能结果

同一台生产机(2 vCPU / 单核绑定 / 内网压测),用带 bench- 前缀的 User-Agent(不计入访问统计,避免压测污染)实测:

路径裸渲染 RPS单次渲染cache 命中 RPS
/blog 列表3402.94 ms~1595
/blog/post/{id} 详情3452.89 ms~1595
  • 裸渲染(绕过缓存、每次真查库真渲染):从迁移第一版的 63/58 RPS 优化到 340/345,提升 5.4–5.9×
  • 与文件版对比:文件版优化到极致是 535 RPS(0 次 DB 查询);MySQL 版 340/345,约 1 ms 的差距是数据库查询的固有成本(每请求 2–3 条全索引 SQL,无全表扫描)。这是为了「数据量 + 全文搜索」付出的、可接受的代价。
  • cache 命中:~1595 RPS,与文件版持平 —— 因为命中缓存的请求根本不查库,只回内存里的预压缩字节。

EXPLAIN 验证过:posts 的列表查询走 idx_sort 索引扫描 + LIMIT,详情走主键 const,全文搜索走 ft_search;categories / blog_stats 是几行的小表,走全表但属正常的小表优化。没有问题型全表扫描。

补一层:数据库本身的读写实测

上面的 RPS 是端到端的(HTTP → Jinja2 渲染 → SQL)。这里再补一组更靠下的数字:剥掉渲染,直接量 MySQL 这一层的读写。方法是在生产机本地、用与应用同款的异步驱动(asyncmy)走 unix socket 连库,对一份生产库的临时拷贝(597 篇 / 19 MB,测完即删,零影响线上)跑基准。配置是最严格的持久化:innodb_buffer_pool_size=64Mflush_log_at_trx_commit=1 + sync_binlog=1

读:全部亚毫秒。19 MB 的整库装得进 buffer pool,命中即内存,没有磁盘 IO。

读查询(对应页面)平均p99单连接 QPS
主键查正文(详情页)0.33 ms0.64 ms~3000
分类页(JOIN + 排序 + 分页)0.47 ms0.66 ms~2100
列表页(排序 + LIMIT/OFFSET0.94 ms1.48 ms~1060
中文全文搜索(ngram MATCH1.32 ms4.47 ms~760

并发读打满 2 核时,详情查吞吐稳定在 ~4000 QPS 平台。

写:受 fsync 限制,但够用。每次持久化提交约等于两次 fsync(redo log + binlog 各一次同步刷盘),云 SSD 上单次约 4–5 ms:

写操作单连接延迟单连接 QPS
改小字段(如拖拽排序)4.8 ms210
关联表 删 + 插(2 次提交)4.6 ms218
改正文 59 KB + 全文索引重建8.4 ms119
新建整篇 59 KB + 全文索引9.7 ms103

单线程写只有 100–210 QPS,但并发下靠 InnoDB 组提交(group commit)把多个并发提交的 fsync 批量合并:20 连接并发时持久化写吞吐爬到 ~1600 QPS。而写在本站只是 admin 发文 / 编辑的低频操作,单次 5–10 ms 完全无感。

一个被纠正的测量坑
第一版写测试用的是 UPDATE ... SET `order`=`order` —— 值没变,MySQL 判定 changed_rows=0,直接跳过 redo / binlog,根本没真刷盘,测出来虚高到 5000+ QPS。改成写入真正会变的值、确认 changed_rows=1 之后,才是上面这组真实的持久化写数字。测性能之前,先确认你测的操作真的发生了。

10. 安全与可靠性

维度做法
网络隔离MySQL 仅绑 127.0.0.1,公网不可达(安全组也不放行 3306);开发机经 SSH 隧道访问
账户隔离生产账户 blog 仅对生产库有权限;测试账户 blog_test 仅对测试库有权限、对生产库零权限
正文大小双重保险:应用层超限抛业务异常映射 413,数据库层 CHECK (LENGTH(body_html) <= 5 MB) 兜底
备份与回滚每日 mysqldump 备份;彻底切库后不保留文件回滚路径,灾难恢复唯一靠 dump 导回
启动健壮性生产连不上库 fail-fast 退出(守护进程拉起重试);测试环境自动降级 skip 博客

结语

这次迁移真正的工程价值不在「换了个存储」,而在三个判断:

  1. 边界感 —— 只迁真正需要数据库的博客(数据量 + 全文搜索),把小数据继续留在内存态,不为「统一」而统一。
  2. 访问模式比存储更重要 —— 迁完第一版反而更慢,证明换存储不换数据访问模式毫无意义;「写时算、读时取」才是读多写少场景的解法。
  3. 用结构化字段,别用 JSON 偷懒 —— 数字用数字列、配置用 KV 表,让读路径零解析。

最终博客完全脱离文件 IO,读路径零聚合运算,中文全文搜索可用,访问统计热路径零 DB IO,性能比迁移第一版快 5–6 倍。而从动手到生产上线、再到这篇复盘写完,由 AI 编程助手独立闭环、约半天(0.5 天)完成 —— 设计、编码、压测、测试、部署、文档一气呵成,性能优化全程由 ApacheBench 实测数字驱动,而非拍脑袋估计。

本文所有代码片段摘自 dreamyouxi.com 仓库实际源码(app/services/blog.py / app/core/db.py / app/core/stats.py / server.py),性能数字来自实测基线文档 PERFORMANCE.md。
撰写日期 2026-06-21。