成为全栈·基础补充·数据库迁移与事务:数据结构怎样安全演进

成为全栈·基础补充·数据库迁移与事务:数据结构怎样安全演进

迁移这件事,我在一个项目里做过一次自认为很规范的操作,然后发现它有一个会毁掉线上数据的隐患。

那次是给已有的表加一列。迁移文件写了、备份也做了、测试也过了。我在笔记里写下"已备份,可随时回滚"。

直到后来有人真的需要回滚时才发现,那句笔记是错的。

备份是在的,但用它回滚会把备份之后写入的所有合法数据一起抹掉。

这篇讲迁移的三个非直觉之处,以及一次真实事故的复盘。

迁移的难,不在写 SQL

写 ALTER TABLE ... ADD COLUMN 谁都会。难的是这三个问题:

  1. 加的这一列,老数据填什么?
  2. 新旧代码同时在跑的那段时间,谁先谁后?
  3. 出错了怎么退?

第 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 复制代码
/** 本地已有库增量升级;历史会员默认已配置,不重写任何密码。 */

第一句就是"给已有库用的" ------这句话存在的意义就是提醒下一个读代码的人:不要假设这是个全新库。

小结

迁移的三个非直觉之处:

  1. 新列的 DEFAULT 是给历史数据的意思,不是给新数据的意思。填错会让整个老用户群登不进去,而且测试测不出来。
  2. 顺序是先加列再发代码,中间状态要靠 DEFAULT 和"老代码能容忍新值"来保证。
  3. 回滚要靠可逆迁移,不是靠备份 ------ 备份只对"紧接着"有效,一旦有写入,它就是个陷阱。

另外两个技术点:

  • 幂等是迁移能安全重试的前提,因为它一定会被中断。
  • 原子性要按运行时适配,且必须返回影响行数------否则上层无法判断成功与否。

而这次事故让我记住的其实不是某个技术点,是一句方法论上的话:

"可以回滚"这句话,必须说清楚"能回滚到什么时间点"。

我笔记里写的是"可以回滚",而它真正的含义是"可以回滚到备份那一刻,且备份之后产生的数据会丢失 "。省略后半句的笔记,比不写笔记更危险------因为它给了错误的安全感。

下一篇讲配置与运行观测。它会讲 M1-34 那个 bug 的另一面:为什么"测试全绿 + 配置已上传"仍然不足以说明服务能用。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
aiks_yuan7 小时前
MySQL GROUP BY 很慢怎么优化:加了索引反而从 1 秒变 7 秒,Using temporary 该不该消(MySQL 8.2 实测 400 万行)
mysql
数据库小学妹8 小时前
MySQL大字段治理:行溢出机制、覆盖索引与拆表
数据库·mysql·性能优化
这个DBA有点耶9 小时前
MySQL 8.0不可见索引:删索引怕出事,不删又拖慢写入——中间态怎么用?
数据库·mysql·代码规范
噜哥带你玩转 AI 大模型12 小时前
面试加分项!CASE WHEN 两种写法、CTE 公共表表达式、四大类窗口函数,老哥一次讲透
mysql·窗口函数·cte·sql 进阶
bksczm12 小时前
InnoDB 内存结构总览:Change Buffer、AHI 与 Log Buffer
数据库·mysql
招风的黑耳12 小时前
SpringBoot+Vue+MySQL 民宿在线预定平台 完整可运行版(源码+文档)
vue.js·spring boot·mysql
.痞老板.15 小时前
mysql初阶--数据库设计
数据库·mysql·oracle
Decay__16 小时前
SQL Server 数据库全栈运维实战系列 — 合集索引(24 篇)
运维·数据库·sql·mysql·架构
Thanks_ks16 小时前
SQL 的 GROUP BY 与聚合函数:从会写 SELECT 到会做统计
数据库·sql·mysql·group by·数据统计·聚合函数·having