成为全栈·基础补充·数据库迁移与事务:数据结构怎样安全演进
迁移这件事,我在一个项目里做过一次自认为很规范的操作,然后发现它有一个会毁掉线上数据的隐患。
那次是给已有的表加一列。迁移文件写了、备份也做了、测试也过了。我在笔记里写下"已备份,可随时回滚"。
直到后来有人真的需要回滚时才发现,那句笔记是错的。
备份是在的,但用它回滚会把备份之后写入的所有合法数据一起抹掉。
这篇讲迁移的三个非直觉之处,以及一次真实事故的复盘。

迁移的难,不在写 SQL
写 ALTER TABLE ... ADD COLUMN 谁都会。难的是这三个问题:
- 加的这一列,老数据填什么?
- 新旧代码同时在跑的那段时间,谁先谁后?
- 出错了怎么退?
第 1 个和第 2 个是技术问题,第 3 个是决策问题------而决策一旦做错,损失不可逆。
第一件事:新列的默认值决定了谁会被影响
先看这个真实的例子。
这个项目在 M1-32 给 users 表加了一列 credentials_configured:
sql
-- 增量迁移;必须先备份并确认该列不存在。不要重放已有 0000 全量迁移。
ALTER TABLE users ADD COLUMN credentials_configured INTEGER NOT NULL DEFAULT 1;
注意那个 DEFAULT 1。
这个数字决定了所有历史用户的命运。改一下试试:
| DEFAULT | 历史用户的表现 | 后果 |
|---|---|---|
1 |
credentials_configured = 1,走正常密码验证 |
一切照旧 |
0 |
credentials_configured = 0,密码登录全部失败 |
整个系统登不进去 |
因为密码验证的第一行就是:
ts
if (!user.credentialsConfigured || !(await verifyPassword(password, user.passwordHash))) {
throw new AppError(ErrCode.USERNAME_OR_PASSWORD_ERROR, 401);
}
DEFAULT 0 会让每个老用户都撞上这个分支。

而这个错误有一个特别恶劣的性质:它不会在迁移时报错。 迁移会成功执行,代码能部署,测试也能过------因为测试用的是新库(测试库里所有用户都是迁移后建的,credentials_configured 显式写了 0)。
只有真正的老数据才会触发它。 而"测试环境没有老数据"这件事,恰恰是你最不该忘记的前提。
所以这里有一条通用规则:
加一列时,DEFAULT 的取值要回答"历史数据的含义",不是"新数据的意思"。
而反过来看新数据是显式写的:
sql
INSERT INTO users (..., credentials_configured, ...) VALUES (..., 0, ...)
默认值只服务于历史数据,新数据一律显式写。 这个分工清楚了,就不会搞混。
顺带说一个相关决策:微信用户建号时 password_hash 存的是一个随机 UUID 的哈希,而不是 null。
因为把它改成 nullable 会牵动密码验证、管理员重置、历史迁移和类型定义。"保持非空 + 用一个独立标志表达状态",比"把约束放开再在别处补判断"改动小得多。
第二件事:新旧代码并存的那段时间
任何一次涉及数据结构的发布,都有一个中间状态:新代码已经上线,旧代码还在跑(或者反过来)。
顺序错了就是事故,而两种顺序各有一种失败方式。
先加列,再发代码
text
1. 应用迁移(加列,老数据 DEFAULT 填好)
2. 部署新代码
这个顺序下,有一段时间新列存在但没有代码写它。
如果新列有 NOT NULL 约束且没有默认值,这段时间内所有 INSERT 都会失败。所以必须配一个 DEFAULT。
而一旦有了 DEFAULT,NOT NULL 就变得安全了------这也是为什么上面的迁移能写成 NOT NULL DEFAULT 1。
先发代码,再加列
这个顺序会直接崩:新代码 SELECT 一个还不存在的列,数据库报错。
除非新代码显式处理了列不存在的情况,否则一步都走不了。
所以结论很直接:先加列,再发代码。 顺序不要反。
而中间状态本身也不该被忽略:
| 阶段 | 谁在写这列 | 需要检查 |
|---|---|---|
| 迁移后、代码上线前 | 只有老代码(不写) | 新列有没有 DEFAULT |
| 新代码上线后 | 新代码写,老代码不写 | 老代码会不会因为这列被改变行为 |
| 全量之后 | 都用新代码 | --- |
中间状态最容易出的问题是"新代码写的新值,老代码读不懂"。
比如你给用户表加了一列 role,老代码遇到一个它不认识的 role 值时会不会崩?如果它用严格枚举校验,就会。
这个风险在设计阶段就要想,而不是发布时才发现。
第三件事:回滚这件事,比备份难
现在说那次事故。
当时的笔记是这么写的:
本次线上迁移采用加列、加表,没有重建用户表。代码异常时,可以优先回滚代码、保留兼容新增结构;若新用户已经写入,直接拿旧备份覆盖整库。
前半句对,后半句是明显的错误,而且我自己写下来了却没察觉。
错在哪里?
| 场景 | 拿旧备份覆盖整库 |
|---|---|
| 代码刚部署、还没有新用户 | ✅ 可以 |
| 已经有新用户注册 | ❌ 新用户的数据被抹掉 |
| 已经有新数据产生(文章、评论) | ❌ 全没了 |
而"已经有新用户"这个条件,在备份做完之后的几天内就会满足。
也就是说:备份的有效期比它看起来短得多。 它只对"紧接着的回滚"有用,一旦有写入发生,它就变成了一个危险的选项。
而更糟的是------当时那张笔记给人留下的印象是"可以回滚"。一个只知道有备份、不知道备份已过期的人,会在两周后的一次故障中,毫不犹豫地选择它。
真正安全的回滚:让迁移可逆,而不是靠备份
正确的做法是设计阶段就让迁移可逆:
sql
-- 加列的逆操作
ALTER TABLE users DROP COLUMN credentials_configured;
SQLite 从 3.35 起支持 DROP COLUMN,所以加列这类操作是真正可逆的。
而如果用了 DELETE COLUMN 会丢数据的操作(比如改类型、拆列),那就要提前准备反向迁移,而不是指望备份。
两者的区别在于:
| 靠备份回滚 | 靠逆迁移回滚 | |
|---|---|---|
| 影响范围 | 整个数据库 | 只有这次改动涉及的 |
| 备份之后的新数据 | 丢失 | 保留 |
| 执行时间 | 慢(要恢复整个库) | 快 |
| 风险 | 高 | 低 |
所以真正的安全来自"迁移可逆",不是"我有备份"。 备份是最后一道防线,不是常规手段。
顺带说,如果确实需要恢复备份,先确认"备份之后有没有写入"------而这个判断需要业务上的信息("有没有新用户注册"),不是看数据库就能知道的。
幂等:一个我一开始忽略的性质
现在讲一个技术细节,它是那个 IF NOT EXISTS 的意义所在。
上面那个微信迁移,在代码里长这样:
ts
/** 本地已有库增量升级;历史会员默认已配置,不重写任何密码。 */
export async function migrateWechat(db: Db): Promise<void> {
const columns = await db.all<{ name: string }>(sql`PRAGMA table_info(users)`);
if (!columns.some((c) => c.name === 'credentials_configured')) {
await db.run(
sql`ALTER TABLE users ADD COLUMN credentials_configured INTEGER NOT NULL DEFAULT 1`,
);
}
await db.run(sql`CREATE TABLE IF NOT EXISTS wechat_identities (...)`);
await db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS uniq_wechat_identity ON ...`);
}
两个技巧:
一、PRAGMA table_info(users) 查已有列。
这是 SQLite 查表结构的命令,返回每列的信息。先查再改,而不是直接改 ------因为 ALTER TABLE ADD COLUMN 在列已存在时会报错。
为什么这个迁移需要幂等? 因为它是给"本地已有库"用的增量升级。而本地库可能有三种状态:没跑过微信功能、跑了一半(加了列没建表)、完整跑过。
一个只考虑"全新库"和"完整旧库"的迁移,会在第三种情况下崩掉。 而第三种状态恰恰是最常见的------它是"上次执行到一半失败了"或者"我从别的分支切回来的"。
而表和索引用 IF NOT EXISTS 更直接,SQLite 原生支持。加列没有 IF NOT EXISTS(SQLite 不支持),所以要自己查。
二、迁移脚本本身也是代码,会有 bug。
说"我只执行一次就好了"是不够的------因为它会失败。网络中断、进程被杀、磁盘满。
一个跑到一半就中断的迁移,会把数据库留在中间状态。 而能安全重跑(幂等)意味着"再执行一次就好",不能安全重跑意味着"你得先搞清楚它跑到哪了"。
所以幂等不是锦上添花,它是迁移能安全重试的前提。
顺带说一个反向的陷阱:迁移文件本身不要重放。
sql
-- 增量迁移;必须先备份并确认该列不存在。不要重放已有 0000 全量迁移。
那句注释是认真的。0000 是建表的完整迁移,里面全是 CREATE TABLE(没有 IF NOT EXISTS),重放会全部报错。
而更严重的是:即使报错被忽略了,有些语句已经执行了------比如它创建了一个结构不同的表(少了后来加的列),因为表已存在而报错跳过。
结果是:一张"看起来建好了"但少了几个列的表。

事务:多语句原子性
现在讲第二个主题,也是 M1-32 里那个 atomic。
多条 SQL 要么都成功、要么都失败,这是事务的保证。问题在于这个项目的两套运行时给了不同的手段:
| 本地 SQLite | Cloudflare D1 | |
|---|---|---|
| 手段 | db.transaction(() => {...}) |
client.batch([...]) |
| 同步性 | 同步 | 异步 |
所以要包一层:
ts
/** 同一批 SQL 在 SQLite 事务或 D1 batch 中执行;异常整体回滚。 */
export async function atomic(db: Db, statements: Statement[]): Promise<number[]> {
const client = (db as unknown as { $client: Database.Database | D1Client }).$client;
if ('batch' in client) {
const results = await client.batch(
statements.map((s) => client.prepare(s.sql).bind(...s.params)),
);
return results.map((r) => r.meta.changes);
}
return client.transaction(() =>
statements.map((s) => client.prepare(s.sql).run(...s.params).changes),
)();
}
atomic 声明成 async 而本地分支同步执行,这是刻意的------业务代码拿到的永远是 Promise。
而这里最值得说的是为什么返回 changes[] 而不是 void。
因为业务上需要知道"影响了多少行":
ts
const changed = await atomic(db, [...]);
if (changed[0] !== 1) throw new AppError(ErrCode.CONFLICT, 409, '账号状态已变化,请重新登录');
如果没有返回值,"更新了 0 行"和"更新了 1 行"就无法区分------而在设置凭据这个场景里,这两件事的意义完全不同。
顺带说一个 D1 的限制:batch 里的语句不能互相引用结果(它们是并发执行的,不是顺序的)。
而我们的代码里有一条:
sql
INSERT INTO wechat_identities(app_id,open_id,user_id,created_at)
SELECT ?, ?, id, ? FROM users WHERE username = ?
它用 SELECT ... WHERE username = ? 来拿 id,而不是 lastInsertRowid() ------因为后者在多语句 batch 里不保证可靠,而 username 是这一批里我们确知唯一的标识。
一条我一开始想省掉的事
那行 PRAGMA table_info 的检查,我一开始觉得多余------理由是"迁移文件是按顺序执行的,跑一次就完了"。
我第一次真正感受到它的价值,是在一台脏了的工作机上。
那时候我改了几个分支、切换过好几次,每次都跑一次 dev:local。有几次是跑到一半我按了 Ctrl-C。
于是就有了第三种状态。 而我那个"跑一次就完了"的迁移,在这个状态下第一次跑会报错(列已存在),而报错之后我没有更多上下文去判断"它到底跑到哪了"。
加上那 7 行检查之后,同一个迁移在三种状态下都能安全执行:
| 库的状态 | 之前的迁移 | 加了检查之后 |
|---|---|---|
| 没跑过 | ✅ | ✅ |
| 完整跑过 | ❌ 列已存在 | ✅ 跳过加列 |
| 跑到一半 | ❌ 表已存在 | ✅ 跳过,继续建表 |
而"跑一半"不是我构造出来的场景,它是 Ctrl-C 留下的常态。
顺带说,这句话现在写在注释里:
ts
/** 本地已有库增量升级;历史会员默认已配置,不重写任何密码。 */
第一句就是"给已有库用的" ------这句话存在的意义就是提醒下一个读代码的人:不要假设这是个全新库。
小结
迁移的三个非直觉之处:
- 新列的 DEFAULT 是给历史数据的意思,不是给新数据的意思。填错会让整个老用户群登不进去,而且测试测不出来。
- 顺序是先加列再发代码,中间状态要靠 DEFAULT 和"老代码能容忍新值"来保证。
- 回滚要靠可逆迁移,不是靠备份 ------ 备份只对"紧接着"有效,一旦有写入,它就是个陷阱。
另外两个技术点:
- 幂等是迁移能安全重试的前提,因为它一定会被中断。
- 原子性要按运行时适配,且必须返回影响行数------否则上层无法判断成功与否。
而这次事故让我记住的其实不是某个技术点,是一句方法论上的话:
"可以回滚"这句话,必须说清楚"能回滚到什么时间点"。
我笔记里写的是"可以回滚",而它真正的含义是"可以回滚到备份那一刻,且备份之后产生的数据会丢失 "。省略后半句的笔记,比不写笔记更危险------因为它给了错误的安全感。
下一篇讲配置与运行观测。它会讲 M1-34 那个 bug 的另一面:为什么"测试全绿 + 配置已上传"仍然不足以说明服务能用。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
