数据库基础体系 · 第 10/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
数据库迁移与在线变更:Expand-Contract、锁风险和可回滚发布
数据库迁移(migration)是把数据库结构、约束、索引或数据从一个已知状态变为另一个状态的过程。在线变更(online change)则进一步要求:在业务持续读写时完成变更,并把请求阻塞、错误率、延迟和数据不一致控制在可接受范围内。
这两个概念经常被混用:
- “迁移成功”只说明数据库最终达到了目标状态;
- “在线变更成功”还要求迁移过程中的并发事务、锁等待、旧版本应用和故障恢复都得到处理;
- “可回滚发布”不等于每条 DDL 都能反向执行,而是要设计出在发布失败时仍能安全恢复服务和数据的路径。
一条简单的 ALTER TABLE,可能涉及以下独立问题:
- DDL 是否需要重写已有数据?
- 执行期间取得什么锁?
- 锁是否会等待已有事务?
- 等待期间是否阻塞读写?
- 新旧应用版本是否能同时访问数据库?
- 迁移失败后,能否撤销结构和数据变化?
- 备份、PITR 和恢复路径是否能覆盖最坏情况?
下面以 PostgreSQL 当前稳定版本的公开语义和 MySQL 8.4 的公开语义为准。除非特别说明,示例中的 PostgreSQL 命令假设使用 psql,MySQL 命令假设使用 mysql 客户端;“在线”不表示绝对无锁,而是表示在明确锁和阻塞边界后,允许业务继续运行。
一、先区分四类变更
数据库变更不只有“改表结构”一种。至少应区分以下四类。
1. 结构变更
例如:
ALTER TABLE orders ADD COLUMN paid_at timestamptz;
CREATE INDEX idx_orders_paid_at ON orders (paid_at);
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
结构变更通常改变系统目录、表元数据、索引或约束。
2. 数据迁移
例如把旧列中的字符串转换为新格式:
UPDATE users
SET normalized_email = lower(trim(email))
WHERE normalized_email IS NULL;
数据迁移通常需要扫描和修改大量行,因此主要风险不是 DDL 锁,而是:
- 长时间持有行锁;
- 产生大量 WAL、binlog 或 undo;
- 放大复制延迟;
- 影响缓存和 IO;
- 与业务写入形成锁冲突;
- 迁移中断后留下部分完成状态。
3. 应用兼容性变更
例如把 users.name 改名为 users.display_name。数据库层面的改名很快,但应用层可能存在多个版本同时运行:
旧应用:只读 name
新应用:只读 display_name
发布期间:旧应用和新应用并存
如果直接删除 name 或只保留新列,旧应用会立即失败。因此应用协议的兼容性往往比 DDL 本身更难。
4. 约束和语义变更
例如:
- 将列从可空改为
NOT NULL; - 增加唯一约束;
- 把外键从不校验改为强制校验;
- 改变默认值;
- 修改枚举或状态机允许的值。
这类变更经常“语句很短”,但验证已有数据时可能扫描整张表,并且会改变之后所有写入的合法性。
二、Expand-Contract 的核心:把不兼容变化拆成兼容阶段
Expand-Contract 是一种将破坏性变化拆开的发布方法。
- Expand(扩展):先增加新结构,使旧应用和新应用都能工作。
- Migrate(迁移):把已有数据逐步填充或转换到新结构。
- Switch(切换):让应用逐步改为读取、写入新结构。
- Contract(收缩):确认旧应用和旧数据不再需要后,删除旧结构。
它不是某个数据库的专用 API,而是一种围绕兼容性和回滚设计的状态转换方法。
1. 为什么直接改名不安全
假设原表是:
CREATE TABLE users (
id bigint PRIMARY KEY,
name text
);
直接执行:
ALTER TABLE users RENAME COLUMN name TO display_name;
数据库结构可能立即成功,但正在运行的旧应用仍然执行:
SELECT id, name FROM users;
结果是:
ERROR: column "name" does not exist
这里的问题不是 DDL 是否原子,而是数据库状态在应用协议上不兼容。事务只能保证数据库内部的原子性,不能让已经编译或部署的旧应用自动理解新列名。
2. 安全的列改名流程
阶段 A:Expand
先添加新列:
ALTER TABLE users ADD COLUMN display_name text;
此操作只增加结构,不删除旧列。此时:
| 组件 | 读取 name |
读取 display_name |
|---|---|---|
| 旧应用 | 可以 | 不使用 |
| 新应用 | 可以 | 可以 |
| 数据库 | 两列都存在 | 两列都存在 |
但“增加列”不自动复制已有数据,也不自动保证两列以后相同。
阶段 B:让新代码兼容双列
常见做法是由应用同时写入两列:
INSERT INTO users (id, name, display_name)
VALUES ($1, $2, $2);
更新时也必须同时修改:
UPDATE users
SET name = $2,
display_name = $2
WHERE id = $1;
更稳妥的读取策略通常是:
SELECT id, COALESCE(display_name, name) AS display_name
FROM users;
这里的顺序应当根据业务规则明确规定。不能把 COALESCE 当作一致性保证:它只能在新列为空时提供回退值,不能解决两列都非空但内容不同的问题。
阶段 C:回填历史数据
UPDATE users
SET display_name = name
WHERE display_name IS NULL;
这条语句在小表上可能足够,但在大表上有明显风险:
- 可能长时间运行;
- 修改大量行,产生大量 WAL/binlog;
- 与用户更新竞争;
- 中断后事务回滚可能持续很久;
- 一次性扫描可能造成 IO 峰值。
因此大表应分批回填。PostgreSQL 没有通用的 UPDATE ... LIMIT,可以使用主键范围或 CTE:
WITH batch AS (
SELECT id
FROM users
WHERE display_name IS NULL
ORDER BY id
LIMIT 1000
)
UPDATE users u
SET display_name = u.name
FROM batch
WHERE u.id = batch.id
AND u.display_name IS NULL;
执行逻辑是:
- 选出最多 1000 个尚未回填的主键;
- 根据主键更新这些行;
- 提交这一小批事务;
- 重复执行,直到没有符合条件的行。
u.display_name IS NULL 是必要的并发保护之一。假设应用已经把某行的新值写入 display_name,回填任务不应再覆盖它。
但是,这仍然不能自动解决所有并发语义。例如:
T1:回填任务读取 name = A
T2:用户请求把 name 改为 B,同时把 display_name 改为 B
T1:随后把 display_name 写成 A
实际结果取决于行锁等待和语句执行顺序。要避免回填覆盖新写入,可以加入条件、使用版本号,或让迁移与应用写入采用明确的锁和更新规则。例如:
UPDATE users u
SET display_name = u.name
WHERE u.id = $1
AND u.display_name IS NULL;
更强的方案是让应用只把旧列作为兼容输入,新列作为唯一事实来源,并通过触发器或统一写路径保证同步。但触发器会增加写入路径复杂度和排障成本,不能不加评估地使用。
阶段 D:切换读取
当以下条件都成立后,应用可以改为主要读取 display_name:
- 历史数据已回填;
- 双写逻辑已经运行;
- 新旧列不一致率为零或在明确范围内;
- 所有仍在运行的旧应用都能访问新列;
- 读取新列的监控已验证。
这一步通常先做成配置开关:
read_display_name = false
逐步打开后,如果发现新列数据异常,可以关闭开关,而不必立即执行结构回滚。
阶段 E:Contract
只有确认以下事实后,才删除旧列:
- 旧应用实例已经全部下线;
- 代码仓库和任务中不再引用旧列;
- 回填及双写已稳定运行;
- 备份和恢复点满足保留要求;
- 已经过观察窗口;
- 已准备好前向修复方案。
最后才执行:
ALTER TABLE users DROP COLUMN name;
删除旧列通常不能作为普通“发布失败回滚”的一部分。它会丢失旧数据,之后即使重新添加同名列,也无法恢复原始内容。Contract 应当是延迟执行的不可逆阶段,而不是与应用发布绑定的即时动作。
三、兼容性不是只有“旧代码”和“新代码”
迁移期间可能同时存在多个数据库客户端:
旧应用版本
新应用版本
后台任务
报表任务
数据导入脚本
异步消费者
只读副本上的查询
因此应定义迁移阶段的兼容性矩阵。例如列改名时:
| 数据库状态 | 旧应用 | 新应用 | 后台任务 |
|---|---|---|---|
只有 name |
可用 | 不可用 | 依赖实现 |
name + display_name,未回填 |
可用 | 需回退读取 | 需双列处理 |
| 两列存在且已回填、持续双写 | 可用 | 可用 | 可用 |
只有 display_name |
不可用 | 可用 | 必须已升级 |
这个矩阵揭示了一个关键条件:
每次发布都必须让“发布前状态”和“发布后状态”在过渡期间互相兼容,至少允许实际存在的版本组合继续运行。
添加可空列与添加非空列不同
这通常较安全:
ALTER TABLE users ADD COLUMN nickname text;
但下面这条语句需要数据库验证已有行:
ALTER TABLE users
ADD COLUMN nickname text NOT NULL;
已有数据无法满足 NOT NULL 时会失败;即使表中没有数据,执行期间也仍可能需要表级锁。
安全流程通常是:
- 添加可空列;
- 应用开始写入非空值;
- 回填历史数据;
- 验证没有空值;
- 再增加
NOT NULL。
PostgreSQL 中可以先增加一个不立即验证的检查约束:
ALTER TABLE users
ADD CONSTRAINT users_nickname_nn
CHECK (nickname IS NOT NULL) NOT VALID;
然后在低峰期或分批完成数据修复后验证:
ALTER TABLE users
VALIDATE CONSTRAINT users_nickname_nn;
NOT VALID 并不表示约束已经对全部历史数据生效。它通常会约束之后的新写入,但已有行要等 VALIDATE CONSTRAINT 完成后才能被确认。验证不是“跳过检查”,而是把历史数据检查拆成单独阶段。
四、锁:短语句也可能长时间阻塞
1. 锁等待时间不等于执行时间
考虑 PostgreSQL:
ALTER TABLE users ADD COLUMN nickname text;
这条操作本身可能很快,但它通常需要取得较强的表锁。若另一个事务正在访问 users,即使该事务只执行了一条普通查询,也可能持有与 DDL 冲突的锁,导致 DDL 等待。
完整时间线可能是:
09:00:00 事务 T1 开始,查询 users
09:00:01 T1 查询结束,但事务未提交
09:00:02 迁移 T2 请求 ALTER TABLE
09:00:02 T2 等待 T1 释放锁
09:05:00 T1 提交
09:05:00 T2 才真正执行
因此“这条 DDL 只要几十毫秒”并不能推出“它最多阻塞业务几十毫秒”。应区分:
总耗时 = 等待锁的时间 + 实际执行时间
锁请求进入等待队列后,还可能阻塞后来到达的事务,形成排队放大。
2. PostgreSQL 的锁风险
PostgreSQL 表级锁模式中,ACCESS EXCLUSIVE 与所有其他表级锁冲突。许多 ALTER TABLE 操作默认需要这种锁,因此应把它视为可能阻塞并发访问的操作,而不是无害的元数据修改。
执行迁移前可以设置超时:
SET lock_timeout = '3s';
SET statement_timeout = '30s';
ALTER TABLE users ADD COLUMN nickname text;
含义是:
lock_timeout:等待锁超过 3 秒就取消当前语句;statement_timeout:语句总执行时间超过 30 秒就取消。
这两个参数只限制当前会话中的语句,不会自动回滚已经提交的其他迁移,也不会修复应用本身的长事务。
诊断阻塞关系可以查询:
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
now() - blocking.xact_start AS blocking_xact_age
FROM pg_stat_activity blocked
JOIN pg_locks blocked_lock
ON blocked_lock.pid = blocked.pid
AND NOT blocked_lock.granted
JOIN pg_locks blocking_lock
ON blocking_lock.locktype = blocked_lock.locktype
AND blocking_lock.database IS NOT DISTINCT FROM blocked_lock.database
AND blocking_lock.relation IS NOT DISTINCT FROM blocked_lock.relation
AND blocking_lock.granted
JOIN pg_stat_activity blocking
ON blocking.pid = blocking_lock.pid
WHERE blocked.datname = current_database();
在生产环境中还应关注:
pg_stat_activity.xact_start很早但state为idle in transaction的会话;- 等待事件是否为锁;
- 迁移会话是否被取消;
- 取消后是否仍有事务未结束。
3. MySQL 的元数据锁
MySQL InnoDB 事务表操作会涉及 metadata lock,MDL,元数据锁。即使 DDL 采用了在线算法,只要需要变更表定义,就可能等待已有事务释放对表元数据的使用。
典型故障路径:
T1:开启事务,访问 orders,但长时间不提交
T2:ALTER TABLE orders ...
T2:等待 MDL
T3:新的业务请求访问 orders
T3:可能在 MDL 队列中继续等待
因此一个原本只想“改一下字段”的 DDL,可能造成连接堆积和请求超时。
MySQL 中可以先查看当前线程:
SHOW FULL PROCESSLIST;
也可以使用 Performance Schema 观察元数据锁,具体可用表和采集项应以实例启用的 Performance Schema 配置为准。例如常见查询形式是:
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
LOCK_TYPE,
LOCK_DURATION,
LOCK_STATUS,
OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_SCHEMA = 'app';
若没有数据,需要检查相关 instrumentation 是否启用,而不能据此断定没有 MDL。
对于 MySQL 的 DDL,ALGORITHM=INSTANT、INPLACE、COPY 和 LOCK=NONE 表示不同的执行能力或要求,但不是对所有表结构、版本、存储引擎和操作都适用的无阻塞保证:
ALTER TABLE users
ADD COLUMN nickname varchar(100) NULL,
ALGORITHM=INSTANT,
LOCK=NONE;
如果当前变更不支持指定算法,MySQL 通常会报错,而不是必然安全地自动降级;具体支持范围必须以 MySQL 8.4 文档中对应操作和存储引擎的说明为准。
即便 LOCK=NONE 可用,也不应理解为“完全没有锁”:
- 仍可能需要短暂的 MDL;
- 开始或提交阶段可能等待;
- 并发 DML 可能影响完成时间;
- 其他事务仍可能阻塞元数据锁获取。
五、在线索引创建:减少阻塞,不等于消除风险
1. PostgreSQL 的 CREATE INDEX CONCURRENTLY
普通方式:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
创建期间可能取得较强锁,影响并发写入。
PostgreSQL 提供:
CREATE INDEX CONCURRENTLY idx_orders_created_at
ON orders (created_at);
它通过多个阶段建立索引,使普通读写通常可以继续进行,但代价是:
- 扫描表的时间更长;
- 需要等待相关事务完成;
- 不能在事务块中执行;
- 失败时可能留下
INVALID索引; - 唯一索引创建时,唯一性检查和并发冲突仍需特别处理。
因此下面的迁移框架是错误的:
BEGIN;
CREATE INDEX CONCURRENTLY idx_orders_created_at
ON orders (created_at);
COMMIT;
应让迁移工具以非事务模式运行该步骤。若创建失败,先检查索引状态:
SELECT indexrelid::regclass, indisvalid, indisready
FROM pg_index
WHERE indexrelid = 'idx_orders_created_at'::regclass;
如果留下无效索引,通常应在确认没有其他依赖后处理:
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_created_at;
DROP INDEX CONCURRENTLY 同样不能放在事务块中。
2. PostgreSQL 唯一约束的两阶段做法
若需要给已有大表增加唯一约束,可以先并发创建唯一索引:
CREATE UNIQUE INDEX CONCURRENTLY users_email_uq_idx
ON users (email);
再把它接入约束:
ALTER TABLE users
ADD CONSTRAINT users_email_uq
UNIQUE USING INDEX users_email_uq_idx;
这两个步骤并不意味着可以跳过数据质量检查。若历史数据有重复值,第一步会失败。并且应用必须在约束建立前就处理好并发写入中的重复键语义,否则会遇到运行时冲突。
3. MySQL 的在线索引能力
InnoDB 支持多种在线 DDL 行为,但具体是否能避免表拷贝、是否允许并发 DML,取决于具体操作。不能仅凭“这是加索引”就推断所有版本和表结构都相同。
例如:
ALTER TABLE orders
ADD INDEX idx_orders_created_at (created_at),
ALGORITHM=INPLACE,
LOCK=NONE;
如果操作不支持指定能力,命令可能失败。实际发布前应在相同 MySQL 大版本、存储引擎、字符集、分区方式和表结构上演练,并观察:
- DDL 是否进入 copy;
- MDL 等待;
- 复制延迟;
- redo/undo、IO 和 buffer pool 压力;
- 失败后是否留下中间对象。
对于不能接受生产 DDL 不确定性的场景,常见替代方案是在线 schema change 工具。这类工具通常通过影子表、触发器或变更捕获机制复制增量,再执行切换;但它们引入了额外写放大、触发器延迟、切换锁和数据校验问题,不是无风险版本的 ALTER TABLE。
六、数据回填的正确性:幂等、分批和并发
1. 幂等是什么
迁移任务可能因为进程重启、锁超时、部署中断而执行多次。幂等意味着重复执行不会把正确结果变成错误结果。
例如:
UPDATE users
SET display_name = name
WHERE display_name IS NULL;
重复执行通常不会改变已经填充的行,因此比无条件覆盖更适合重试。
但“语句可重复”不一定等于“业务结果幂等”。如果 name 在回填期间持续变化,重复执行可能得到不同结果。必须先定义事实来源:
- 以旧列为事实来源;
- 以新列为事实来源;
- 以应用事件或审计表为事实来源。
2. 分批提交的数学直觉
一次事务更新 行,事务资源大致随 增长:
锁持有时间、日志量、回滚工作量、复制压力 ≈ 与批量大小正相关
把它拆成每批 行后,批次数约为:
单批失败的回滚规模从 降低到最多 ,但总扫描和提交次数会增加。批量大小不是越小越好:
- 太大:单批锁和 IO 峰值高;
- 太小:事务提交开销大,迁移时间变长;
- 合适的值应由锁等待、复制延迟、WAL/binlog 增长和业务延迟观测决定。
这里的公式只是资源拆分的直觉模型,不是性能保证。
3. 分页不要依赖深层 OFFSET
下面的做法在大表上可能越来越慢:
SELECT id
FROM users
ORDER BY id
OFFSET 10000000
LIMIT 1000;
因为数据库可能需要先定位并跳过大量记录。更稳定的方式是基于索引键的 keyset 分页:
SELECT id
FROM users
WHERE id > $last_id
ORDER BY id
LIMIT 1000;
随后保存本批最大 id 作为下一批的 $last_id。前置条件是 id 可比较、具有合适索引,并且迁移逻辑能正确处理新增行和删除行。
对 PostgreSQL,可使用:
WITH batch AS (
SELECT id
FROM users
WHERE id > $1
AND display_name IS NULL
ORDER BY id
LIMIT 1000
)
UPDATE users u
SET display_name = u.name
FROM batch
WHERE u.id = batch.id
AND u.display_name IS NULL;
每一批独立提交。若应用也会更新 display_name,应通过条件、版本号或锁定策略防止迁移覆盖业务写入。
4. 失败恢复
迁移任务应记录至少以下状态:
迁移版本
开始时间
最后提交的主键或游标
处理行数
失败批次
错误信息
当前校验结果
不要只记录“迁移开始”和“迁移完成”。如果进程在第 500 批退出,任务应能从安全位置继续,而不是重新无条件覆盖所有行。
七、为约束建立“先数据、后规则”的顺序
假设要把 orders.customer_id 设置为非空并建立外键。直接执行:
ALTER TABLE orders
ALTER COLUMN customer_id SET NOT NULL;
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers(id);
可能遇到:
- 历史数据有空值;
- 存在指向不存在客户的值;
- DDL 等待表锁;
- 外键验证扫描大表;
- 新应用与旧应用对空值处理不一致。
更稳妥的顺序是:
- 添加或确认目标列;
- 先在应用层停止产生非法值;
- 查询并修复历史脏数据;
- 增加约束;
- 验证约束状态;
- 最后收紧
NOT NULL或删除兼容逻辑。
PostgreSQL 可以使用 NOT VALID 拆分外键的建立和历史验证:
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id)
REFERENCES customers(id)
NOT VALID;
然后:
ALTER TABLE orders
VALIDATE CONSTRAINT orders_customer_fk;
这不会把非法历史数据变合法,也不等于没有锁;它只是把“约束对新数据生效”和“验证旧数据”分成两个阶段。
MySQL 外键、检查约束和 DDL 行为还受到存储引擎及具体版本语义影响。不能把 PostgreSQL 的 NOT VALID 语义直接迁移到 MySQL;跨数据库迁移工具必须对每个引擎建立独立的状态和验证逻辑。
八、事务边界:数据库原子性不等于发布原子性
1. PostgreSQL
PostgreSQL 的许多 DDL 可以放在事务中:
BEGIN;
ALTER TABLE users ADD COLUMN nickname text;
CREATE INDEX idx_users_nickname ON users (nickname);
COMMIT;
若事务失败,通常可以回滚这些变更。但以下因素会改变迁移设计:
CREATE INDEX CONCURRENTLY不能在事务块中执行;- 长事务会持有锁并增加等待;
- DDL 事务提交前,其他会话看不到已提交的结构状态;
- 应用部署和数据库事务不是同一个原子操作。
例如:
T1:提交数据库迁移
T2:新应用部署失败
数据库已经处于新状态,不能依赖应用发布失败自动回滚数据库。此时只有在新结构兼容旧应用的情况下,旧应用才能继续运行。
2. MySQL
MySQL 8.4 中,很多 DDL 会导致隐式提交,且 DDL 的原子性、崩溃恢复和具体执行行为取决于操作及存储引擎。不能像 PostgreSQL 那样假设一组 DDL 可以用:
BEGIN;
...
ROLLBACK;
整体撤销。
因此 MySQL 迁移应把每条 DDL 当作可能独立提交的步骤,按以下方式设计:
- 步骤之间保持新旧应用兼容;
- 每一步有前置检查和后置验证;
- 失败后使用后续兼容迁移或恢复备份;
- 不把多条 DDL 的“成功或全部回滚”当作前提。
3. 迁移工具的事务包装陷阱
许多迁移框架默认:
BEGIN
执行所有迁移
COMMIT
这对普通 PostgreSQL DDL 可能适用,但对 CREATE INDEX CONCURRENTLY 不适用;对 MySQL 则可能与隐式提交发生冲突。
迁移工具至少应支持:
事务迁移
非事务迁移
单语句迁移
可重试迁移
需要人工确认的迁移
并明确记录每一步的提交边界,而不是只给每个文件一个版本号。
九、锁风险的评估方法
执行变更前,应回答四个问题:
1. 需要什么锁
不要只看 SQL 表面语义,要查对应数据库版本文档中的锁模式或 DDL 算法。例如:
- PostgreSQL
ALTER TABLE不同子命令的锁要求可能不同; - PostgreSQL
CREATE INDEX与CREATE INDEX CONCURRENTLY行为不同; - MySQL 的
ALGORITHM和LOCK能力依赖具体操作; - 增加默认值、修改类型、重建表、增加约束的成本不能混为一谈。
2. 是否会扫描或重写整表
如果操作需要读取或重写 行,风险通常与以下因素有关:
数据量
行宽
索引数量
并发写入率
日志和复制能力
磁盘剩余空间
即使 DDL 获取锁的时间很短,重写期间产生的 IO 和日志也可能影响业务。
3. 谁会阻塞谁
应绘制时间线,而不是只看单条 SQL:
长事务 T1
↓ 持有表或元数据锁
迁移 T2 等待
↓ 进入等待队列
新请求 T3 继续等待
↓
连接池耗尽、超时、错误率上升
迁移本身可能没有消耗 CPU,却能通过锁队列造成全站故障。
4. 失败后留下什么
要明确检查:
- PostgreSQL 是否留下无效索引;
- MySQL 是否完成了 copy、留下新表或中间状态;
- 应用是否已经开始依赖新列;
- 回填是否提交了部分批次;
- 复制链路是否出现延迟;
- 迁移进程取消后是否仍有长事务。
十、一个可执行的 PostgreSQL 示例:新增列并在线回填
以下示例把 users.name 迁移为 users.display_name。假设:
- PostgreSQL;
users.id是主键;- 应用会在过渡期双写;
- 每批 1000 行;
- 迁移脚本与应用部署分开;
- 迁移任务每批独立提交。
第一步:扩展结构
SET lock_timeout = '3s';
SET statement_timeout = '30s';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS display_name text;
预期结果:
ALTER TABLE
如果 3 秒内无法取得所需锁,语句失败。此时不应立刻无限重试,而应诊断阻塞会话、确认业务时段和长事务情况。
第二步:确认回填范围
SELECT count(*) AS remaining
FROM users
WHERE display_name IS NULL;
预期结果类似:
remaining
-----------
842315
这个数字是待处理行数,不是迁移成功的证明。它可能因应用写入而变化。
第三步:运行一批回填
BEGIN;
WITH batch AS (
SELECT id
FROM users
WHERE display_name IS NULL
ORDER BY id
LIMIT 1000
)
UPDATE users u
SET display_name = u.name
FROM batch
WHERE u.id = batch.id
AND u.display_name IS NULL;
COMMIT;
可通过客户端返回的 affected rows 判断本批处理了多少行。重复运行直到:
SELECT count(*)
FROM users
WHERE display_name IS NULL;
返回:
0
但还应检查内容一致性:
SELECT count(*) AS mismatched
FROM users
WHERE display_name IS DISTINCT FROM name;
IS DISTINCT FROM 能正确处理 NULL,比使用 <> 更适合作为 PostgreSQL 中的空值安全比较。
如果结果为:
mismatched
------------
0
只能说明当前两列相同。要保证之后继续相同,还需要应用双写或其他同步机制。
第四步:验证新写路径
应用完成双写后,可以用数据库查询检查:
SELECT count(*) AS mismatched
FROM users
WHERE display_name IS DISTINCT FROM name;
线上发布期间应定期执行或采样检查,并监控:
- 新列为空的比例;
- 两列不一致的比例;
- 新旧读取结果差异;
- 写入错误;
- 迁移批次耗时和锁等待;
- PostgreSQL WAL 和副本延迟。
第五步:收紧约束
如果业务要求 display_name 非空,先确认:
SELECT count(*)
FROM users
WHERE display_name IS NULL;
结果为零后再执行:
SET lock_timeout = '3s';
ALTER TABLE users
ALTER COLUMN display_name SET NOT NULL;
这一步仍可能等待表锁。验证通过不表示 DDL 一定能立即取得锁,因此仍需要超时和阻塞监控。
第六步:延迟删除旧列
ALTER TABLE users
DROP COLUMN name;
这是不可逆风险较高的步骤。删除前应确认旧版本应用、脚本、报表和数据导出程序都不再访问 name,并确保备份或 PITR 保留期覆盖观察窗口。
十一、可回滚发布:回滚的是状态,不只是 SQL
1. 三种回滚经常被混为一谈
应用回滚
把代码恢复到旧版本。
它要求旧代码仍能访问当前数据库结构。因此 Expand-Contract 的核心价值之一,就是让应用回滚仍然可行。
迁移回滚
执行反向 DDL,例如:
ALTER TABLE users DROP COLUMN display_name;
它只有在新列没有重要数据、且没有被新代码依赖时才安全。对已删除数据,反向 DDL 无法恢复内容。
数据恢复
通过备份、WAL 归档和 PITR 将数据库恢复到某个时间点,或恢复到临时环境后导出需要的数据。
这通常会影响更大范围的数据,不应作为普通发布回滚的第一选择。
2. 为什么破坏性变更不能即时回滚
假设 Contract 阶段已经执行:
ALTER TABLE users DROP COLUMN name;
随后发现某个旧后台任务仍依赖 name。重新执行:
ALTER TABLE users ADD COLUMN name text;
只能创建一个空列,不能恢复原先每一行的值。除非有额外备份或历史数据副本,否则信息已经丢失。
因此发布状态应按以下顺序设计:
兼容扩展
→ 数据迁移
→ 应用切换
→ 观察
→ 延迟收缩
越靠前越容易回滚,越靠后越可能只能前向修复或使用恢复方案。
3. 前向修复有时比回滚可靠
如果新列已经被应用写入,回滚数据库结构可能损失这些新数据。更安全的做法可能是:
- 保留新旧列;
- 修复新代码或配置;
- 让新应用兼容当前结构;
- 重新执行校验和回填;
- 稳定后再决定是否收缩。
这就是前向修复(forward fix):不强行把系统恢复到旧结构,而是把当前状态修正到一个可用、可验证的状态。
十二、备份、PITR 与迁移恢复边界
迁移发布需要结合恢复能力,但备份不能替代在线变更设计。
1. RPO 与 RTO 的含义
- RPO(Recovery Point Objective):最多允许丢失多长时间的数据;
- RTO(Recovery Time Objective):最多允许花多长时间恢复服务。
如果 RPO 是 5 分钟,说明恢复点可能落后当前最多约 5 分钟;它不能保证恢复到迁移执行前一秒。
如果 RTO 是 30 分钟,说明恢复过程必须在 30 分钟内完成;它不能证明应用和数据库结构在恢复后一定兼容。
2. PITR 不等于撤销一条错误 DDL
时间点恢复可以把数据库恢复到迁移之前,但通常需要:
- 找到正确恢复时间;
- 恢复到独立实例或临时目录;
- 校验数据和结构;
- 决定是否切换;
- 处理迁移时间点之后的业务写入。
如果错误迁移后仍有大量合法业务写入,直接恢复到旧时间点会丢失这些写入。更复杂的恢复可能需要从临时恢复实例导出目标数据,再合并回生产库。
3. 恢复演练必须覆盖迁移场景
至少演练以下路径:
备份恢复是否可用
WAL/binlog 是否连续
PITR 是否能定位到迁移前时间
恢复后的 schema 是否可被旧应用访问
副本是否能重新建立
迁移中断后是否能继续
部分回填数据是否可校验
“备份任务显示成功”只证明备份流程报告成功,不证明恢复一定成功。
十三、常见错误及其真实表现
错误一:把 DDL 的执行时间当成阻塞时间
错误判断:
这条 ALTER 只需要 20 毫秒,所以不会影响业务。
真实情况可能是:
等待锁 10 分钟
实际执行 20 毫秒
期间连接池和请求队列已经耗尽
应分别记录锁等待时间和实际执行时间。
错误二:认为 CONCURRENTLY 或 LOCK=NONE 代表无锁
它们只是减少某些阶段对业务的阻塞,仍可能:
- 等待事务;
- 在开始或结束阶段取得锁;
- 因复制、IO 或并发写入变慢;
- 在失败后留下中间状态。
错误三:只迁移历史数据,不处理并发写入
一次性回填结束时,应用可能已经写入新旧两列不同的值。迁移需要明确同步策略,而不是只执行一条 UPDATE。
错误四:数据库迁移成功就立即删除旧结构
迁移工具成功只说明 DDL 或数据任务完成,不说明:
- 所有旧应用已经下线;
- 所有后台任务已经更新;
- 所有只读查询已经改写;
- 备份保留期已经覆盖回滚需要。
错误五:依赖迁移文件的反向脚本
反向脚本适合可逆、无数据损失的结构变化,例如撤销一个尚未被使用的新增列。它不适合:
- 删除有业务数据的列;
- 不可逆的数据压缩或格式转换;
- 改写后丢失原始值的迁移;
- 已经有新旧版本并发运行的发布。
错误六:用单个大事务回填整个表
失败表现可能是:
- 锁持有时间过长;
- 事务日志快速膨胀;
- 副本延迟;
- 回滚耗时与执行耗时相当甚至更长;
- 业务延迟突然升高。
分批提交降低单次故障半径,但不自动保证数据一致性,仍需幂等条件和校验。
十四、一个可操作的发布状态机
把迁移写成状态机,比把它看成“一次脚本执行”更准确。
S0:旧结构,旧应用
↓
S1:扩展结构,旧应用仍可用
↓
S2:新旧应用均可用,开始双写
↓
S3:历史数据部分回填
↓
S4:历史数据完成,校验通过
↓
S5:读取切换到新结构
↓
S6:观察窗口
↓
S7:删除旧结构
每个状态都应有进入条件和退出条件。
例如:
S2 → S3
进入:新列存在,双写代码已部署
退出:回填任务可重复执行,监控已就绪
S4 → S5
进入:空值为 0,不一致率满足要求
退出:新读取路径错误率和延迟稳定
S6 → S7
进入:旧应用和旧任务已确认下线
退出:旧列删除成功,结构校验通过
故障时的路径也应明确:
S1 失败:删除未提交的扩展,或修复迁移
S2 失败:关闭新读取,保留双写和新列
S3 失败:暂停批处理,从最后安全位置继续
S5 失败:切回旧读取,但保留兼容结构
S7 失败:优先前向修复;必要时使用备份或 PITR
这里的“切回旧读取”只有在旧列仍然维护、且数据未被破坏时才成立,这正是 Contract 必须延迟的原因。
十五、发布前后的验证
发布前
应在与生产接近的环境验证:
- 表结构、行数、索引和分区形式;
- 数据库大版本和存储引擎;
- 事务隔离级别;
- 长事务和锁等待行为;
- 复制拓扑;
- 迁移失败后的中间状态;
- 迁移工具是否自动包装事务;
- 应用旧版本、新版本、后台任务是否可同时运行。
发布中
重点观察:
- DDL 锁等待;
- 数据库连接池使用率;
- 请求延迟和错误率;
- 回填每批耗时;
- WAL、binlog、redo、undo 增长;
- 主从或流复制延迟;
- 磁盘空间;
- 死锁和锁超时;
- 新旧列不一致数;
- 迁移进度是否停滞。
进度不能只使用“已运行时间”。更可靠的指标是:
剩余行数
已处理主键范围
每批处理行数
最近一批提交时间
失败重试次数
校验差异数
发布后
验证数据库事实,而不只看应用健康检查:
-- PostgreSQL 示例
SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
ORDER BY ordinal_position;
还应检查:
- 新约束是否存在且状态正确;
- 索引是否有效;
- 新列是否持续产生非法值;
- 旧列是否仍被访问;
- 读写流量是否已经切换;
- 副本是否追平;
- 备份是否覆盖新结构。
十六、最终原则
数据库迁移的难点通常不在于记住某条 ALTER TABLE 语法,而在于设计一个并发环境中的状态转换:
- 先把结构扩展到同时兼容旧代码和新代码;
- 再以可重试、可观测、可校验的方式迁移历史数据;
- 通过双写、回退读取或其他明确机制处理并发写入;
- 在执行 DDL 前评估锁、扫描、重写、日志和复制影响;
- 把数据库事务边界与应用发布边界分开考虑;
- 把“应用回滚”“迁移回滚”和“数据恢复”作为三种不同路径;
- 将不可逆的 Contract 阶段延迟到观察窗口之后;
- 对 PostgreSQL 和 MySQL 的事务 DDL、锁、在线算法分别验证,不能互相套用;
- 用备份、PITR 和恢复演练覆盖真正的数据损失场景;
- 失败时优先保持兼容并进行前向修复,而不是盲目执行反向 DDL。
当迁移被设计成状态机,而不是一次性脚本时,在线变更才真正具备可控的锁风险、明确的故障路径和可回滚的发布边界。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:数据库连接与连接池:容量、超时、排队、泄漏和故障恢复
- 下一篇:数据库备份与恢复:全量、增量、PITR、RPO/RTO 和恢复演练
- 延伸:数据库 Schema 设计:数据类型、主外键、约束、NULL 与演进
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论