Rust 服务的数据库迁移,怕的不是改表,是上线过程没退路
本地改个字段很简单,生产库改字段很紧张。真正危险的不是 SQL 写错,而是旧代码、新代码、旧数据、新数据同时存在时,系统还能不能跑。
数据库迁移经常被当成上线附属动作:代码合了,顺手跑个 migration。
小项目可以这样。服务一旦有多实例部署、灰度发布、回滚要求,迁移就必须被当成发布设计的一部分。
迁移不是“把表改成新样子”,而是让新旧代码在一段时间内都能工作。
向后兼容比一步到位更重要
最危险的迁移是这样的:
alter table users drop column name;如果旧版本服务还在运行,它可能还会读写 name 字段。字段一删,旧服务直接炸。
更稳的迁移通常拆成几个发布阶段:
新增字段
新代码双写
回填历史数据
新代码切读新字段
确认稳定后删除旧字段注意,这不是“第几步”的流程模板,而是一条兼容性原则:先让结构兼容,再让代码迁移,确认没有旧依赖后再清理。
Rust 代码里可以先表达双字段:
#[derive(Debug)]
struct UserRow {
id: u64,
name: Option<String>,
display_name: Option<String>,
}
fn visible_name(row: &UserRow) -> Option<&str> {
row.display_name.as_deref().or(row.name.as_deref())
}这段逻辑很朴素,但它给灰度发布留了空间:旧数据还没回填时,新代码也能读。
先加 nullable 字段,再回填,再加约束
给大表加非空字段很容易出问题。
不要一上来就:
alter table users add column display_name text not null;更稳的方向是:
alter table users add column display_name text;然后分批回填:
update users
set display_name = name
where display_name is null
limit 1000;不同数据库对 limit update 支持不同,真实脚本要按数据库方言调整。重点不是这句 SQL,而是“分批”。
回填完成并验证后,再加约束:
alter table users alter column display_name set not null;我的偏好是:约束一定要加,但不要在数据还没准备好时硬加。
约束是保护未来数据的,不应该成为生产迁移的惊喜炸点。
大表迁移要避免长事务和长锁
生产库最怕长时间锁表。
这些操作都要谨慎:
- 大表加带默认值的列
- 重建大索引
- 全表 update
- 修改字段类型
- 删除大字段
迁移脚本应该尽量短事务、可重复、可观察。
#[derive(Debug)]
struct BackfillPlan {
batch_size: u64,
sleep_millis: u64,
}
fn conservative_backfill_plan() -> BackfillPlan {
BackfillPlan {
batch_size: 1000,
sleep_millis: 100,
}
}这个函数不关心具体数据库,但表达了一个态度:回填任务应该主动让出资源,不要把生产库当离线计算平台。
如果是 PostgreSQL,加索引要优先考虑 create index concurrently。如果是 MySQL,要看具体版本和 DDL 算法。这里不能偷懒,迁移 SQL 必须按数据库特性写。
迁移脚本要可重复,不要靠“应该只跑一次”
迁移工具通常会记录版本,理论上脚本只跑一次。但脚本本身仍然应该尽量可重复。
比如创建索引:
create index if not exists idx_orders_user_id on orders(user_id);添加数据修复任务时,也尽量按条件更新:
update users
set status = 'active'
where status is null;Rust 侧的迁移状态可以简单表示:
#[derive(Debug, PartialEq, Eq)]
enum MigrationState {
Pending,
Applied,
Failed,
}
fn can_apply(state: MigrationState) -> bool {
matches!(state, MigrationState::Pending | MigrationState::Failed)
}真实迁移工具会处理版本和锁,但你写脚本时仍然要假设:上线过程中可能失败,可能重跑,可能只执行了一半。
回滚不等于反向执行 SQL
很多 migration 文件都有 up 和 down。但生产回滚不一定能靠 down 解决。
比如你删除了字段,数据已经没了,down 再加字段也恢复不了原值。
更现实的回滚思路是:
- 代码可以回滚到旧版本
- 数据结构在一段时间内兼容旧版本
- 危险删除延后执行
- 有备份和恢复预案
我对生产迁移的判断是:回滚能力主要来自兼容性设计,不来自 down 脚本。
down 对本地开发和测试有价值,但不要把它当生产保险。
迁移和部署顺序要写进发布说明
迁移经常出问题,是因为发布说明只写“执行 migration”。
更好的说明应该回答:
- 哪些 SQL 先执行
- 哪个版本代码开始双写
- 回填任务怎么启动
- 如何验证回填完成
- 哪个指标证明新读路径正常
- 旧字段什么时候可以删
这不是流程洁癖。数据库迁移一旦跨多个发布窗口,靠记忆一定会出错。
可以用一个结构表达发布计划:
#[derive(Debug)]
struct MigrationCheckpoint {
name: &'static str,
expected: &'static str,
}
const CHECKPOINTS: &[MigrationCheckpoint] = &[
MigrationCheckpoint { name: "column_added", expected: "new column exists" },
MigrationCheckpoint { name: "backfill_done", expected: "null count is zero" },
];真实项目里这可以变成 runbook。重点是让迁移有可检查的中间状态。
结论
数据库迁移的核心不是 SQL 技巧,而是上线期间的新旧兼容。
先扩展结构,再迁移代码和数据,确认稳定后再收缩结构;大表操作要分批、可观察、可暂停;回滚能力主要靠兼容性,不靠幻想中的反向 SQL。
Rust 服务越强调可靠性,数据库迁移越不能被当成上线脚注。