上周帮一个朋友排查线上问题,场景特别常见:订单表里已经写了扣库存记录,但订单状态却显示创建失败。折腾了一下午,最后发现根因就一句话——程序里开启事务的代码写错了分支,扣库存那条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背后至少做了这么几件事:
- 当前事务持有的所有锁并不会马上释放吗?不,恰恰相反,锁在事务结束时释放。
- 事务修改过的数据页不一定落盘,但事务的commit记录必须先写进WAL日志,并确保刷到磁盘。
- 事务状态会在系统目录
pg_xact(旧版本叫pg_clog)中标记为committed。 - 返回客户端一条
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 经验教训:提交、回滚、保存点的最佳实践清单
整理一份我个人的实践清单,都是踩过坑换来的:
- 开启显式事务之前,想清楚这个事务里有几个操作,哪些必须同生共死,哪些允许部分成功。
- 代码里开启事务后,用
try/finally或者等价写法把COMMIT和ROLLBACK都写在收尾处。我的习惯是先写ROLLBACK兜底,再写正常分支的COMMIT,宁可有冗余回滚,也不要漏掉回滚。 - 一个事务尽量在几百毫秒内完成,不要在里面做远程HTTP调用、发消息、等待用户输入。事务活得越久,占锁越久,膨胀风险越高。
- 批量处理如果不能接受一行失败全部回滚,就用保存点或异常块隔离单条记录;如果允许部分成功,也可以考虑按批次拆分多个独立事务。
- 在线上执行会改变表结构的DDL之前,先查一下
pg_stat_activity里有没有长事务。DDL需要的ACCESS EXCLUSIVE锁极其霸道,一旦有长事务,DDL会卡到地老天荒。 - 涉及序列值的时候,别指望回滚能抹掉空洞。对账逻辑如果要求主键严格连续,说明设计思路有问题,得从业务侧解决。
- 定期监控
idle in transaction,这个状态比active状态可怕得多。它不消耗CPU,但占用连接、持有锁、阻止vacuum,是个安静的生产事故制造机。
最后再分享一个个人体会。做了这些年数据库相关工作,我发现提交、回滚、保存点这几个操作,语法上谁都会写,但真正拉开差距的是"边界意识"。你写代码时脑子里有没有一张图:哪些操作在哪个边界内,失败时回滚到哪一层,这个边界外的东西是不是安全。想清楚这些,很多事务相关的线上事故根本不会发生。这套思路,比记住再多命令都管用。