返回文章列表

Rust 服务的数据库迁移,怕的不是改表,是上线过程没退路

208·2 分钟阅读
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 文件都有 updown。但生产回滚不一定能靠 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 服务越强调可靠性,数据库迁移越不能被当成上线脚注。