3. 迁移脚本的幂等性与可重入设计

在数据库迁移的 CI/CD 流水线中,脚本被重复执行几乎是必然事件:流水线重试、发布回滚后重新部署、多环境并行推进、故障恢复后补跑,都会让同一份迁移脚本再次落到数据库上。如果脚本只保证“第一次能跑通”,第二次执行就可能因为对象已存在、数据已写入或约束已添加而报错,甚至造成数据重复。幂等性与可重入设计要解决的核心问题就是:同一份迁移脚本,无论执行一次还是多次,最终数据库都应达到同一个确定状态,且不产生副作用累积。

这里需要区分两个容易混淆的概念。幂等(Idempotent)强调“重复执行的结果等价于执行一次”,关注的是最终状态;可重入(Re-entrant)强调“脚本在中途失败或被中断后,可以再次从头执行而不需要人工清理残留”,关注的是执行过程的可恢复性。一个可重入的脚本通常也是幂等的,但一个幂等脚本未必可重入——例如用 INSERT ... ON CONFLICT DO NOTHING 保证不重复插入,但如果脚本前半段建了表、后半段失败,重跑时建表语句仍可能报错。工程上要同时满足两者。

幂等 DDL 的编写方式

DDL 是迁移脚本中最容易破坏幂等性的部分,因为多数数据库的 CREATE、ALTER 在对象已存在时会直接报错。常见处理策略有三类。

第一类是使用数据库原生的条件语法。PostgreSQL 支持 CREATE TABLE IF NOT EXISTS、CREATE INDEX IF NOT EXISTS、DROP ... IF EXISTS;MySQL 支持 CREATE TABLE IF NOT EXISTS,但 CREATE INDEX 不支持 IF NOT EXISTS,需要先查 information_schema 判断。第二类是通过系统目录或信息模式做状态检测,再决定是否执行。第三类是把 DDL 包在异常捕获块中,忽略“已存在”类错误码。

-- PostgreSQL:幂等建表 + 幂等加列
CREATE TABLE IF NOT EXISTS orders (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    amount      NUMERIC(12,2) NOT NULL,
    status      TEXT NOT NULL DEFAULT 'created',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE orders
    ADD COLUMN IF NOT EXISTS channel TEXT;

CREATE INDEX IF NOT EXISTS idx_orders_user_status
    ON orders (user_id, status);

对于不支持 IF NOT EXISTS 的场景,状态检测是更通用的手段。例如 MySQL 中给表加列前先查询:

-- MySQL:先检测再执行(在迁移工具中通常配合存储过程或应用层判断)
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = DATABASE()
  AND table_name = 'orders'
  AND column_name = 'channel';

SET @ddl = IF(@col_exists = 0,
    'ALTER TABLE orders ADD COLUMN channel VARCHAR(32)',
    'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

这种“检测—判断—执行”的模式虽然啰嗦,但把幂等逻辑显式化,便于审查和测试。需要注意的是,检测与执行之间存在竞态窗口,在并发迁移场景下应配合数据库锁(如 LOCK TABLE 或迁移工具自身的互斥锁)使用。

幂等 DML 与状态检测

DML 的幂等性主要围绕“不重复写入”和“可安全重放”展开。插入类操作优先使用 upsert 语义:PostgreSQL 用 ON CONFLICT,MySQL 用 INSERT ... ON DUPLICATE KEY UPDATE。更新类操作要保证“重复执行结果一致”,例如把状态从 A 改为 B 时,加上 WHERE status = 'A' 条件,第二次执行影响行数为 0,不会产生额外变化。

-- 幂等插入:存在则更新,不存在则插入
INSERT INTO config (key, value, updated_at)
VALUES ('migration.batch_size', '500', now())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
    updated_at = EXCLUDED.updated_at;

-- 幂等更新:限定前置状态,重复执行影响 0 行
UPDATE orders
SET status = 'archived'
WHERE status = 'closed'
  AND created_at < now() - INTERVAL '90 days';

数据回填(backfill)是 DML 幂等设计的重点场景。假设要把 orders.channel 为空的记录按规则补齐,直接 UPDATE ... SET channel = 'web' 会把已人工修正过的记录覆盖掉。更安全的做法是只处理“尚未处理”的行,并分批提交:

-- 分批幂等回填:只处理 channel IS NULL 的行
UPDATE orders
SET channel = 'web'
WHERE channel IS NULL
  AND id IN (
      SELECT id FROM orders
      WHERE channel IS NULL
      ORDER BY id
      LIMIT 500
  );

每批执行后 channel IS NULL 的集合都会缩小,重复执行不会改变已处理行,中断后重跑也能从剩余行继续。这就是“状态检测驱动条件执行”的典型用法:脚本不假设自己从零开始,而是每次先观察当前状态,再只做尚未完成的部分。

回滚安全机制

幂等设计必须与回滚策略一起考虑。迁移工具通常为每个版本提供 up 和 down 两个方向,但 down 同样要幂等:删除表用 DROP TABLE IF EXISTS,删除列前先检测列是否存在,删除索引用 DROP INDEX IF EXISTS。对于不可逆操作(如删除列、清空数据),应在脚本中显式标注,并在流水线中禁止自动回滚,改为前向修复(forward fix)。

回滚安全还涉及事务边界。DDL 在多数数据库中支持事务(PostgreSQL、SQL Server 支持,MySQL 的 DDL 会隐式提交),因此把“检测 + DDL + 数据变更”放在同一事务中,可以保证失败时整体回退,避免出现“表建了但数据没填”的中间态。对于 MySQL 这类 DDL 隐式提交的数据库,应把每个迁移拆成足够小的原子步骤,并在脚本头部记录版本号,使重跑时能识别已完成步骤。

实践建议:为每个迁移脚本维护一个“已应用版本表”(如 schema_migrations),迁移工具在事务内先写入版本记录再执行变更;重跑时先查该表,已应用的版本直接跳过。这是幂等性最外层、也最可靠的保障。

幂等性检查清单

在把迁移脚本合入主干前,可以用下面这份清单做一次快速审查。它覆盖了 DDL、DML、回滚和验证四个维度,适合作为 CI 中的静态检查项或代码评审清单。

维度 检查项 判断依据
DDL 建表/建索引是否使用 IF NOT EXISTS 或等价检测 重复执行不报“已存在”错误
DDL 加列/改类型前是否检测当前结构 第二次执行影响 0 行或跳过
DML 插入是否使用 upsert 语义 重复执行不产生重复行
DML 更新是否限定前置状态 重复执行影响行数为 0
回滚 down 脚本是否同样幂等 重复回滚不报错
验证 是否有执行后状态断言 能检测出“执行成功但状态不对”

最后要强调验证环节。幂等脚本不能只靠“没报错”来判断成功,而应在脚本末尾加入状态断言:检查目标列是否存在、目标行数是否在预期范围、约束是否生效。例如回填完成后断言 SELECT COUNT(*) FROM orders WHERE channel IS NULL 为 0。这类断言让脚本具备自检能力,也让 CI 流水线能在迁移后自动判定结果,而不是把问题留到应用层报错时才暴露。

迁移脚本幂等执行模型 同一脚本多次执行 → 状态检测 → 条件执行 → 结果一致 迁移脚本 up / down schema_migrations 版本记录表 状态检测 对象是否存在? 数据是否已处理? 版本是否已应用? 条件执行 IF NOT EXISTS ON CONFLICT WHERE 前置状态 确定状态 重复执行 结果等价 无副作用累积 回滚安全(down 方向) DROP IF EXISTS · 检测列存在性 · 不可逆操作标记为 forward fix 事务边界:DDL + DML 同事务,失败整体回退 执行后状态断言 COUNT(channel IS NULL) = 0 · 列存在 · 约束生效 · 版本已记录 重跑

把上述机制组合起来,一个迁移脚本的典型结构是:先查版本表判断是否已应用,未应用则开启事务,依次执行带条件判断的 DDL、幂等 DML,写入版本记录,执行状态断言,最后提交。任何一步失败都回滚,重跑时从版本表读取进度,跳过已完成部分。这样,脚本在 CI 流水线中无论被触发多少次,数据库都会收敛到同一个确定状态,迁移过程也就具备了工程上可依赖的稳定性。