AI Coding 实录:博客从文件存储迁移到 MySQL
半天闭环的工程复盘 · 设计 / 编码 / 压测 / 测试 / 部署全由 AI 完成 —— dreamyouxi.com · 2026-06-21
这篇文章记录把个人站博客模块从「JSON 文件 + HTML 文件」存储迁移到 MySQL 的完整工程过程:数据建模、连接池抽象、读路径的「写时聚合固化」优化、中文全文搜索、访问统计的方案选型,以及一组真实压测数字。文中每段代码、每个 SQL、每条索引都来自仓库实际代码,性能数字来自实测基线文档。
从方案设计、数据建模、编码实现,到压测调优、自动化测试、生产部署、本文撰写,整个过程由 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 的才迁
图 1 · 文件系统下的组织与「读一次列表」路径
2. 整体架构:两层抽象
迁移后博客完全脱离文件 IO,读写全部走 MySQL,分两层:
| 层 | 文件 | 职责 |
|---|---|---|
| 通用数据库层 | app/core/db.py | asyncmy 连接池 + execute / fetch_all / fetch_one 原语 + 启动 fail-fast / 测试降级 |
| 博客业务层 | app/services/blog.py | 持有 5 张表的 DDL 与全部 SQL,纯查库;不碰任何文件路径 |
一个关键约束:服务以 单 worker 运行(内存态架构的硬前提),所以连接池开得很小(见第 4 节)。运行时博客的正文、元数据、分类、统计、访问量五类数据全在 MySQL 库 dreamyouxi_blog;原 data/blogs/ 目录与迁移脚本在确认稳定后已彻底删除,回滚只靠 mysqldump 备份。
图 2 · MySQL 下的两层架构、5 张表与「读一次列表」路径
3. 数据模型:5 张表
建表 DDL 集中在 app/services/blog.py:71-118,统一 ENGINE=InnoDB、CHARSET=utf8mb4、COLLATE=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_kv(v TEXT)与 blog_stats(v 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 TABLE 带 IF NOT EXISTS;post_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、过滤、排序、分页。换了存储,但没换数据访问模式。
博客是典型的读多写少:发文章 / 改文章 / 增删分类是低频写,浏览列表 / 看详情是高频读。于是优化方向明确 —— 把聚合搬到写时算一次并固化,读时直接取。
图 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。
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,列表 / 详情):
| 阶段 | 列表 | 详情 | 关键改动 |
|---|---|---|---|
| 迁移第一版(查全表) | 63 | 58 | 照搬文件版思路,每请求 COUNT/SUM/过滤 |
| SQL 精准查 | 195 | 221 | WHERE+ORDER BY+LIMIT/OFFSET 交给索引 |
| 写时 JSON 固化 | 323 | 333 | 聚合搬到写时,读时取(JSON 版) |
| 结构化 post_count 列 | 340 | 345 | JSON → 整数列,读路径零解析 |
合计 5.4–5.9×。详见第 9 节。
6. 中文全文搜索
全文搜索是迁 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
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 列表 | 340 | 2.94 ms | ~1595 |
/blog/post/{id} 详情 | 345 | 2.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=64M、flush_log_at_trx_commit=1 + sync_binlog=1。
读:全部亚毫秒。19 MB 的整库装得进 buffer pool,命中即内存,没有磁盘 IO。
| 读查询(对应页面) | 平均 | p99 | 单连接 QPS |
|---|---|---|---|
| 主键查正文(详情页) | 0.33 ms | 0.64 ms | ~3000 |
分类页(JOIN + 排序 + 分页) | 0.47 ms | 0.66 ms | ~2100 |
列表页(排序 + LIMIT/OFFSET) | 0.94 ms | 1.48 ms | ~1060 |
中文全文搜索(ngram MATCH) | 1.32 ms | 4.47 ms | ~760 |
并发读打满 2 核时,详情查吞吐稳定在 ~4000 QPS 平台。
写:受 fsync 限制,但够用。每次持久化提交约等于两次 fsync(redo log + binlog 各一次同步刷盘),云 SSD 上单次约 4–5 ms:
| 写操作 | 单连接延迟 | 单连接 QPS |
|---|---|---|
| 改小字段(如拖拽排序) | 4.8 ms | 210 |
| 关联表 删 + 插(2 次提交) | 4.6 ms | 218 |
| 改正文 59 KB + 全文索引重建 | 8.4 ms | 119 |
| 新建整篇 59 KB + 全文索引 | 9.7 ms | 103 |
单线程写只有 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 博客 |
结语
这次迁移真正的工程价值不在「换了个存储」,而在三个判断:
- 边界感 —— 只迁真正需要数据库的博客(数据量 + 全文搜索),把小数据继续留在内存态,不为「统一」而统一。
- 访问模式比存储更重要 —— 迁完第一版反而更慢,证明换存储不换数据访问模式毫无意义;「写时算、读时取」才是读多写少场景的解法。
- 用结构化字段,别用 JSON 偷懒 —— 数字用数字列、配置用 KV 表,让读路径零解析。
最终博客完全脱离文件 IO,读路径零聚合运算,中文全文搜索可用,访问统计热路径零 DB IO,性能比迁移第一版快 5–6 倍。而从动手到生产上线、再到这篇复盘写完,由 AI 编程助手独立闭环、约半天(0.5 天)完成 —— 设计、编码、压测、测试、部署、文档一气呵成,性能优化全程由 ApacheBench 实测数字驱动,而非拍脑袋估计。