Skip to content

SAVEPOINT 保存点 ​

保存点(SAVEPOINT)是事务内部的「书签」:它让你在事务执行到一半时打一个标记,之后可以只回滚到这个标记为止,而不必放弃整个事务。这是 PostgreSQL 里实现「局部回滚」「错误恢复」「批量导入容错」的核心手段。本节讲清保存点的三条命令、栈式语义、同名覆盖、子事务(subtransaction)内部机制,以及在 PL/pgSQL 与批量导入中的典型用法。

前置知识

阅读本节前,建议先了解:BEGIN / COMMIT / ROLLBACK、MVCC 概述

基础概念 ​

一个事务在 PostgreSQL 内部并不是「一坨不可分割的黑盒」,它可以被保存点切分成若干段:

text
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
大量保存点会消耗资源子事务过多会带来性能问题(子事务溢出)
回滚到保存点不释放已持有的锁锁在整个事务结束时才释放

语法与用法 ​

text
-- 建立保存点
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

最简可运行示例:

sql
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;
text
BEGIN
INSERT 0 1
SAVEPOINT
ERROR:  duplicate key value violates unique constraint "accounts_pkey"
DETAIL:  Key (id)=(3) already exists.
ROLLBACK
COMMIT

对比:如果不使用保存点,冲突之后的任何语句都会报 25P02,整个事务只能被丢弃:

sql
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;
text
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 会同时撤销所有在它之后定义的保存点(因为那些点位于被撤销的区域内),被覆盖的保存点也会一并失效:

sql
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;
text
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 会报错:

sql
-- 错误!sp2 已在回滚 sp1 时被销毁
RELEASE SAVEPOINT sp2;
text
ERROR:  savepoint "sp2" does not exist

另一种情况是主动定义同名保存点:PostgreSQL 不报错,而是直接让新定义取代旧定义,旧保存点之后的所有变更都归属到新保存点名下了:

sql
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;
text
BEGIN
INSERT 0 1
SAVEPOINT
INSERT 0 1
SAVEPOINT
INSERT 0 1
ROLLBACK
 id
-----
 201
 202
(2 rows)

这是一个容易被忽略的语义:在循环里反复用同一个保存点名(例如循环体内固定 SAVEPOINT sp_data),每次迭代都会覆盖上一次的保存点。多数场景这正是想要的效果(每轮迭代可独立回滚),但要清楚它意味着「回滚粒度是当前这轮,不是第一轮」:

sql
-- 循环中复用同名保存点:每轮都可独立回滚
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 不会销毁保存点 ​

与很多人的直觉相反,回滚到某个保存点之后,该保存点依然存在,你可以在同一个事务里反复回滚到它:

sql
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 提交
text
BEGIN
INSERT 0 1
SAVEPOINT
INSERT 0 1
ROLLBACK
INSERT 0 1
ROLLBACK
INSERT 0 1
COMMIT

这个「可重复回滚」的特性在重试型逻辑里很有用:每轮尝试失败就 ROLLBACK TO SAVEPOINT sp 回到干净状态,再重试一次,而不必重开事务。

sql
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;
text
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
COMMIT

RELEASE SAVEPOINT:合并而不是撤销 ​

RELEASE SAVEPOINT 的作用是销毁保存点但保留其数据变更。它常用于「这一段确认成功了,不需要再回滚」的场景,同时也能帮助 PostgreSQL 复用内部资源:

sql
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;
text
BEGIN
SAVEPOINT
INSERT 0 1
INSERT 0 1
RELEASE
ERROR:  savepoint "sp_batch" does not exist
 id
-----
 601
 602
(2 rows)

COMMIT

RELEASE 的另一个价值是及时释放被子事务占用的资源:在一个大事务里创建成千上万个子事务后,及时 RELEASE 有助于控制内存与锁表规模(后文「开销」一节详述)。

保存点的核心用途:把「出错就全盘回滚」变成「局部容错」 ​

没有保存点时,事务内任何一句报错都会让整个事务作废。保存点把「语句级容错」还给了你:

sql
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;   -- 转账成功,只是没有审计记录
text
BEGIN
UPDATE 1
SAVEPOINT
INSERT 0 1
ROLLBACK
UPDATE 1
COMMIT

这里的语义正是业务想要的:主流程必须成功,辅助流程(审计、埋点、通知记录)失败可降级。若不用保存点,审计写失败会把已经完成的扣款一并拖垮。

不要把保存点当成「绕过约束」

保存点容错只适合「非关键步骤可选」的场景。核心业务数据完整性必须靠约束保证,而不是靠「失败就回滚这一步」来掩盖。否则会写出「数据看起来对、其实少了一半」的系统。

子事务(subtransaction)与 XID:内部机制 ​

每个 SAVEPOINT 在 PostgreSQL 内部都会开启一个子事务。子事务有自己的 subxid(子事务 ID),它会从同一个 XID 空间分配编号。顶层事务的 XID 是惰性分配的:只读时不存在,一旦有写入才分配:

sql
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;
text
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)中的状态记录。因此在一个循环里创建十万级保存点会明显变慢:

sql
-- 不推荐:百万行循环中每行都建保存点
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),块外的外层事务不受影响,因此异常处理天然带有保存点开销。

sql
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 异常处理)让你能逐行跳过坏数据:

sql
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);    -- 坏数据:主键冲突
text
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 只要有一行坏数据,整条语句都会失败并回滚(这是语句级原子性)。要逐行容错,需要逐行插入并配合保存点:

sql
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;
text
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

最终只有合法行进入了表,且整个导入仍是原子的——要么全部合法行都进去,要么一行都不进:

sql
SELECT id, owner, balance FROM import_accounts ORDER BY id;
text
 id | owner | balance
----+-------+---------
  1 | Alice |  100.00
  3 | Carol |  200.00
(2 rows)

在真实工具链里,这个「逐行保存点 + 记录失败行」模式通常写成 PL/pgSQL 函数:循环里每个 BEGIN ... EXCEPTION 都会隐式创建保存点,捕获异常后把坏行写入一张 import_errors 表,以便人工修复后重放(代码示例见下文「最佳实践」)。

保存点与锁:回滚不释放已经拿到的锁 ​

一个常被误解的点:ROLLBACK TO SAVEPOINT 不会释放该子事务期间获得的锁,锁要等到整个事务结束(COMMIT / ROLLBACK)才释放。

text
会话 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 持有

这意味着保存点不能用来「缩短持锁时间」。如果目标是尽快释放锁,唯一的办法是尽快结束整个事务:

sql
-- 正确做法:提交(或回滚)整个事务来释放锁
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
COMMIT;   -- 锁在这里释放
text
BEGIN
UPDATE 1
COMMIT

实战示例 ​

场景:一个订单批量审核脚本。每条订单的审核包含三个动作:更新订单状态、写审核记录、写积分流水。要求:

  • 单条订单处理失败不影响其他订单;
  • 整个批次要么全部生效,要么全部不生效(原子性);
  • 失败的订单要记录原因,便于重放。

第 1 步:建表并准备数据

sql
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');
text
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 1

第 2 步:用保存点逐条审核

sql
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);   -- 违反 CHECK
text
BEGIN
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 会把它们一起撤销:

sql
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;
text
ROLLBACK
INSERT 0 1
 id | status
----+---------
  2 | pending
(1 row)

订单 2 回到 pending——子事务的局部原子性生效了。

第 3 步:继续处理后续订单并验证结果

sql
-- 订单 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');
text
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.

回滚该子事务、记录失败原因并提交:

sql
ROLLBACK TO SAVEPOINT sp_order;
INSERT INTO audit_failures (order_id, reason) VALUES (4, 'duplicate audit record');
COMMIT;
text
ROLLBACK
INSERT 0 1
COMMIT

最终结果如下:

sql
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;
text
 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 步:清理

sql
DROP TABLE orders, order_audit, order_points, audit_failures;
text
DROP TABLE

注意事项 ​

  • SAVEPOINT 只能在事务块内使用。 没有 BEGIN 时执行会报 SQLSTATE 25P01(no_active_sql_transaction)。
sql
-- 错误!SAVEPOINT can only be used in transaction blocks
SAVEPOINT sp;
  • ROLLBACK TO SAVEPOINT 不结束事务,也销毁不了保存点本身。 真正想让保存点消失要用 RELEASE;想结束事务要用 COMMIT / ROLLBACK。
sql
-- 回滚到保存点后事务仍在进行
BEGIN;
SAVEPOINT sp;
ROLLBACK TO SAVEPOINT sp;
-- 这里仍处于事务中,提示符是 *
COMMIT;
  • 同名保存点会静默覆盖,不报错。 在循环里复用同名保存点时要清楚回滚粒度是「最近一次定义」;同理 ROLLBACK TO SAVEPOINT 不释放已消耗的序列值,保存点回滚与序列回滚是两回事(见 BEGIN / COMMIT / ROLLBACK)。
sql
-- 不推荐:以为回滚到的是第一个 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。
sql
-- 错误认知:以为回滚到保存点就释放了锁
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 等集合式写法。
sql
-- 不推荐:百万次循环,每次一个隐式保存点
DO $$ BEGIN
  FOR i IN 1..1000000 LOOP
    BEGIN
      INSERT INTO t (id) VALUES (i);
    EXCEPTION WHEN others THEN NULL;
    END;
  END LOOP;
END $$;

最佳实践 ​

  • 保存点用于「可选步骤」与「逐行容错」,不要用它替代数据库约束或掩盖数据一致性问题。
sql
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;
  • 在循环里优先用集合式语句而非逐行保存点,这是避免子事务溢出的第一原则。
sql
-- 推荐:一条语句搞定,不产生子事务
INSERT INTO target (id, payload)
SELECT s.id, s.payload
FROM   staging s
ON CONFLICT (id) DO NOTHING;
  • 确需逐行容错时,把失败行落到专门的错误表,而不是默默吞掉异常;成功的小批次则及时 RELEASE SAVEPOINT,帮助 PostgreSQL 复用内部资源。
sql
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、日志上报等慢操作挪出事务,保存点无法解决长事务持锁问题;重试型逻辑则用「保存点回到干净状态」而非重开事务,避免反复建立连接与重复前置查询。
sql
-- 推荐:事务内只做数据库操作
BEGIN;
SAVEPOINT sp;
UPDATE ...;
RELEASE SAVEPOINT sp;
COMMIT;
-- 事务外再发通知

-- 重试用保存点回到干净状态
BEGIN;
SAVEPOINT sp_try;
-- 尝试一:失败
ROLLBACK TO SAVEPOINT sp_try;
-- 尝试二:换参数重试
COMMIT;

下一节

继续学习:事务的 ACID 特性

参考链接 ​