SAVEPOINT 保存点
保存点(SAVEPOINT)是事务内部的「书签」:它让你在事务执行到一半时打一个标记,之后可以只回滚到这个标记为止,而不必放弃整个事务。这是 PostgreSQL 里实现「局部回滚」「错误恢复」「批量导入容错」的核心手段。本节讲清保存点的三条命令、栈式语义、同名覆盖、子事务(subtransaction)内部机制,以及在 PL/pgSQL 与批量导入中的典型用法。
前置知识
阅读本节前,建议先了解:BEGIN / COMMIT / ROLLBACK、MVCC 概述
基础概念
一个事务在 PostgreSQL 内部并不是「一坨不可分割的黑盒」,它可以被保存点切分成若干段:
BEGIN
│
├── 语句 1
├── SAVEPOINT sp1 ← 打一个书签
│ ├── 语句 2
│ ├── SAVEPOINT sp2 ← 再打一个书签
│ │ └── 语句 3
│ └── ROLLBACK TO SAVEPOINT sp2 ← 只撤销语句 3
├── RELEASE SAVEPOINT sp1 ← 释放书签(保留数据)
└── COMMIT几个必须记住的事实:
| 事实 | 说明 |
|---|---|
| 保存点只在事务块内有效 | 没有 BEGIN 时执行 SAVEPOINT 会报错 25P01 |
SAVEPOINT name 建立保存点,不结束事务 | 保存点按定义顺序构成栈 |
ROLLBACK TO SAVEPOINT name 回滚,不结束事务、也不销毁该保存点 | 事务仍在进行,可反复回滚到它并最终 COMMIT |
RELEASE SAVEPOINT name 只释放书签 | 不撤销数据,其覆盖的变更保持已应用状态 |
| 同名保存点会覆盖旧的那一个 | 后定义的取代先定义的,旧名字不再可用 |
| 保存点背后是子事务(subtransaction) | 每个保存点对应一个真实的子事务,有独立 subxid |
| 大量保存点会消耗资源 | 子事务过多会带来性能问题(子事务溢出) |
| 回滚到保存点不释放已持有的锁 | 锁在整个事务结束时才释放 |
语法与用法
-- 建立保存点
SAVEPOINT savepoint_name
-- 或者显式带 TRANSACTION 关键字(三种等价写法)
SAVEPOINT savepoint_name TRANSACTION
SAVEPOINT TRANSACTION savepoint_name
-- 回滚到保存点(可省略 TO SAVEPOINT,写成 ROLLBACK name)
ROLLBACK [ WORK | TRANSACTION ] TO [ SAVEPOINT ] savepoint_name
ROLLBACK [ WORK | TRANSACTION ] TO savepoint_name
-- 释放(销毁)保存点
RELEASE [ SAVEPOINT ] savepoint_name
RELEASE savepoint_name TRANSACTION
RELEASE TRANSACTION savepoint_name最简可运行示例:
BEGIN;
INSERT INTO accounts (id, balance) VALUES (3, 999.00);
SAVEPOINT sp_after_insert;
-- 这一句会失败(主键冲突)
INSERT INTO accounts (id, balance) VALUES (3, 888.00);
-- 只撤销失败的那一句,事务继续
ROLLBACK TO SAVEPOINT sp_after_insert;
-- 事务还可以正常提交
COMMIT;BEGIN
INSERT 0 1
SAVEPOINT
ERROR: duplicate key value violates unique constraint "accounts_pkey"
DETAIL: Key (id)=(3) already exists.
ROLLBACK
COMMIT对比:如果不使用保存点,冲突之后的任何语句都会报 25P02,整个事务只能被丢弃:
BEGIN;
INSERT INTO accounts (id, balance) VALUES (3, 999.00);
INSERT INTO accounts (id, balance) VALUES (3, 888.00); -- 失败
-- 错误!事务已 aborted,这一句也会失败
UPDATE accounts SET balance = 0 WHERE id = 3;BEGIN
INSERT 0 1
ERROR: duplicate key value violates unique constraint "accounts_pkey"
DETAIL: Key (id)=(3) already exists.
ERROR: current transaction is aborted, commands ignored until end of transaction block详细说明
保存点是栈式的:后进先出,同名覆盖
保存点按定义顺序构成一个栈,ROLLBACK TO SAVEPOINT 会同时撤销所有在它之后定义的保存点(因为那些点位于被撤销的区域内),被覆盖的保存点也会一并失效:
BEGIN;
INSERT INTO accounts (id, balance) VALUES (101, 1.00);
SAVEPOINT sp1;
INSERT INTO accounts (id, balance) VALUES (102, 2.00);
SAVEPOINT sp2;
INSERT INTO accounts (id, balance) VALUES (103, 3.00);
-- 回滚到 sp1:sp2 也被一起撤销,103 与 102 都消失
ROLLBACK TO SAVEPOINT sp1;
SELECT id FROM accounts WHERE id IN (101, 102, 103) ORDER BY id;BEGIN
INSERT 0 1
SAVEPOINT
INSERT 0 1
SAVEPOINT
INSERT 0 1
ROLLBACK
id
-----
101
(1 row)注意 ROLLBACK TO SAVEPOINT sp1 之后,sp2 不再存在(它被回滚区间覆盖掉了)。此时再执行 RELEASE SAVEPOINT sp2 会报错:
-- 错误!sp2 已在回滚 sp1 时被销毁
RELEASE SAVEPOINT sp2;ERROR: savepoint "sp2" does not exist另一种情况是主动定义同名保存点:PostgreSQL 不报错,而是直接让新定义取代旧定义,旧保存点之后的所有变更都归属到新保存点名下了:
BEGIN;
INSERT INTO accounts (id, balance) VALUES (201, 1.00);
SAVEPOINT sp;
INSERT INTO accounts (id, balance) VALUES (202, 2.00);
SAVEPOINT sp; -- 同名覆盖,旧的 sp 不再是回滚目标
INSERT INTO accounts (id, balance) VALUES (203, 3.00);
-- 回滚到的是「第二个 sp」,201 与 202 都会保留
ROLLBACK TO SAVEPOINT sp;
SELECT id FROM accounts WHERE id IN (201, 202, 203) ORDER BY id;BEGIN
INSERT 0 1
SAVEPOINT
INSERT 0 1
SAVEPOINT
INSERT 0 1
ROLLBACK
id
-----
201
202
(2 rows)这是一个容易被忽略的语义:在循环里反复用同一个保存点名(例如循环体内固定 SAVEPOINT sp_data),每次迭代都会覆盖上一次的保存点。多数场景这正是想要的效果(每轮迭代可独立回滚),但要清楚它意味着「回滚粒度是当前这轮,不是第一轮」:
-- 循环中复用同名保存点:每轮都可独立回滚
BEGIN;
SAVEPOINT sp_data;
INSERT INTO accounts (id, balance) VALUES (301, 1.00);
SAVEPOINT sp_data; -- 覆盖
INSERT INTO accounts (id, balance) VALUES (302, 2.00);
ROLLBACK TO SAVEPOINT sp_data; -- 只撤销 302
COMMIT; -- 301 保留ROLLBACK TO SAVEPOINT 不会销毁保存点
与很多人的直觉相反,回滚到某个保存点之后,该保存点依然存在,你可以在同一个事务里反复回滚到它:
BEGIN;
INSERT INTO accounts (id, balance) VALUES (401, 1.00);
SAVEPOINT sp;
INSERT INTO accounts (id, balance) VALUES (402, 2.00);
ROLLBACK TO SAVEPOINT sp; -- 第一次回滚,402 消失
INSERT INTO accounts (id, balance) VALUES (403, 3.00);
ROLLBACK TO SAVEPOINT sp; -- 再回滚一次,403 也消失
INSERT INTO accounts (id, balance) VALUES (404, 4.00);
COMMIT; -- 401 与 404 提交BEGIN
INSERT 0 1
SAVEPOINT
INSERT 0 1
ROLLBACK
INSERT 0 1
ROLLBACK
INSERT 0 1
COMMIT这个「可重复回滚」的特性在重试型逻辑里很有用:每轮尝试失败就 ROLLBACK TO SAVEPOINT sp 回到干净状态,再重试一次,而不必重开事务。
BEGIN;
SAVEPOINT sp_attempt;
-- 第一轮尝试(失败)
INSERT INTO accounts (id, balance) VALUES (501, -1.00);
-- 失败后回到保存点,换成合法数据重试,事务并未作废
ROLLBACK TO SAVEPOINT sp_attempt;
INSERT INTO accounts (id, balance) VALUES (501, 1.00); -- 这次合法
COMMIT;BEGIN
SAVEPOINT
ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (501, -1.00).
ROLLBACK
INSERT 0 1
COMMITRELEASE SAVEPOINT:合并而不是撤销
RELEASE SAVEPOINT 的作用是销毁保存点但保留其数据变更。它常用于「这一段确认成功了,不需要再回滚」的场景,同时也能帮助 PostgreSQL 复用内部资源:
BEGIN;
SAVEPOINT sp_batch;
INSERT INTO accounts (id, balance) VALUES (601, 1.00);
INSERT INTO accounts (id, balance) VALUES (602, 2.00);
-- 这两行确认无误,释放保存点(数据保留)
RELEASE SAVEPOINT sp_batch;
-- 错误!释放后不能再用它回滚
ROLLBACK TO SAVEPOINT sp_batch;
-- 但数据仍在事务里,最终 COMMIT 时一并生效
SELECT id FROM accounts WHERE id IN (601, 602) ORDER BY id;
COMMIT;BEGIN
SAVEPOINT
INSERT 0 1
INSERT 0 1
RELEASE
ERROR: savepoint "sp_batch" does not exist
id
-----
601
602
(2 rows)
COMMITRELEASE 的另一个价值是及时释放被子事务占用的资源:在一个大事务里创建成千上万个子事务后,及时 RELEASE 有助于控制内存与锁表规模(后文「开销」一节详述)。
保存点的核心用途:把「出错就全盘回滚」变成「局部容错」
没有保存点时,事务内任何一句报错都会让整个事务作废。保存点把「语句级容错」还给了你:
BEGIN;
UPDATE accounts SET balance = balance - 100.00 WHERE owner = 'Alice';
SAVEPOINT sp_audit; -- 审计写入是「尽力而为」,失败不应影响转账
INSERT INTO audit_log (action, detail) VALUES ('transfer', 'Alice -100');
-- 假设 audit_log 写入失败(例如表被锁或约束冲突)
ROLLBACK TO SAVEPOINT sp_audit;
UPDATE accounts SET balance = balance + 100.00 WHERE owner = 'Bob';
COMMIT; -- 转账成功,只是没有审计记录BEGIN
UPDATE 1
SAVEPOINT
INSERT 0 1
ROLLBACK
UPDATE 1
COMMIT这里的语义正是业务想要的:主流程必须成功,辅助流程(审计、埋点、通知记录)失败可降级。若不用保存点,审计写失败会把已经完成的扣款一并拖垮。
不要把保存点当成「绕过约束」
保存点容错只适合「非关键步骤可选」的场景。核心业务数据完整性必须靠约束保证,而不是靠「失败就回滚这一步」来掩盖。否则会写出「数据看起来对、其实少了一半」的系统。
子事务(subtransaction)与 XID:内部机制
每个 SAVEPOINT 在 PostgreSQL 内部都会开启一个子事务。子事务有自己的 subxid(子事务 ID),它会从同一个 XID 空间分配编号。顶层事务的 XID 是惰性分配的:只读时不存在,一旦有写入才分配:
BEGIN;
-- 顶层事务还没有 XID(还没写过数据)
SELECT pg_current_xact_id_if_assigned() AS top_xid;
INSERT INTO accounts (id, balance) VALUES (701, 1.00);
SELECT pg_current_xact_id() AS top_xid; -- 现在分配了
SAVEPOINT sp1;
INSERT INTO accounts (id, balance) VALUES (702, 2.00); -- 这是子事务的变更
SELECT pg_current_xact_id() AS xid; -- 仍是顶层 XID
COMMIT;BEGIN
top_xid
---------
(1 row)
INSERT 0 1
top_xid
---------
1234600
(1 row)
SAVEPOINT
INSERT 0 1
xid
---------
1234600
(1 row)
COMMIT只有 COMMIT 时才把顶层 XID 标记为已提交,子事务的变更随之一起生效;ROLLBACK TO SAVEPOINT 则只把对应子事务标记为中止,其写入对其它事务仍不可见(MVCC 快照天然隔离未提交数据,见 可见性规则)。
顺带记住 pg_current_xact_id_if_assigned() 与 pg_current_xact_id() 的差别:前者在事务尚未分配 XID 时返回 NULL 而不强制分配,后者则会在需要时分配一个 XID。因此上例第一个 SELECT 用 if_assigned 看到 NULL,第二个 SELECT 用无后缀版本才拿到真实 XID。
保存点的开销:子事务不是免费的
每个活跃子事务都会占用内存中的子事务条目、一个子事务 XID(从同一个 XID 空间消耗编号,加速 XID 环绕)、锁表条目(事务持有的锁在子事务回滚时不会释放),以及 pg_subtrans / 提交日志(Commit Log)中的状态记录。因此在一个循环里创建十万级保存点会明显变慢:
-- 不推荐:百万行循环中每行都建保存点
BEGIN;
DO $$
BEGIN
FOR i IN 1..1000000 LOOP
BEGIN
INSERT INTO big_table (id, payload) VALUES (i, 'x');
EXCEPTION WHEN unique_violation THEN
NULL; -- 每次异常处理都会创建子事务
END;
END LOOP;
END $$;
COMMIT;每个 BEGIN ... EXCEPTION 块都对应一个隐式保存点,循环一百万次就意味着上百万个子事务。观察子事务数量需要借助 pg_stat_database 或日志,更实用的信号是:同样的数据量,加了异常处理的导入明显变慢。
子事务「溢出」
当一个事务内的子事务数量超过阈值(内部实现细节,与缓存大小相关)时,PostgreSQL 会退化为逐个子事务查询 pg_subtrans,性能急剧下降。这是「批量导入加异常处理」模式最典型的性能陷阱。
PL/pgSQL 的 BEGIN ... EXCEPTION 就是保存点
在 PL/pgSQL 里写异常处理块时,PostgreSQL 会自动创建保存点。EXCEPTION 块只回滚块内的变更(相当于 ROLLBACK TO SAVEPOINT),块外的外层事务不受影响,因此异常处理天然带有保存点开销。
DO $$
BEGIN
INSERT INTO accounts (id, balance) VALUES (801, 1.00);
INSERT INTO accounts (id, balance) VALUES (801, 2.00); -- 冲突
EXCEPTION
WHEN unique_violation THEN
RAISE NOTICE '重复键,已忽略';
END $$;
-- 更高效的写法:用 ON CONFLICT 替代异常处理,避免子事务
INSERT INTO accounts (id, balance)
SELECT g, 1.00 FROM generate_series(1, 10000) AS g
ON CONFLICT (id) DO NOTHING;批量导入:跳过坏数据的容错模式
把外部数据导入数据库时,「遇到一行坏数据就整批失败」通常不可接受。保存点(或 PL/pgSQL 异常处理)让你能逐行跳过坏数据:
CREATE TABLE import_accounts (
id bigint PRIMARY KEY,
owner text NOT NULL,
balance numeric(12,2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO import_accounts (id, owner, balance) VALUES
(1, 'Alice', 100.00),
(2, 'Bob', -50.00), -- 坏数据:余额为负
(3, 'Carol', 200.00),
(3, 'Dave', 10.00); -- 坏数据:主键冲突CREATE TABLE
ERROR: new row for relation "import_accounts" violates check constraint "import_accounts_balance_check"
DETAIL: Failing row contains (2, Bob, -50.00).一条 INSERT ... VALUES 只要有一行坏数据,整条语句都会失败并回滚(这是语句级原子性)。要逐行容错,需要逐行插入并配合保存点:
BEGIN;
SAVEPOINT sp_row;
INSERT INTO import_accounts (id, owner, balance) VALUES (1, 'Alice', 100.00);
RELEASE SAVEPOINT sp_row;
SAVEPOINT sp_row;
INSERT INTO import_accounts (id, owner, balance) VALUES (2, 'Bob', -50.00);
ROLLBACK TO SAVEPOINT sp_row; -- 跳过 Bob 这个坏行
SAVEPOINT sp_row;
INSERT INTO import_accounts (id, owner, balance) VALUES (3, 'Carol', 200.00);
RELEASE SAVEPOINT sp_row;
SAVEPOINT sp_row;
INSERT INTO import_accounts (id, owner, balance) VALUES (3, 'Dave', 10.00);
ROLLBACK TO SAVEPOINT sp_row; -- 主键冲突,跳过 Dave
COMMIT;BEGIN
SAVEPOINT
INSERT 0 1
RELEASE
SAVEPOINT
ERROR: new row for relation "import_accounts" violates check constraint "import_accounts_balance_check"
DETAIL: Failing row contains (2, Bob, -50.00).
ROLLBACK
SAVEPOINT
INSERT 0 1
RELEASE
SAVEPOINT
ERROR: duplicate key value violates unique constraint "import_accounts_pkey"
DETAIL: Key (id)=(3) already exists.
ROLLBACK
COMMIT最终只有合法行进入了表,且整个导入仍是原子的——要么全部合法行都进去,要么一行都不进:
SELECT id, owner, balance FROM import_accounts ORDER BY id; id | owner | balance
----+-------+---------
1 | Alice | 100.00
3 | Carol | 200.00
(2 rows)在真实工具链里,这个「逐行保存点 + 记录失败行」模式通常写成 PL/pgSQL 函数:循环里每个 BEGIN ... EXCEPTION 都会隐式创建保存点,捕获异常后把坏行写入一张 import_errors 表,以便人工修复后重放(代码示例见下文「最佳实践」)。
保存点与锁:回滚不释放已经拿到的锁
一个常被误解的点:ROLLBACK TO SAVEPOINT 不会释放该子事务期间获得的锁,锁要等到整个事务结束(COMMIT / ROLLBACK)才释放。
会话 A:
BEGIN;
SAVEPOINT sp;
UPDATE accounts SET balance = balance - 10 WHERE id = 1; -- 拿到 id=1 的行锁
ROLLBACK TO SAVEPOINT sp; -- 行锁不会释放
BEGIN
SAVEPOINT
UPDATE 1
ROLLBACK
会话 B(被阻塞,直到会话 A 结束整个事务):
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
-- 等待中,因为 id=1 的行锁仍被会话 A 持有这意味着保存点不能用来「缩短持锁时间」。如果目标是尽快释放锁,唯一的办法是尽快结束整个事务:
-- 正确做法:提交(或回滚)整个事务来释放锁
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
COMMIT; -- 锁在这里释放BEGIN
UPDATE 1
COMMIT实战示例
场景:一个订单批量审核脚本。每条订单的审核包含三个动作:更新订单状态、写审核记录、写积分流水。要求:
- 单条订单处理失败不影响其他订单;
- 整个批次要么全部生效,要么全部不生效(原子性);
- 失败的订单要记录原因,便于重放。
第 1 步:建表并准备数据
DROP TABLE IF EXISTS orders, order_audit, order_points, audit_failures CASCADE;
CREATE TABLE orders (
id bigint PRIMARY KEY,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected'))
);
CREATE TABLE order_audit (
order_id bigint PRIMARY KEY REFERENCES orders (id),
action text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_points (
order_id bigint PRIMARY KEY REFERENCES orders (id),
points integer NOT NULL CHECK (points >= 0)
);
CREATE TABLE audit_failures (
order_id bigint,
reason text,
failed_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (id, status) VALUES
(1, 'pending'), (2, 'pending'), (3, 'pending'), (4, 'approved');
-- 订单 4 已有审核记录,稍后会触发主键冲突
INSERT INTO order_audit (order_id, action) VALUES (4, 'approved');DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 1第 2 步:用保存点逐条审核
BEGIN;
-- 订单 1:正常
SAVEPOINT sp_order;
UPDATE orders SET status = 'approved' WHERE id = 1;
INSERT INTO order_audit (order_id, action) VALUES (1, 'approved');
INSERT INTO order_points (order_id, points) VALUES (1, 100);
RELEASE SAVEPOINT sp_order;
-- 订单 2:积分写入失败,整条订单回滚
SAVEPOINT sp_order;
UPDATE orders SET status = 'approved' WHERE id = 2;
INSERT INTO order_audit (order_id, action) VALUES (2, 'approved');
INSERT INTO order_points (order_id, points) VALUES (2, -100); -- 违反 CHECKBEGIN
SAVEPOINT
UPDATE 1
INSERT 0 1
INSERT 0 1
RELEASE
SAVEPOINT
UPDATE 1
INSERT 0 1
ERROR: new row for relation "order_points" violates check constraint "order_points_points_check"
DETAIL: Failing row contains (2, -100).此时订单 2 的状态更新与审核记录都被临时写入,但因为处在同一个子事务里,ROLLBACK TO SAVEPOINT 会把它们一起撤销:
ROLLBACK TO SAVEPOINT sp_order;
-- 记录失败原因
INSERT INTO audit_failures (order_id, reason) VALUES (2, 'points check violation');
SELECT id, status FROM orders WHERE id = 2;ROLLBACK
INSERT 0 1
id | status
----+---------
2 | pending
(1 row)订单 2 回到 pending——子事务的局部原子性生效了。
第 3 步:继续处理后续订单并验证结果
-- 订单 3:正常
SAVEPOINT sp_order;
UPDATE orders SET status = 'approved' WHERE id = 3;
INSERT INTO order_audit (order_id, action) VALUES (3, 'approved');
INSERT INTO order_points (order_id, points) VALUES (3, 300);
RELEASE SAVEPOINT sp_order;
-- 订单 4:审核记录已存在,触发主键冲突
SAVEPOINT sp_order;
UPDATE orders SET status = 'approved' WHERE id = 4;
INSERT INTO order_audit (order_id, action) VALUES (4, 'approved');SAVEPOINT
UPDATE 1
INSERT 0 1
INSERT 0 1
RELEASE
SAVEPOINT
UPDATE 1
ERROR: duplicate key value violates unique constraint "order_audit_pkey"
DETAIL: Key (order_id)=(4) already exists.回滚该子事务、记录失败原因并提交:
ROLLBACK TO SAVEPOINT sp_order;
INSERT INTO audit_failures (order_id, reason) VALUES (4, 'duplicate audit record');
COMMIT;ROLLBACK
INSERT 0 1
COMMIT最终结果如下:
SELECT id, status FROM orders ORDER BY id;
SELECT order_id, action FROM order_audit ORDER BY order_id;
SELECT order_id, points FROM order_points ORDER BY order_id;
SELECT order_id, reason FROM audit_failures ORDER BY order_id; id | status
----+----------
1 | approved
2 | pending
3 | approved
4 | approved
(4 rows)
order_id | action
----------+----------
1 | approved
3 | approved
4 | approved
(3 rows)
order_id | points
----------+--------
1 | 100
3 | 300
(2 rows)
order_id | reason
----------+--------------------------
2 | points check violation
4 | duplicate audit record
(2 rows)注意订单 4:因为它的审核记录早已存在,子事务回滚把「状态更新」也撤销了,所以它仍是 approved(原本就是),但没有产生新的审核记录,也没有积分流水——这就是「子事务整体回滚」的效果。
第 4 步:清理
DROP TABLE orders, order_audit, order_points, audit_failures;DROP TABLE注意事项
SAVEPOINT只能在事务块内使用。 没有BEGIN时执行会报 SQLSTATE25P01(no_active_sql_transaction)。
-- 错误!SAVEPOINT can only be used in transaction blocks
SAVEPOINT sp;ROLLBACK TO SAVEPOINT不结束事务,也销毁不了保存点本身。 真正想让保存点消失要用RELEASE;想结束事务要用COMMIT/ROLLBACK。
-- 回滚到保存点后事务仍在进行
BEGIN;
SAVEPOINT sp;
ROLLBACK TO SAVEPOINT sp;
-- 这里仍处于事务中,提示符是 *
COMMIT;- 同名保存点会静默覆盖,不报错。 在循环里复用同名保存点时要清楚回滚粒度是「最近一次定义」;同理
ROLLBACK TO SAVEPOINT不释放已消耗的序列值,保存点回滚与序列回滚是两回事(见 BEGIN / COMMIT / ROLLBACK)。
-- 不推荐:以为回滚到的是第一个 sp
BEGIN;
SAVEPOINT sp;
INSERT INTO orders (id) VALUES (901);
SAVEPOINT sp; -- 覆盖了上一个
INSERT INTO orders (id) VALUES (902);
ROLLBACK TO SAVEPOINT sp; -- 只撤销 902
COMMIT;
-- 序列不回退
BEGIN;
SAVEPOINT sp;
INSERT INTO orders (id) VALUES (nextval('some_seq')); -- 序列已前进
ROLLBACK TO SAVEPOINT sp; -- 序列不回退
COMMIT;- 保存点不释放锁。 不要指望用「建保存点 → 做完 → 回滚到保存点」来提前归还行锁或表锁,锁只在事务结束时释放;同时
RELEASE SAVEPOINT不能撤销数据,它是「保留变更 + 销毁书签」,想撤销要用ROLLBACK TO SAVEPOINT。
-- 错误认知:以为回滚到保存点就释放了锁
BEGIN;
SAVEPOINT sp;
UPDATE orders SET status = 'approved' WHERE id = 1; -- 持锁
ROLLBACK TO SAVEPOINT sp; -- 锁仍在
COMMIT; -- 此时才释放
-- 错误认知:以为 RELEASE 会撤销变更
BEGIN;
SAVEPOINT sp;
INSERT INTO orders (id) VALUES (902);
RELEASE SAVEPOINT sp; -- 数据仍保留,最终会提交
COMMIT;- 大量保存点会造成子事务溢出,性能急剧下降。 热循环里避免每行都建保存点,优先用
ON CONFLICT DO NOTHING、INSERT ... SELECT ... WHERE NOT EXISTS等集合式写法。
-- 不推荐:百万次循环,每次一个隐式保存点
DO $$ BEGIN
FOR i IN 1..1000000 LOOP
BEGIN
INSERT INTO t (id) VALUES (i);
EXCEPTION WHEN others THEN NULL;
END;
END LOOP;
END $$;最佳实践
- 保存点用于「可选步骤」与「逐行容错」,不要用它替代数据库约束或掩盖数据一致性问题。
BEGIN;
-- 主流程:必须成功
UPDATE accounts SET balance = balance - 100 WHERE owner = 'Alice';
-- 可选步骤:失败可降级
SAVEPOINT sp_optional;
INSERT INTO audit_log (action) VALUES ('transfer');
ROLLBACK TO SAVEPOINT sp_optional;
COMMIT;- 在循环里优先用集合式语句而非逐行保存点,这是避免子事务溢出的第一原则。
-- 推荐:一条语句搞定,不产生子事务
INSERT INTO target (id, payload)
SELECT s.id, s.payload
FROM staging s
ON CONFLICT (id) DO NOTHING;- 确需逐行容错时,把失败行落到专门的错误表,而不是默默吞掉异常;成功的小批次则及时
RELEASE SAVEPOINT,帮助 PostgreSQL 复用内部资源。
DO $$
DECLARE r record;
BEGIN
FOR r IN SELECT * FROM staging LOOP
BEGIN
INSERT INTO target (id, payload) VALUES (r.id, r.payload);
EXCEPTION WHEN others THEN
INSERT INTO import_errors (row_data, reason) VALUES (r::text, SQLERRM);
END;
END LOOP;
END $$;
-- 成功批次及时释放
BEGIN;
SAVEPOINT sp_batch;
-- ... 一批插入 ...
RELEASE SAVEPOINT sp_batch; -- 确认成功,释放
COMMIT;- 把网络调用、文件 IO、日志上报等慢操作挪出事务,保存点无法解决长事务持锁问题;重试型逻辑则用「保存点回到干净状态」而非重开事务,避免反复建立连接与重复前置查询。
-- 推荐:事务内只做数据库操作
BEGIN;
SAVEPOINT sp;
UPDATE ...;
RELEASE SAVEPOINT sp;
COMMIT;
-- 事务外再发通知
-- 重试用保存点回到干净状态
BEGIN;
SAVEPOINT sp_try;
-- 尝试一:失败
ROLLBACK TO SAVEPOINT sp_try;
-- 尝试二:换参数重试
COMMIT;下一节
继续学习:事务的 ACID 特性