从 JSON 文件到 MySQL:一次个人项目的数据迁移
家庭自用项目起步时,用一个大 JSON 文件当数据库是完全合理的:零运维、肉眼可读、
fs.writeFileSync 一把梭。但当一个文件里挤着几万条作业记录、
每次请求都要整读整写、还开始出现并发写坏的担忧时,就是该迁数据库了。
先定边界:所有读写收敛到一个模块
好在项目一开始就把持久化收敛在单个 db.ts 里,业务层只调用函数不知道存储形态。
迁移的实际工作量就变成了:把每个函数的「读写 JSON」换成「执行 SQL」。
如果你还没有这种收敛层,先做收敛再谈迁移。
幂等建表 + 懒初始化
个人项目没有独立的运维步骤,建表放在应用里,首次查询时幂等执行:
CREATE TABLE IF NOT EXISTS assignments (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
type VARCHAR(16) NOT NULL,
title VARCHAR(120) NOT NULL,
words JSON NOT NULL,
status VARCHAR(12) NOT NULL DEFAULT 'pending',
created_at VARCHAR(32) NOT NULL,
INDEX idx_user (user_id, status)
);
两个值得说的决定:
-
词语、批改明细这类强伴随数据直接存
JSON列, 不为它们建表。个人项目里关系查询需求远少于「整存整取」,JSON 列让表结构稳定很多。 -
时间存 UTC 字符串而不是
DATETIME, 读写都由 Node 侧完成格式化,彻底绕开 MySQL 时区这个经典大坑。
老表补列走 information_schema 检查 + ALTER,
同样幂等——这样「升级版本」和「首次部署」走的是同一条代码路径。
迁移脚本:可重复执行,不删旧数据
迁移脚本的原则有三条:幂等(跑一百遍结果一样,按业务主键判重)、 不删旧 JSON(留作备份,出问题还能回看)、可指定范围 (只迁某个用户,方便先拿自己的账号试)。
// 伪代码:核心就是一个按唯一键去重的 upsert
for (const item of oldData.assignments) {
await conn.execute(
'INSERT INTO assignments (id, user_id, ...) VALUES (?, ?, ...) ' +
'ON DUPLICATE KEY UPDATE title = VALUES(title), ...',
[item.id, item.userId, ...]
);
}
平铺的图片目录也顺势改成 uploads/<userId>/<name>
的结构,配一个按用户隔离的读取路由——数据「按用户隔离」这件事,
从存储层就该开始做,而不是只靠业务代码自觉。
一个打包相关的坑
迁到 MySQL(mysql2)后,生产构建开始 500。原因是打包器把
mysql2 也 bundle 了,而它依赖 string_decoder 这类
Node 内置模块。解法是把它加进 serverExternalPackages,
让它运行时从 node_modules 直接 require。同理,启动期钩子里
import 数据库层也会触发同类打包问题——建表还是要走查询时的懒初始化。
总结
- 持久化先收敛到一个模块,迁移成本会低一个数量级;
- 个人项目的表结构可以「宽 JSON 列 + 少量索引」,别过度设计;
- 迁移脚本幂等 + 不删源数据,半夜跑错也不怕;
- 时间统一由应用侧写 UTC 字符串,绕开数据库时区。