在线修改表结构时,系统里同时存在一份正在变化的表定义和至少两个版本的应用代码。旧代码按旧列读写,新代码按新列读写,后台回填任务又在补写历史行。三方都在提交事务,任何一方都可能把另一方刚写下的值改掉。DDL 本身还要拿表锁,一条看起来只改元数据的 ALTER TABLE 也可能让整张表的普通查询排队。

本篇用一张合成工单表,把“整数优先级”迁成“文本优先级”,并在迁移后期引入一个旧格式表达不了的新取值。要回答的问题有三个:回填期间新写入的值怎样不被覆盖;旧代码还在只写旧列时,新列怎样不落后;从哪一步开始,回退到旧代码就会丢语义,系统怎样拒绝一次假装无损的回退。实验结论都在本地 PostgreSQL 16.15 上用真实事务、真实线程和独立进程验证,证据在 evidence/24/;只引用文档、没有实验的论断在文中单独标出。

需求、不变量与范围

合成表 ticket 有主键 id、标题 title 和整数列 priority,取值 1、2、3。目标结构用文本列 priority_level 表示优先级,取值 low、normal、high,迁移后期再允许 urgent。映射固定为 1↔low、2↔normal、3↔high;urgent 没有对应整数,只能退化成 3。

迁移期间必须成立的不变量如下。

  • I1:回填不改写任何已经由业务写入新值的行。观察位置是逐行核对:每行 priority_level 等于该行最后一次已提交业务写入所期望的值。
  • I2:在越过不可逆点之前,旧列与新列表达同一个优先级。观察位置是校验查询 COUNT_MISMATCH(新列非空且不等于 lvl_of(priority) 的行数,另排除 urgent 与 3 的组合,越过不可逆点后这是合法映射)与 COUNT_NULL(新列为空的行数),两者都应为 0。
  • I3:一旦存在旧格式无法表达的值,回退守卫拒绝回退到旧代码,并报告无法表达的行数;收缩之后不存在数据库内的回退路径。

范围之外的内容:多副本与逻辑复制下的 DDL 传播、分区表、外部 schema 迁移工具、跨服务的数据契约。实验规模是教学假设:主用例 3000 行种子数据、4 个写线程,崩溃用例 4000 行、3 个写线程。实验不报告吞吐、延迟或 DDL 耗时,输出中的写入次数每次运行不同,只用来说明并发写确实发生过。

三个应用版本的读写契约

迁移期间同时存在三种代码,每种只认识自己那一部分结构。

表 24-1:各版本读写的列

版本 读 写 认识 urgent
v1(旧) priority 只写 priority 否
v2(双写) 切读前读 priority,切读后读 priority_level 同一条 UPDATE 写两列 否,传入 urgent 直接抛异常
v3(只写新列) priority_level 只写 priority_level 越过不可逆点之后

v2 的双写在一条语句里完成,所以两列要么同时更新,要么都不更新。v1 不知道新列存在,v3 不再维护旧列。两边各缺一半,缺口交给数据库内的兼容触发器补齐。

数据模型:新列、约束与兼容触发器

扩张阶段只加一个可空的新列和两条 CHECK:level_domain 限定取值集合,no_urgent_before_cutover 在越过不可逆点之前拒绝 urgent。把 urgent 的禁止条件做成数据库约束,而不只是应用里的判断,是为了让“能否撤回越过动作”也由数据库检查决定,后文的守卫依赖这一点。

这两条 CHECK 与加列写在同一条 ALTER TABLE 里,没有带 NOT VALID,PostgreSQL 会在持有 AccessExclusiveLock 期间扫描全表以校验既有行(DEBUG1 下可见 verifying table)。新列此时全为 NULL,CHECK 对 NULL 为真,扫描不会失败,但持锁时间随表大小增长。P4 为 NOT NULL 专门采用 NOT VALID 加 VALIDATE,同样的写法也适用于这里:两条 CHECK 加 NOT VALID,到 P4 一并 VALIDATE,语义不变。本章实现保留了带扫描的写法,表规模只有几千行,没有测量扫描时长。

兼容触发器按“本条语句改了哪一列”推导另一列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
IF TG_OP = 'INSERT' THEN
IF NEW.priority_level IS NULL AND NEW.priority IS NOT NULL THEN
NEW.priority_level := lvl_of(NEW.priority);
ELSIF NEW.priority IS NULL AND NEW.priority_level IS NOT NULL THEN
NEW.priority := int_of(NEW.priority_level);
END IF;
ELSE
IF NEW.priority IS DISTINCT FROM OLD.priority
AND NEW.priority_level IS NOT DISTINCT FROM OLD.priority_level THEN
NEW.priority_level := lvl_of(NEW.priority);
ELSIF NEW.priority_level IS DISTINCT FROM OLD.priority_level
AND NEW.priority IS NOT DISTINCT FROM OLD.priority THEN
NEW.priority := int_of(NEW.priority_level);
END IF;
END IF;
RETURN NEW;

这是 BEFORE INSERT OR UPDATE ... FOR EACH ROW 触发器。PostgreSQL 16 文档 43.10.1 说明,BEFORE 行级触发器返回修改过的 NEW,写入的就是修改后的行。v1 只改 priority,触发器补新列;v3 只改 priority_level,触发器补旧列;v2 两列都改,触发器不动。触发器不处理“本条语句没碰优先级”的情况,因此扩张之前就存在、此后没被业务改过的行,新列一直为空,这部分由回填负责。

另有两张辅助表:migration_state 是单行表,记录当前阶段与越过不可逆点的时间 no_return_at;backfill_progress 记录回填作业的断点 last_id、上界 max_id、已提交批次数与改写行数。

阶段状态机与每一步能否回退

图 24-1:迁移阶段、推进条件与回退边界(各条件由 S1、S3、S10 用例验证)

stateDiagram-v2
    P0: P0 基线(只有 priority)
    P1: P1 扩张(加列+约束+触发器,同一事务)
    P2: P2 双写(部署 v2)
    P3: P3 分批回填
    P4: P4 校验(NOT VALID→VALIDATE→SET NOT NULL)
    P5: P5 切读(v2 读新列)
    P6: P6 停写旧列(v3)
    NR: 已越过不可逆点(允许 urgent)
    P7: P7 收缩(删触发器与旧列)
    P0 --> P1: lock_timeout 内拿到锁,否则 55P03 回滚重试
    P1 --> P2
    P2 --> P3
    P3 --> P4: COUNT_NULL=0 且 COUNT_MISMATCH=0
    P4 --> P5
    P5 --> P6
    P6 --> NR: DROP CONSTRAINT no_urgent_before_cutover
    NR --> P6: 重新加约束,校验扫描通过(无 urgent 行)
    NR --> NR: 重新加约束遇到 urgent 行,23514,保持已越过
    NR --> P7: DROP TRIGGER + DROP COLUMN priority
    P6 --> P0: 守卫放行(未越过,旧列完整且一致)
    NR --> NR: 回退请求,守卫拒绝(报告 urgent 行数)
    P7 --> P7: 回退请求,守卫拒绝;v1 查询 42703

表 24-2:各阶段的表锁、两类代码的行为与回退方式

阶段 动作与表锁 v1 新代码 回退
P1 扩张 ADD COLUMN、ADD CONSTRAINT、CREATE TRIGGER,同一事务;AccessExclusiveLock,持有到提交;CHECK 不带 NOT VALID,持锁期间扫描全表 读写旧列,触发器补新列 未部署 删除触发器与新列,无数据损失
P2 双写 部署 v2,无 DDL 同上 读旧列,写两列 下线 v2;旧列完整
P3 回填 分批条件 UPDATE,只有行锁 同上 同上 停止回填即可
P4 校验 ADD CHECK NOT VALID(短暂 AccessExclusiveLock)→ VALIDATE(ShareUpdateExclusiveLock)→ SET NOT NULL(AccessExclusiveLock,免扫描) 写入经触发器补齐,不违反 NOT NULL 同上 DROP NOT NULL / DROP CONSTRAINT
P5 切读 应用配置切换 同上 读新列 切回读旧列;两列一致
P6 停写旧列 部署 v3 应已下线;若仍在,经触发器仍兼容 只写新列,触发器推导旧列 守卫检查后可回退
越过不可逆点 DROP CONSTRAINT;AccessExclusiveLock 把 urgent 读成 3 可写 urgent 无 urgent 行时可撤回,有则不可
P7 收缩 DROP TRIGGER、DROP COLUMN;AccessExclusiveLock 查询失败 42703 只用新列 无(只能从备份恢复,本章不覆盖)

P1 把加列和建触发器放在同一事务,是为了不留下“列已存在、触发器尚未建立”的窗口;在那个窗口里 v1 改过的行,新列会保持空值或旧值。P4 的 NOT NULL 有一个隐含前提:v1 插入时总是给出 priority。如果旧代码会插入 priority 为空的行,触发器推不出新列,P4 之后这类插入会以 23502 失败。

正常路径:并发写入下走完七个阶段

S1 用 3000 行种子数据,启动两个 v1 写线程,再在 P2 加入两个 v2 写线程。每个线程独占 id % 4 相同的行,所以同一行的写入顺序就是该线程的程序顺序,线程据此记下每行最后一次已提交写入的期望值,结束后逐行核对。线程还会插入新行,ID 从 100000 起。

回填用键集分页:每批的上界取断点之后第 250 行的主键。v1 在扩张之前插入的行 ID 从 100000 起,主键在 3000 与 100000 之间是空洞;按 last_id + 250 的固定步长切区间,会在空洞上提交大量不改任何行的批次。

S1 的关键输出:

1
2
3
4
5
6
7
ok   P4 校验:新列无空值 (expected=0, actual=0)
ok P4 校验:新旧列逐行一致 (expected=0, actual=0)
ok 每行新列等于该行最后一次已提交业务写入(回填未覆盖新值) (expected=0, actual=0)
ok SET NOT NULL 由已验证 CHECK 证明,未扫描全表
ok 切读:抽样 429 行按旧列与按新列读出相同结果 (expected=0, actual=0)
ok 不可逆点之前:回退到 v1 的守卫放行(old column authoritative-compatible)
ok 收缩后 v1 读取旧列失败(undefined_column) (expected=42703, actual=42703)

输出中还有一行 note writes ...,记录本次运行 v1、v2 的写入次数与回填批次数,这些计数每次运行都不同,只说明并发写确实发生过。逐行核对之所以必要,是因为两列一致并不能说明值是对的,S8 会给出一个两列一致但值已被覆盖的例子。

DDL 本身的锁与扫描

迁移脚本里的每条 DDL 都要单独判断两件事:拿什么锁,是否扫描或重写整表。PostgreSQL 16 文档对 ALTER TABLE 的说明是:除非另有注明,各子形式都取 ACCESS EXCLUSIVE;多个子命令合在一条语句里时,取其中最严格的锁。

锁队列:一条排队的 ALTER 挡住后来的 SELECT

图 24-2:长事务、排队的 DDL 与后来的普通查询(S2 观察到的锁状态)

sequenceDiagram
    participant A as 长事务 A
    participant DB as ticket 表锁
    participant B as ALTER TABLE(B)
    participant C as 普通 SELECT(C)
    A->>DB: BEGIN; SELECT(AccessShareLock,granted)
    B->>DB: 请求 AccessExclusiveLock
    DB-->>B: granted=false,pg_blocking_pids(B)={A}
    C->>DB: 请求 AccessShareLock
    DB-->>C: granted=false,pg_blocking_pids(C)={B}
    A->>DB: COMMIT
    DB-->>B: 授予,ALTER 完成
    DB-->>C: 授予,SELECT 返回 100

S2 中,A 只执行了一次 SELECT count(*) 就停在事务里,持有 AccessShareLock。B 的 ALTER TABLE 等待 AccessExclusiveLock。随后到达的 C 只是普通查询,它请求的 AccessShareLock 与 A 持有的锁并不冲突,却仍被挂起。pg_locks 视图不暴露等待队列中的先后关系,文档建议用 pg_blocking_pids();该函数把“排在前面、等待冲突锁”的进程也算作阻塞者(soft block)。输出中 C 的阻塞者正是 B:

1
2
ok   ALTER 被长事务阻塞(hard block) (expected=[long-reader], actual=[long-reader])
ok pg_blocking_pids 显示 SELECT 被排队中的 ALTER 阻塞(soft block),而不是被长事务直接阻塞 (expected=[ddl], actual=[ddl])

阻塞者以会话的 application_name 显示:long-reader 是长事务 A,ddl 是 B。这一现象的成立条件是:存在一个持有任意表锁且不结束的事务,DDL 需要与之冲突的锁,并且没有设置超时。文档 13.3.4 节写明,只要没有检测到死锁,等锁的事务会无限等待。

lock_timeout:拿不到锁就退出队列

S3 在同样的长事务下,让 DDL 会话设置 lock_timeout = '300ms'。ALTER 在超时后以 SQLSTATE 55P03(lock_not_available)失败,退出等待队列;之后 C 的 SELECT 在长事务仍处于 idle in transaction 时就返回了。迁移脚本把 DDL 包在 withLockRetry 中:事务内 SET LOCAL lock_timeout,遇到 55P03 就回滚、退避后重试,其他错误直接抛出。用例在第二次失败后提交长事务,输出为 [1:55P03, 2:55P03],第 3 次成功。

这里的保证只有一条:任何一次排队最多挡住后续查询 lock_timeout 那么久。lock_timeout 只限制排队时长;拿到锁之后,持锁期间的扫描(例如 P1 的 CHECK 校验)同样阻塞后续查询。如果长事务一直不结束,重试会耗尽次数,DDL 无法完成,此时要处理的是那个长事务。

ADD COLUMN:不带默认值或带非易变默认值时不重写表

文档 ALTER TABLE 的 Notes 写明:带非易变 DEFAULT 加列时,默认值在语句执行时求值并存入元数据,既有行读取时使用它;不带 DEFAULT 时为 NULL;两种情况都不重写表。易变默认值则需要重写整表及其索引。S4 用两个观察点核对:pg_class.relfilenode 是否变化,以及 client_min_messages = debug1 时服务端是否发出 rewriting table 消息。

1
2
3
4
ok   无默认值:relfilenode 不变
ok 常量默认值记录在 pg_attribute.atthasmissing/attmissingval (expected=true, actual=true)
ok 易变默认值 clock_timestamp():出现 rewriting table 消息
ok 易变默认值:relfilenode 改变(表被重写)

不重写表不等于不拿锁。同一用例在事务内查询 pg_locks,ADD COLUMN 持有 AccessExclusiveLock 直到提交,所以它同样会陷入上一节的锁队列。

CHECK NOT VALID、VALIDATE 与免扫描的 SET NOT NULL

直接 SET NOT NULL 要扫描全表,并在整个扫描期间持有 AccessExclusiveLock。PostgreSQL 12 起,如果已有一个有效的 CHECK 能证明该列没有 NULL,扫描可以跳过。迁移中的做法是三步:先 ADD CONSTRAINT level_nn CHECK (priority_level IS NOT NULL) NOT VALID,它不检查既有行,加约束的 AccessExclusiveLock 只持有很短时间;再 VALIDATE CONSTRAINT,它扫描既有行,但文档说明只取 SHARE UPDATE EXCLUSIVE;最后 SET NOT NULL。

S5、S6 分别验证这三步。S5 的边界用例中,约束标为 NOT VALID 之后,更新一条尚未修复的旧行,哪怕只改别的列,也会因整行不满足 CHECK 而以 23514 失败。因此这条约束只能在回填完成之后再加,否则 v1 对未回填行只改其他列(例如标题)的更新会开始报 23514;v1 改优先级时,触发器先补上新列,不受影响。

1
2
3
4
5
6
ok   边界:更新一条尚未修复的旧行的其他列,也因该行违反约束而失败 (expected=23514, actual=23514)
ok VALIDATE 事务持有 ShareUpdateExclusiveLock (expected=[ShareUpdateExclusiveLock], actual=[ShareUpdateExclusiveLock])
ok VALIDATE 未提交期间,另一会话的 UPDATE 不被阻塞 (expected=1, actual=1)
ok 已验证 CHECK:出现“existing constraints ... sufficient to prove”
ok 已验证 CHECK:没有 verifying table(未扫描)
ok 负向对照:只有 NOT VALID 的 CHECK 时仍扫描全表(verifying table)

负向对照说明,免扫描的前提是约束已验证(pg_constraint.convalidated = true),NOT VALID 的约束不算数。判断依据是服务端 DEBUG1 消息,消息文本可以在 16.15 的服务端二进制中找到;tablecmds.c 源码在本环境无法访问,本文没有引用源码中的控制流。SET NOT NULL 自身仍取 AccessExclusiveLock,免扫描只缩短持锁时间,排队问题仍然要靠 lock_timeout 解决。

CREATE INDEX CONCURRENTLY 失败后的 INVALID 索引

本次迁移不需要新索引,但切读之后按新列查询通常需要。文档说明,并发建索引先在目录里登记一个 invalid 索引,再分两个事务扫描表;扫描中出现死锁或唯一冲突时命令失败,索引留在目录里,查询会忽略它,写入却仍要维护它。S7 用一个重复值让唯一索引构建失败:

1
2
3
4
5
6
ok   重复值导致 CIC 失败(unique_violation) (expected=23505, actual=23505)
ok 失败后索引仍在目录中,pg_index.indisvalid=false (expected=false, actual=false)
ok INVALID 索引不被查询使用
ok negative control exposes: 用 IF NOT EXISTS 重试“成功”返回,但索引仍是 INVALID
ok 边界:CIC 不能在事务块内执行(active_sql_transaction) (expected=25001, actual=25001)
ok 恢复:DROP INDEX CONCURRENTLY、修复重复值、重建后 indisvalid=true (expected=true, actual=true)

第四行是一个容易写进迁移脚本的错误:为了让脚本可重入而写 CREATE INDEX CONCURRENTLY IF NOT EXISTS,失败后重跑只收到一条 NOTICE,命令正常返回,索引依旧无效。文档对 IF NOT EXISTS 的说明是,不保证已有索引与将要创建的索引一致。所以脚本完成的判据应该是查询 pg_index.indisvalid,命令有没有报错不能作为判据。

回填写法的比较

表 24-3:回填写回方式与验证结果

写法 能否保住并发写入的新值 证据 失败边界
应用侧读旧值、计算、无条件 UPDATE ... WHERE id = ? 不能 S8 负向对照,high 被改回 low 读与写之间任何一次业务写都会丢
应用侧读—算—写,写回时加 AND priority_level IS NULL 能 S8,10 行中跳过 1 行 只适用于“新列非空即已写过”的语义;若新代码会写 NULL,判据失效
数据库内单语句 SET priority_level = lvl_of(priority) WHERE ... AND priority_level IS NULL 能 S8 真实行锁交错;S1、S11 并发写 依赖 READ COMMITTED 的重新求值;REPEATABLE READ 下按文档 13.2.2 会报序列化失败,需整批重试(未实验)
数据库内单语句重算,不加 IS NULL 碰巧能,直到出现 urgent S8 负向对照,urgent 被改为 high 新值只要不能从旧列推导出来就会被覆盖
版本列比较 未实验(NOT_RUN) — 要求包括 v1 在内的所有写入者都递增版本,v1 做不到,仍需触发器

选用第三种。它的保证来自 PostgreSQL 16 文档 13.2.1 描述的行为:READ COMMITTED 下,UPDATE 找到的目标行若已被并发事务修改,会等待对方结束;对方提交后,在新版本的行上重新计算 WHERE 条件,不满足就跳过该行。S8 直接观察到了这个过程:业务事务未提交时,回填会话在 transactionid:ShareLock 上等待,pg_blocking_pids 指向业务会话;业务提交后,回填批次改写 9 行,跳过了已写入 high 的那一行。

关键实现

回填的一个批次就是一个事务,事务内依次锁断点行、条件更新本批、推进断点:

1
2
3
4
5
6
7
8
9
10
11
long hi = Db.queryLong(c, """
SELECT coalesce(max(id), ?) FROM (
SELECT id FROM ticket WHERE id > ? AND id <= ? ORDER BY id LIMIT ?) s""", max, last, max, batch);
int n = Db.update(c, """
UPDATE ticket SET priority_level = lvl_of(priority)
WHERE id > ? AND id <= ? AND priority_level IS NULL""", last, hi);
if (inTx != null) inTx.run();
Db.update(c, """
UPDATE backfill_progress SET last_id = ?, batches = batches + 1, rows_updated = rows_updated + ?
WHERE job = ?""", hi, n, job);
c.commit();

断点与数据在同一事务里提交,所以“数据已改、断点没推进”和“断点推进了、数据没改”都不会出现。max_id 在作业创建时取新列仍为空的最大主键,扩张之后插入的行已经由新代码或触发器填好新列。inTx 钩子只在崩溃实验中使用,让进程停在提交之前。

越过与撤回不可逆点都在持有 migration_state 行锁的事务里完成。撤回时重新加约束:

1
2
3
Db.exec(tx, "SELECT 1 FROM migration_state FOR UPDATE");
Db.exec(tx, "ALTER TABLE ticket ADD CONSTRAINT no_urgent_before_cutover CHECK (priority_level <> 'urgent')");
Db.update(tx, "UPDATE migration_state SET no_return_at = NULL, phase = 'P6-new-only'");

不带 NOT VALID 的 ADD CONSTRAINT 会扫描全表。只要还有一行 urgent,语句就以 23514 失败,整个事务回滚,状态保持“已越过”。能否撤回由这次扫描判定,操作者的判断不参与。回退守卫 rollbackToV1 在同一行锁下读取阶段:已收缩则拒绝;已越过则拒绝并统计 urgent 行数;否则要求旧列无空值且 COUNT_MISMATCH = 0 才放行。

故障反例

回填读到旧值,新代码先写入了新值

图 24-3:应用侧无条件写回的覆盖时间线(S8 确定性交错)

sequenceDiagram
    participant BF as 回填(应用侧)
    participant DB as ticket id=3
    participant V2 as v2 业务写
    BF->>DB: SELECT id, priority WHERE priority_level IS NULL
    DB-->>BF: id=3, priority=1
    V2->>DB: UPDATE priority_level='high', priority=3(提交)
    BF->>DB: UPDATE priority_level='low' WHERE id=3(无条件)
    Note over DB: 触发器见新列被改、旧列未改,把 priority 推导为 1
    Note over DB: 结果 low/1,两列一致,high 丢失
1
2
ok   negative control exposes: 读—算—无条件写回把新写入的 high 覆盖为 low
ok negative control exposes: 被覆盖后新旧两列仍一致(mismatch=0),列间比对查不出这次丢失

第二条暴露了校验的盲区:兼容触发器把被覆盖的新列同步回旧列,两列重新一致,P4 的列间校验会顺利通过。列间比对只能发现“两列不一致”,发现不了“两列一致地错了”。I1 的观察位置因此是逐行期望值,校验查询只负责 I2。

旧代码仍在运行,只写旧列

S9 的负向对照只加列、不建触发器,回填完成后让 v1 改一行优先级、插入一行:

1
2
ok   negative control exposes: v1 更新后新列仍是旧值(mismatch=1)
ok negative control exposes: v1 新插入的行新列为空(null=1)

这两行在切读之后都会被读错。加上兼容触发器后,同样的操作得到 mismatch=0、null=0,v1 插入的 priority=2 被补为 normal,v3 只写新列 low 后 v1 读到 priority=1。触发器的保证只覆盖走 SQL 写入路径、且触发器处于启用状态的写入;禁用触发器的批量导入不在保证之内。S8 为了构造“扩张之前就存在的行”,就是先禁用触发器再灌数。

新取值让回退失去意义

S10 先确认越过之前数据库拒绝 urgent(23514),守卫放行回退。越过之后、还没有写入 urgent 时,守卫已经拒绝回退,但撤回越过动作可以成功。写入一行 urgent 之后,撤回失败:

1
2
3
4
ok   存在 urgent 行时撤回失败:重新加约束的校验扫描发现违规 (expected=23514, actual=23514)
ok 守卫拒绝回退,报告 1 行旧格式无法表达
ok negative control exposes: v1 把 urgent 读成 priority=3(high),语义已丢失
ok negative control exposes: v1 改优先级再改回 3 后,新列变为 high,urgent 永久丢失

后两行是绕过守卫、直接让 v1 上线的结果。v1 读到的 3 与真正的 high 无法区分;用户在 v1 界面把优先级改成 2 再改回 3,触发器按旧列推导出 high,urgent 就从数据库中消失了。这个点之后,“回滚”只剩两条路:继续向前修复,或者从越过之前的备份恢复并承担其后写入的损失。本章没有实现后者,也不把它当作无损回退。

回填进程在批次中途被杀

S11 让独立 JVM 回填进程每批在 UPDATE 之后、提交之前停顿 1 秒,同时运行 v1、v2、v3 三个写线程。父进程读到 IN_TX batch=4 时,确认数据库里该会话处于 idle in transaction,然后执行 kill -9:

1
2
3
4
5
note worker exit=137 last_id at kill=600
ok 服务端清理了被杀进程的会话 (expected=0, actual=0)
ok 断点未前进:被中断批次整体回滚 (expected=600, actual=600)
ok 重启的回填进程正常结束 (expected=0, actual=0)
ok 每行等于最后一次已提交业务写入 (expected=0, actual=0)

客户端进程消失后,服务端会话随连接关闭而结束,未提交的第 4 批整体回滚。重启的进程从断点 600 继续,条件更新使重做的批次不会改写这段时间内业务写过的行。断点前进与数据改写在同一事务里,是续跑正确的前提。如果把断点写到另一个事务或外部文件,崩溃窗口里就会出现断点与数据不一致。

恢复流程

  • DDL 拿不到锁:55P03 回滚后退避重试;重试耗尽时用 pg_blocking_pids 找出持锁的长事务,处理该事务,不要去掉 lock_timeout 后直接重跑。
  • 回填中断:直接重启同一作业,断点在 backfill_progress,条件更新使重复执行无害。完成判据是 last_id = max_id、COUNT_NULL = 0、COUNT_MISMATCH = 0。
  • VALIDATE 失败:约束保持 NOT VALID,新写入仍受约束;修复违规行后再次 VALIDATE。
  • 并发建索引失败:按 indisvalid = false 找出残留索引,DROP INDEX CONCURRENTLY,修复数据后重建,以 indisvalid = true 作为完成判据。
  • 越过不可逆点之后需要回退:先尝试 uncross,它能成功就说明没有 urgent 行;失败则只能向前修复。收缩之后数据库内没有回退路径。

证据与限制

全部用例由 examples/schema-change/run.sh 一条命令运行,最近一次运行输出 SUMMARY passed=96 failed=0,退出码 0。S1–S10 属于真实组件实验,S11 属于多进程集成。以 negative control exposes 开头的负向对照共 8 处,另有 S6 的 NOT VALID 对照,每一处都真实观察到了错误结果。

这些实验不证明以下内容:多副本或逻辑复制环境下 DDL 与触发器的行为;REPEATABLE READ、SERIALIZABLE 下回填与业务写的交错(文档 13.2.2 描述为序列化失败,本章未实验);更大表上 VALIDATE、重写和并发建索引的耗时;数据库服务端崩溃(本章只杀客户端进程);真实滚动发布,三版本代码由同一 JVM 内的不同类模拟。“新旧代码并存时最多相差一个版本”的约束来自 F1 在线异步 schema 变更论文(Rae 等,PVLDB 6(11), 2013):论文用 delete-only、write-only 等中间状态和 schema 租约保证同一时刻最多两个相邻版本在用,回填任务跳过回填开始后被用户事务更新过的行。本文借用了这些概念,没有实现租约与写栅栏,三版本共存的顺序由实验脚本控制。逐条边界见 evidence/24/LIMITS.md,来源见 SOURCES.md 的第 24 篇一节。

本批导航

  • 上一篇:23 分片扩容时的数据交接
  • 下一篇:25 缓存旧值回填与失效修复
  • 本批索引:仓库 batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md
  • 运行入口:batch-3/examples/schema-change/run.sh
  • 证据目录:batch-3/evidence/24/
  • 本篇工程与证据快照:backend-systems-24.tar.gz(SHA-256 见同目录 backend-systems-24.tar.gz.sha256)。正文中的 examples/、evidence/、SOURCES.md 路径都相对仓库的 batch-3/ 目录,与快照包内布局一致。