# 变更模式，服务照常

> 用锁超时、NOT VALID 约束、并发索引和扩展收缩，在繁忙数据库上发布模式变更而不造成停顿。

---

LLMS 索引： [llms.txt](/llms.txt)

---

<span id="basics-chapter-26"></span>

现在你知道队列为什么会形成了。一个 `ALTER` 在报表后面等待排他锁，后面的每个请求也都在等待，即使只是普通读取（[一个等待中的锁会阻塞后面的所有人](/25-concurrency/#a-waiting-lock-blocks-everyone-behind-it)）。你的连接池被填满，网站因为一次“什么也没做”的迁移而宕机。GoCardless [记录过](https://gocardless.com/blog/zero-downtime-postgres-migrations-the-hard-parts)完全相同的情况：一次很快的外键迁移排在长时间读取后面，API 请求又排在迁移后面，API 大约宕机了 15 秒。

这一章讲的是另一半：周二下午两点发布模式变更，而不形成那条队列。

模式变更造成伤害，不是因为它们慢，而是因为它们锁住了什么以及锁了多久。因此，每条语句都要问两个问题：**它取得什么锁**，以及**它持有锁多久**？几乎每个危险变更都有安全版本：同一个变更拆成几个步骤，让弱锁持有几分钟，或让强锁只持有几毫秒。下面就是这些拆分，以及测量结果。

所以，迁移的第一条规则与 DDL 毫无关系。

## 规则一：lock_timeout 和重试 {#rule-one-lock_timeout-and-retry}

在每个迁移会话上设置 [`lock_timeout`](/43-glossary/#lock_timeout)。它到期后，语句会失败（`canceling statement due to lock timeout`），离开队列，后面的流量也会重新流动，即使报表仍然没有结束。

> [!EXAMPLE]- 动手验证 · lab `25-migrations`
>
> ```sql
> select lab.prove('after the lock timeout, the lookup completes while the report is still open',
>   (select id from dblink_get_result('web') t(id bigint)) = 42
>   and (select state from pg_stat_activity where pid = :report_pid) = 'idle in transaction');
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

这个错误说明系统正在正常工作。请求排队几秒不会被察觉；无上限的等待就是一次故障。失败的语句什么也没有改变，因此要在有上限的尝试次数内重试它（只有超时而没有重试，会把本来能完成的迁移变成失败的发布）：

```bash
# retry a DDL statement up to 10 times with a short lock_timeout
for i in $(seq 1 10); do
  if psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
       -c "set lock_timeout = '2s'" \
       -c "alter table events add column note text"; then
    exit 0
  fi
  echo "attempt $i failed, retrying" >&2
  sleep $((i * 2))
done
exit 1
```

把它放进迁移工具的连接初始化设置中，设为 1 到 5 秒：足够长，可以穿过短事务之间的空隙；又足够短，可以让停顿不被察觉。如果重试仍然不断失败，就找出阻塞者，通常是长事务或处于 `idle in transaction` 的会话（[backend_xmin 会指出阻碍清理的事务](/31-observability/#backend_xmin-names-the-transaction-holding-vacuum-back)）。

还要设置一个宽松的 [`statement_timeout`](/43-glossary/#statement_timeout)，这样意外重写 500 GB 的表会在几分钟内失败，而不是持有排他锁一小时。只有在并发构建索引（以及执行同类操作 `reindex concurrently` 和 `drop index concurrently`）时，才把这两个超时都关闭（[单独运行并发构建，不设超时，并检查索引是否有效](#run-a-concurrent-build-alone-untimed-and-check-its-valid)）。

## 每次变更都是系统目录编辑、扫描或重写 {#every-change-is-a-catalog-edit-a-scan-or-a-rewrite}

拿到锁之后，重要的是操作会持续多久。变更要么只编辑几行**系统目录**记录（无论表有多大都只需几毫秒），要么**扫描**每一行来检查某件事，要么**重写**表及其索引；后一种操作的时间、磁盘和 WAL 都会随着表增长。在 `events` 上（275 MB、200 万行），不同做法的区别，是一次不被察觉的发布与一次持续 9 秒的冻结；在 CI 的 1,000 行数据上，你根本分辨不出它们。

因此，要在用生产数据恢复出的副本上测试迁移（这也[验证你的备份](/32-backups/#basics-chapter-32)），并观察两个数字。重写会改变 `pg_relation_filenode`，也就是表所在的文件；扫描会增加 `seq_scan`，即表的完整扫描次数。

目标锁是 `ShareUpdateExclusive`，它既不阻塞读取，也不阻塞写入（[大多数 ALTER TABLE 形式都会取得 ACCESS EXCLUSIVE](/25-concurrency/#most-alter-table-forms-take-access-exclusive)）。下面的每种安全模式，都会让变更中耗时较长的部分在这种锁下运行。

## 常量默认值没有代价；易变默认值会重写 {#constant-defaults-are-free-volatile-defaults-rewrite}

从 PostgreSQL 11 开始，添加带常量默认值的列只改系统目录。易变默认值不是：

```sql
alter table events add column source text not null default 'web';
select pg_relation_filenode('events');
alter table events add column ingest_id uuid default gen_random_uuid();
select pg_relation_filenode('events');
```

```text
Time: 65.657 ms
 pg_relation_filenode
                16943
...
Time: 9236.902 ms (00:09.237)
 pg_relation_filenode
                17220
```

第一个 filenode 就是表原来拥有的那个：常量没有触碰任何行。Postgres 把 `'web'` 存在系统目录中，读取旧行时再提供它。UUID 需要每行有不同的值，因此 Postgres 在 `AccessExclusive` 下重写了全部 200 万行，耗时 9 秒。规则是[易变性](/43-glossary/#volatility)，而不是有没有调用函数：`now()` 是稳定的，只计算一次，会像常量一样保存。

对于逐行值，要把它拆开：先添加不带默认值的列（只改系统目录），再 `set default`，让新行得到默认值（也只改系统目录），最后按批次对旧行执行[回填](#backfill-in-short-batches-that-walk-the-primary-key)。

<span id="where-a-default-lives-when-no-row-has-it" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-where-a-default-lives-when-no-row-has-it" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：没有行拥有默认值时，默认值存在哪里
>
> 常量进入了 `pg_attribute`，成为该列的“缺失值”：`atthasmissing = t`、`attmissingval = {web}`。旧元组的属性数少于表的属性数，Postgres 会从这里填上缺口。`add column ts2 timestamptz default now()` 耗时 17 ms，filenode 保持为 17220，而每个已有行都会报告迁移发生的时刻，这可能不是你想要的结果。生成列遵循相同逻辑：虚拟生成列（PostgreSQL 18+ 的默认形式）只改系统目录，存储生成列会重写表（小测试表的 filenode 从 19978 变为 19986）。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('a constant default does not rewrite: same filenode',
> >   pg_relation_filenode('events') = :fn0);
> > select lab.prove('every existing row reports the moment of the migration',
> >   (select count(distinct ts2) from events) = 1);
> > -- the rewrite is shown on a 200,000 row copy of events to keep the lab quick
> > select lab.prove('a volatile default (gen_random_uuid()) rewrites: new filenode',
> >   pg_relation_filenode('events_copy') <> :cfn);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

## 先通过 CHECK，再设置 NOT NULL {#not-null-goes-through-a-check-first}

`set not null` 必须证明不存在空值，因此会在 `AccessExclusive` 下扫描整张表，并在从磁盘读取每一页时阻塞读取。使用 CHECK 作为过渡：

```sql
-- 1. brief AccessExclusive, no scan: new rows are checked from now on
alter table events
  add constraint events_user_id_nn check (user_id is not null) not valid;
-- 2. ShareUpdateExclusive: scans, but reads and writes continue
alter table events validate constraint events_user_id_nn;
-- 3. brief AccessExclusive, no scan: Postgres trusts the validated CHECK
alter table events alter column user_id set not null;
-- 4. the CHECK is now redundant
alter table events drop constraint events_user_id_nn;
```

第 3 步才是巧妙之处（PostgreSQL 12+）：一个证明 `col is not null` 的有效 CHECK，可以让 `SET NOT NULL` 跳过扫描。在 PostgreSQL 18 上可以跳过 CHECK：直接把 NOT NULL 本身添加为 `NOT VALID`，然后验证它。

<span id="counting-the-scans-and-the-postgresql-18-route" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-counting-the-scans-and-the-postgresql-18-route" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：统计扫描次数，以及 PostgreSQL 18 的路径
>
> `seq_scan` 在 `pg_stat_user_tables` 中会统计完整扫描。直接执行 `set not null` 会让它从 11 增加到 12（在我们已缓存的表上耗时 130 ms）。在上述模式中，第 2 步使它增加一次（耗时 87 ms，流量可以在锁下通过），第 3 步没有增加，耗时不到 4 ms。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > begin;
> > select xact_seq_scans('events') as s0 \gset
> > alter table events add constraint events_user_id_nn check (user_id is not null) not valid;
> > select lab.prove('step 1, add check ... not valid: no scan',
> >   xact_seq_scans('events') = :s0);
> > alter table events validate constraint events_user_id_nn;
> > select lab.prove('step 2, validate: one scan',
> >   xact_seq_scans('events') = :s0 + 1);
> > alter table events alter column user_id set not null;
> > select lab.prove('step 3, set not null with a valid CHECK in place: no scan',
> >   xact_seq_scans('events') = :s0 + 1);
> > rollback;
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)
>
> 在 18 中：
>
> ```sql
> -- no scan
> alter table events
>   add constraint events_user_id_not_null not null user_id not valid;
> -- scans, under a weak lock
> alter table events validate constraint events_user_id_not_null;
> ```
>
> 两者之间，插入空值已经会被拒绝。需要注意的是：验证之前，`\d` 已经显示 `not null`，`pg_attribute.attnotnull` 也已经是 true。应当检查 `pg_constraint.convalidated`。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('18: add not null ... not valid takes AccessExclusive',
> >   my_strongest('events') = 'AccessExclusiveLock');
> > select lab.prove('before validation, pg_constraint.convalidated is false',
> >   (select not convalidated from pg_constraint where conname = 'events_user_id_not_null'));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

## 先添加 CHECK NOT VALID，再验证 {#add-a-check-not-valid-then-validate-it}

永远不要一步给大表添加 CHECK。先以 `NOT VALID` 添加它（短暂持锁，不扫描，并立即对新写入强制执行），然后再执行 `VALIDATE CONSTRAINT`（在 `ShareUpdateExclusive` 下扫描），正如上面 NOT NULL 模式的第 1、2 步。如果验证发现坏行，它会失败，但约束会保留下来，在你修复旧数据时继续保护新写入。

> [!EXAMPLE]- 动手验证 · lab `25-migrations`
>
> ```sql
> alter table events add constraint events_no_login check (kind <> 'login') not valid;
> -- validate fails with check_violation (caught in the lab)
> select lab.prove('if validation hits a violating row it fails, and the constraint stays, not valid',
>   (select not convalidated from pg_constraint where conname = 'events_no_login'));
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

## 先添加 NOT VALID 外键，再验证 {#add-foreign-keys-not-valid-then-validate}

一步添加外键会在阻塞两张表写入的同时扫描子表。按相同方式拆开：

```sql
-- ShareRowExclusive on both tables: blocks writes briefly, no scan
alter table events add constraint events_project_fk2
  foreign key (project_id) references projects (id) not valid;
-- ShareUpdateExclusive on events, RowShare on projects: traffic continues
alter table events validate constraint events_project_fk2;
```

`add` 操作仍然会阻塞父表的写入，而父表可能是最繁忙的表，并且像其他操作一样排队（GoCardless 就是被这个问题绊倒的），所以要给它设置 `lock_timeout`。还要自己并发地为引用列建索引；没有这个索引，每次从 `projects` 删除数据都会扫描 `events`（[有目的地选择约束](/14-relationships/#basics-chapter-14)）。

## 选择与表流量匹配的索引构建方式 {#pick-the-index-build-that-matches-the-tables-traffic}

Postgres 有两种构建索引的方式，选择可以用一句话说清。普通的 `CREATE INDEX` 做的工作最少，但在完成前会阻止表上的所有写入；`CREATE INDEX CONCURRENTLY` 允许写入通过，却要付出两倍读取、大量等待以及中途失败的可能。

下面是在没有其他操作运行时，以两种方式构建同一个 `(project_id, created_at)` 索引，目标表是 `events`：

```text
   build    | table_scans | pages_read |  wal  | index_pages
------------+-------------+------------+-------+-------------
 plain      |           1 |      35175 | 54 MB |        7703
 concurrent |           2 |      70348 | 54 MB |        7703
```

同一个索引，同样的 WAL，表读取次数却翻倍：在我们的机器上，并发构建耗时约为普通构建的 1.5 倍。

这是基准测试看到的代价。用户感受到的代价，则是写入发生了什么。构建索引期间，四个客户端尽可能快地向 `events` 插入数据：

| 构建方式 | 构建期间完成的插入 | 最慢插入 |
|---|---|---|
| `create index` | 41 到 56 | 723 到 751 ms |
| `create index concurrently` | 1,848 到 2,119 | 16 到 103 ms |

<span id="tbl-26-1"></span>

**Table 26-1.** 选择与表流量匹配的索引构建方式


普通构建期间到达的每次插入，都要等它全部完成。在 `events` 上，这不到一秒；在 200 GB 的表上，这就是整个构建过程，而每次结账、注册和 webhook 都在队列中等待。

区别只有一个锁。普通构建的 `ShareLock` 允许读取通过，却阻塞每次写入都需要的 `RowExclusiveLock`；并发构建的 `ShareUpdateExclusiveLock` 两者都不阻塞。

<span id="how-we-measured-the-write-stall-and-the-lock-caught-mid-build" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-how-we-measured-the-write-stall-and-the-lock-caught-mid-build" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：我们如何测量写入停顿，以及构建中途捕获的锁
>
> 四个 `pgbench` 客户端运行单行插入，持续 5 秒；构建在开始 1.5 秒后启动，`pgbench -l` 记录每次插入的延迟。构建是串行的（没有并行工作进程），因此上面的每一页和每一个 WAL 字节都属于构建；时间是五次运行的中位数，表已缓存。
>
> 普通构建中，最慢插入的耗时就是构建的耗时：每个等待中的插入都在构建提交的瞬间完成，而少数在“构建期间”完成的插入，是在构建取得锁之前刚好落地的。并发构建的 16 到 103 ms 属于磁盘和 CPU 的普通竞争。普通构建后让迁移事务保持打开，就能把这一状态定格下来：
>
> ```text
>  pid  |             query              |       mode       | granted | blocked_by
> ------+--------------------------------+------------------+---------+------------
>  3932 | create index plain_idx on even | ShareLock        | t       | {}
>  3933 | insert into events (account_id | RowExclusiveLock | f       | {3932}
> ```
>
> 同一时刻发出的主键查找立即返回。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('a plain build holds a Share lock on events',
> >   exists (select 1 from pg_locks where pid = :b_mig and relation = 'events'::regclass
> >              and mode = 'ShareLock' and granted));
> > select lab.prove('an INSERT waits for it, on a relation lock',
> >   (select wait_event_type || ':' || wait_event from pg_stat_activity where pid = :b_web) = 'Lock:relation'
> >   and exists (select 1 from pg_locks where pid = :b_web and relation = 'events'::regclass
> >                  and mode = 'RowExclusiveLock' and not granted));
> > select lab.prove('a concurrent build reads it twice',
> >   :cic_scans = 2 and :cic_pages between 1.9 * :events_pages and 2.1 * :events_pages);
> > select lab.prove('while a primary key lookup goes straight through',
> >   (select k from dblink('b_hold', 'select kind from events where id = 42') t(k text)) is not null);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

<span id="parallel-builds-memory-buys-workers-workers-buy-time" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-parallel-builds-memory-buys-workers-workers-buy-time" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：并行构建：内存换来工作进程，工作进程换来时间
>
> B-树构建会通过 `max_parallel_maintenance_workers`（默认 2）在多个工作进程间拆分扫描和排序，但 `maintenance_work_mem`（默认 64 MB）为整个构建设定预算，而且包括领导者（leader）在内的每个进程都需要其中 32 MB。因此，在默认设置下，允许两个工作进程也只会实际使用一个。下面是我们在 8 核机器上进行七次构建的中位数：
>
> | maintenance_work_mem | 允许的工作进程数 | 进程数 | 构建耗时中位数 |
> |---|---|---|---|
> | 64 MB | 0 | 1 | 0.82 s |
> | 1 GB | 0 | 1 | 1.06 s |
> | 64 MB | 2 | 2 | 0.53 s |
> | 256 MB | 2 | 3 | 0.50 s |
> | 1 GB | 4 | 5 | 0.37 s |
> 
> <span id="tbl-26-2"></span>
> 
> **Table 26-2.** 选择与表流量匹配的索引构建方式

>
> 单靠内存没有带来任何收益（排序 200 万个键时，溢写磁盘的代价很低）。内存带来的是工作进程，而工作进程带来了时间收益：使用五个进程时，速度是原来的 2.2 倍。对于有空闲核心的服务器上的大构建，要在迁移会话中同时提高这两个设置。并发构建只会并行化第一次扫描。要进行全服务器范围的配置，请参阅[每个 autovacuum 工作进程也会占用 maintenance_work_mem](/30-configuration/#each-autovacuum-worker-takes-maintenance_work_mem-too)。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('at the defaults (64 MB, 2 workers allowed) the build gets only one worker: each needs 32 MB',
> >   current_setting('maintenance_work_mem') = '64MB'
> >   and current_setting('max_parallel_maintenance_workers') = '2'
> >   and (select scans - :s0 = 2 from events_reads()));
> > select lab.prove('with 256 MB it gets both workers: three processes scan the table',
> >   (select scans - :s0 = 3 from events_reads()));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

### 如果你得问它是否繁忙，就使用并发构建 {#if-you-have-to-ask-whether-its-busy-build-concurrently}

只有在表上没有人写入时，才使用普通的 `CREATE INDEX`：**新表或空表**（包括在同一迁移中更早创建的表，此时普通构建能让表、索引和约束保持原子性）；**真正的维护窗口**，且写入确实已停止，而不是“通常很安静”；或**无人写入的表**，例如查找表、归档表，或已经停止接收行的分区。

生产环境中任何会接收写入的表，都使用 `CREATE INDEX CONCURRENTLY`。如果你得问一张表是否繁忙，就把它当作繁忙。

普通构建不是糟糕的默认值：它更快、具有事务性，而且失败时能干净地回滚。它只是不适合那些你最常需要建立索引的大型繁忙表。

### 单独运行并发构建，不设超时，并检查索引是否有效 {#run-a-concurrent-build-alone-untimed-and-check-its-valid}

1. **为这条语句关闭 `lock_timeout`**，这是[规则一：lock_timeout 和重试](#rule-one-lock_timeout-and-retry)的例外。并发构建的锁即使在等待时也不会阻塞读取或写入，因此超时不会保护任何流量。更糟的是，Postgres 会把它应用到构建等待其他事务的场景：1 秒超时会取消一个卡在慢报表后面的构建，并留下无效索引。为构建设置 `lock_timeout = 0` 和 `statement_timeout = 0`。（普通构建要保留它：只要它在**等待**锁，就已经会阻塞后面的写入。）
2. **单独运行。** 它拒绝在事务块中运行，因此要用工具的“无事务”开关，为它提供自己的迁移。同一张表上的第二个并发构建会排在第一个后面。
3. **观察它。** `pg_stat_progress_create_index` 会显示阶段、已完成的页数和它正在等待的会话（`current_locker_pid`），等待对象可能来自数据库中的任何位置：对该表尚未提交的写入、repeatable read 事务、打开的游标或正在运行的查询都可能成为等待对象。空闲的 read committed 会话不算。
4. **完成后检查 `indisvalid`。** 失败、取消或超时的构建会留下无效索引（[并发构建失败时](#when-a-concurrent-build-fails)）。重试前，先并发删除它。

> [!TIP] 让并发索引迁移可以重复运行
>
> 失败后，`create index concurrently if not exists` 是一个陷阱：无效索引已经存在，所以它什么也不做，却报告成功。先删除同名的无效索引，再创建它，并在每次发布后检查 `indisvalid`。

<span id="what-a-concurrent-build-does-phase-by-phase" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-what-a-concurrent-build-does-phase-by-phase" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：并发构建做了什么，逐阶段看
>
> 普通构建只有一次遍历：加锁、扫描、排序、写入树、提交。并发构建面对的是不断变化的表，因此会分阶段运行（[文档](https://www.postgresql.org/docs/18/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY)）。我们在一次构建期间每隔几毫秒采样 `pg_stat_progress_create_index`；开始时 `events` 中有一个未提交的插入，并有一个空闲的 repeatable read 事务打开；两者都在 0.3 秒后释放：
>
> ```text
>                  phase                  | blocks_done | blocks_total | locker
> ----------------------------------------+-------------+--------------+--------
>  waiting for writers before build       |           0 |            0 |   2081
>  building index: scanning table         |       34375 |        35173 |
>  building index: sorting live tuples    |       35173 |        35173 |
>  building index: loading tuples in tree |           0 |            0 |
>  index validation: scanning index       |        7415 |         7703 |
>  index validation: sorting tuples       |           0 |            0 |
>  index validation: scanning table       |       34855 |        35173 |
>  waiting for old snapshots              |       35172 |        35173 |   2082
> ```
>
> （`locker` 是 `current_locker_pid`；每一行是该阶段看到的最后一个样本。）
>
> 从上到下读取。索引进入系统目录时是无效的，因此新事务知道它存在。构建会等待已经在写这张表的事务（插入事务，pid 2081），扫描全部 35,173 页，排序 200 万个条目，然后把索引标记为 ready（`indisready`）：此时写入会开始维护它。验证阶段读取新索引的 7,703 页，并再次读取表，把期间到达的行加入进去。最后，它会等待所有更早的快照（repeatable read 会话，pid 2082），并把自己标记为有效。普通构建只会显示三个“building index”阶段。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('a plain build that is only waiting for its lock already blocks the inserts behind it',
> >   (select not granted from pg_locks where pid = :b_web and relation = 'events'::regclass));
> > select lab.prove('a 1s lock_timeout cancels a concurrent build waiting on an old snapshot, leaving an invalid index',
> >   (select indisready and not indisvalid from pg_index where indexrelid = 'kind_idx'::regclass));
> > select lab.prove('a second concurrent build on the same table queues behind the first',
> >   exists (select 1 from pg_locks where pid = :b_mig and relation = 'events'::regclass
> >              and mode = 'ShareUpdateExclusiveLock' and not granted));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

<span id="which-sessions-hold-a-concurrent-build-up" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-which-sessions-hold-a-concurrent-build-up" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：哪些会话会拖住并发构建
>
> 最后的等待会覆盖数据库中快照早于验证阶段的每个事务，无论它访问了哪张表。我们测试了每种会话：
>
> | 构建开始时打开的会话 | 会拖住构建吗？ |
> |---|---|
> | 处于事务中空闲、read committed，读取或写入 `accounts` | 否 |
> | 处于事务中空闲、repeatable read，读取 `accounts` | 是：“waiting for old snapshots” |
> | 在 `accounts` 上打开游标的 read committed 会话 | 是：“waiting for old snapshots” |
> | 仍在任何表上运行查询 | 是：“waiting for old snapshots” |
> | 向 `events` 未提交的插入 | 是：“waiting for writers before build” |
> 
> <span id="tbl-26-3"></span>
> 
> **Table 26-3.** 单独运行并发构建，不设超时，并检查索引是否有效

>
> Read committed 每条语句都会获取新快照，因此空闲会话不会持有任何东西；repeatable read 会一直保留快照，直到事务结束。线索是 `backend_xmin` 在 `pg_stat_activity` 中的值：只要某个会话的这一值早于构建，它就会阻碍构建，甚至一个写成单条长语句的监控循环也包括在内。构建等待时没有危害（插入会直接通过）；它只是无法结束。
>
> 还有两个意外。构建的锁会与该表上的清理冲突：autovacuum 会让开，防回卷清理不会，于是构建会等待。唯一索引构建从第二次扫描开始就强制唯一性：当我们的索引仍无效、构建停留在“waiting for old snapshots”阶段时，一次重复插入因 `23505` 失败。一次失败留下的无效索引，会一直拒绝重复值，直到你删除它。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('an idle read committed transaction that read another table does not hold it up',
> >   (select indisvalid from pg_index where indexrelid = 'kind_idx'::regclass)
> >   and (select state from pg_stat_activity where pid = :b_hold) = 'idle in transaction');
> > select lab.prove('an idle repeatable read transaction on another table parks it in "waiting for old snapshots"',
> >   (select current_locker_pid = :b_hold and wait_event = 'virtualxid' from build_progress)
> >   and pg_blocking_pids(:b_cic) = array[:b_hold]);
> > select lab.prove('while the unique build waits, still invalid, a duplicate insert is already rejected (23505)',
> >   (select not indisvalid from pg_index where indexrelid = 'handles_handle_key'::regclass));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

<span id="when-a-concurrent-build-fails" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-when-a-concurrent-build-fails" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：并发构建失败时
>
> 失败的普通构建会回滚。失败的并发构建（唯一索引中出现重复值、死锁、取消或超时）会留下一个**无效**索引，并以 `INVALID` 标记出现在 `\d` 输出中。查询永远不会使用它；如果它已经通过第一次扫描，之后的每次写入仍然会维护它（我们有一次很晚才取消构建，它在 20,000 次插入中持续增长）；而且它的名称会与你的重试冲突。找出它们，并发删除它们，修复原因，再次构建：
>
> ```sql
> select indexrelid::regclass, indisvalid, indisready
>   from pg_index where not indisvalid;
>
> drop index concurrently projects_account_name_key;  -- a failed unique build
> ```
>
> `indisready` 表明你拥有的是哪一种索引。遇到重复值的唯一索引构建会在第一次扫描中失败，因此留下的索引尚未 ready，写入会跳过它。验证阶段失败或被取消的索引已经 ready，在你删除它之前，写入都会为它付出代价。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('every write still maintains it: it grew with 20,000 inserts',
> >   pg_relation_size('kind_idx') > :k0);
> > select lab.prove('and leaves an index behind, indisvalid = false (and here indisready = false)',
> >   (select not indisvalid and not indisready from pg_index
> >     where indexrelid = 'projects_account_name_key'::regclass));
> > create unique index concurrently if not exists projects_account_name_key
> >   on projects (account_id, name);
> > select lab.prove('"if not exists" after a failure does nothing and reports success: still invalid',
> >   (select not indisvalid from pg_index where indexrelid = 'projects_account_name_key'::regclass));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

### 并发构建唯一索引，然后把它挂接上 {#build-a-unique-index-concurrently-then-attach-it}

直接添加唯一约束，会在 `AccessExclusive` 下构建它的索引。先并发构建索引，再把它挂接上。挂接只改系统目录，但仍然需要短暂的 `AccessExclusive`，因此要在 `lock_timeout` 下像其他 `ALTER` 一样运行：

```sql
create unique index concurrently users_email_key on users (email);
alter table users add constraint users_email_key unique using index users_email_key;
```

主键也是相同做法（`primary key using index`），前提是列已经是 NOT NULL；如果还不是，挂接操作会执行 `SET NOT NULL`，并在该锁下扫描表。

永远不要在线上表上运行普通 `REINDEX`。它会在索引上取得 `AccessExclusive`，而且因为规划任何查询都会锁住该表的每个索引，即使从不访问被重建索引的主键查找，也会一直等到提交。改用 `reindex index concurrently`（PostgreSQL 12+）。分区表完全拒绝 `concurrently`，因此要并发构建每个分区的索引，再把它们挂接到父表的索引上。

<span id="rebuild-with-reindex-concurrently-plain-reindex-blocks-reads-too" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-rebuild-with-reindex-concurrently-plain-reindex-blocks-reads-too" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：使用 REINDEX CONCURRENTLY 重建；普通 REINDEX 也会阻塞读取
>
> 对于膨胀的索引（[何时重建](/10-vacuum/#index-bloat-often-costs-more-than-table-bloat)）或受排序规则库变更影响的索引，`reindex index concurrently`（PostgreSQL 12+）会在旧索引旁边构建一个 `..._ccnew` 副本，然后交换两者；所用锁及注意事项与并发创建相同。如果失败，删除残留的 `_ccnew` 后重试；残留的 `_ccold` 表示它已经成功，只有旧副本需要删除。
>
> 普通 `REINDEX` 是紧急情况下人们最容易使用的办法，却比普通 `CREATE INDEX` 更糟：它还会在索引上取得 `AccessExclusiveLock`，而且规划任何查询都会锁住表上的每个索引。普通重建 `events_created_at_idx` 时，`select kind from events where id = 42` 明明不会使用该索引，却一直等到提交，正如 [REINDEX 文档](https://www.postgresql.org/docs/18/sql-reindex.html)所警告的那样。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('and that blocks even a primary key lookup: planning it opens every index on the table',
> >   (select wait_event from pg_stat_activity where pid = :b_web) = 'relation');
> > select lab.prove('reindex concurrently builds a second copy beside the old one, named _ccnew',
> >   (select command = 'REINDEX CONCURRENTLY' from build_progress)
> >   and to_regclass('events_created_at_idx_ccnew') is not null);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

<span id="partitioned-tables-build-each-partition-then-attach" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-partitioned-tables-build-each-partition-then-attach" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：分区表：逐个构建分区索引，然后挂接
>
> 对分区表执行 `create index concurrently` 会失败（`cannot create index on partitioned table "events_m" concurrently`），而在父表上执行普通构建，会同时对每个分区取得 `ShareLock`。并发构建各个部分，再把它们组装起来。在 `events` 的三个按月分区上：
>
> ```sql
> -- 1. the parent's index: catalog only, 2 ms, marked invalid
> create index events_m_kind_idx on only events_m (kind);
>
> -- 2. each partition, concurrently, then attach it (one pair per partition)
> create index concurrently events_m_2026_07_kind_idx on events_m_2026_07 (kind);
> alter index events_m_kind_idx attach partition events_m_2026_07_kind_idx;
> -- ... and the same for events_m_2026_08 and events_m_2026_09
> ```
>
> 父表的索引会在最后一个分区索引挂接后自行变为有效。之后使用 `partition of` 创建的分区会自动获得匹配的索引，并以普通方式构建；这没问题，因为它们是空的。对一个已经包含行的表执行 `attach partition`，也会在锁下以普通方式构建缺失的索引，因此要在挂接表之前给它建好索引。[分区章节](/39-partitioning/#constraints-must-carry-the-partition-key)提供了生成这些语句的工具。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > select lab.prove('a plain create index on the parent takes Share on every partition',
> >   strongest(:'pm_locks', 'events_m_2026_07') = 'ShareLock'
> >   and strongest(:'pm_locks', 'events_m_2026_08') = 'ShareLock'
> >   and strongest(:'pm_locks', 'events_m_2026_09') = 'ShareLock');
> > select lab.prove('after the last attach, it turns valid on its own',
> >   (select indisvalid from pg_index where indexrelid = 'events_m_kind_idx'::regclass)
> >   and (select count(*) from pg_inherits where inhparent = 'events_m_kind_idx'::regclass) = 3);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

## 扩宽类型没有代价；大多数其他类型变更都会重写 {#widening-a-type-is-free-most-other-type-changes-rewrite}

放宽限制没有代价。增加限制、改变标度或改变表示方式，都会重写表以及该列上的每个索引。在一张 100,000 行的表上：

| 变更 | 会重写吗？ |
|---|---|
| `varchar(20)` 到 `varchar(50)`，`varchar(n)` 到 `text` | 否 |
| `text` 到 `varchar(100)`，即使所有值都放得下 | **是** |
| `int` 到 `bigint` | **是** |
| `numeric(12,2)` 到 `numeric(12,4)` | **是** |
| `timestamp` 到 `timestamptz`，会话处于 UTC | 否 |
| `timestamp` 到 `timestamptz`，任何其他时区 | **是** |

<span id="tbl-26-4"></span>

**Table 26-4.** 扩宽类型没有代价；大多数其他类型变更都会重写


因此，要用 CHECK 约束表达长度限制。对于时间戳，转换会把每个旧值当作会话时区中的时间来读取。如果保存的值确实是 UTC，先设置 `timezone = 'UTC'`：字节不会改变，也不会发生重写。如果它们保存的是本地钟表时间，就把会话设置到那个时区并接受重写；把它们按 UTC 转换虽然可以跳过重写，却会让每个值按偏移量发生移动。

`integer` 主键在 21 亿处插入失败，而 `alter column id type bigint` 会在 `AccessExclusive` 下重写表及其每个索引：在这种规模下会停机数小时。在线修复是一个由本章各部分组成的小项目，安全完成需要数周，而且完全可以避免：从第一天起就[使用 bigint](/13-keys/#basics-chapter-13)。

对于已经存在的表，要把每个序列与它的最大值画在一起，并在它远未达到最大值时就发出告警：

```sql
select schemaname, sequencename, data_type, last_value, max_value,
       round(100.0 * last_value / max_value, 1) as pct_used
  from pg_sequences
 order by pct_used desc nulls last;
```

有一个陷阱：从 PostgreSQL 10 之前升级的数据库，可能有 `serial` 列，类型为 `integer`，却由 `bigint` 序列支持。它们报告的比例不到百分之一，而列本身却已经接近溢出，因此要把 `last_value` 与列类型的取值上限比较，而不只是与序列的 `max_value` 比较。

<span id="the-int-to-bigint-problem" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-26-the-int-to-bigint-problem" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：从 int 到 bigint 的问题
>
> 在线交换的顺序如下，这样可以让外键始终保持生效：
>
> 1. **添加并同步。** 添加 `id_new bigint`，允许为空、没有默认值（只改系统目录），并添加一个触发器，在每次插入和更新时设置 `new.id_new := new.id`。
> 2. **回填。** 按批次遍历主键，对已有行执行回填。
> 3. **在不持有长锁的情况下让它变成 NOT NULL：** `add constraint ... check (id_new is not null) not valid`，然后 `validate constraint`，就像上面 NOT NULL 模式的第 1、2 步。（在 PostgreSQL 18 中使用 `not null id_new not valid` 和 `validate`。）
> 4. **并发地在 `id_new` 上建立唯一索引。**
> 5. **对每个引用列重复相同操作。** 每个子表的 `parent_id` 都获得一个 `bigint` 双胞胎列，以及自己的触发器、回填和 NOT NULL；然后从这个双胞胎列向父表的 `id_new` 建立外键，以 `NOT VALID` 添加并验证。外键可以引用唯一索引，因此不必等待新的主键。
> 6. **在一个短事务中交换。** 设置 `lock_timeout` 并重试：删除旧外键和旧主键，在新索引上执行 `add primary key using index`（不扫描：经过验证的 CHECK，或 PostgreSQL 18 的 not-null 约束，已经证明不存在空值），重命名父表和每个子表中的列，把标识或序列移到新列（先执行 `alter sequence ... as bigint`，因为 `int` 序列会在 2,147,483,647 处停止），然后删除触发器。新的外键已经验证过，因此完整性从未中断。
> 7. **之后再删除旧列。** 等到没有代码再读取它们，在下一个发布中删除。
>
> 上面的类型表，已通过比较每次变更前后的 `pg_relation_filenode` 核对过。每个“否”都保留了表文件；每个“是”都产生了新文件。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > -- rewrites() compares pg_relation_filenode('tc') before and after the change
> > select lab.prove('varchar(20) to varchar(50): no rewrite', not rewrites('alter column v20 type varchar(50)'));
> > select lab.prove('text to varchar(100): rewrite, even though every value fits', rewrites('alter column t2 type varchar(100)'));
> > select lab.prove('int to bigint: rewrite', rewrites('alter column i type bigint'));
> > select lab.prove('numeric(12,2) to numeric(12,4): rewrite', rewrites('alter column n2 type numeric(12,4)'));
> > select lab.prove('timestamp to timestamptz with the session in UTC: no rewrite',
> >   not rewrites('alter column ts1 type timestamptz', 'UTC'));
> > select lab.prove('timestamp to timestamptz in another time zone: rewrite',
> >   rewrites('alter column ts2 type timestamptz', 'America/New_York'));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)
>
> 在序列还剩几年可用时开始交换。一个从 2,147,483,646 重新开始的 `int` 标识列，只能插入两行，然后就会拒绝此后的每次插入：
>
> ```text
> ERROR:  nextval: reached maximum value of sequence "tickets_id_seq" (2147483647)
> ```
>
> 于是，文章中的监控查询会显示 `tickets_id_seq` 已达到 100%。
>
> > [!EXAMPLE]- 动手验证 · lab `25-migrations`
> >
> > ```sql
> > do $$
> > begin
> >   insert into tickets (title) values ('c');
> >   raise exception 'no error';
> > exception when sqlstate '2200H' then
> >   if sqlerrm <> 'nextval: reached maximum value of sequence "tickets_id_seq" (2147483647)' then
> >     raise exception 'NOT PROVED: %', sqlerrm;
> >   end if;
> > end $$;
> > select lab.prove('the third insert fails: nextval reached the int maximum (2147483647)', true);
> > select lab.prove('pg_sequences shows tickets_id_seq at 100 percent of its maximum',
> >   (select round(100.0 * last_value / max_value, 1) = 100.0
> >    from pg_sequences where sequencename = 'tickets_id_seq'));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

## 重命名要用扩展与收缩发布 {#renames-ship-as-expand-and-contract}

`rename column` 只改系统目录。问题在于，几十个应用进程都知道旧名字：代码和模式不可能在同一瞬间改变，因此谁先改变都会破坏另一个。永远不要做让新旧代码无法同时存活的变更。把它拆成[**扩展**和**收缩**](/43-glossary/#expand-and-contract)，并在两者之间发布：

```text
  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
  │ 1. expand    │  │ 2. deploy    │  │ 3. backfill  │  │ 4. deploy    │
  │ add new col  │─▶│ write both,  │─▶│ copy old to  │─▶│ read new,    │
  │ (nullable)   │  │ read old     │  │ new, batched │  │ write both   │
  └──────────────┘  └──────────────┘  └──────────────┘  └──────┬───────┘
                                                               │
                    ┌──────────────┐  ┌──────────────┐         │
                    │ 6. contract  │  │ 5. deploy    │         │
                    │ drop old col │◀─│ stop writing │◀────────┘
                    │              │  │ old column   │
                    └──────────────┘  └──────────────┘
```

用这种方式把 `events.kind` 重命名为 `event_type`，需要三次迁移和三次发布。这就是零停机的真实代价，也是谨慎选择名称的最佳理由。触发器或视图可以替代部分应用代码，但二者都不能消除这些阶段。同样的步骤也可以用来拆分列、合并表，也可以改变 API 发出的 ID（[通过同时接受两种 ID，让 API 脱离整数 ID](/13-keys/#move-an-api-off-integer-ids-by-accepting-both)）。

## 按主键逐步遍历，在短批次中回填 {#backfill-in-short-batches-that-walk-the-primary-key}

`update events set status = payload->>'status'` 是一个跨越 200 万行的事务：产生 200 万个新行版本和 200 万个死行版本（[更新就是插入](/06-mvcc/#basics-chapter-6)），每行都锁到提交，阻碍清理，让整张表大小的 WAL 流向副本；如果在最后一行失败，所有工作都会回滚。要分批：

- **按范围遍历主键**（`id > $1 and id <= $1 + 10000 and status is null`），永远不要使用 `OFFSET`。每个批次的代价相同，而 `status is null` 让任务可以重启。
- **每个批次都提交**，让每个批次的耗时远低于一秒。过程可以做到这一点（[过程可以提交事务](/24-functions/#procedures-can-commit)）；我们的任务约 16 秒填完了 200 万行。
- **预期会膨胀：** 快速回填可能在 autovacuum 追上之前让表翻倍，因此大型任务要在中途执行清理。
- **把它作为可恢复的任务**放在模式迁移之外运行，并在副本延迟时暂停它。

批量加载新行是另一种任务，有更好的工具（[冻结分区，挂接已构建的分区，再分析父表](/27-loading/#freeze-the-partition-attach-it-built-analyze-the-parent)）。

## 先删除代码，再删除列 {#drop-code-first-then-the-column}

删除列只改系统目录，但会取得 `AccessExclusive`，因此要设置 `lock_timeout`。危险在应用：明确写出该列名的代码会失败，没写列名的代码也可能失败，例如在启动时缓存了列清单的 ORM。先发布忽略该列的代码，等到所有旧进程都消失，再删除它。

删除表会在每张通过外键与它相连的表上取得 `AccessExclusive`。先逐条删除这些外键，每条语句都设置一个较短的 `lock_timeout`，这样就没有一条语句需要同时取得所有锁；如果想保留廉价的撤销方案，可以先重命名表，下一次发布再删除。

## 事务性 DDL 会持有所有锁直到提交 {#transactional-ddl-holds-every-lock-until-commit}

Postgres 的 DDL 具有事务性，因此大多数工具会把每次迁移包进事务，不会出现只执行一半的迁移。代价是：每把锁都会一直持有到提交。

```sql
begin;
alter table accounts add column region text;
alter table users    add column region text;
update accounts set region = 'us';
-- still holding AccessExclusive on accounts and users
commit;
```

`update` 之后，`pg_locks` 仍然显示两张表上的 `AccessExclusiveLock`，对任意一张表的每个查询都会排队，直到 `commit`。

> [!EXAMPLE]- 动手验证 · lab `25-migrations`
>
> ```sql
> begin;
> alter table accounts add column region text;
> alter table users    add column region text;
> update accounts set region = 'us';
> select lab.prove('after the update, the transaction still holds AccessExclusive on accounts and users',
>   my_strongest('accounts') = 'AccessExclusiveLock' and my_strongest('users') = 'AccessExclusiveLock');
> rollback;
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/25-migrations.sql)

你持有的每一把锁，都会让下一个等待变得更糟。因此：**每次迁移只处理一张有风险的表**，并且**模式迁移中不做数据变更**。（MySQL 会分别提交每条 DDL 语句；参见[DDL 具有事务性](/11-not-mysql/#ddl-is-transactional)。）

## 本章要点 {#the-short-version}

- 每次迁移都设置 `lock_timeout`（1 到 5 秒）并重试。等待锁的 DDL 会阻塞后面的所有操作，甚至包括读取。
- 判断变更是只改系统目录、扫描还是重写，并在生产规模的数据上测试。
- 常量默认值会立即完成；`gen_random_uuid()` 这样的易变默认值会重写。
- 以 `NOT VALID` 添加 CHECK、FOREIGN KEY 和 NOT NULL 约束，然后在流量可以通过的锁下执行 `VALIDATE`。
- 普通 `create index` 会在整个构建期间停止写入；`concurrently` 让写入继续，但读取量翻倍。普通方式只用于无人写入的表。
- 并发构建时关闭 `lock_timeout`，并检查残留的 `INVALID` 索引。永远不要在线上表上运行普通 `reindex`。
- `int` 到 `bigint`、`text` 到 `varchar(n)` 以及标度变更都会重写。
- 重命名用扩展与收缩发布；回填要在迁移之外作为按主键范围分批运行的任务。

## 延伸阅读 {#further-reading}

- [ALTER TABLE](https://www.postgresql.org/docs/18/sql-altertable.html)，PostgreSQL 文档。每种形式的锁级别、重写行为和 `NOT VALID` 规则。
- [CREATE INDEX: building indexes concurrently](https://www.postgresql.org/docs/18/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY)，PostgreSQL 文档。两阶段构建等待什么，以及为什么会留下无效索引。
- [Zero-downtime Postgres migrations: the hard parts](https://gocardless.com/blog/zero-downtime-postgres-migrations-the-hard-parts)，GoCardless。上面那次锁队列故障，由亲历它的团队讲述。
- [Zero-downtime Postgres schema migrations need this: lock_timeout and retries](https://postgres.ai/blog/20210923-zero-downtime-postgres-schema-migrations-lock-timeout-and-retries)，Nikolay Samokhvalov，PostgresAI。规则一的依据，以及重试策略。
- [strong_migrations](https://github.com/ankane/strong_migrations)、[Squawk](https://squawkhq.com/) 和 [pgroll](https://github.com/xataio/pgroll)。把这些规则编码进工具，让评审者不用靠记忆。
