Files
blog/content/posts/rename-cascade-data-loss.md

88 lines
4.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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. **有备份**。迁移前刚打了 `cattype` 那次重建的备份,恢复 `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 很贴心地帮我把外键也"改名"了——贴心到把我的数据一起删了。
---
*本文已脱敏,不含真实域名、密钥或用户数据。*