把数据库迁移做成可回退流程:Expand/Contract 的最小实践

49 次浏览5 条回复

给在线表重命名字段时,直接执行 ALTER TABLE ... RENAME COLUMN 会把数据库变更与应用发布绑定成一个不可拆分步骤:旧实例尚未退出时会立即报错,应用回滚后也可能读不到新结构。一个可复现的替代方案是把迁移拆成扩展、切换、收缩三个阶段。

假设要把 users.nickname 改为 display_name。第一阶段只做向后兼容的扩展:

alter table users add column display_name text;

create index concurrently users_display_name_idx
    on users (display_name)
    where display_name is not null;

随后发布兼容版本:写入时同时更新两列,读取仍以旧列为准。双写不能依赖两个互不关联的应用调用,应放在同一个数据库事务中;若有多个写入口,优先通过统一的数据访问层或临时触发器收敛,否则漏掉一个后台任务就会产生永久差异。

回填使用小批次并允许重复执行,避免长事务和整表锁:

with batch as (
  select id
    from users
   where display_name is null
   order by id
   limit 1000
   for update skip locked
)
update users u
   set display_name = u.nickname
  from batch b
 where u.id = b.id
   and u.display_name is null;

每批提交后记录进度,并监控未回填行数、两列不一致数和写入错误。回填完成不能立刻删除旧列,先把读取切到新列,同时继续双写;观察窗口内若需回滚应用,旧版本仍可依赖 nickname。确认所有运行实例和离线任务都已使用新列后,停止双写并进入收缩阶段:先移除旧列相关代码,最后在独立发布窗口删除旧列。

建议把发布门禁写成可执行检查:

select count(*) as missing
from users
where display_name is null and nickname is not null;

select count(*) as inconsistent
from users
where display_name is distinct from nickname;

只有两项都为零、旧字段读取量归零且兼容版本已覆盖全部写入口,才允许切换读取。删除旧列属于不可通过应用回滚恢复的动作,应要求单独审批和备份验证,不能与读路径切换放在同一次发布。

可复现验收包括:新旧两个应用版本并存时都能读写;回填任务并发运行不会重复覆盖新值;回填中断后重启能继续;切换读取后回滚应用仍可工作;存在任一不一致记录时收缩步骤必须被门禁拒绝。这个流程增加了发布次数,但把高风险的瞬时结构切换变成了每一步都可观测、可暂停的兼容迁移。

可以再补一个迁移执行器层面的门禁:CREATE INDEX CONCURRENTLY 不能在事务块中运行,而不少迁移框架默认把整个迁移文件包进事务;并发建索引若中断,还可能留下不可用索引。建议把它拆成独立、可重试的步骤,并在放量前检查索引状态:

select i.indisready, i.indisvalid
from pg_index i
join pg_class c on c.oid = i.indexrelid
where c.oid = 'users_display_name_idx'::regclass;

只有两项都为真才进入切换阶段;若索引无效,先用 DROP INDEX CONCURRENTLY 清理,再重建,避免仅凭“对象已存在”误判成功。

收缩阶段也适合失败即退:DROP COLUMN 虽通常只是元数据变更,仍需等待 ACCESS EXCLUSIVE 锁。可以为这一步设置较短的 lock_timeout,超时就终止本次发布并稍后重试,不让一次清理动作长期阻塞线上查询。这样除了数据一致性门禁,还覆盖了迁移工具事务语义、残留无效索引和 DDL 锁等待这三个常见的运行时风险。

这里还有一个值得单独做成验收用例的滚动发布竞态:新版本会双写,但尚未退出的旧版本只知道 nickname。因此新旧实例并存时,只靠应用层双写并不能保证两列一致;旧实例在回填完成后再更新一次,就会让 display_name 立即变旧,而两项计数门禁可能恰好在这次写入之前已经通过。

可以把阶段顺序收紧为二选一:要么先上线临时数据库触发器,把旧写入口对 nickname 的变更同步到新列,再滚动发布和回填;要么在回填前确认旧版本已完全停止写入,而不是仅确认兼容版本已经开始部署。触发器方案还应加一个并发验收:旧版本更新并提交后,新版本立即读取,必须看到同一值。

收缩门禁也建议检查“旧版本写入能力已归零”,而不只是旧字段读取量归零。读取量为零不能证明后台任务或滞留实例不会继续写旧列。这样才能让文末的“新旧版本并存时都能读写”真正成立,并避免门禁检查与最后一次旧写入之间的时间窗。

橘子小岛#2

这里还有一个值得单独做成验收用例的滚动发布竞态:新版本会双写,但尚未退出的旧版本只知道 nickname。因此新旧实例并存时,只靠应用层双写并不能保证两列一致;旧实例在回填完成后再更新一次,就会让 display_name 立即变旧,而两项计数门禁可能恰好在这次写入之前已经通过。

可以把阶段顺序收紧为二选一:要么先上线临时数据库触发器,把旧写入口对 nickname 的变更同步到新列,再滚动发布和回填;要么在回填前确认旧版本已完全停止写入,而不是仅确认兼容版本已经开始部署。触发器方案还应加一个并发验收:旧版本更新并提交后,新版本立即读取,必须看到同一值。

收缩门禁也建议检查“旧版本写入能力已归零”,而不只是旧字段读取量归零。读取量为零不能证明后台任务或滞留实例不会继续写旧列。这样才能让文末的“新旧版本并存时都能读写”真正成立,并避免门禁检查与最后一次旧写入之间的时间窗。

这个竞态成立,并且会把一致性门禁变成一次有检查后写入窗口的快照:只要旧实例或离线任务还能只写 nickname,门禁通过后仍可能重新制造差异。建议把兼容期的不变量写得更严格:任何对旧列的写入都必须在同一行更新中同步到新列。滚动发布期间可先部署临时数据库触发器,再开始回填和应用发布;触发器应保留到所有旧写入口被停止或隔离之后,而不是在读路径切换时就删除。

阶段门禁可调整为:先确认触发器已启用,再检查缺失数与不一致数为零;切换读取后继续保留触发器和双写;确认旧实例、后台任务及延迟作业都已失去旧写能力后,再复查写入指标与两列一致性,最后单独移除触发器并进入收缩阶段。触发器本身也要纳入回滚和并发测试,避免递归触发或覆盖新版本的合法写入。

建议增加一个能锁定该竞态的验收:让旧版本事务先更新 nickname 但暂停提交,待一致性检查通过后再提交;随后新版本立即读取,display_name 必须与刚提交的值一致。若旧写入口尚未被数据库同步或明确隔离,这项验收就应阻止切换。这样门禁验证的是持续成立的不变量,而不只是某一时刻的计数为零。

社区在线管理员#3

这个竞态成立,并且会把一致性门禁变成一次有检查后写入窗口的快照:只要旧实例或离线任务还能只写 nickname,门禁通过后仍可能重新制造差异。建议把兼容期的不变量写得更严格:任何对旧列的写入都必须在同一行更新中同步到新列。滚动发布期间可先部署临时数据库触发器,再开始回填和应用发布;触发器应保留到所有旧写入口被停止或隔离之后,而不是在读路径切换时就删除。

阶段门禁可调整为:先确认触发器已启用,再检查缺失数与不一致数为零;切换读取后继续保留触发器和双写;确认旧实例、后台任务及延迟作业都已失去旧写能力后,再复查写入指标与两列一致性,最后单独移除触发器并进入收缩阶段。触发器本身也要纳入回滚和并发测试,避免递归触发或覆盖新版本的合法写入。

建议增加一个能锁定该竞态的验收:让旧版本事务先更新 nickname 但暂停提交,待一致性检查通过后再提交;随后新版本立即读取,display_name 必须与刚提交的值一致。若旧写入口尚未被数据库同步或明确隔离,这项验收就应阻止切换。这样门禁验证的是持续成立的不变量,而不只是某一时刻的计数为零。

触发器如果只做 NEW.display_name := NEW.nickname,会在新版本开始以新列为准后把合法的新值覆盖掉。更稳妥的做法是根据本次更新实际改了哪一列来判定方向,并对双列冲突直接失败,而不是任选一侧:

create function sync_user_name_columns() returns trigger
language plpgsql as $$
begin
  if tg_op = 'INSERT' then
    if new.nickname is null then
      new.nickname := new.display_name;
    elsif new.display_name is null then
      new.display_name := new.nickname;
    elsif new.nickname is distinct from new.display_name then
      raise exception 'conflicting user name columns';
    end if;
  elsif new.nickname is distinct from old.nickname
     and new.display_name is not distinct from old.display_name then
    new.display_name := new.nickname;
  elsif new.display_name is distinct from old.display_name
     and new.nickname is not distinct from old.nickname then
    new.nickname := new.display_name;
  elsif new.nickname is distinct from old.nickname
     and new.display_name is distinct from old.display_name
     and new.nickname is distinct from new.display_name then
    raise exception 'conflicting user name columns';
  end if;
  return new;
end $$;

create trigger users_name_compat
before insert or update of nickname, display_name on users
for each row execute function sync_user_name_columns();

这样旧版本单写旧列、新版本单写新列以及兼容版本同值双写都有明确结果;两列同时写成不同值则暴露调用方缺陷,不会形成静默分叉。验收时可分别覆盖插入、单侧更新、同值双写、冲突双写和显式写入 NULL,并确认失败事务不会留下半更新状态。

还应核对所有写入通道是否都会触发该逻辑:使用 session_replication_role = replica 的导入任务或按副本语义应用的变更可能绕过普通触发器。若存在这类通道,应在迁移窗口禁用、改为显式双写,或为其增加独立一致性门禁;否则触发器已启用仍不等于旧列写入不变量已经覆盖全部入口。

花径画布#4

触发器如果只做 NEW.display_name := NEW.nickname,会在新版本开始以新列为准后把合法的新值覆盖掉。更稳妥的做法是根据本次更新实际改了哪一列来判定方向,并对双列冲突直接失败,而不是任选一侧:

create function sync_user_name_columns() returns trigger
language plpgsql as $$
begin
  if tg_op = 'INSERT' then
    if new.nickname is null then
      new.nickname := new.display_name;
    elsif new.display_name is null then
      new.display_name := new.nickname;
    elsif new.nickname is distinct from new.display_name then
      raise exception 'conflicting user name columns';
    end if;
  elsif new.nickname is distinct from old.nickname
     and new.display_name is not distinct from old.display_name then
    new.display_name := new.nickname;
  elsif new.display_name is distinct from old.display_name
     and new.nickname is not distinct from old.nickname then
    new.nickname := new.display_name;
  elsif new.nickname is distinct from old.nickname
     and new.display_name is distinct from old.display_name
     and new.nickname is distinct from new.display_name then
    raise exception 'conflicting user name columns';
  end if;
  return new;
end $$;

create trigger users_name_compat
before insert or update of nickname, display_name on users
for each row execute function sync_user_name_columns();

这样旧版本单写旧列、新版本单写新列以及兼容版本同值双写都有明确结果;两列同时写成不同值则暴露调用方缺陷,不会形成静默分叉。验收时可分别覆盖插入、单侧更新、同值双写、冲突双写和显式写入 NULL,并确认失败事务不会留下半更新状态。

还应核对所有写入通道是否都会触发该逻辑:使用 session_replication_role = replica 的导入任务或按副本语义应用的变更可能绕过普通触发器。若存在这类通道,应在迁移窗口禁用、改为显式双写,或为其增加独立一致性门禁;否则触发器已启用仍不等于旧列写入不变量已经覆盖全部入口。

这项修正应纳入迁移门禁。此前只要求“保留触发器”还不够;触发器必须同时证明同步方向正确、冲突会显式失败,并且所有写入通道都受约束。建议把落地检查补成三项:

  1. 上线前枚举应用、批处理、导入和复制写入路径,核对 pg_trigger.tgenabled 与各路径使用的 session_replication_role。对可能绕过普通触发器的路径,应在迁移窗口暂停、改为显式同值双写,或设置独立的一致性门禁。
  2. 冲突分支使用稳定的错误码并接入计数告警;一旦出现双列异值写入,应阻止阶段切换,而不是由调用方无差别重试。显式写入 NULL、ORM 全字段更新以及同值双写都要作为回归用例。
  3. 移除触发器前,先确认旧写入口和绕过路径均已失去写能力,再跨越最长事务时长及队列延迟窗口重复执行缺失数、不一致数检查,避免把一次瞬时为零当成持续不变量。

不建议未经评估就把触发器改为 ENABLE ALWAYS:复制应用链路可能因此重复执行同步逻辑。若确需覆盖 replica 会话,应先验证复制语义和回环风险。这样可以把这条回复中的双向同步实现与运行期门禁合并为一套可验收的收缩前条件。