psql 事务控制(AUTOCOMMIT)
事务是 PostgreSQL 的立身之本,而 psql 作为最常用的客户端,给了你两套控制事务的开关:一套是 SQL 层面的 BEGIN/COMMIT/ROLLBACK(与任何客户端一致),另一套是 psql 自己的 AUTOCOMMIT 变量与 --single-transaction 命令行选项。前者在《事务基础》已经讲过语义,本页聚焦的是psql 特有的那部分:默认的自动提交到底怎么工作、AUTOCOMMIT off 模式下每一句话都发生了什么、以及如何用 -1 --single-transaction 把一整个迁移脚本包进单个事务。
这一页同时是「脚本安全」的一页。迁移脚本最常见的两类事故——改到一半发现错了,已经提交的回不来;以及以为成功了,实际什么都没进库——都能通过 ON_ERROR_STOP 与事务的正确组合避免。我们会把「批量脚本的安全模式」作为一等公民来写:什么时候该一个大事务包到底、什么时候必须分段提交、长事务的代价在哪里。
阅读前提是你已经理解事务的 ACID 语义与隐式提交概念;本页只谈 psql 客户端视角的行为差异,不再重复事务本身的理论。
前置知识
建议先阅读:psql 变量与 \gset
基础概念
先厘清三个层次的东西,避免概念互相打架:
| 层次 | 机制 | 归属 |
|---|---|---|
| 服务器事务 | BEGIN/COMMIT/ROLLBACK/SAVEPOINT | PostgreSQL 服务端语义,所有客户端通用 |
| 自动提交开关 | psql 变量 AUTOCOMMIT(默认 on) | psql 客户端行为,决定「要不要替你发 BEGIN」 |
| 脚本事务包装 | 命令行选项 -1 / --single-transaction | psql 客户端行为,把 -c/-f 的全部输入包进一个事务 |
AUTOCOMMIT = on(默认) 时,每条 SQL 一执行成功就自动提交,语句之间没有事务包裹。这并不意味着你开不了事务——显式敲 BEGIN 后,psql 会把后续语句交给这个事务,直到你 COMMIT 或 ROLLBACK。
AUTOCOMMIT = off 时,psql 会在「不在事务里的每条语句」之前替你发一个隐式 BEGIN,此后所有变更持续累积,直到你显式 COMMIT/ROLLBACK 才落盘。它模拟的是 Oracle 等数据库的默认行为。
--single-transaction(-1) 则面向「一次性脚本」:psql -1 -f migrate.sql 会在执行脚本前发 BEGIN,脚本全部执行完再发 COMMIT,把整个脚本变成一个原子操作。
AUTOCOMMIT on : S1[提交] S2[提交] S3[提交] —— 每句一事务
显式 BEGIN : BEGIN S1 S2 COMMIT —— 你自己画边界
AUTOCOMMIT off: [BEGIN] S1 S2 S3 ... 你敲 COMMIT —— psql 替你开,你负责收尾
--single-transaction : BEGIN 输入1 输入2 输入3 COMMIT —— psql 替你开也替你收语法与用法
默认模式下的显式事务(与普通 SQL 无异):
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;切换自动提交模式:
\set AUTOCOMMIT off -- 关闭自动提交(注意是小写 off 即可)
INSERT INTO t VALUES (1); -- 此时已被隐式 BEGIN 包住,未提交
SELECT * FROM t; -- 本会话可见
COMMIT; -- 显式提交后才真正落盘
\set AUTOCOMMIT on -- 改回来(不会自动提交当前未决事务!)命令行选项:
# 多条 -c:各自独立提交(默认)
psql -d appdb -c "TRUNCATE staging;" -c "INSERT INTO staging SELECT * FROM import;"
# --single-transaction(等价 -1):两条 -c 包进一个事务
psql -d appdb -1 --set=ON_ERROR_STOP=1 \
-c "TRUNCATE staging;" \
-c "INSERT INTO staging SELECT * FROM import;"脚本 + 错误即回滚的标准姿势:
psql -X -d appdb -v ON_ERROR_STOP=1 --single-transaction -f migrate_0042.sql退出码速查(脚本判断成败的依据):
| 退出码 | 含义 |
|---|---|
0 | 正常结束 |
1 | psql 自身的致命错误(如内存不足、文件不存在) |
2 | 连接中断 |
3 | 脚本执行出错且 ON_ERROR_STOP 处于开启状态 |
详细说明
默认行为:AUTOCOMMIT on 下的边界感
默认模式下,没有 BEGIN 包裹的每条语句就是一个事务。这带来的直接后果:
DELETE FROM orders WHERE created_at < '2020-01-01';
-- 执行完的这一瞬间,删除已经提交,不存在“撤销”机会而一旦进入显式事务,psql 的提示符会看到 BEGIN 回显,其间任何语句失败只会把事务置为「已中止」状态,后面的语句会被拒绝(current transaction is aborted, commands ignored until end of transaction block),直到 ROLLBACK。这个「失败后必须先回滚」的规则与客户端无关,是服务器的事务状态机决定的;psql 的 ON_ERROR_ROLLBACK 变量可以替你在交互模式里自动打 SAVEPOINT 缓解它(后面专述)。
对交互式使用,建议保持默认 AUTOCOMMIT on:临时 UPDATE/DELETE 改错了顶多一行,但忘了 COMMIT 的 off 模式会把「改动去哪了」的问题搅得很混乱。
显式 BEGIN/COMMIT/ROLLBACK 与 SAVEPOINT
显式事务在 psql 里没有任何特殊语法,就是 SQL。值得展开的是 SAVEPOINT 在长脚本里的价值:
BEGIN;
SAVEPOINT before_risky;
UPDATE big_table SET status = 'done' WHERE batch = 42;
-- 发现不对劲,只回滚这一小段
ROLLBACK TO SAVEPOINT before_risky;
-- 换个写法重来
UPDATE big_table SET status = 'done' WHERE batch = 42 AND id % 2 = 0;
COMMIT;SAVEPOINT 让「一个事务内部的局部失败」可以被局部撤销,在数据修复场景比「整段重来」高效得多。它与 psql 的配合没有任何额外语法,但注意 SAVEPOINT 是栈式管理,ROLLBACK TO 之后该保存点仍然存在(可重复回滚),RELEASE SAVEPOINT 则销毁它。
AUTOCOMMIT off 模式的运行细节
\set AUTOCOMMIT off 之后,psql 的行为变成:
- 隐式 BEGIN:在执行任何「不在事务内、本身也不是事务控制命令」的语句前,psql 自动发一个
BEGIN; - 白名单感知:本身是事务控制(
BEGIN/COMMIT/ROLLBACK)或不能在事务块内执行的命令(VACUUM、CREATE DATABASE、CREATE INDEX CONCURRENTLY等)不会触发隐式 BEGIN——psql 识别得出来,这些命令照常运行; - 变更持续累积:所有 DML/DDL 都攒在同一个事务里,直到你显式敲
COMMIT(或END)才提交,敲ROLLBACK则全部作废; - 切换开关不收尾:从
off改回on不会替你提交当前未决事务;未决事务依旧悬着,你必须自己 COMMIT; - 退出即回滚:带着未提交事务
\q退出或连接断开,服务器一律回滚。
\set AUTOCOMMIT off
INSERT INTO audit_log(msg) VALUES ('step1'); -- 未提交
INSERT INTO audit_log(msg) VALUES ('step2'); -- 同一事务
SELECT * FROM audit_log; -- 本会话可见,其他会话看不到
ROLLBACK; -- 两行全部消失autocommit-off 模式的价值主要在两种场景:把多条相关 DML 自然地聚成一个事务而不想手写 BEGIN,以及从 Oracle 等数据库迁移过来的工程师保持肌肉记忆。代价是「忘记 COMMIT」的风险:交互式改完数据忘了提交,换一个会话查不到,还以为改丢了。因此它更适合在脚本里明确使用,交互环境保持默认。
AUTOCOMMIT off 下没有专属的提交元命令
psql 没有类似 \commit、\rollback 的元命令。AUTOCOMMIT off 模式下,提交与回滚就是敲 SQL 的 COMMIT/ROLLBACK 语句——psql 识别它们是事务控制语句,不会为其再包隐式 BEGIN。部分资料把它们写成「元命令」是不准确的。
退出、断连与未决事务
服务器把「连接消失」视为回滚信号:Ctrl+C 退出 psql、网络断开、psql \q、甚至客户端机器断电,未提交事务一律回滚,绝无「半个事务」落地。这是事务模型给的安全网,但要警惕它的另一面——psql 正常退出时不会提醒你还有未决事务。在 autocommit-off 或显式 BEGIN 的长会话里,养成「收尾先 COMMIT/ROLLBACK 再 \q」的习惯;不确定时先查 SELECT pg_current_xact_id(); 之外更直观的手段——直接再查一次你改过的表确认预期。
补充两个与断连相关的工程细节:
- Ctrl+C 的两层含义:交互模式下第一次 Ctrl+C 中断当前正在执行的语句(服务器收到取消请求,SQLSTATE
57014),psql 回到输入提示符;如果当时正处于显式事务中,事务保持「已中止」状态,需要 ROLLBACK 复位——别把「语句被取消」当成「事务已回滚」。 - 长事务脚本对网络抖动敏感:psql 没有自动重连,脚本跑到一半网络闪断,未决事务由服务器回滚、脚本以非零码退出。跨机房跑长迁移时,把「断点续跑」设计进脚本(分段提交 + 幂等)比指望网络可靠更现实。
\gset、\gexec 与事务的组合
上一页的 \gset 与本页的事务没有冲突,但有一个值得知道的细节:\gset 执行的查询同样遵守当前事务状态——在显式事务里 \gset 取到的是事务内未提交的视角,这恰好是你想要的「基于事实的分支」。
\gexec 则是把「查询结果逐值当作下一条命令执行」的利器,典型用法是批量 DDL:
BEGIN;
SELECT 'COMMENT ON TABLE ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname)
|| ' IS ''owned by data team'''
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname = 'public'
\gexec
COMMIT;\gexec 生成的每条命令默认各自独立提交(如果 AUTOCOMMIT on);用显式 BEGIN 包住后,整个批量 DDL 变成一个原子操作——要么全部注释生效,要么全部不作数。批量加列、批量建索引、批量授权都可以套用这个模式。
事务里的 DDL:与 MySQL 完全不同的心智模型
如果你从 MySQL 转来,第一个要刷新的认知是:PostgreSQL 的 DDL 是事务性的。CREATE TABLE、ALTER TABLE ... ADD COLUMN、CREATE INDEX(非 CONCURRENTLY)都可以放进事务,要么全部生效要么全部消失:
BEGIN;
ALTER TABLE orders ADD COLUMN channel text;
UPDATE orders SET channel = 'unknown';
CREATE INDEX orders_channel_idx ON orders (channel);
COMMIT; -- 三步一起生效,回滚则一起消失这直接改变了迁移脚本的写法:PostgreSQL 的迁移可以放心用 --single-transaction 把 DDL 与数据订正原子化,失败即完整回滚,不存在「列加了但数据没刷」的中间态;MySQL 的 DDL 隐式提交则迫使迁移按语句分步。
两条边界要记住:
CREATE INDEX CONCURRENTLY不能在事务块里跑——它自身跨多个事务提交,语法上就禁止出现在显式事务与--single-transaction包装中,这类操作必须单独拆出脚本;- DDL 也拿重锁:多数
ALTER TABLE要 ACCESS EXCLUSIVE 锁,把 DDL 放在事务靠后的位置、缩短持锁时间,并警惕「DDL 排在长查询后面」把后面所有语句堵死——锁队列一旦被堵,受害面远不止本事务。
--single-transaction:把脚本包进一个事务
-1 / --single-transaction 作用于本次 psql 进程的全部输入(多条 -c 或一次 -f,可混用):
psql -1 -f part1.sql -f part2.sql -c "ANALYZE;"
等效于:BEGIN; part1.sql 的内容; part2.sql 的内容; ANALYZE; COMMIT;它的语义要点:
BEGIN在执行第一条输入前发出,COMMIT在最后一条输入后发出;- 脚本里若自己写了显式
BEGIN/COMMIT,会与外层事务嵌套产生警告(PostgreSQL 不支持真正嵌套事务,内层 BEGIN 只是个空操作警告,内层 COMMIT 会提前结束外层事务)——使用--single-transaction时脚本内部不要再写事务控制语句; - 必须配合
ON_ERROR_STOP,这是官方文档明示的组合,理由见下一小节; --single-transaction无法与CREATE INDEX CONCURRENTLY、CREATE DATABASE这类「不能在事务块内执行」的语句共存,脚本包含它们时会直接报错。
ON_ERROR_STOP 与事务:安全脚本的双保险
先看反例——不加 ON_ERROR_STOP 的 --single-transaction 脚本:
psql -1 -f bad_migrate.sql
├── CREATE TABLE t (...) ✔ 执行(未提交)
├── INSERT INTO t ... ✘ 语法错误 → 事务进入“已中止”状态
├── ALTER TABLE t ADD ... ✘ current transaction is aborted, commands ignored…
└── COMMIT 服务器实际执行 ROLLBACK —— 什么都没进库最危险的地方在于:最后的服务器端 COMMIT 实际以 ROLLBACK 告终,psql 仍可能以退出码 0 结束。上层脚本若只看退出码,会得出「迁移成功」的错误结论。
加上 ON_ERROR_STOP=1 后行为完全不同:
psql -v ON_ERROR_STOP=1 --single-transaction -f migrate.sql
├── CREATE TABLE t (...) ✔ 执行(未提交)
├── INSERT INTO t ... ✘ 出错 → 立即停止执行,退出码 3
└── 连接关闭 → 未提交事务被服务器回滚于是形成一条铁律:凡是用 --single-transaction,必配 --set=ON_ERROR_STOP=1(或 -v ON_ERROR_STOP=1)。CI/CD 的迁移流水线再以退出码 3 为失败信号,实现「要么全部生效,要么库纹丝不动」的原子发布。
失败语句与事务状态机:25P02 的细节
显式事务里一旦某条语句失败,事务不会立即回滚,而是进入「已中止(aborted)」状态——这是理解 psql 事务行为的关键状态机:
BEGIN;
INSERT INTO t (id) VALUES (1); -- 假设这里主键冲突,失败
SELECT * FROM t; -- 之后的所有语句都会被拒绝ERROR: duplicate key value violates unique constraint "t_pkey"
ERROR: current transaction is aborted, commands ignored until end of transaction blockROLLBACK; -- 唯一的出路:先回滚,事务才能恢复可用这个状态机(SQLSTATE 25P02)决定了三件事:
- 失败后继续跑是无效功:已中止事务里的每条语句都白跑,psql 不加
ON_ERROR_STOP时脚本的剩余部分全部浪费在「重复报错」上; - 回滚是唯一的复位键:
SAVEPOINT是唯一的例外——ROLLBACK TO SAVEPOINT能把事务从已中止状态拉回到保存点; - 退出码会被污染:不加 ON_ERROR_STOP 时,脚本最后的 COMMIT 在已中止事务上实际执行 ROLLBACK,退出码可能仍是 0(前文详述的反例)。
ON_ERROR_ROLLBACK on 的实现原理正是围绕这个状态机:psql 在每条语句前自动打 SAVEPOINT、失败时自动 ROLLBACK TO,把「已中止」化解为「局部撤销」。
死锁与重试:事务脚本的并发课题
事务脚本不是只跟自己打交道——只要并发存在,就有两个必须认识的 SQLSTATE:40P01(deadlock_detected,死锁)与 40001(serialization_failure / 可序列化冲突)。死锁的剧本:事务 A 锁了行 1 等行 2,事务 B 锁了行 2 等行 1,服务器在 deadlock_timeout(默认 1s)后主动检测并牺牲其中一个,被牺牲者收到 40P01,整个事务已回滚。
psql 脚本层面能做的不是「避免」死锁(那是设计问题),而是「接住」它:
\set ON_ERROR_STOP on
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;脚本被死锁打断时,psql 以非零退出码结束,重试交给外层调度(带退避的再次执行整个脚本)。与驱动层「进程内捕获重试」不同,psql 的天然重试单元是整个脚本——这也再次说明:脚本要保持幂等(重跑安全),重试才有意义。
降低死锁概率的两条设计纪律,在写事务脚本时同样适用:
- 多事务修改多行时,按一致的顺序访问(例如都按
id升序 UPDATE),打破「互相等待」的环形条件; - 事务起点先锁热点行(
SELECT ... FOR UPDATE),把「先干活后补锁」改成「先立桩后干活」,用短暂排队替代死锁回滚。
另外注意 ON_ERROR_ROLLBACK on 与重试的关系:它只做「语句级局部回滚」,不会替你重试死锁——事务在 40P01 之后已整体回滚,psql 会继续执行后续语句(随后每条都报错),因此脚本级死锁重试永远应该交给外层调度 + 幂等脚本。
长事务的代价与监控信号
「事务边界按业务原子性划分」的另一面是:事务越小越好,除非原子性要求更大。长事务的四笔账:
| 代价 | 机制 | 后果 |
|---|---|---|
| 膨胀 | 事务旧版本元组无法被 vacuum 回收 | 表与索引虚胖,查询变慢 |
| WAL 堆积 | 事务提交前 WAL 不能被回收复制 | 磁盘压力、备库延迟 |
| 锁持有 | 事务越长,锁持有越久 | 其他会话排队,超时雪崩 |
| 快照老化 | 长事务持久的快照阻止 xmin 推进 | 全库 vacuum 效率下降 |
监控长事务的标准查询:
SELECT pid,
now() - xact_start AS xact_age,
state,
wait_event_type,
left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_age DESC;超过 5 分钟的活跃事务就该看一眼在干什么;超过 30 分钟通常意味着事故在酝酿。配合 pg_stat_activity 的 application_name(psql 里通过连接串设置)可以立即定位到人或服务。设置 idle_in_transaction_session_timeout(服务端参数)可以强制掐掉「开着事务发呆」的会话,是长事务问题的兜底防线。
批量脚本的安全模式:大事务还是分段提交?
--single-transaction 不是万能的。一个把 5000 万行 DELETE 包进单个事务的脚本,本质是在服务器上构造一个巨型事务:WAL 大量堆积、表与索引膨胀来不及被 autovacuum 清理、复制延迟拉大、锁持有时间拉长、失败重试成本巨大(一行都不能少地重来)。批量脚本应该按「业务原子性」划界,而不是按「技术上的方便」:
| 场景 | 推荐模式 | 理由 |
|---|---|---|
| 结构迁移(几十条 DDL) | --single-transaction + ON_ERROR_STOP | 数量小,原子性价值高 |
| 一次性小数据订正 | 单事务包住 | 改错了能整体回滚 |
| 千万行级 DELETE/UPDATE | 分批 + 每批一个事务 | 控制事务大小与锁持有时长 |
| 数据迁移中的全量搬运 | \copy/COPY 分段提交 | 后续 \copy 一页详述 |
| 含 CONCURRENTLY 操作的脚本 | 绝不单事务包裹 | 语法上就不允许 |
分段提交的骨架:
\set ON_ERROR_STOP on
TRUNCATE batch_state; -- 记录批次状态的小表
INSERT INTO batch_state SELECT 1, false;
DO $$
DECLARE
moved bigint;
BEGIN
LOOP
WITH victim AS (
SELECT ctid FROM big_table WHERE archived = false LIMIT 100000
),
moved_rows AS (
DELETE FROM big_table USING victim WHERE big_table.ctid = victim.ctid
RETURNING big_table.*
)
INSERT INTO archive_table SELECT * FROM moved_rows;
GET DIAGNOSTICS moved = ROW_COUNT;
RAISE NOTICE 'moved % rows', moved;
EXIT WHEN moved = 0;
COMMIT; -- 每十万行提交一次(在 DO 内不可行时退化为外部脚本循环)
END LOOP;
END $$;上面这种「过程内循环提交」在 PostgreSQL 的 DO 块里并不可行(DO 本身运行在单个事务内),实际工程中请用外部脚本循环实现分批:bash 循环调用 psql、每轮跑一个 LIMIT 化的 DELETE,直到影响行数为 0。要点不在写法而在节奏——每个事务只装得下它该装的东西。
交互模式的 ON_ERROR_ROLLBACK
与 ON_ERROR_STOP 名字相近的 ON_ERROR_ROLLBACK 是另一回事:\set ON_ERROR_ROLLBACK on(或 interactive)让 psql 在交互模式下为每条语句自动打 SAVEPOINT,出错时自动回滚到上一语句之后而不是中止整个事务。它解决的是交互体验问题(一条手滑的 DELETE 不至于废掉整个事务),与脚本化的 ON_ERROR_STOP 互补:脚本要停,交互要活。
取值有三种:未设(关闭)、on(总是启用,脚本模式也会打 SAVEPOINT)、interactive(仅交互会话启用,脚本不受影响,默认推荐)。体验一下差别:
\set AUTOCOMMIT off
\set ON_ERROR_ROLLBACK interactive
BEGIN;
DELETE FROM orders WHERE id = 999; -- 不存在的行,DELETE 成功但影响 0 行(无感)
SELECT 1/0; -- 除零报错
SELECT 'still alive'; -- 事务仍然可用:自动 ROLLBACK TO SAVEPOINT 救了它
ROLLBACK;代价要清楚:自动 SAVEPOINT 意味着每条语句前多一次轻量操作,且已中止前的部分效果需要靠你心里记账——自动回滚只回滚到「上一条语句之后」,之前已成功的语句仍在事务里等你最终 COMMIT 或 ROLLBACK。它不是「出错自动提交」也不是「部分提交」,别把语义记反。
输出控制配合:让事务脚本可审计
事务脚本往往成百上千行,执行回显必须可控。三个常用组合:
\t on -- tuples_only:压掉表头与行数统计,日志更干净
\a -- 切到 unaligned,长结果不换行
\echo === step 3: 初始化字典表 === -- 人工里程碑,日志里可搜索配合 psql -v ECHO=queries(或 -a)能把输入的每条 SQL 也打出来,事后审计「到底执行过什么」时非常关键。生产迁移脚本的日志规范通常就是:ON_ERROR_STOP + ECHO=all + \o 落盘。
迁移工具视角:框架事务语义与 psql 的衔接
团队里迁移脚本往往不直接由 psql 驱动,而是被 Flyway、Liquibase、自研迁移框架等工具托管。此时理解「工具的事务语义」与「psql 的事务选项」如何衔接至关重要:
- 每个迁移文件一个事务是多数迁移工具的默认语义——对应到 psql 层就是「工具逐文件调用 psql,每次带
--single-transaction --set=ON_ERROR_STOP=1」; - 迁移历史表与业务变更必须同事务:工具记录「版本 0042 已应用」的 INSERT 与业务 DDL 落在同一个事务里,失败时两者一起回滚,历史表才不会「记录了成功但结构没变」;
- psql 脚本内部不要再有 BEGIN/COMMIT:被工具包进事务后再写事务控制会破坏原子性假设(前文详述);
- CONCURRENTLY 类操作必须标记为「非事务迁移」:主流工具都有对应的「非事务执行」开关,这类迁移单独拆文件、单独调度。
用 psql 自制一个最小迁移执行器时,上述语义同样适用:
#!/bin/bash
# migrate.sh —— 按序执行 pending/ 目录下的迁移,逐文件事务
set -euo pipefail
DB="$1"
for file in $(ls pending/*.sql | sort); do
echo "== applying $file =="
psql -X -d "$DB" -v ON_ERROR_STOP=1 --single-transaction -f "$file"
# 成功后移入 applied/(与业务变更不在同一事务,属可接受折衷:
# 更严谨的做法是把「记录历史」写进迁移文件最后一行)
mv "$file" applied/
done这个骨架没有「历史表原子性」,因此注释里指明了折衷方向——在迁移文件末尾追加一条 INSERT INTO schema_migrations ...,让它与业务变更同生共死。理解了 psql 的事务选项,无论工具多么花哨,事务语义都能还原成这几条基本组合。
实战示例
示例一:交互体验 AUTOCOMMIT off
\set AUTOCOMMIT off
\echo 自动提交已关闭
UPDATE products SET price = price * 0.9 WHERE category = 'clearance';
-- 提示:UPDATE 3(但未提交)
-- 另开一个会话验证:
-- SELECT * FROM products WHERE category='clearance'; -- 仍是旧价
COMMIT; -- 或 ROLLBACK;
\set AUTOCOMMIT on体会三个动作:本会话看到新值、其他会话看不到、COMMIT 后全局可见。
示例二:标准迁移脚本包
工程化的迁移脚本配套(bash 驱动 + SQL 正文):
#!/bin/bash
set -euo pipefail
DB="${1:?usage: $0 <dbname>}"
psql -X -d "$DB" -v ON_ERROR_STOP=1 --single-transaction -f 0042_add_tenant.sql-- 0042_add_tenant.sql
ALTER TABLE orders ADD COLUMN tenant_id bigint NOT NULL DEFAULT 0;
ALTER TABLE order_items ADD COLUMN tenant_id bigint NOT NULL DEFAULT 0;
CREATE INDEX idx_orders_tenant ON orders (tenant_id, created_at);
INSERT INTO schema_migrations(version, applied_at) VALUES ('0042', now());任何一条失败 → 退出码 3 → set -e 让 bash 立即失败 → 服务器回滚全部。CI 里对迁移结果的判断只有「0 或非 0」一种语义。
示例三:\gexec + 事务批量授权
BEGIN;
SELECT format('GRANT SELECT ON %I.%I TO readonly_user;', schemaname, tablename)
FROM pg_tables
WHERE schemaname = 'public'
\gexec
COMMIT;GRANT SELECT ON public.orders TO readonly_user
GRANT SELECT ON public.users TO readonly_user
…数十张表一步到位且原子。若中途某条 GRANT 失败(例如目标角色不存在),ON_ERROR_STOP 会让 psql 立刻退出、事务整体回滚。
示例四:分段提交的大批量清理
#!/bin/bash
# 每轮删除 10 万行,直到删干净;每轮都是独立事务
TOTAL=0
while :; do
MOVED=$(psql -X -d appdb -v ON_ERROR_STOP=1 -At -c \
"WITH victim AS (
SELECT ctid FROM audit_events WHERE event_time < '2024-01-01' LIMIT 100000
),
del AS (DELETE FROM audit_events e USING victim WHERE e.ctid = victim.ctid RETURNING 1)
SELECT count(*) FROM del;")
TOTAL=$((TOTAL + MOVED))
echo "batch moved: ${MOVED}, total: ${TOTAL}"
[ "$MOVED" -eq 0 ] && break
sleep 0.5
done
echo "done: ${TOTAL}"每轮事务极小、锁持有短、复制延迟可感知、失败只需从断点继续——与「一个大事务包 2000 万行」的写法对比,代价差异是数量级的。-At 即 \pset tuples_only on + \pset format unaligned 的命令行等价形式,让 MOVED 只拿到纯数字。
示例五:CI 中的迁移流水线
把本页所有要素串成一条 CI 流水线脚本(GitLab CI/GitHub Actions 的 step 通用):
#!/bin/bash
# ci_migrate.sh —— 迁移失败即阻断发布
set -euo pipefail
DB_URL="${DATABASE_URL:?DATABASE_URL is required}"
MIGRATION="migrations/${MIGRATION_FILE:?MIGRATION_FILE is required}"
echo "=== preflight:检查迁移文件与目标库连通性 ==="
[ -f "$MIGRATION" ] || { echo "migration not found: $MIGRATION"; exit 1; }
psql -X "$DB_URL" -v ON_ERROR_STOP=1 -At -c "SELECT 1" > /dev/null
echo "=== lint:检查脚本内是否误写了事务控制 ==="
if grep -niE '^\s*(BEGIN|COMMIT|ROLLBACK);' "$MIGRATION"; then
echo "ERROR: migration script must not contain explicit transaction control"
exit 1
fi
echo "=== applying(单事务原子执行)==="
set +e
psql -X "$DB_URL" \
-v ON_ERROR_STOP=1 \
--single-transaction \
-v ECHO=queries \
-f "$MIGRATION"
RC=$?
set -e
case $RC in
0) echo "migration applied successfully" ;;
3) echo "migration failed (SQL error, transaction rolled back)"; exit 1 ;;
2) echo "connection lost during migration"; exit 1 ;;
*) echo "psql failed with exit code $RC"; exit 1 ;;
esac
echo "=== postflight:连通性与版本确认 ==="
psql -X "$DB_URL" -At -c "SELECT version FROM schema_migrations ORDER BY applied_at DESC LIMIT 1;"流水线设计的四个要点与本页知识一一对应:
- lint 阶段禁止脚本自带事务控制,保证
--single-transaction的原子性假设成立; - 退出码分支:只有 0 视为成功,3(SQL 错误已回滚)与 2(连接中断)分别给出可读的失败原因;
- ECHO=queries 留痕:CI 日志里能看到每条 SQL 与其结果,事后审计不缺料;
- postflight 确认版本落表:迁移历史与结构变更同事务时,这一步永远成功——它失败即说明历史表没进事务,流水线设计有缺陷。
注意事项
--single-transaction 必须配 ON_ERROR_STOP。 不加时出错后脚本带着「已中止」状态跑完全程,最终以 ROLLBACK 收场,且退出码可能仍是 0——上层编排完全无法感知失败。这是本页最重要的一条注意事项。
--single-transaction 与脚本内事务控制互斥。 脚本里已有的 BEGIN/COMMIT 会与外层事务产生嵌套警告并破坏原子性假设;含 CREATE INDEX CONCURRENTLY、CREATE DATABASE 等语句的脚本会直接报错。要么改脚本,要么放弃单事务。
AUTOCOMMIT off 不是「安全模式」。 它只是延迟提交点。长会话里未提交的修改依然持有行锁、阻止 vacuum 推进、阻塞其他会话;忘了 COMMIT 反而制造排障事故。
切换 AUTOCOMMIT 不影响未决事务。 off → on 不会自动提交当前事务,on → off 也不会自动开启事务。收尾永远靠显式 COMMIT/ROLLBACK。
失败的事务必须先 ROLLBACK。 事务一旦出错进入已中止状态,后续所有语句都会被拒绝;交互模式可用 ON_ERROR_ROLLBACK 自动兜底,脚本模式则交给 ON_ERROR_STOP + 整体回滚。
大事务有物理代价。 单事务体积受 WAL、复制、vacuum 与锁持有时间共同约束;「业务原子性」才是事务边界的划分依据,别为了省事把千万行删除包进一个事务。
退出码语义要接入编排。 0/1/2/3 各有含义;CI 里只应把 0 视为成功。用 psql -v ON_ERROR_STOP=1 统一让任何 SQL 错误映射到非零退出码。
-c 的每段是独立事务。 psql -c A -c B 不等于 psql -c "A; B",两者的事务边界不同(前者两段各自提交,后者一段一个事务);需要原子时加 -1。
SAVEPOINT 是事务内的局部撤销,不是救命稻草。 它无法替代提交前的评审;RELEASE 与 ROLLBACK TO 的栈语义也要清楚,嵌套过深的保存点让脚本更难读。
工具托管的事务语义要写进交接文档。 迁移框架、调度系统、人工 psql 三种入口的事务边界各不相同;「这个脚本由谁在什么事务语义下执行」不写清楚,出事后无法还原现场。
死锁重试交给外层调度,别指望 psql 内部重试。 psql 没有语句级异常捕获,40P01 之后事务已整体回滚;让脚本保持幂等、由调度系统带退避重跑整个脚本,才是可维护的重试形态。
最佳实践
- 脚本一律
psql -X -v ON_ERROR_STOP=1起步。 隔离个人配置、错误即停,这两个参数应该成为所有自动化脚本的第一行肌肉记忆。 - 结构迁移用
--single-transaction --set=ON_ERROR_STOP=1。 DDL 数量少、原子价值高,是单事务模式最标准的适用场景。 - 交互保持 AUTOCOMMIT on,脚本才考虑 off。 交互里用显式 BEGIN 画边界比常开 off 模式安全得多。
- 事务边界按业务原子性划分,不按脚本文件划分。 「这组语句要么全有意义要么全无意义」才是 BEGIN/COMMIT 的判定问题。
- 千万行级 DML 分批提交。 每批 1~10 万行是常见量级,配合
LIMIT游标化删除与轮间sleep,把锁、WAL、复制的峰值都压平。 - 用 RETURNING + \gset 感知事务内的事实。 分支判断基于事务内视角的数据,而不是上一轮的陈旧快照。
- \gexec 批量 DDL 记得用事务包住。 生成式批量操作默认逐条提交,显式 BEGIN/COMMIT 让它原子化。
- 迁移日志规范:ECHO=all + \t/\a 收敛输出 + \o 落盘。 事后可审计「输入了什么、输出了什么、哪一步停的」。
- CI 只认退出码 0。 迁移流水线对
psql的返回码做唯一真值判断,任何非 0 都阻断发布。 - 脚本内绝不混用显式 BEGIN 与 --single-transaction。 二选一:要么脚本自管事务,要么外层包装。
- 长事务期间盯 autovacuum 与复制延迟。 无法避免的大事务要安排在低峰,并监控 WAL 堆积与 standby 延迟。
- 把「失败重跑」设计进去。 分段提交的脚本天然支持断点续跑;单事务脚本失败即全回滚,重跑前先确认幂等性(IF NOT EXISTS、ON CONFLICT 等)。
- 为长事务设置 idle_in_transaction_session_timeout 兜底。 人为疏忽总会发生,让服务器自动掐掉「开着事务发呆」的会话,比事后救火便宜得多。
- 多对象更新按固定顺序,热点行先 FOR UPDATE。 死锁的重试成本永远比「多花两分钟设计访问顺序」高;脚本与在线服务共享表时尤其如此。
- 交互会话开 ON_ERROR_ROLLBACK=interactive,脚本会话保持 ON_ERROR_STOP。 两个开关的名字像、职责完全不同,各守各的场景。
下一节
继续学习:psql \copy 数据导入导出