news 2026/10/18 20:46:21

PostgreSQL事务核心机制:提交、回滚与保存点实战详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL事务核心机制:提交、回滚与保存点实战详解

上周帮一个朋友排查线上问题,场景特别常见:订单表里已经写了扣库存记录,但订单状态却显示创建失败。折腾了一下午,最后发现根因就一句话——程序里开启事务的代码写错了分支,扣库存那条SQL根本没有被包进同一个事务里。这种问题在企业项目里太典型了,十次有八次绕不开 PostgreSQL 中事务的提交、回滚与保存点这三个操作。

今天就把这块掰开揉碎讲清楚。适合刚上手PostgreSQL的开发和运维,也适合整天写SQL但没仔细研究过事务细节的老手。文章不会扯虚的,每一步都给命令、给现象、给原因,看完你至少能回答三个灵魂拷问:事务到底是怎么提交的?回滚是不是真的把数据"撤回去"了?保存点到底能救回多少操作?

1. 先搞清楚事务解决什么问题,再谈提交和回滚

1.1 一个典型的丢事务事故:库存扣了,订单没建

很多刚接触事务的同学,最开始都是被"原子性"这个词教育的,但实际项目里踩坑的方式远比教科书精彩。我见过最经典的一个案例是这样的:一个下单接口,先更新库存表,再插入订单表。

// 错误示范:两条SQL各自处于独立事务 jdbc.execute("UPDATE t_inventory SET stock = stock - 1 WHERE sku_id = 123"); jdbc.execute("INSERT INTO t_order(user_id, sku_id, amount) VALUES (1, 123, 99.00)");

这段代码跑起来,绝大多数时候是好的。但只要第二条SQL因为字段超长、非空约束、主键冲突之类的原因抛异常,库存就已经被扣掉了,订单却不存在。用户付了钱没订单,后台对账怎么都对不上。为这种问题背锅的DBA和程序员,我认识不止一个。

更隐蔽的坑在事务边界上。Java里面用JDBC的时候,很多人知道要setAutoCommit(false),但异常分支里忘了写rollback(),直接return了。这时候连接没有提交也没有回滚,被丢回连接池。下一次从连接池拿到这个连接的程序,会"继承"上一次那个未提交的事务,接着执行新的SQL,然后commit。那会发生什么?上一次的UPDATE和这一次的UPDATE被莫名其妙地合并到了一个事务里,该回滚的没回滚,不该提交的可能被提交了。等到线上出现脏数据、锁等待,查半天才能翻出这种陈年旧账。

事务最大的价值,就是把一系列操作绑成"一个整体":要么全部成功,要么全部失败。扣库存和建订单必须同生共死,就像你去商场刷卡买手机,POS机扣款成功但小票打印失败,店员一定不会让你直接走人,而是要把这笔交易作废,重新走一遍完整流程。

1.2 ACID里容易被忽略的两件事:一致性与持久性

教科书上讲事务四大特性ACID,原子性、一致性、隔离性、持久性。前两个句子谁都背得出来,但有两件事经常被忽视。

第一,一致性不是数据库单方面保证的。数据库能保证约束、主键、外键这些,但"余额不能为负""库存不能超卖"这类业务规则,数据库如果没配CHECK约束,就得靠事务里的逻辑来保证。你可以在一个事务里先判断后更新,也可以让事务在A和B两个账户间转账时保证A减多少B就加多少。事务只是给你这个"保证一致性的机会",你得真的用对才行。

第二,持久性不等于每一次提交都立刻把数据刷进磁盘。PostgreSQL的持久性核心是WAL(Write-Ahead Logging)机制:事务提交时,必须先把commit日志记录写入WAL文件并落盘,而数据页本身可以留在内存里稍后写。这样即使数据库突然崩溃,重启后也能通过WAL把已提交的修改恢复出来。所以commit返回成功,意味着"这条事务已经安全了",而不代表"数据页已经写到磁盘物理文件里了"。

另外还有一个绕不开的参数叫synchronous_commit。默认值是on,每次提交都要等WAL刷盘后才返回成功。如果业务对持久性要求没那么苛刻,可以把它调成off,提交响应会快一些,代价是数据库主机掉电时可能丢失最近一小段已提交的事务。注意是"可能丢失",不是"必然丢失"。到底怎么选,取决于你的业务容忍度,没有绝对答案。

1.3 PostgreSQL里一个事务的基本生命周期

PostgreSQL里最基础的事务流程就是三句话:BEGIN开启事务,COMMIT提交,ROLLBACK回滚。

-- 转账场景:甲扣100,乙加100 BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;

如果执行过程中发现第二笔更新有问题,比如把乙的余额加错了,可以在COMMIT之前直接放弃:

BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; -- 发现金额不对,撤掉整个事务 ROLLBACK;

ROLLBACK之后,事务里所有操作都当作没发生过,包括已经扣掉的甲账户100块。这一个又一个的独立"过程",在PostgreSQL内部是通过事务ID(xid)来标记的。数据库会给每个写事务分配一个递增的事务ID,并记录它的状态是IN_PROGRESS、COMMITTED还是ABORTED。后面所有判断"某行数据我能不能看见",都依赖这些状态。

有一点必须提醒:在psql或者大部分客户端里,默认每条独立的SQL语句是自动提交的,也就是说你执行一条UPDATE,马上就生效了,根本不需要COMMIT。只有当你主动执行了BEGIN,数据库才会进入"显式事务块"模式。进了这个模式之后,如果你某条SQL执行出错,这个事务会进入一个叫 "Aborted" 的异常状态,后面你再执行任何SQL都会得到这样的报错:

ERROR: current transaction is aborted, commands ignored until end of transaction block

此时唯一能做的就是ROLLBACK,或者用COMMIT来结束这个失败的事务(COMMIT在这种情况下等价于ROLLBACK)。很多新手第一次遇到这个报错会懵,其实意思很简单:事务里已经出过错了,数据库不让你继续在这个坏掉的事务里跑新语句,必须彻底结束它再重来。

2. 提交与回滚:最常用的操作,背后却藏着一堆细节

2.1 begin、commit、rollback的几种写法,别被花活坑了

PostgreSQL很贴心地给了好几种等价写法。开启事务可以用BEGIN、BEGIN TRANSACTION、BEGIN WORK,也可以用标准SQL的START TRANSACTION;提交可以用COMMIT、COMMIT TRANSACTION、COMMIT WORK,甚至END;回滚可以用ROLLBACK、ROLLBACK TRANSACTION、ROLLBACK WORK,还有一个冷门的ABORT。

这些写法在功能上完全等价,但不同项目里的代码风格可能不一样。如果你看到别的同事用了START TRANSACTION,别惊讶,它就是标准SQL的写法,本质跟BEGIN一样。我个人的习惯是统一用最简短的BEGIN/COMMIT/ROLLBACK,少敲几个字母,脚本里也清爽。需要注意的是,事务和事务之间不要嵌套写BEGIN,如果你在事务里又执行了一次BEGIN,PostgreSQL会给出警告:WARNING: there is already a transaction in progress,然后忽略这条命令,而不是自动帮你开启一个子事务。子事务是靠保存点实现的,后面会详细讲。

重要提醒:代码里如果用了BEGIN,一定要有配套的COMMIT或ROLLBACK。尤其是Java、Go这类服务端程序,连接是从连接池里拿的,如果不能保证每次都在finally里正确关闭事务,轻则产生锁等待,重则把上一个事务的错误状态带到下一个请求里。我见过太多线上事故,追到源头就是"某个分支忘了回滚"。

2.2 提交的时候数据库到底做了什么

很多人以为COMMIT就是"把数据保存成功",但在PostgreSQL里,一条COMMIT背后至少做了这么几件事:

  1. 当前事务持有的所有锁并不会马上释放吗?不,恰恰相反,锁在事务结束时释放。
  2. 事务修改过的数据页不一定落盘,但事务的commit记录必须先写进WAL日志,并确保刷到磁盘。
  3. 事务状态会在系统目录pg_xact(旧版本叫pg_clog)中标记为committed。
  4. 返回客户端一条COMMIT确认消息。

这里的核心是"先写日志,后写数据"。WAL里记录的是"事务修改的日志描述",而不是直接改数据文件。如果写完数据页还没来得及落盘就断电,数据库重启时会重放WAL,让数据恢复到一个一致状态;如果commit日志已经刷盘,那这个事务就算真正提交成功了。这套机制保证了持久性,也让"COMMIT速度"相对稳定,不像MySQL InnoDB那样要纠结redo log刷盘策略。

顺带一提,synchronous_commit调成off后,commit不会等待WAL立刻刷盘,而是交给后台进程批量刷。这时候你执行COMMIT会非常快,但如果在WAL刷盘前发生操作系统崩溃或掉电,最近提交的少量事务可能在崩溃恢复时"丢失"——注意这里说的是"丢失已提交事务",属于踩了持久性的红线,所以对账务、订单类业务,我强烈建议保持默认的on。

2.3 回滚的代价:不是删数据,而是"贴标签"

我对很多运维朋友说过一句话:在PostgreSQL里,你要把回滚理解成"记录一个aborted标记",而不是"把数据变回原来的样子"。

举个例子:

BEGIN; UPDATE account SET balance = 0 WHERE id = 1; ROLLBACK;

这条UPDATE执行时,数据页上已经产生了新的行版本,旧的行版本也还保留着。ROLLBACK只是把当前事务标记为 aborted。之后再开一个事务查询,数据库通过事务状态和MVCC可见性规则判断:这个aborted事务产生的行版本对别人不可见,于是你看到的还是旧值。那些"被废弃"的行版本不会立刻物理消失,它们变成了一种叫 dead tuple 的东西,要等后台的VACUUM(清理进程)来回收。

这带来两个非常实际的影响:

第一,频繁回滚的大事务会让表膨胀。我有一个客户跑批任务,一个事务里insert了几百万行,跑到一半发现数据源有问题,直接ROLLBACK。回滚本身几秒钟就完了,但表空间膨胀了好几个GB,最后手动执行了VACUUM FULL才把空间释放出来。所以设计批量导入任务时,尽量别让单个事务积累太多数据再整体回滚。

第二,序列(sequence)不回滚。PostgreSQL里用serial自增主键或者nextval()取序列值的时候,一旦调用了nextval(),这个计数就被消耗了。即使后面事务回滚,序列也不会退回去。这就是为什么你会看到自增主键里有空洞,比如1、2、5,中间缺了3和4。这不是bug,是序列的设计初衷——它要保证并发环境下分配的编号不重复,所以不能因为事务回滚就"还回来"。谁要是想让主键绝对连续,那不能用数据库序列,得另想办法,比如业务自定义分配器。

3. 保存点:让失败只发生在局部,不用每次推倒重来

3.1 什么时候该用保存点:一个批量导入的真实场景

先抛一个场景:你往一张配置表里批量插入1000条记录,每条记录之间没有依赖关系。如果用一个大事务包住,插入到第500条时有一条数据违反唯一约束,整个事务就要全部回滚,前面499条白干,还得重头再来。如果不用事务呢?每插一条都自动提交,成功的插进去了,失败的那条单独报错,其他999条不受影响——看似没问题,但速度太慢,而且如果跑到一半程序崩了,你无法知道到底插了多少条,没有原子性保障。

保存点(SAVEPOINT)就是为这种场景设计的。它允许你在一个大事务里,给某个位置打一个"锚点",之后如果出错了,只需要回滚到这个锚点,而不是回滚整个事务。锚点之前的操作全部保留,锚点之后的操作全部撤销。

这句话值得读三遍:保存点不是开启一个新事务,而是在当前事务内部划出一段可回退的边界。

3.2 保存点的基本操作:savepoint、rollback to、release

PostgreSQL里保存点的操作有三种关键词:

  • SAVEPOINT sp_name:在当前事务里创建一个保存点。
  • ROLLBACK TO SAVEPOINT sp_name:回滚到指定保存点,撤销这个保存点之后的所有操作。
  • RELEASE SAVEPOINT sp_name:释放保存点,但不撤销任何操作,只是把这个锚点删掉。

看个完整例子:

BEGIN; INSERT INTO city (id, name) VALUES (1, '北京'); SAVEPOINT sp_before_update; UPDATE city SET name = '北平' WHERE id = 1; -- 发现改错了,回滚到保存点,只撤销刚才的update ROLLBACK TO SAVEPOINT sp_before_update; -- 这时city表里仍然有 (1, '北京'),因为insert没有被回滚 COMMIT;

执行完COMMIT之后,你查city表会发现id=1的name还是北京。这条INSERT保住了,UPDATE被撤销了。这就是保存点的核心价值:局部失败,局部回滚。

再看RELEASE的作用:

BEGIN; SAVEPOINT sp1; UPDATE account SET balance = balance - 10 WHERE id = 1; RELEASE SAVEPOINT sp1; -- 此时sp1这个保存点已经不存在了 -- 如果再执行 ROLLBACK TO SAVEPOINT sp1,会报错:savepoint "sp1" does not exist COMMIT;

RELEASE之后,修改没有提交,还在当前事务里,事务最终COMMIT时才一起生效。它只是取消了这个锚点,让你不能再"部分回滚"。所以如果你已经确认这一段操作没问题,用RELEASE把它释放掉,既能让后续无路可退,也能避免保存点堆积。

3.3 嵌套保存点与同名覆盖,这是最容易被忽略的细节

保存点是可以嵌套的:

BEGIN; INSERT INTO t VALUES (1); SAVEPOINT sp_outer; INSERT INTO t VALUES (2); SAVEPOINT sp_inner; INSERT INTO t VALUES (3); ROLLBACK TO SAVEPOINT sp_inner; -- 现在3被撤销,2还在,1还在 COMMIT;

如果直接回滚到外层保存点sp_outer,那sp_inner及之后的所有操作都会被撤销,包括2和3。嵌套带来的心智负担不轻,所以我一般建议:非必要不深度嵌套,保持一到两层就够了。

同名保存点是个很容易踩的坑。执行两次同名SAVEPOINT sp1,后面的会覆盖前面的,也就是说第一个锚点“失效”了。举个例子:

BEGIN; SAVEPOINT sp1; INSERT INTO t VALUES (1); SAVEPOINT sp1; -- 同名覆盖了第一个sp1 INSERT INTO t VALUES (2); ROLLBACK TO SAVEPOINT sp1; -- 结果:1被保留,2被撤销 COMMIT;

很多人写循环代码时喜欢在每一轮都用同一个名字创建保存点,理论上没问题,因为每次新的保存点会覆盖旧的,回滚时也只回滚当前轮。但一定要注意,别指望还能回滚到更早的同名保存点。循环里如果异常发生,回滚到保存点后,你还可以继续循环,这是正确姿势。

3.4 保存点救不了的场景:序列、游标、异常块

保存点很强大,但它不是万能的,有几个地方它确实无能为力。

序列不回退。前面讲回滚时说过nextval()的消耗不会回滚,保存点也一样。你在保存点之后调用了序列,回滚到保存点,序列值不会归还,主键空洞照旧。

游标会失效。如果在保存点之后声明了一个游标(CURSOR),回滚到该保存点时,这个游标会被关闭。之后再用这个游标取数据,会得到"cursor does not exist"之类的错误。所以有游标的逻辑,别和保存点的回滚边界纠缠在一起。

部分FATAL级别的错误会让整个事务直接“死掉”。保存点只能处理常规错误。如果遇到类似磁盘满、死锁重试序列化失败等严重情况,事务可能直接被置为 aborted,此时保存点无法挽救。比如在REPEATABLE READ隔离级别下发生了可序列化冲突(serialization failure),整个事务都需要重做,ROLLBACK TO SAVEPOINT是救不回来的。

还有一个常见误解:函数里的EXCEPTION子句其实就隐式使用了保存点机制。当一个PL/pgSQL函数内部有BEGIN ... EXCEPTION WHEN ...块时,PostgreSQL会自动为这个块创建一个子事务(保存点)。块内出错会被异常处理器捕获,块外未提交的操作保留。这个特性很好用,但会带来额外的子事务开销。如果你在一个循环里给每一行都包一个异常块,性能可能会明显下降,因为每次成功执行也要支付子事务的创建和释放成本。

4. 从事务说到隔离级别与锁,热词里全是高频面试点

4.1 四种隔离级别速览,PostgreSQL默认到底隔离了啥

事务隔离级别解决的是"并发事务互相能看到什么"的问题。标准SQL定义了四个级别,PostgreSQL全都支持,但它的实现有些特殊:

隔离级别脏读不可重复读幻读PostgreSQL实际行为
READ UNCOMMITTED理论上可能可能可能实际等同READ COMMITTED,不会出现脏读
READ COMMITTED不可能可能可能默认级别,每条语句取新快照
REPEATABLE READ不可能不可能理论可能,实际快照机制下基本不会事务开始时取快照
SERIALIZABLE不可能不可能不可能通过SSI机制检测冲突

PostgreSQL里没有真正的脏读,原因很简单:MVCC的可见性规则本来就不允许你去看未提交事务的数据,所以READ UNCOMMITTED这个级别下,你依然只能读到已提交数据。默认隔离级别是READ COMMITTED,绝大多数在线业务用的都是它。

设置事务隔离级别有两种方式:

-- 方式一:在BEGIN时指定 BEGIN ISOLATION LEVEL REPEATABLE READ; -- 方式二:在事务中修改 BEGIN; SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

注意SET TRANSACTION必须在事务块内使用,而且要放在事务的第一个查询之前,否则生效的是下一条语句。

4.2 用一个并发测试看懂读已提交与可重复读

空说概念不好懂,我拿一个实际测试来说明。建一张账户表,里面只有一行数据:

CREATE TABLE account (id int PRIMARY KEY, balance numeric); INSERT INTO account VALUES (1, 100);

打开两个psql会话A和B。

会话A执行:

BEGIN; SELECT balance FROM account WHERE id = 1; -- 看到 100,事务先不提交

会话B执行:

BEGIN; UPDATE account SET balance = 50 WHERE id = 1; COMMIT;

这时候回会话A,再执行一次同一个查询。如果你的隔离级别是默认的READ COMMITTED,你会看到50。因为READ COMMITTED下,每个SQL语句执行时都会重新获取一个快照,所以能看到其他事务最新提交的数据,这就会出现"同一个事务里,前后两次查询结果不一样"的情况——也就是不可重复读。

如果你在会话A开始事务时用了BEGIN ISOLATION LEVEL REPEATABLE READ,那么第二次查询会依然看到100。因为在可重复读级别下,快照在事务的第一个查询时就固定了,之后哪怕别人提交了修改,你的事务内部始终看到同一个版本的数据。

这个实验在生产环境里非常实用。比如一个报表事务里要跑多个查询,如果中间其他事务改了数据,默认级别下报表会出现前后统计不一致。改成REPEATABLE READ可以保证整个报表事务看到同一份快照。但代价是,如果某个被你读取过的行后来被别的事务修改并提交了,你在本事务里再尝试更新这一行,会受到"could not serialize access due to concurrent update"错误,整个事务需要重试。

4.3 事务没提交时锁的表现:为什么别人卡住了,你却不卡

PostgreSQL的多版本并发控制(MVCC)决定了普通SELECT不会阻塞写操作,写操作也不会阻塞普通SELECT。所以哪怕一个事务长时间开着,其他会话查询这张表依然秒回。很多人因此放松警惕,觉得"事务挂着就挂着呗,又不影响别人"——这是错误印象。

真正会卡的是写入和DDL。看这个经典演示:

会话A:

BEGIN; UPDATE account SET balance = balance - 10 WHERE id = 1; -- 不提交

会话B执行同样一行的更新:

UPDATE account SET balance = balance - 20 WHERE id = 1; -- 这条语句会一直卡住,等待会话A释放行锁

为啥卡住?因为同一行同时只能有一个事务持有"最新版本"的修改权。会话A改了这行没提交,会话B再改,就得等A提交或回滚后才能动手。这种现象叫锁等待。如果你的连接池里全是这种没提交的事务,后面的写请求全堵在锁队列里,业务接口很快就timeout。

比行锁更狠的是DDL锁。ALTER TABLE、DROP TABLE、TRUNCATE这类操作需要拿到ACCESS EXCLUSIVE锁级别,它跟几乎所有其他锁都冲突。一个事务只要还保持着哪怕一行SHARE或ROW EXCLUSIVE锁,DDL就会在后面干瞪眼。线上最常见的"ALTER TABLE卡死了",九成是因为有个idle in transaction的连接占着旧锁没释放。锁等待的排查方法,下一章具体展开。

5. 现场排查:长事务、锁等待、死锁的快速定位

5.1 最有用的两张视图:pg_stat_activity 和 pg_locks

排查事务问题,我第一件事就是查pg_stat_activity。这张视图会给你当前所有后端进程的状态,核心字段有这么几个:

  • state:active表示正在执行SQL,idle表示空闲,idle in transaction表示事务开了但啥也没干,这是最典型的"隐形锁源"。
  • xact_start:当前事务的开始时间。用now() - xact_start能算出事务已经跑了多久。
  • query:该连接最近执行的SQL。
  • wait_event_type和wait_event:如果这个连接在等待什么,这里会显示,比如等待锁、等待IO等。

查找所有长事务:

SELECT pid, state, now() - xact_start AS transaction_duration, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY transaction_duration DESC;

专门抓"开了事务不提交"的连接:

SELECT pid, usename, application_name, client_addr, now() - xact_start AS idle_in_tx_duration FROM pg_stat_activity WHERE state = 'idle in transaction' AND now() - xact_start > interval '5 minutes';

一般超过5分钟不提交的事务,要么是代码漏了提交,要么是业务卡在某个外部调用上,都属于高嫌疑对象。确认无误后,可以用pg_terminate_backend(pid)强制终止这个后端进程,它的事务会自动回滚,锁也会释放。注意终止前要和业务方确认,别把正在处理的正常事务给掐了。

5.2 一个完整的锁等待排查实例

假设线上有人报告,UPDATE一条SQL执行了好几分钟还没返回。我来演示一遍排查思路。

第一步,先看谁在等待:

SELECT pid, state, wait_event_type, wait_event, query FROM pg_stat_activity WHERE wait_event_type = 'Lock';

如果查到某条UPDATE的wait_event是transactionid或tuple,说明它在等行锁。这时候打开pg_locks,看锁的持有关系。一个比较省事的查找阻塞链SQL是这样的:

SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, blocked.query AS blocked_query, blocking.query AS blocking_query FROM pg_stat_activity blocked JOIN pg_locks bl ON bl.pid = blocked.pid JOIN pg_locks bkl ON bkl.locktype = bl.locktype AND bkl.database = bl.database AND bkl.relation = bl.relation AND bkl.pid <> bl.pid JOIN pg_stat_activity blocking ON blocking.pid = bkl.pid WHERE NOT bl.granted AND bkl.granted;

结果里blocked_pid是受害者,blocking_pid是持锁者。再看blocking_query,十有八九是一句UPDATE后面没有COMMIT,或者连接处于idle in transaction。和业务确认后,直接SELECT pg_terminate_backend(blocking_pid);就能把堵塞解除。

这里我踩过一个坑:有些程序里的连接池配置了连接空闲检测,但检测的是idle状态,对idle in transaction视而不见。连接池把连接还回来后,连接以为是干净的,实际上事务还挂着。要彻底根治,除了在代码层用try/finally保证提交或回滚之外,最好把连接池的testOnReturn或者数据库侧的idle_in_transaction_session_timeout参数配起来。PostgreSQL里可以设置:

SET idle_in_transaction_session_timeout = '60s';

超过60秒不提交的事务,数据库直接帮你断开。这个参数建议在线上设为合理值,能挡掉不少"僵尸事务"。

5.3 为什么大事务回滚那么"快"——准确说是表面快

总有人说"PostgreSQL回滚大事务很快",这话只说对了一半。回滚的"快",是因为它只是把事务状态标记成aborted,并不需要把每一行都物理改回旧值。但从资源清理的角度看,它把大量清理工作留给了后面的VACUUM。

假如你在大事务里更新了500万行,然后ROLLBACK,表面上几秒就结束了,但数据文件里已经留下了500万个废弃的行版本。后台VACUUM要把它们标记为可复用空间,需要扫表、处理可见性映射,如果表特别大,这个过程可能持续几分钟甚至更久。在这期间,如果还伴随大量其他事务,表膨胀几乎是必然的。

所以我的实操建议是:大事务不要一条道走到黑。大批量数据操作,能用分批提交就别一个事务塞几十万行。比如导入100万行数据,每5000行提交一次,失败只回滚当前批次。真遇到不得不整体回滚的场景,回滚之后尽快做一次VACUUM,必要时VACUUM FULL重建表,把膨胀的空间真正释放掉。

保存点和子事务也会加重这种负担。每个SAVEPOINT都会创建一个子事务,子事务状态记录在pg_subtrans里。如果你在循环里创建了成千上万个保存点,事务结束时清理这些子事务状态也需要额外开销,性能会明显下降。保存点是给"偶发错误"用的,不是给你"每行一个保护伞"用的。循环里想让每行都独立失败,用PL/pgSQL函数里的EXCEPTION块也没问题,但别在热路径上给每个操作都套一层异常捕获,那是在拿性能换便利。

5.4 经验教训:提交、回滚、保存点的最佳实践清单

整理一份我个人的实践清单,都是踩过坑换来的:

  1. 开启显式事务之前,想清楚这个事务里有几个操作,哪些必须同生共死,哪些允许部分成功。
  2. 代码里开启事务后,用try/finally或者等价写法把COMMIT和ROLLBACK都写在收尾处。我的习惯是先写ROLLBACK兜底,再写正常分支的COMMIT,宁可有冗余回滚,也不要漏掉回滚。
  3. 一个事务尽量在几百毫秒内完成,不要在里面做远程HTTP调用、发消息、等待用户输入。事务活得越久,占锁越久,膨胀风险越高。
  4. 批量处理如果不能接受一行失败全部回滚,就用保存点或异常块隔离单条记录;如果允许部分成功,也可以考虑按批次拆分多个独立事务。
  5. 在线上执行会改变表结构的DDL之前,先查一下pg_stat_activity里有没有长事务。DDL需要的ACCESS EXCLUSIVE锁极其霸道,一旦有长事务,DDL会卡到地老天荒。
  6. 涉及序列值的时候,别指望回滚能抹掉空洞。对账逻辑如果要求主键严格连续,说明设计思路有问题,得从业务侧解决。
  7. 定期监控idle in transaction,这个状态比active状态可怕得多。它不消耗CPU,但占用连接、持有锁、阻止vacuum,是个安静的生产事故制造机。

最后再分享一个个人体会。做了这些年数据库相关工作,我发现提交、回滚、保存点这几个操作,语法上谁都会写,但真正拉开差距的是"边界意识"。你写代码时脑子里有没有一张图:哪些操作在哪个边界内,失败时回滚到哪一层,这个边界外的东西是不是安全。想清楚这些,很多事务相关的线上事故根本不会发生。这套思路,比记住再多命令都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 4:44:52

从能跑到无可挑剔:打造impeccable代码的工程实践指南

1. 从一个词出发&#xff1a;为什么"impeccable"值得单独拿出来聊第一次看到"impeccable"这个词被当作一个项目标题&#xff0c;我愣了一下。它不是一个技术名词&#xff0c;不是框架名&#xff0c;也不是某个工具库的缩写&#xff0c;而是一个纯粹的英文形…

作者头像 李华
网站建设 2026/10/18 19:22:20

SQL性能优化实战:慢SQL排查、执行计划分析与索引设计指南

1. 一条慢SQL引发的思考&#xff1a;优化到底在优化什么先说个我自己的真实经历。几年前接手过一个内部报表系统&#xff0c;数据量大概几百万行&#xff0c;不算夸张&#xff0c;但每天凌晨的统计任务总是跑不完&#xff0c;经常早上上班一看&#xff0c;报表还是空的。当时第…

作者头像 李华
网站建设 2026/10/16 17:56:18

信用卡违约预测:从类别不平衡到模型融合的完整实战

简介&#xff1a;这份资源面向金融风控方向的数据科学从业者与机器学习学习者&#xff0c;聚焦信用卡违约预测这一典型信贷风险评估场景&#xff0c;帮助读者掌握从数据预处理到模型融合的完整建模思路。压缩包内共1个文件&#xff0c;为单个predict.py脚本&#xff0c;包体约4…

作者头像 李华
网站建设 2026/10/15 22:00:54

AutoCAD PGP文件重写:用纯文本提升绘图效率的底层方法

1. 项目概述&#xff1a;为什么一张文本文件能决定CAD绘图节奏的快慢在某高校机械制图实验室带学生实操时&#xff0c;我常看到同一张装配图&#xff0c;老工程师12分钟完成标注与修改&#xff0c;而刚入职的新人卡在“怎么快速切换线型”“如何不点菜单直接倒圆角”上&#xf…

作者头像 李华
网站建设 2026/10/17 8:07:40

基于Python+Vue的支教管理系统开发实战:Django与Flask双后端架构

1. 为什么是“Python Vue”&#xff1a;支教系统背后的真实业务场景做这套系统的起因其实挺朴素。之前帮一个公益助学组织做信息化改造&#xff0c;他们的日常管理还停留在微信群接龙和Excel表格&#xff1a;哪个学校需要什么科目的支教老师、哪批志愿者能来几周、每天上了哪些…

作者头像 李华
网站建设 2026/10/17 1:58:02

陶瓷勺做淘宝商品图:勺柄弧度和勺口先保真,AI设计只承担准确布局

陶瓷勺商品图可以先整理授权原图和可靠资料&#xff0c;再用剪映当前图片设计能力试布局。AI背景或排版不能把勺柄、勺口和实际器型改成新款&#xff1b;商品事实与当前发布显示要求&#xff0c;都需要独立核准&#xff0c;不能从一张漂亮设计图推断。本文没有真实商品图效果测…

作者头像 李华