1. 为什么表结构变更这件事值得单独拎出来讲
但凡做过一段时间数据库运维或者后端开发,手里过的表没有一百也有八十,真正让人半夜爬起来处理告警的,往往不是慢查询,而是某次表结构变更没想清楚就上线了。ALTER TABLE这个命令看起来简单到不能再简单,加个列、删个列、挂个约束,语法一查就有,但真到生产环境里,一条语句锁表几分钟、约束加上去之后历史数据校验失败、删列之后发现某个视图还在引用,这类事故我见过太多。
这篇内容围绕 PostgreSQL 里四类最高频的表结构变更操作展开:增加约束、删除约束、增加列、删除列。别看只有四个动作,每一个背后都牵扯到锁机制、数据校验方式、依赖关系处理、回滚预案这几层东西。适合谁看?刚接手 PostgreSQL 的后端同学、需要自己维护表结构的全栈开发者、以及那些从其他数据库迁过来、发现 PG 的玩法不太一样的老手。哪怕你只是偶尔写几条 DDL,把这里面的门道摸清楚,至少能避开八成以上的低级事故。
我个人的习惯是,任何一次表结构变更之前,先在测试库上把语句跑一遍,用EXPLAIN看执行计划是其次,关键是看它会不会触发全表重写、会不会长时间持有排他锁。PostgreSQL 在这方面其实做得相当克制,很多操作是不需要重写表的,但前提是你得知道哪些参数、哪些写法能帮你绕开重写。下面我就按实际操作顺序,把这四类变更逐个拆开讲,中间穿插我自己踩过的坑和验证过的参数配置。
2. 增加约束:先想清楚校验代价再动手
2.1 约束类型与加约束的两种路径
PostgreSQL 里常见的约束无非几种:主键约束、唯一约束、外键约束、检查约束、非空约束。加约束的语法都是ALTER TABLE ... ADD CONSTRAINT ...,但真正决定这条语句快慢和影响范围的,是约束在添加时是否需要扫描全表来校验已有数据。
拿检查约束举例,最直白的写法是:
ALTER TABLE orders ADD CONSTRAINT chk_amount_positive CHECK (amount > 0);这条语句在 PG 里的行为是:拿排他锁,扫描整张orders表,逐行验证amount > 0,全部通过才把约束挂上去。表小的时候无所谓,几百万行以上的表,这个扫描过程可能持续几十秒甚至几分钟,期间这张表的写入全部被阻塞。这就是很多人加完约束发现业务卡顿的根源。
PG 给了一条绕开长时间锁表的路径,就是分两步走:先加一个NOT VALID的约束,再单独校验。
ALTER TABLE orders ADD CONSTRAINT chk_amount_positive CHECK (amount > 0) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT chk_amount_positive;第一步只拿一个很短的锁,把约束定义登记进去,不扫描数据;第二步VALIDATE CONSTRAINT拿的是较弱的锁(SHARE UPDATE EXCLUSIVE),允许并发读写,只阻塞其他 DDL。这个拆分是 PG 加约束场景里最值得记住的技巧,尤其是检查约束和外键约束,都支持NOT VALID的写法。
2.2 外键约束的特殊处理与索引配套
外键约束比检查约束多一层麻烦:它不仅校验子表数据,还会在父表删除或更新时触发检查。加外键的时候,如果子表数据量大,同样建议走NOT VALID再VALIDATE的路子:
ALTER TABLE order_items ADD CONSTRAINT fk_order FOREIGN KEY (order_id) REFERENCES orders(id) NOT VALID; ALTER TABLE order_items VALIDATE CONSTRAINT fk_order;这里有个容易被忽略的点:外键列上如果没有索引,父表做删除或更新时会去扫子表全表。很多人加完外键就不管了,结果父表删一条记录慢得离谱。所以加外键的同时,顺手把子表外键列的索引建上,这是配套动作,不是可选项。
CREATE INDEX CONCURRENTLY idx_order_items_order_id ON order_items(order_id);注意这里用了CONCURRENTLY,建索引不锁写。代价是它不能放在事务块里执行,而且失败会留下一个 invalid 索引,需要手动清理。这个细节后面讲增加列的时候还会提到,因为 PG 里凡是涉及"不阻塞业务"的操作,基本都带这类约束条件。
2.3 加约束时的锁级别速查
不同约束拿的锁不一样,我整理了一张表,方便你动手前对照:
| 约束类型 | 直接 ADD 的锁 | NOT VALID 后 VALIDATE 的锁 | 是否支持 NOT VALID |
|---|---|---|---|
| 检查约束 CHECK | ACCESS EXCLUSIVE | SHARE UPDATE EXCLUSIVE | 支持 |
| 外键约束 FOREIGN KEY | ACCESS EXCLUSIVE(含子表扫描) | SHARE UPDATE EXCLUSIVE | 支持 |
| 唯一约束 UNIQUE | ACCESS EXCLUSIVE(建索引) | 不适用 | 不支持 |
| 主键约束 PRIMARY KEY | ACCESS EXCLUSIVE(建索引) | 不适用 | 不支持 |
| 非空约束 NOT NULL | ACCESS EXCLUSIVE(PG 12+ 可走 VALIDATE) | 视版本而定 | 部分支持 |
唯一约束和主键约束因为底层要建唯一索引,没法用NOT VALID绕,但可以用CREATE UNIQUE INDEX CONCURRENTLY先把索引建好,再用ALTER TABLE ... ADD CONSTRAINT ... USING INDEX把索引挂成约束。这个套路在给大表加主键时特别管用,我实测过一张两千万行的表,直接加主键锁了将近四分钟,换成先并发建唯一索引再挂约束,业务几乎无感。
注意:
ADD CONSTRAINT ... USING INDEX要求索引名和约束名对应,且索引必须是唯一索引、不能是部分索引、不能带表达式。挂之前先用\d确认索引状态是 valid。
3. 删除约束:依赖关系比语法更值得关注
3.1 删约束的基本语法与级联风险
删约束的语法简单到一行:
ALTER TABLE orders DROP CONSTRAINT chk_amount_positive;但真正需要动脑子的是这个约束有没有被其他对象依赖。比如一个唯一约束背后往往站着一个唯一索引,而这个索引可能被外键引用、被查询计划器依赖。直接DROP CONSTRAINT在 PG 里默认是RESTRICT行为,如果有依赖对象,语句会报错而不是静默删除,这其实是好事,逼你先看清楚依赖链。
如果你确认要连带删除依赖对象,可以加CASCADE:
ALTER TABLE orders DROP CONSTRAINT orders_pkey CASCADE;但我要说句实在话,CASCADE在生产环境里我基本不用。它删得太干脆,你以为是删一个约束,结果把引用它的外键、视图、甚至其他表的约束一起带走了,事后排查起来非常痛苦。稳妥的做法是先查依赖:
SELECT conname, conrelid::regclass, confrelid::regclass FROM pg_constraint WHERE conrelid = 'orders'::regclass OR confrelid = 'orders'::regclass;把跟这张表相关的约束全列出来,看清楚哪些是"我加的"、哪些是"别人引用的",再决定删哪个、要不要先通知相关方。
3.2 删主键与删唯一约束的连锁反应
删主键是删约束里最需要谨慎的一类。主键往往被其他表的外键引用,删掉主键之后,那些外键就失去了参照目标,PG 会直接拒绝删除并报错。这时候你有两个选择:要么先删引用方外键再删主键,要么用CASCADE一把梭。前者安全但步骤多,后者快但风险高。
我的经验是,删主键之前先跑一遍这个查询,把引用方全部找出来:
SELECT conname AS fk_name, conrelid::regclass AS referencing_table FROM pg_constraint WHERE confrelid = 'orders'::regclass AND contype = 'f';拿到引用方列表之后,逐个确认业务上是否还需要这层关联。如果只是临时调整,建议用事务包起来,删完验证没问题再提交:
BEGIN; ALTER TABLE order_items DROP CONSTRAINT fk_order; ALTER TABLE orders DROP CONSTRAINT orders_pkey; -- 验证业务查询正常 COMMIT;删唯一约束的情况类似,唯一约束背后是唯一索引,删约束等于删索引,那些依赖这个索引做等值查询的语句执行计划会立刻变化。删之前用EXPLAIN看一眼关键查询,确认没有语句强依赖这个索引,否则删完可能引发一波慢查询。
3.3 删约束后的索引清理
有一个细节很多人不知道:删唯一约束或主键约束时,背后的索引会跟着一起删,但如果你之前是手动建的索引再挂成约束的,删约束之后索引是否保留取决于挂约束的方式。用USING INDEX挂上去的约束,删约束时索引默认保留,需要你手动DROP INDEX。这个差异在清理历史遗留结构时特别容易踩坑,删完约束发现索引还在,占着空间还影响写入性能。
-- 删约束后检查残留索引 SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'orders';确认没有业务依赖之后,再决定是否清理。清理索引同样建议用DROP INDEX CONCURRENTLY,避免锁表。
4. 增加列:默认值和重写表的关系是核心
4.1 加列为什么会重写表
给表加一列,直觉上应该是"在表末尾追加一个字段定义",几毫秒的事。但在早期 PostgreSQL 版本里,ALTER TABLE ... ADD COLUMN ... DEFAULT ...会触发全表重写:PG 要把每一行都读出来、补上新列的默认值、再写回去。一张千万行的表,这个操作可能锁表好几分钟。
PG 11 之后这个行为有了重大优化:如果新列的默认值是常量(非 volatile 表达式),PG 不再重写表,而是把默认值记录在元数据里,读取时动态补上。这个优化让加列变得非常轻量,但有几个前提条件必须满足:
- 默认值必须是常量,比如
0、'unknown'、now()这种 volatile 函数不行 - 不能同时加
NOT NULL约束且表里已有数据(这个组合在 PG 11 之前会重写,PG 11 之后对常量默认值也做了优化,但仍有版本差异)
-- 轻量加列,不重写表(PG 11+) ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending'; -- 会触发重写,因为默认值是 volatile 函数 ALTER TABLE orders ADD COLUMN created_at TIMESTAMPTZ DEFAULT now();第二句之所以重写,是因为now()每行的值可能不同,PG 没法用元数据统一表达。如果你确实需要给历史数据填当前时间,正确做法是分两步:先加一个不带默认值的列,再分批更新。
4.2 大表加列的安全操作流程
给大表加列,我一般按这个顺序走:
- 先加列,不带默认值也不带 NOT NULL,这一步几乎瞬间完成
- 如果需要默认值,用
ALTER COLUMN ... SET DEFAULT单独设置,常量默认值同样不重写 - 历史数据需要填充的,用分批 UPDATE 处理,每批控制在几千行,避免长事务
- 数据填完之后再加 NOT NULL 约束,走
NOT VALID加VALIDATE的路子
-- 第一步:加列 ALTER TABLE orders ADD COLUMN region_code VARCHAR(10); -- 第二步:设默认值(常量,不重写) ALTER TABLE orders ALTER COLUMN region_code SET DEFAULT 'CN'; -- 第三步:分批填充历史数据 UPDATE orders SET region_code = 'CN' WHERE region_code IS NULL AND id BETWEEN 1 AND 5000; -- 循环推进,每批之间留出间隔 -- 第四步:加非空约束 ALTER TABLE orders ADD CONSTRAINT nn_region_code CHECK (region_code IS NOT NULL) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT nn_region_code;这里第四步我用的是检查约束模拟非空,而不是直接SET NOT NULL。原因是SET NOT NULL在部分版本里仍然会扫全表拿强锁,而CHECK ... NOT VALID加VALIDATE的组合锁级别更友好。当然 PG 12 之后SET NOT NULL也做了优化,如果你的版本够新,直接设也问题不大,但养成走 VALIDATE 的习惯总归更稳。
4.3 加列时的存储与对齐细节
还有一个偏底层但值得知道的点:PG 的列在磁盘上是按定义顺序存储的,新加的列追加在末尾。对于定长类型(如int、bigint),列的顺序会影响行内对齐和存储效率。如果你加的是一个小类型列,而表里原本都是大类型,可能会因为对齐填充导致行变大。这个影响在绝大多数业务场景下可以忽略,但如果你在做极致的存储优化,加列时考虑一下类型选择和顺序是有意义的。
另外,加列之后统计信息不会自动更新,查询计划器可能对新列的选择性判断不准。加完列并填充数据之后,记得跑一次ANALYZE:
ANALYZE orders;这个动作很便宜,但对后续查询性能影响不小,尤其是新列会出现在 WHERE 条件里的时候。
5. 删除列:物理删除与逻辑删除的取舍
5.1 删列的真实代价
ALTER TABLE ... DROP COLUMN ...在 PG 里默认是逻辑删除:把列的元数据标记为已删除,磁盘上的数据不会立刻回收,后续写入的新行不再包含这一列。这个设计的好处是删列操作非常快,不需要重写表;坏处是磁盘空间不会马上释放,而且被删列的数据在物理上还残留着,直到表被重写或 VACUUM FULL。
ALTER TABLE orders DROP COLUMN region_code;这条语句执行很快,但如果这一列占的空间很大,你可能需要后续做一次表重写来真正回收空间:
VACUUM FULL orders;注意VACUUM FULL会拿排他锁,期间表不可读写,大表上要谨慎安排时间窗口。更温和的方式是用pg_repack这类工具做在线重写,但那是另一个话题了。
5.2 删列前的依赖排查清单
删列比加列危险得多,因为列可能被视图、索引、约束、触发器、甚至其他表的生成列引用。删之前我习惯跑一遍这套排查:
-- 查依赖该列的视图 SELECT dependent_ns.nspname AS schema, dependent_view.relname AS view_name FROM pg_depend JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid JOIN pg_class AS dependent_view ON pg_rewrite.ev_class = dependent_view.oid JOIN pg_class AS source_table ON pg_depend.refobjid = source_table.oid JOIN pg_attribute ON pg_depend.refobjid = pg_attribute.attrelid AND pg_depend.refobjsubid = pg_attribute.attnum JOIN pg_namespace AS dependent_ns ON dependent_view.relnamespace = dependent_ns.oid WHERE source_table.relname = 'orders' AND pg_attribute.attname = 'region_code';这套查询能帮你把引用该列的视图找出来。索引和约束的依赖相对好查,\d orders就能看到。触发器里的引用需要翻触发器定义,这个没有特别好的系统查询,只能靠pg_get_triggerdef逐个看。
排查完之后,如果确认没有依赖,直接DROP COLUMN即可。如果有依赖但你确定要删,加CASCADE会连带删除依赖对象,但同样建议先看清楚再动手。
5.3 逻辑删除与物理删除的选择
有些团队会选择"逻辑删除"列:不真的DROP COLUMN,而是把列改名加个_deprecated后缀,观察一段时间确认没人用再物理删除。这个做法在多人协作、代码和数据库变更不同步的环境里特别实用。
-- 第一步:改名标记废弃 ALTER TABLE orders RENAME COLUMN region_code TO region_code_deprecated_202401; -- 观察期过后,确认无引用再物理删除 ALTER TABLE orders DROP COLUMN region_code_deprecated_202401;改名本身是元数据操作,很快,但它会让所有还在引用旧列名的代码立刻报错,这反而是好事——能快速暴露遗漏的引用点。观察期长短看团队节奏,我一般建议至少覆盖一个完整的发布周期。
6. 实操中反复踩到的坑与排查速查
6.1 锁等待与超时设置
表结构变更最怕的就是拿不到锁,或者拿到锁之后把业务堵死。PG 里可以给 DDL 语句设置锁等待超时,避免无限期排队:
SET lock_timeout = '5s'; ALTER TABLE orders ADD COLUMN note TEXT;如果 5 秒内拿不到锁,语句直接失败,不会一直挂着阻塞后面的请求。这个设置我在生产环境里是标配,配合重试机制使用。重试的时候要注意,失败的 DDL 可能已经部分生效(比如NOT VALID约束已经加上但 VALIDATE 没跑完),重试前先查一下当前状态。
SELECT conname, convalidated FROM pg_constraint WHERE conrelid = 'orders'::regclass;convalidated为 false 说明约束加了但没校验完,补跑VALIDATE CONSTRAINT即可,不用重复加。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| ADD COLUMN 卡住很久 | 默认值是 volatile 函数,触发全表重写 | 改用常量默认值,或分步加列后分批填充 |
| ADD CONSTRAINT 阻塞写入 | 直接 ADD 拿了 ACCESS EXCLUSIVE 锁并扫全表 | 改用 NOT VALID + VALIDATE 两步走 |
| DROP CONSTRAINT 报依赖错误 | 有其他对象引用该约束 | 查 pg_constraint 和 pg_depend,先处理依赖方 |
| DROP COLUMN 后空间没释放 | PG 逻辑删除,数据物理残留 | 安排窗口做 VACUUM FULL 或在线重写 |
| 加完列查询变慢 | 统计信息未更新 | 手动跑 ANALYZE |
| 并发建索引失败留下 invalid 索引 | CREATE INDEX CONCURRENTLY 中断 | 查 pg_index.indisvalid,删掉重建 |
6.3 几个我验证过的操作习惯
第一个习惯是所有 DDL 都包在事务里,除了CREATE INDEX CONCURRENTLY这种明确不支持事务的。包事务的好处是出错可以整体回滚,不会留下半成品结构。但要注意,包了事务之后锁会一直持有到 COMMIT,所以事务里不要塞无关操作,尽快提交。
第二个习惯是变更前先备份表结构,用pg_dump --schema-only把相关表的定义导出来存一份。真出问题的时候,这份备份比任何记忆都可靠。
第三个习惯是在低峰期做变更,但不要假设低峰期就没有锁竞争。长事务、未提交的查询、甚至一个忘了关的客户端连接,都可能持有锁。变更前跑一下这个查询看看有没有长事务:
SELECT pid, now() - xact_start AS duration, query FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY duration DESC;有超过几分钟的事务就先处理掉,再动手做 DDL。
7. 版本差异与参数配置的补充说明
PG 各个大版本在 DDL 行为上有不少差异,这里挑几个跟本文主题直接相关的说一下。PG 11 之前,加带默认值的列一律重写表;PG 11 开始常量默认值不重写。PG 12 之前,SET NOT NULL一律扫全表;PG 12 开始如果已有等价的检查约束,可以跳过扫描。这些版本差异意味着你在网上抄来的语句,放到自己的环境里行为可能完全不同,动手前先SELECT version();确认一下。
参数层面,maintenance_work_mem会影响建索引和 VALIDATE 约束的速度,适当调大能明显缩短操作时间,但要注意它是按操作分配的,不是全局常驻。lock_timeout和statement_timeout建议在 DDL 会话里显式设置,不要依赖全局配置,避免影响其他业务。
SET maintenance_work_mem = '512MB'; SET lock_timeout = '10s'; SET statement_timeout = '300s';这三个参数组合起来,基本能覆盖大部分表结构变更场景的安全需求。maintenance_work_mem给大一点让校验和建索引快一些,lock_timeout防止无限等锁,statement_timeout兜底防止语句跑飞。
我在实际项目里处理过一张接近一亿行的订单表,加列、加检查约束、删旧列一整套操作下来,靠的就是上面这些拆分策略和参数配置,全程业务没有明显感知。核心思路其实就一句话:把一个大操作拆成多个小操作,每个小操作都选锁级别最低的写法,中间留出验证和回滚的余地。这个思路比记住任何一条具体语法都管用,因为语法会随版本变,思路不会。