后端与架构
在 SQLite 上修改列类型,为何会悄无声息地删掉另一张表的数据
阅读需 6 分钟
在 SQLite 上修改列类型,为何会悄无声息地删掉另一张表的数据
在把这个网站的内容改造成多语言时,我写了一个迁移:把几个文本列改成 {"en": "...", "es": "...", "zh": "..."} 形式的 JSON,并把已有的值保留为英文版本。生产环境用的是 MySQL;测试和本地环境用的是 SQLite,并启用了 RefreshDatabase。这个迁移看起来毫无风险,但在测试套件里,它让另一张表的数据消失了,而且没有任何报错。
现象
受影响的列中有一个在 users 表上。projects 表有一个指向 users 的外键 user_id,并设置了 ON DELETE CASCADE。检查数据转换的那个测试会先插入一些旧数据,再执行迁移,然后检查结果。迁移之后,用户还在,列也正确转换了,但这个用户的项目全都不见了。
没有异常,没有失败的查询,日志里也没有警告。唯一的信号是一个断言:它期望查到数据,结果一条也没有。
SQLite 为什么要重建整张表
SQLite 的 ALTER TABLE 是有意设计得很精简的:可以重命名表、重命名列、新增列,在较新的版本中还可以删除列,但不能修改已有列的类型。任何提供"修改列"功能的工具,包括 Laravel 的 ->change(),都只能按照 SQLite 官方文档推荐的步骤来模拟:
CREATE TABLE __temp__users (...新的定义...);
INSERT INTO __temp__users (...) SELECT ... FROM users;
DROP TABLE users;
ALTER TABLE __temp__users RENAME TO users;
对 users 表本身的数据来说,这样做没有问题:每一行都会被复制,并以原来的表名回来。问题出在第三行。
级联删除从哪里来
SQLite 文档写得很明确:当外键约束启用时,DROP TABLE 会在删除表之前,先对这张表执行一次隐式的 DELETE FROM。这次隐式删除和普通删除没有区别,所以所有指向这张表的 ON DELETE CASCADE 都会被触发。
在我的场景里,删除旧的 users 表时,先删掉了所有用户行,级联删除随之删掉了所有指向这些用户的项目,然后重命名后的副本又把用户"带了回来"。父表在重建后完好无损,子表却没有。整个过程中没有任何环节把这当作错误,因为在 SQLite 看来一切正常:删除了一行,而规则要求同时删除它的子记录。
为什么关闭外键约束也没用
这种重建流程的标准做法,是在重建前执行 PRAGMA foreign_keys = OFF,结束后再打开;Laravel 在 SQLite 上重建表时也会尝试这样做。但在我的测试里,这层保护根本没有生效。
原因是 SQLite 文档里的另一句话:PRAGMA foreign_keys 在事务内部不起作用。事务一旦开始,就无法再开启或关闭外键检查。RefreshDatabase 会把每个测试包在一个事务里,以便结束后全部回滚,而我的转换测试恰好是在这个测试内部执行迁移的。PRAGMA 语句照常执行,没有报任何错,外键约束却依然开着。
这正是这个 bug 如此隐蔽的原因:每一个环节都完全按照文档描述在工作。
解决办法
其实我根本不需要重建这些表。现在迁移会先向数据库查询每一列当前的类型,只对类型确实不同的列调用 ->change():
foreach (self::FIELDS as $table => $columns) {
$pending = array_filter(
$columns,
fn ($column) => Schema::getColumnType($table, $column) !== 'text'
);
if ($pending !== []) {
Schema::table($table, function (Blueprint $t) use ($pending) {
foreach ($pending as $column) {
$t->longText($column)->nullable()->change();
}
});
}
}
类型已经一致的列,不会重建,不会 DROP,也就不会触发级联。类型确实不同的列,修改照常进行。数据转换是单独的一步,只包装那些还不是 JSON 对象的值,所以迁移执行两次结果也完全相同。
如果你确实需要修改一张被其他表以级联方式引用的表的列类型,请确保重建在事务之外进行(Laravel 迁移可以通过 public $withinTransaction = false; 关闭事务),同时关闭外键约束,完成后再用 PRAGMA foreign_key_check 检查数据完整性。并且一定要用真实数据测试,而不只是空表结构。
给使用 SQLite 的 Laravel 项目的几点建议
- 测试里的 SQLite 不是一个缩小版的 MySQL。尤其是表结构变更,规则完全不同:在 MySQL 上一次轻量的
ALTER,在 SQLite 上可能是整张表的重建。 - 级联导致的数据丢失不会抛出错误。如果迁移涉及被其他表依赖的表,就写一个测试:先创建子表数据,迁移后再数一遍。
- 让迁移尽量小,并且可以重复执行。只修改真正需要修改的部分,不仅更整洁,在这里它本身就是解决方案。
- 花点时间读一遍 SQLite 关于
ALTER TABLE和外键的文档。导致这个 bug 的两种行为,那里都写得清清楚楚。
这个 bug 从未进入生产环境,因为生产环境不用 SQLite。但测试只有在行为与被保护的代码一致时才有意义,而有一段时间,我的测试一直在默默地检查一个刚刚丢掉一半数据的数据库。