From 0c6027229c98dd2efb1fdffc1a684e8f5b0683c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=99=BE=E5=A7=90?= Date: Wed, 26 Aug 2026 18:46:56 +0800 Subject: [PATCH] =?UTF-8?q?post:=20=E4=B8=80=E6=9D=A1=20RENAME=20=E6=B8=85?= =?UTF-8?q?=E4=BA=86=20235=20=E8=A1=8C=E6=95=B0=E6=8D=AE(SQLite=20CASCADE?= =?UTF-8?q?=20=E4=BA=8B=E6=95=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/posts/rename-cascade-data-loss.md | 87 +++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 content/posts/rename-cascade-data-loss.md diff --git a/content/posts/rename-cascade-data-loss.md b/content/posts/rename-cascade-data-loss.md new file mode 100644 index 0000000..2715381 --- /dev/null +++ b/content/posts/rename-cascade-data-loss.md @@ -0,0 +1,87 @@ +--- +title: "一条 RENAME 清了 235 行数据" +description: "给 SQLite 表加个枚举值,两步 RENAME 迁移,把两个表 470 行数据级联删了个干净。备份救回来了,但教训值得复盘:SQLite 的 RENAME 会偷偷传播外键引用。" +date: 2026-08-26T18:44:56+08:00 +draft: false +tags: + - 数据库 + - SQLite + - 迁移 + - 事故 +--- + +## 背景 + +给资源分类加一个 `boot_disk`(USB 启动盘)类型。原表 `categories` 的 `type` 字段 CHECK 约束里没有这个值,得改约束。 + +当时没多想,用了最常见的「重建表」套路——但这是在 **SQLite** 里,规矩和 MySQL 不太一样。 + +## 事故:三步走,第二步就出事 + +```sql +-- ① 改约束最通用的做法:先改名让路 +ALTER TABLE categories RENAME TO categories_old; + +-- ② 重建带新 CHECK 的新表 +CREATE TABLE categories (... CHECK (type IN ('os_images', 'boot_disk', ...))); + +-- ③ 把数据拷回去,删掉旧表 +DROP TABLE categories_old; +``` + +**第一步就埋了雷。** + +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 级联)**没有拦阻,直接删**。 + +## 救回来的三条命 + +这单没出人命,靠三件事: + +1. **有备份**。迁移前刚打了 `app.db.bak-cattype-20260826131355`,恢复 `resources` 235 行。 +2. **恢复时发现二次级联**。`DROP resources` 又清了 `resource_channels`,也是靠同一份备份恢复。 +3. **审计脚本兜底**。迁移后不是只看表面,而是逐表核对行数,才确认两个表都受损、都恢复到位。 + +数据完整性最后核对通过(唯一差异是运行期正常新增的访问日志)。 + +## 教训(已写成铁律) + +> **🔴 生产库迁移铁律(2026-08-26)**:涉及有外键的表做 ALTER/RENAME/DROP 前,必须先 `PRAGMA foreign_keys = OFF`(迁移脚本内关,完成后 ON)。 + +补几条更通用的: + +1. **SQLite 改有外键的表结构,先关外键。** `PRAGMA foreign_keys = OFF` 再动手,做完开回来。 +2. **优先"不重建原表"的方式**。SQLite 的 `ALTER TABLE` 能力有限(3.25+ 支持 `RENAME COLUMN`、3.35+ 支持 `DROP COLUMN`,但**不支持 `ADD CONSTRAINT`**),所以改 CHECK 约束往往只能重建——那就更要先关外键。 +3. **任何迁移前必须备份。** 今天能救回来全因为那行 `cp`。 +4. **迁移后必须全表行数核对。** 别只看"没报错"——级联删除往往是"零报错的静默灾难",靠的是数量审计揪出来。 +5. **换一种姿势问自己**:在 SQLite 上,"重建表"这种在 MySQL 顺手的习惯,恰恰是最危险的路径。 + +## 一个更大的背景 + +这件事只是今天一整天 win-ippt 重构里的一环——同一个会话里,还做了 6 分类接口重构(`boot_disk` 正是这次迁移要支持的)、CDN 本地化提速 3000 倍、设备快照补全、减法重构归档死代码、Shlink 密钥轮换……功能链路很长。 + +但**唯独这一条值得单独立传**:它是今天唯一「如果没备份就真的丢数据」的时刻,也是唯一一条升级成了铁律的教训。 + +> 迁移工具越"智能",越要警惕它替你做的决定。SQLite 的 RENAME 很贴心地帮我把外键也"改名"了——贴心到把我的数据一起删了。 + +--- + +*本文已脱敏,不含真实域名、密钥或用户数据。*