背景
给资源分类加一个 boot_disk(USB 启动盘)类型。原表 categories 的 type 字段 CHECK 约束里没有这个值,得改约束。
当时没多想,用了最常见的「重建表」套路——但这是在 SQLite 里,规矩和 MySQL 不太一样。
事故:三步走,第二步就出事
| |
第一步就埋了雷。
SQLite 的 RENAME 不是简单的改名——它会自动把引用这张表的其它外键一并改指向。resources 表里有一条 REFERENCES categories(id) ON DELETE CASCADE,被自动改成了指向 categories_old。
于是第三步 DROP TABLE categories_old 的时候,ON DELETE CASCADE 触发:resources 全部 235 行被级联删除。
恢复 resources 又 DROP 了一次,结果再次级联,把引用它的 resource_channels 又清了 235 行。
一圈下来:一个加枚举值的需求,干掉了两个表 470 行数据。
为什么 SQLite 会这样
MySQL/PostgreSQL 里,ALTER TABLE ... RENAME 是原子的、不碰外键的。但 SQLite 的 RENAME 是"改内部引用"——所有指向这张表的外键定义,会被一起改写。
关键差异:
| 数据库 | RENAME 行为 | DROP 影响 |
|---|---|---|
| MySQL | 只改表名,外键不动 | 有关联记录时 DROP 会报错拦住 |
| SQLite | 外键引用跟着改名 | 改名后 DROP 旧名 → 触发 CASCADE |
MySQL 的 CASCADE 删除,其实绝大多数时候也不会真的删干净——因为 DROP 会因关联记录被约束拦下。而 SQLite 这一套组合拳(改名传播 + DROP 级联)没有拦阻,直接删。
救回来的三条命
这单没出人命,靠三件事:
- 有备份。迁移前刚打了
cattype那次重建的备份,恢复resources235 行。 - 恢复时发现二次级联。
DROP resources又清了resource_channels,也是靠同一份备份恢复。 - 审计脚本兜底。迁移后不是只看表面,而是逐表核对行数,才确认两个表都受损、都恢复到位。
数据完整性最后核对通过(唯一差异是运行期正常新增的访问日志)。
教训(已写成铁律)
🔴 生产库迁移铁律(2026-08-26):涉及有外键的表做 ALTER/RENAME/DROP 前,必须先
PRAGMA foreign_keys = OFF(迁移脚本内关,完成后 ON)。
补几条更通用的:
- SQLite 改有外键的表结构,先关外键。
PRAGMA foreign_keys = OFF再动手,做完开回来。 - 优先"不重建原表"的方式。SQLite 的
ALTER TABLE能力有限(3.25+ 支持RENAME COLUMN、3.35+ 支持DROP COLUMN,但不支持ADD CONSTRAINT),所以改 CHECK 约束往往只能重建——那就更要先关外键。 - 任何迁移前必须备份。 今天能救回来全因为那行
cp。 - 迁移后必须全表行数核对。 别只看"没报错"——级联删除往往是"零报错的静默灾难",靠的是数量审计揪出来。
- 换一种姿势问自己:在 SQLite 上,“重建表"这种在 MySQL 顺手的习惯,恰恰是最危险的路径。
一个更大的背景
这件事只是今天一整天 win-ippt 重构里的一环——同一个会话里,还做了 6 分类接口重构(boot_disk 正是这次迁移要支持的)、CDN 本地化提速 3000 倍、设备快照补全、减法重构归档死代码、Shlink 密钥轮换……功能链路很长。
但唯独这一条值得单独立传:它是今天唯一「如果没备份就真的丢数据」的时刻,也是唯一一条升级成了铁律的教训。
迁移工具越"智能”,越要警惕它替你做的决定。SQLite 的 RENAME 很贴心地帮我把外键也"改名"了——贴心到把我的数据一起删了。
本文已脱敏,不含真实域名、密钥或用户数据。