sqlite-utils 4.2 发布了,亮点是 table.transform() 功能变得更靠谱:它现在能完整保留 check 约束、unique 约束,甚至列注释这种以前一改表就容易丢的边缘信息。听起来是个正常的小版本迭代。

但发布没多久,一个会导致程序崩溃的 bug(issue #842)就被人发现了,团队紧急出了 4.2.1 修复。这个“先发后崩”的节奏不是巧合——它正好暴露了一件很多人不知道的事:SQLite 这个数据库,原生几乎改不了表。

发生了什么

  • 改进.transform() 保留 check 约束、unique 约束、列注释,新增 check 约束的 introspection 属性
  • 参与.5 位社区贡献者提交代码
  • 意外.4.2 发布后很快暴露崩溃 bug,4.2.1 紧急修复
  • 谁在意.用 sqlite-utils 做数据处理、Datasette 生态开发的人,以及在生产环境跑 SQLite 迁移的团队

SQLite 为什么改不了表

SQLite 原生的 ALTER TABLE 能做的事情少得可怜:改表名、改列名,某些条件下能加删列。想改列类型、加约束、调整列顺序、换主键——统统不支持。这不是 bug,是 SQLite 从设计上就没打算支持。

这有点像一栋已经浇筑好承重结构的房子,想挪一根柱子——常规办法根本不存在,唯一的路是拆了重建。SQLite 生态里管这叫“重建表”范式:建一张新 schema 的表,把数据拷过去,删掉旧表,把新表改名顶替,期间还要手动重建索引、触发器、视图,事后跑 PRAGMA 校验数据完整性。

table.transform() 干的就是这件事——把这一整套繁琐手工活封装成一次函数调用。

“重建表”四步走 建新表 新 schema 拷贝数据 逐行迁移 删旧表 原表下线 改名顶替 迁移完成 transform() 把这四步封装成一次函数调用

改进有边界,不是万能钥匙

4.2 版本确实解决了一部分痛点:check 约束、unique 约束、列注释以前很容易在重建表过程中悄悄丢失,现在能保住了。

  • 风险.复杂或手写的 CHECK 约束仍可能无法完美还原;表达式索引、部分索引、生成列、虚拟表需要额外核对;外键循环和被外部引用的表会让替换过程更复杂;视图、触发器的依赖关系有时得手动重建

也就是说,“保留了更多边缘 case”这句话背后,边界依然存在。它只是把一部分手工活自动化,不是把整套问题解决了。

拿它去对比 Alembic 会更清楚这一点。Alembic 是 SQLAlchemy 生态里的正式迁移框架,有版本历史、能升能降、有安全校验。sqlite-utils 的 transform() 从来不是要取代它,而是给脚本化、数据处理场景提供一个便利层。

两种工具,两种定位 sqlite-utils 脚本化便利层 一次函数调用 无版本历史 适合原型 / 数据处理 Alembic 正式迁移框架 版本历史 + 升降级 SQLAlchemy 集成 适合生产环境

生产环境要多算一笔账

如果数据库跑在 LiteFS 这类复制方案上,情况更复杂。LiteFS 本身不管 schema 版本,也不检查应用兼容性,它只负责复制。重建表式迁移意味着长时间占用写锁、产生大量复制流量,部署顺序没排对,就有失败风险。这是原文完全没提、却直接决定这次更新能不能用于生产的关键变量。

4.2 发布后很快暴露的崩溃 bug,某种程度上正是这种敏感度的注脚——建新表、拷数据、删旧表、改名,任何一步在边缘情况下出岔子,都可能是一次崩溃。

便利是省下来的时间,风险是没显形的代价。

我更在意的是,这次事故被处理得很轻:发布说明末尾一句“后来发现有个崩溃 bug,4.2.1 修了”,语气平淡得像顺手改了个拼写错误。但重建表这套操作本身技术复杂度不低,能这么快定位问题、发新版本修复,说明维护响应是靠谱的——这一点该给。

真正该记住的是另一件事:sqlite-utils 帮你省掉了手写重建表的麻烦,但没有,也从来没打算,替你承担 Alembic 那种版本化和安全校验的责任。拿它去做原型和数据清洗,是它的强项;拿它去做生产环境的 schema 迁移而不做额外校验,风险要自己扛。