日常如果跟 PostgreSQL 打交道,DDL 和 DML 这两个词绝对绕不开。简单说,DDL 管的是"结构":建表、加字段、改类型、删表;DML 管的是"数据":插入、更新、删除、查询。我见过不少同事写业务 SQL 很溜,但一到改大表结构、批量更新、误删恢复这种场景就犯怵,根本原因是对这两类操作底层的行为差异没吃透——锁、事务、日志、MVCC 这些背后逻辑搞明白了,线上操作才敢放手去做。
这篇文章把我这几年在项目里反复用到的 PostgreSQL DDL 和 DML 操作完整梳理一遍,既有最基础的语法细节,也会把容易坑人的锁冲突、事务回滚、表膨胀这些点讲透。打算系统掌握 PostgreSQL 的开发者可以通读,日常要维护数据的 DBA 或者后端同学也能直接当操作手册翻。
1. 先把DDL和DML的边界理清楚
1.1 两个概念到底各管什么
很多人刚接触数据库时,对 DDL 和 DML 的分类只有一个模糊印象,真要说出哪些命令属于哪一类,反而会卡壳。这里把边界划清楚:
- DDL(Data Definition Language,数据定义语言):操作对象的结构和属性,包括 CREATE、ALTER、DROP、TRUNCATE,以及 COMMENT、GRANT、REVOKE 这类元数据和权限操作。说白了就是"给房子改户型、装修、拆墙"。
- DML(Data Manipulation Language,数据操纵语言):操作对象里的数据内容,包括 INSERT、UPDATE、DELETE、SELECT。严格分类时也有人把 SELECT 单独拆成 DQL(Data Query Language),但实际工作里讨论 DML 时经常把查询也带进去,本文核心还是落在增删改上。
这个分类不止是概念游戏,它直接决定了一个人能对数据库做什么。比如我给业务账号授权,通常只给 INSERT、UPDATE、DELETE、SELECT 的权限,DDL 权限几乎不给。这样即使应用被拖库或者有人误操作,他也只能改数据,动不了表结构,伤害面缩小一大截。
1.2 区分它们有什么用
我刚开始做运维的时候也觉得这分类无所谓,直到线上出了几次事故才明白,区分 DDL 和 DML 是判断操作风险的第一依据。
第一是锁行为差异。普通的 DML 语句拿的是 RowExclusiveLock,表上的读写操作彼此基本不冲突,业务可以并发跑。而 DDL 语句,比如 ALTER TABLE、DROP TABLE,拿的是更高级别的锁,通常直接到 AccessExclusiveLock,一旦执行就会阻塞这张表上所有的读写。一个是"大家排队进出",一个是"直接封路施工",风险等级完全不一样。
第二是日志和复制开销。DML 会修改数据页,产生大量 WAL,这些日志要传给备库、要归档;DDL 大多数时候只写系统元数据,日志量小很多。但有例外,比如 TRUNCATE 虽然不逐行记录数据,但它本身也作为事务的一部分写日志,而且它对表的锁影响比 DELETE 大得多。
第三是变更管理流程。生产环境的表结构变更,成熟团队都会走审批、评估锁时间、准备回滚方案,本质就是因为 DDL 的风险是结构级的;而数据变更更多依赖业务逻辑和 WHERE 条件的准确性。你把这两类操作分清楚,后面所有排查和优化的思路就顺了。
2. 常用DDL操作怎么用才靠谱
2.1 CREATE TABLE:建表时就把约束想全
建表是 DDL 里最基础的操作,但基础不代表随便写。很多人上来就是一把梭,字段类型差不多就行,约束能省则省,结果线上跑了一阵才发现数据质量一塌糊涂。下面这张表基本能代表我日常建表的习惯:
CREATE TABLE users ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, username VARCHAR(64) NOT NULL, email VARCHAR(255) UNIQUE, status SMALLINT NOT NULL DEFAULT 1 CONSTRAINT chk_users_status CHECK (status IN (0, 1, 2)), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );几个要点说一下。
自增主键我建议优先用GENERATED ALWAYS AS IDENTITY,这是 SQL 标准写法,比老式的BIGSERIAL更规范,权限控制也更干净。如果你用的是高版本 PostgreSQL,新表直接上 identity 就好。
约束命名很多人会忽略,但一旦表上线、数据多了,想改约束却不知道约束叫什么名字,就得先查系统表,非常麻烦。像我上面写的那样,把chk_users_status这种有意义的约束名直接写进建表语句,后面执行ALTER TABLE ... DROP CONSTRAINT会省很多事。同理,外键约束也建议显式命名。
字段类型选型这块,我的原则是能用数值就不用字符串存数字,能用TIMESTAMPTZ就不用TIMESTAMP。TIMESTAMPTZ存的是带时区的时间点,写入时自动按数据库时区解释,查询时按客户端时区展示,逻辑统一不出岔子。我接手过的项目里,时间字段用错的例子太多了,十有八九是时区问题引发的。
如果是超大表,建表时直接考虑分区。PostgreSQL 原生支持声明式分区:
CREATE TABLE logs ( id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL ) PARTITION BY RANGE (created_at); CREATE TABLE logs_2025_01 PARTITION OF logs FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');分区表后面做数据清理,直接DROP PARTITION完事,比一条条 DELETE 不知道快到哪里去了。
2.2 ALTER TABLE:改表结构的坑都集中在锁和重写
ALTER TABLE 是日常变更里最容易出问题的操作,因为不是所有改结构动作都一样快。同样是加一个字段,可能毫秒级完成,也可能把几百万行数据的表重写一遍,锁表几分钟甚至更久。
先看最常见的加字段:
ALTER TABLE users ADD COLUMN nickname VARCHAR(50);在 PostgreSQL 11 之前,如果你加字段时带一个常量默认值,比如ADD COLUMN status SMALLINT DEFAULT 1,大表会被强制全表重写,目的是把默认值物理写入每行,那个锁表时间在生产环境能让人血压拉满。PostgreSQL 11 之后做了优化,加带常量默认值的字段不再重写表,只改元数据,执行速度飞快。但注意,这个优化只对"常量默认值"有效。
如果你加的是非常量默认值,比如DEFAULT gen_random_uuid()或者DEFAULT now()这种,PG 没法在元数据层面直接模拟每行的返回值,仍然要把表重写一遍。所以对大表加这种字段,更稳妥的做法是分三步走:
-- 第一步:先加允许为空的字段,不设默认值 ALTER TABLE users ADD COLUMN external_id UUID; -- 第二步:分批填充数据(不要一条UPDATE刷全表) UPDATE users SET external_id = gen_random_uuid() WHERE external_id IS NULL AND id BETWEEN 10001 AND 20000; -- 第三步:字段填完后,再补默认值和NOT NULL约束 ALTER TABLE users ALTER COLUMN external_id SET DEFAULT gen_random_uuid(); ALTER TABLE users ALTER COLUMN external_id SET NOT NULL;这样每一步持有锁的时间都很短,业务基本无感。
改字段类型是另一个重灾区:
ALTER TABLE users ALTER COLUMN id TYPE BIGINT;任何类型转换都伴随着全表扫描和重写,有些转换不写 USING 甚至会直接失败,比如把VARCHAR改成INTEGER:
ALTER TABLE users ALTER COLUMN age TYPE INTEGER USING age::INTEGER;USING 表达式定义了怎么把旧值转换成新值。改类型前先预估表大小和重写耗时,别在生产高峰期干这件事。
约束的添加也有讲究。突然给已有大量数据的表加 CHECK 约束或外键约束,PG 默认会扫描整表验证,期间持有 ACCESS EXCLUSIVE 锁。规范做法是两阶段:
ALTER TABLE users ADD CONSTRAINT chk_users_age CHECK (age >= 0) NOT VALID; ALTER TABLE users VALIDATE CONSTRAINT chk_users_age;第一阶段只加约束不校验,元数据层面立刻完成,锁很短;第二阶段 VALIDATE 只需要 ShareLock 级别的锁,不阻塞读写。两条命令之间的空档期如果有新写入的非法数据,VALIDATE 会失败,所以这种方案适合对数据错误容忍度高的场景,严格的场景还是要在维护窗口做。
2.3 DROP 和 TRUNCATE:删东西之前先想清楚
删表和清数据的操作,风险等级完全不同。很多新手分不清 DROP TABLE 和 TRUNCATE 的区别,这里直接列个对比表:
| 操作 | 动结构 | 动数据 | 锁级别 | 可回滚 | 触发器 |
|---|---|---|---|---|---|
| DROP TABLE | 删除 | 删除 | ACCESS EXCLUSIVE | 事务内可回滚 | 不触发 |
| TRUNCATE | 保留 | 清空 | ACCESS EXCLUSIVE | 事务内可回滚 | 不触发行级触发器 |
| DELETE | 保留 | 按条件删除 | ROW EXCLUSIVE | 事务内可回滚 | 触发行级触发器 |
很多人以为 TRUNCATE 是 DML,其实在 PostgreSQL 里它属于 DDL 范畴,拿的是 ACCESS EXCLUSIVE 锁,执行期间所有对该表的读写都会被阻塞。它的优势是速度极快,因为不是逐行删除数据,而是直接把表的存储文件标记为废弃并重新分配。
TRUNCATE 有两个容易踩的坑。第一,如果这张表被其他表的外键引用,直接 TRUNCATE 会报错,提示存在外键依赖。这时候你可以选择TRUNCATE table_a, table_b CASCADE,把引用它的表一起清空,但这意味着你可能删掉了不该删的数据,用之前一定要确认依赖关系。第二,TRUNCATE 不触发行级触发器,如果你依赖触发器做审计或者级联操作,它不会生效。
DROP TABLE 更不用说,表结构和数据一起没。DROP TABLE xxx CASCADE会连带删除依赖这个表的所有对象,比如视图、外键约束,这个 CASCADE 我是能不用就不用,真要用了也先查好依赖再动手。
实操心得:删表或清空数据之前,先用
\d table_name看一眼依赖,再用BEGIN; ... ROLLBACK;包一层试运行。我在测试环境踩过 DROP 后才发现视图全没了的坑,从那以后 CASCADE 慎用成了铁律。
3. 常用DML操作:学会少踩坑的做法
3.1 INSERT:从单条到批量
INSERT 看着最简单,但性能差距可以非常大。一条一条插入和批量插入之间,可能差出几十倍的效率。
单条插入不用多说,重点是批量场景。批量插入有三种常见姿势:
-- 方式一:一条语句多组VALUES INSERT INTO users (username, email) VALUES ('alice', 'a@example.com'), ('bob', 'b@example.com'), ('carol', 'c@example.com'); -- 方式二:从另一张表查出来插入 INSERT INTO users_new (id, username, email) SELECT id, username, email FROM users WHERE status = 1; -- 方式三:COPY高速导入 COPY users (username, email) FROM '/path/to/users.csv' WITH (FORMAT csv, HEADER true);方式一减少了解析和提交开销,适合几千行以内。方式二适合做表迁移或数据归档。方式三 COPY 是 PostgreSQL 导入文本数据最快的路径,比逐条 INSERT 能快一个数量级,大批量导数据首选。
生产环境经常遇到的需求是"有则更新,无则插入",PostgreSQL 的ON CONFLICT直接搞定,不需要先查再决定:
INSERT INTO users (id, username, email) VALUES (1001, 'alice', 'a@example.com') ON CONFLICT (email) DO UPDATE SET username = EXCLUDED.username, updated_at = now();这里有个前提:ON CONFLICT 后面括号里的列必须有唯一约束或唯一索引,否则直接报错。EXCLUDED代表当前语句想插入的那行数据,用它可以引用新值。
INSERT 还有一个很实用的能力,就是RETURNING。我经常用它拿到数据库生成的字段:
INSERT INTO users (username, email) VALUES ('bob', 'b@example.com') RETURNING id, created_at;不用再发一条 SELECT 去查主键,省一次往返,逻辑也清爽。
3.2 UPDATE:别忽略WHERE,别一把梭全表
UPDATE 的教训主要集中在对 WHERE 的敬畏上。不带 WHERE 的 UPDATE 就是全表更新,这个操作在生产环境出现一次,基本就是一次事故。我自己的习惯是,执行影响面大的 UPDATE 之前,一定先跑一条同条件 SELECT 确认行数:
SELECT count(*) FROM users WHERE status = 2; -- 确认是预期行数后 UPDATE users SET status = 3 WHERE status = 2;PostgreSQL 的 UPDATE 还支持跨表更新,语法跟 MySQL 的 JOIN UPDATE 不一样,是用 FROM 子句实现:
UPDATE orders o SET amount = o.amount * 1.1 FROM customers c WHERE o.customer_id = c.id AND c.level = 'vip';这个写法实际工作中非常高频,比如按用户等级批量调价。注意 FROM 子句会把所有满足条件的行关联进来,如果关联后有重复,更新结果可能不是你预期的值,所以关联键必须能唯一标识目标行。
批量更新大表是另一个难点。PostgreSQL 的 UPDATE 没有 LIMIT 子句,直接一条语句更新几百万行会持有事务和锁很长时间,WAL 会猛涨,还会拖垮备库回放。常规做法是用主键分批:
UPDATE users SET status = 3 WHERE status = 2 AND id BETWEEN 1 AND 10000;每批几千到几万行,批与批之间留出缓冲,循环执行。这样每个事务短平快,锁持有时间可控,对业务影响最小。
还要理解 UPDATE 的底层代价。PostgreSQL 用的是 MVCC 模型,UPDATE 实际上不是改原行,而是生成一个新版本的行,旧版本行留待 VACUUM 回收。所以高频 UPDATE 会让表的数据文件膨胀,如果 autovacuum 跟不上,表会越来越大,查询也越来越慢。这个问题后面章节细说。
3.3 DELETE:清理数据的正确姿势
DELETE 的第一原则和 UPDATE 一样:WHERE 条件必须想清楚。其次,你要明白 DELETE 和 TRUNCATE 的取舍。只想清空整表,用 TRUNCATE;按条件删部分数据,只能用 DELETE。
生产环境按条件删大量数据时,别一条 DELETE 梭哈,要分批:
DELETE FROM audit_logs WHERE created_at < now() - interval '90 days' AND id IN ( SELECT id FROM audit_logs WHERE created_at < now() - interval '90 days' ORDER BY id LIMIT 5000 );为什么分批?因为一个大事务 DELETE 几百万行,期间持有大量行锁和事务快照,不仅长时间阻塞相关读写,而且如果中途失败回滚,回滚本身也是一场灾难。分批删除能让每一批的锁开销和 WAL 量控制在合理范围。
DELETE 的RETURNING也很香。清理数据前想留个底,直接取回被删的行:
DELETE FROM temp_orders WHERE expired_at < now() RETURNING id, expired_at;有次帮业务清理脏数据,被删之前需要审计,我就是用 RETURNING 把这些行直接写进了历史表的,一条语句搞定插入和删除两个需求。
DELETE 的另一个隐蔽开销是外键检查。如果子表有外键引用这张表,每删一行都要扫描子表确认没有引用,没有索引支持时这个扫描代价非常恐怖。删除大表数据之前,检查一下外键列上有无索引,没有的话考虑加一个。
4. 事务和锁:DDL与DML一起用时必须懂的知识
4.1 PostgreSQL 的 DDL 能在事务里回滚
PostgreSQL 和 MySQL 有一个很大的不同:PG 的 DDL 是支持事务的。也就是说,你执行CREATE TABLE、ALTER TABLE、甚至DROP TABLE,只要包在事务里,都可以回滚。
BEGIN; ALTER TABLE users ADD COLUMN wechat VARCHAR(50); -- 发现不对劲 ROLLBACK; -- 表结构回到原来的样子这一点在生产变更脚本里价值巨大。我写结构变更脚本时,默认都会套上事务,脚本里每一步都验证,只要任何一步报错或者数据校验不过,直接 ROLLBACK,不会留下半截变更。
但要注意,事务和锁是双刃剑。事务越长,持有的锁时间越久,阻塞其他会话的时间也越久。我见过一个同事把整个报表数据清洗放在一个事务里跑了一个小时,结果对同一张表的业务写入全被卡死,故障持续到事务结束。事务能救你,也能坑你,关键是控制它的大小。
4.2 表级锁概览:什么操作会堵住什么操作
PostgreSQL 的表级锁模式很多,日常真正需要记住的是下面几种:
| 锁模式 | 典型操作 | 和谁冲突 |
|---|---|---|
| ACCESS SHARE | SELECT | 只和 ACCESS EXCLUSIVE 冲突 |
| ROW SHARE | SELECT FOR UPDATE | 和 EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
| ROW EXCLUSIVE | INSERT / UPDATE / DELETE | 和 SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
| SHARE | CREATE INDEX(非并发) | 和 ROW EXCLUSIVE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
| ACCESS EXCLUSIVE | ALTER TABLE / DROP TABLE / TRUNCATE | 和所有锁模式冲突 |
从这张表能看出两件关键事:
- DML 语句之间不互相冲突,所以业务读写能高并发。
- DDL 的 ARMAGEDDON:一旦拿到 ACCESS EXCLUSIVE,整张表的读写全部停摆,直到 DDL 完成。
排查锁等待的思路也很固定。遇到 DDL 卡住,先用这条 SQL 看谁在等、等的是什么:
SELECT pid, wait_event_type, wait_event, state, query FROM pg_stat_activity WHERE backend_type = 'client backend' ORDER BY pid;如果看到某条ALTER TABLE一直处于active但wait_event_type是Lock,那就是在等其他事务释放锁。再用 pg_locks 关联查一下是哪个 pid 持有了这把冲突锁,找对应的人确认业务是否跑完。能做的处理一般是等锁、终止阻塞会话、或者调整业务节奏。
死锁方面 PostgreSQL 会自动检测,发现死锁会回滚其中一个事务并输出提示。应用侧要做的不是等死锁发生,而是统一加锁顺序、缩短事务时间,从源头避免。
4.3 MVCC和隔离级别:理解DML行为的关键
PostgreSQL 默认的隔离级别是 READ COMMITTED,每条语句执行时都取一个新快照,所以同一事务里两次 SELECT 可能看到不同数据。如果业务需要整个事务看到一致的快照,就要手动调成 REPEATABLE READ:
BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT * FROM users WHERE status = 2; -- 整个事务期间,这个快照不变 COMMIT;REPEATABLE READ 下如果两个事务并发更新同一行,后提交的一方会收到序列化失败错误,需要在应用层重试。
MVCC 带来的另一个生产问题是表膨胀。每一条 UPDATE 和 DELETE 都会留下死元组,VACUUM 负责回收这些旧版本。如果某个长事务一直不提交,它开启的快照之前的所有死元组都不能被清理,这个事务跑得越久,表的膨胀就越严重。所以:
- 尽量缩短事务时间,别让事务空转。
- 监控 autovacuum 是否正常工作,不要轻易关闭它。
- 对频繁更新的表,可以考虑调整 fillfactor 参数,给 UPDATE 留出页内空间。
ALTER TABLE users SET (fillfactor = 70);这个选项在建表或重置时生效,之后的数据更新会在页内留出位置,减少行版本跨页迁移的写放大。代价是每个页能装的行数变少,查询要扫描更多页。适合频繁 UPDATE 的表,不适合只读表。
5. 性能优化和问题排查:生产环境真实踩坑记录
5.1 大表 DDL 怎么才能不打断业务
生产环境最怕的就是对一张几千万行的表直接执行 ALTER TABLE 加索引或加约束,一个锁就把业务全堵死。这里把我常用的几个不打断业务的方案列一下。
创建索引用并发方式:
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);CONCURRENTLY让建索引过程不阻塞表的读写,但代价是耗时比普通方式长,而且不能在事务块里和其他语句一起执行。还有一个坑:如果执行过程中被中断,PostgreSQL 会留下一个 INVALID 状态的索引,查询不会使用它,但会拖慢写入,需要手动 DROP 之后再重建。
SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE indisvalid = false;加约束的分阶段方案(NOT VALID 加 VALIDATE)在前面 ALTER TABLE 部分已经讲了,这里再强调一遍:它对大表加 CHECK、外键约束来说几乎是标配做法,能把锁持有时间从"全表扫描那么久"缩短到"毫秒级"。
如果要做更大规模的重构,比如改字段类型或者调整表结构,没法避免全表重写,那就得考虑"影子表"方案:新建一张新结构表,业务写入切换到新表,同时把老表数据分批灌入,最后切换访问入口,老表退役。这套流程要应用层配合,属于大工程,但确实是通过在线方式完成大表结构升级的可靠路径。
5.2 常见问题速查表
把这些年踩过的坑整理成一张表,排查的时候按图索骥:
| 现象 | 可能原因 | 排查命令 | 处理思路 |
|---|---|---|---|
| DDL 执行卡住不动 | 被其他事务的锁阻塞 | pg_stat_activity 看 wait_event | 找到持锁会话,确认是否可终止 |
| UPDATE 之后表越来越大 | MVCC 死元组没被及时回收 | SELECT pg_stat_user_tables 查 n_dead_tup | 确认 autovacuum 开启,必要时手动 VACUUM |
| 并发更新报 serialization failure | REPEATABLE READ 下更新冲突 | 看日志错误码 40001 | 应用层捕获后重试事务 |
| 批量插入非常慢 | 索引、触发器、约束过多 | EXPLAIN ANALYZE 定位瓶颈 | 导入前删索引,导完重建 |
| 误删了数据 | 手滑或者 FORGOT WHERE | 无 | 用备份做 PITR 时间点恢复,前提是有备份 |
| 触发了死锁 | 事务之间锁顺序不一致 | 日志中死锁详情 | 统一资源加锁顺序,缩短事务 |
| 执行 ALTER TYPE 几分钟没反应 | 正在全表重写 | pg_stat_progress 看重写进度 | 等待或择低峰执行 |
误删数据这里多说一句。PostgreSQL 没有像某些商业数据库那样开箱即用的闪回功能,恢复依赖物理备份加 WAL 归档做时间点恢复(PITR)。所以生产库的备份策略和恢复演练是不能省的基础设施,平时不演练,出事必手忙脚乱。
5.3 批量操作的实用调优手段
批量 DML 性能优化,我通常按优先级做这几件事:
第一,导入大量数据前先砍掉非必要索引。每插入一行,所有索引都要同步更新,索引越多,写入越慢。常规做法是先把索引 DROP 掉,COPY 导入完成后再统一CREATE INDEX CONCURRENTLY重建。综合耗时往往比边插边维护索引快很多。
第二,调整 maintenance_work_mem。建索引、VACUUM、加外键这些维护操作会用这个参数决定内存窗口大小。默认值偏保守,建大索引时可以按机器内存临时调大:
SET maintenance_work_mem = '1GB'; CREATE INDEX CONCURRENTLY idx_users_created_at ON users (created_at);注意这个参数是会话级的,改大了只影响当前会话,不用担心污染全局配置。
第三,大表频繁 DML 之后手动 VACUUM 一下。autovacuum 有触发阈值,但高峰期可能跟不上。低峰期手动执行VACUUM (ANALYZE)能及时回收死元组并刷新统计信息,让优化器拿到更准确的估算,后续查询计划不容易跑偏。
第四,需要快速清理超大表时,如果全表都不要了,直接 TRUNCATE 而不是 DELETE,这个前文提过。如果只要保留一部分,也可以考虑"建新表+INSERT SELECT+切换名称"的模式,比 DELETE 大部分数据快得多,因为写新表是顺序写,而 DELETE 是随机写旧表还要留死元组。
6. 最后分享几个没写在文档里的实战心得
这几年的经验总结下来,其实就三句话。
第一,所有结构变更都要当"发布"来做,而不是当"命令"来做。先测试环境验证,估算耗时和锁影响,准备好回滚语句。我在生产环境吃过没准备回滚的亏,一条 ALTER 把老表的默认值逻辑改错了,业务写入直接报错,最后翻 LOG 才把问题定位回来。从那以后,任何 DDL 变更脚本我一定是BEGIN; ... COMMIT;包好,旁边备着逆操作的 ROLLBACK。
第二,DDL 和 DML 混用时要格外小心事务边界。有人喜欢在一个事务里先 DELETE 再 ALTER,看起来一气呵成,实际上把数据清理的长时间锁和结构变更的 ACCESS EXCLUSIVE 锁叠在一起,阻塞面直接翻倍。正确姿势是拆成独立的事务,每个事务短平快。
第三,监控和备份才是数据库安全感的来源。DDL 操作前看一眼pg_stat_activity有没有长事务,DML 大批量操作前确认磁盘空间和 WAL 目录不会写爆,日常每天做备份恢复演练。这些都是"看不见的功夫",但事故发生时,只有它们能兜底。
另外再给个实用小技巧:拿不准某条 DDL 或 DML 在真实数据量上的表现时,先在测试环境跑EXPLAIN ANALYZE看执行计划和耗时,再在目标表上借用BEGIN; ... ROLLBACK;实测一次锁行为。这两步能过滤掉百分之八十的低级失误。PostgreSQL 这东西,你越了解它的锁和 MVCC,写出来的操作就越稳,踩坑的次数就越少。