news 2026/10/11 21:25:34

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

作者头像

张小明

前端开发工程师

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

PostgreSQL 的事务机制,尤其提交、回滚和保存点这三个操作,看起来是数据库里最简单的几条 SQL,但几乎每个项目都会有人在这上面翻车。语法本身不难,难的是很多人对"这三个操作分别承担什么职责、在什么时机用哪个、出问题时数据库内部到底发生了什么"理解得不够透彻。这篇内容适合两拨人:一是刚接触 Postgres、想系统理解事务工作原理的同学;二是已经在生产环境跑过几年、被莫名锁表或者长事务折磨过的开发者。我会尽量少堆抽象概念,按照实际使用和排查场景展开。

1. 为什么要把提交、回滚、保存点放在一起理解

很多初学者把事务当成"BEGIN 之后写 SQL,成功就 COMMIT,失败就 ROLLBACK",然后就此打住。一旦真正进入业务开发,很快会发现事务不是这么简单的一个开关,而是有三个不同粒度的操作:提交是确认整批变更,回滚是撤销整批变更,保存点则是在一批变更里划出"可回退的中间点"。

1.1 先从转账场景看三个操作的本质

假设你要实现一个最简单的转账:A 扣 100 元,B 加 100 元。第一条 SQL 执行成功,第二条 SQL 因为某个约束报错了。此时如果你不做任何处理,账户两边就是对不上的。事务要解决的正是这个问题:把这两条 SQL 绑定成一个原子单元,要么全部生效,要么全部不生效。

提交就是在两条 SQL 都成功之后,把结果固定下来;回滚就是在第二条失败时,把第一条也撤销。而保存点解决的是另一个问题:如果一个事务里不止两条 SQL,而是二十条,其中第七条执行到一半发现数据有问题,但前面六条都正确,你不想让整个事务回滚,只想撤回到第六条结束时的状态,这时候保存点就是那根"绳子"。

这三个操作不是三个独立功能,而是同一个"原子性"需求下的三种尺度:提交管全部确认,回滚管全部撤销,保存点管部分撤销。理解这一点,后面所有细节都好办了。

1.2 PostgreSQL 事务机制的三个关键认知

第一,PostgreSQL 默认不会自动创建保存点,你必须显式声明;而提交和回滚才是事务内天然存在的两种结局。第二,提交和回滚是事务级别的整体行为,保存点是语句级别的部分回滚,两者作用粒度不同。第三,PostgreSQL 在"事务如何保证原子性"上走的是另一条技术路线,没有传统意义上的 undo 日志,而是靠多版本并发控制加事务状态判断来实现,这一点会在后面详细解释。

把这三条认知建立起来之后,再去看文档里的 COMMIT、ROLLBACK、SAVEPOINT 语法,你会发现每一个选项都不会是死记硬背的东西,而是对应着具体的使用场景和代价。

2. COMMIT 提交并没有想象中那么简单

提交是所有事务操作里最"理所当然"的一个,但恰恰是它隐藏的问题最多。我见过不少线上事故,最后定位到根因时发现,问题不在 SQL 写得差,而在提交时机和提交方式上。

2.1 提交时数据库到底做了哪些工作

一条 COMMIT 发出后,PostgreSQL 内部不是简单地把数据"写进磁盘"就完事。它至少要完成这几件核心工作:把当前事务标记为已提交状态、把事务提交的记录写入预写日志并持久化、释放事务持有的所有锁、唤醒正在等待这些锁的其他会话。

很多人会在这里产生一个误解:COMMIT 成功后,修改的数据页已经落盘了。其实并没有。PostgreSQL 采用 Write-Ahead Log 机制,事务提交时真正必须落盘的是 WAL 日志,而不是数据页。数据页可能在之后由后台进程写回,也可能在下一个检查点才写回。所以事务提交的定义是"日志先落盘,状态不可逆",而不是"磁盘数据立刻变成新值"。这也是为什么 COMMIT 返回成功之后,如果机器立刻断电,数据库重放 WAL 日志后仍然能恢复到这个事务修改的最终状态。

这里还有一个很多人没注意的配置项:synchronous_commit。默认是on,也就是每次提交都强制把 WAL 刷到磁盘;如果业务对单机事务的持久性要求略低,可以设置成off。off模式下提交会更快,因为允许 WAL 在内存里多待一段时间,但换来的是数据库崩溃时可能丢几个刚刚提交的事务。这个参数对批量写入场景吞吐影响非常大,值得根据业务容忍度单独调,而不是所有库都用一个默认值。

2.2 隐式提交与显式提交:事务边界比想象中容易踩

PostgreSQL 里显式开启事务用的是BEGIN或者START TRANSACTION,这条命令本身不会分配事务 ID,真正的写操作执行时才分配。但有一个常见情况是autocommit:比如 JDBC 连接默认autocommit=true,表面上你在写一条 UPDATE,实际上它自动被包在了一个独立事务里执行并提交。很多应用层代码根本没有调用BEGIN的环节,仍然能正常工作,靠的就是这个机制。

另一种容易被忽略的是 DDL 语句。PostgreSQL 允许把CREATE TABLE、ALTER TABLE这类 DDL 放在事务块里,并且可以随事务一起回滚。这意味着你可以在事务里先建表、插数据,发现问题后直接 ROLLBACK,整张表都会消失。很多从其他数据库迁移过来的开发者对这一点非常不习惯,但这是 PostgreSQL 的一个重要优势。

不过要注意,有几个命令不能在事务块内执行,比如VACUUM、CREATE DATABASE、CREATE TABLESPACE。如果你在一个显式事务里尝试运行VACUUM,会直接收到错误提示,而不是"继续执行事后回滚"。这算不上隐式提交,但不少人在写自动化脚本时没注意到这个限制,导致整个脚本跑挂。

2.3 提交时机对业务的影响

提交除了决定数据持久性,还直接决定锁的持有时间。PostgreSQL 里事务持有的行锁、表锁,一直到提交或回滚才释放。这意味着,一个事务里执行太多耗时的业务操作,尤其是外部接口调用、文件读取、消息队列发送,锁就会一直压在手上,后面的请求只能排队等锁。

我见过一个典型案例:业务代码在事务里调用支付回调接口,因为第三方接口响应慢,数据库连接一直保持"事务进行中"状态,其他会话对这个表的更新全部排队。最终表现是业务整体变慢,查pg_stat_activity一看,大量会话在等待同一个行锁。所以我的经验是:事务里只放数据库相关操作,外部请求一律放到事务提交之后再发;如果需要先回源再入库,那就先把外部结果拿到,再开启事务写入。

3. ROLLBACK 回滚:没有传统 undo 日志是怎么撤销的

如果说提交是对"结果"的确认,那么回滚看重的是"撤销路径"。Oracle 有专门的 undo 表空间,MySQL InnoDB 有 undo log 回滚段,PostgreSQL 的做法完全不一样。不理解这一点,你在排查某些回滚性能问题时可能方向完全跑偏。

3.1 PostgreSQL 回滚的核心机制:MVCC 加事务状态

PostgreSQL 的每个数据行上遗留着两个关键字段:插入该行版本的事务 ID,和删除/更新该行的事务 ID。事务是否提交,由事务状态文件记录,也就是pg_xact(旧称 clog)。当一条 UPDATE 执行时,PostgreSQL 不是说把原来的行修改掉,而是生成一个新版本的行,同时把旧版本的删除事务 ID 标记为当前事务。新版本和旧版本同时存在,只是可见性不同。

如果事务回滚,数据库不会把磁盘上的数据"倒回去",而是把这个事务在pg_xact里标记为 aborted 状态。其他任何事务在做可见性判断时,看到该事务是 aborted,就会忽略它插入的所有行,而它标记为删除的旧版本也会被视为仍然有效。从逻辑上看,数据回到了事务开始前的样子;从物理上看,那些死元组还躺在页面里,之后由 VACUUM 清理。

这个机制解释了为什么回滚通常比提交"便宜"得多:不需要读旧值、写回旧值,只需改一个状态标记,然后等待后台清理。但也引出一个重要问题:如果一个回滚发生在一个已经写入了大量数据的事务里,死元组会占据大量空间,后续 VACUUM 压力会不小。因此不要以为"回滚就是零成本",对超长事务来说,回滚后的清理成本一样值得重视。

3.2 一条语句出错时,事务状态会怎么变化

这是日常开发中最容易懵的点。很多人以为事务里任意一条 SQL 出错,整个事务会自动回滚。实际上不是。PostgreSQL 的默认行为是:出错的语句本身被回滚到这条语句开始之前的状态,但事务并没有整体回滚,其他已成功执行的语句仍然保留。然而,事务会进入 aborted 状态,从这一刻开始,事务块内所有后续命令都会被服务端拒绝执行,直到你发出 ROLLBACK 或 COMMIT。

在 psql 里你会看到类似这样一则错误:

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

出现这个提示之后,你输入任何 SELECT、INSERT 都没用,只有 ROLLOWBACK 或 COMMIT 才能把事务状态清掉。这里有一个很多人误解的操作:aborted 状态下执行 COMMIT,数据库不会真的提交前面那些成功语句,而是把它当作 ROLLBACK 处理。也就是说,提交失败事务里某个成功语句,这种"部分提交"是做不到的。所以在应用代码里,一旦捕获到 SQL 异常,正确姿势是先调用连接层的 rollback,再决定是重试还是放弃。

3.3 回滚的代价与需要避免的误用

回滚的代价最明显的是锁不能提前释放。虽然出错的语句被撤销了,但是事务没有结束,之前所有未释放的锁依然保持。如果你的应用在事务里先更新了几张表,然后最后一条 SQL 抛异常,接着没有回滚而是继续尝试另一条 SQL,服务端会直接报 aborted,但之前那些行锁仍然锁着,直到会话最终回滚。

另一个常见误用是把"回滚"当成日常业务分支手段。比如某个业务分支不对,就执行 ROLLBACK 想丢弃当前事务里的修改,然后继续。这在事务已经 aborted 的情况下是完全行不通的,因为 aborted 状态下你不能再执行任何新语句,只能结束事务。正确做法是:如果业务有"这个分支失败但还要继续处理其他分支"的需求,一开始就该用保存点,而不是试图依赖事务级回滚。

4. SAVEPOINT 保存点的正确打开方式

保存点在很多项目里是使用率最低的事务操作,但它的价值在长事务和异常处理里非常突出。一句话概括:保存点让你在事务内部获得"局部回滚"能力,不至于为了一条失败数据把整批操作全部推翻。

4.1 保存点的语法与实用场景

保存点的基础语法非常直白:

BEGIN; INSERT INTO orders (user_id, amount) VALUES (1, 100); SAVEPOINT sp_after_order; -- 假设这条插入明细时发现库存不足 INSERT INTO order_items (order_id, sku, quantity) VALUES (currval('orders_id_seq'), 'A001', 2); -- 发现异常,回滚到保存点 ROLLBACK TO SAVEPOINT sp_after_order; -- 此时订单还在,只是明细被撤掉了,可以换一种库存方案继续 INSERT INTO order_items (order_id, sku, quantity) VALUES (currval('orders_id_seq'), 'B002', 1); RELEASE SAVEPOINT sp_after_order; COMMIT;

执行ROLLBACK TO SAVEPOINT之后,保存点本身并不会消失,你还可以再次回滚到同一个保存点;RELEASE SAVEPOINT才是把它丢弃。如果回滚到某个保存点,那么在这个保存点之后建立的其它保存点也会一并释放。

批量导入是最典型的应用场景。假设你要导入十万条数据,每处理一千条建一个保存点,如果第一千零一条执行失败,只回滚到这条记录之前的状态,而不是把前面九万九千多条全部推翻。我自己做数据迁移时,经常在一个事务里用循环分批插入,每批一个保存点,遇到失败记录先回滚到本批保存点,把异常数据记录到错误表,然后继续下一批。这样整个迁移过程可以一口气跑完,不需要中途重来。

4.2 保存点与子事务的关系

保存点在 PostgreSQL 内部是通过子事务实现的。每建一个保存点,实际上就是开启了一个子事务,拥有自己的子事务 ID。如果以后你在 PL/pgSQL 函数里写EXCEPTION块,这个块内部其实也会自动创建一个子事务,效果上相当于是隐式保存点。这也是为什么 PL/pgSQL 里可以对某一段代码做异常捕获,捕获之后外层事务的大部分内容依然可以继续提交。

这里有一个需要注意的限制:子事务嵌套层级不是无限的,PostgreSQL 的上限是 64 层。如果你在代码里嵌套太多 PL/pgSQL 的EXCEPTION块,或者手动连续创建保存点而不释放,超过这个层级会直接报错:

ERROR: cannot have more than 64 levels of subtransaction

日常业务基本不会碰到这个上限,但写递归调用,或者说在循环里不断套保存点时,最好留意一下层级。

4.3 使用保存点的边界条件

很多人以为回滚到保存点就能把那个点之后的锁也释放掉,这是不对的。PostgreSQL 的文档明确说过,ROLLBACK TO SAVEPOINT不会释放保存点建立之后获取的行锁或者表锁,这些锁仍然要等整个事务结束时才会释放。如果你的业务在保存点之后执行了SELECT ... FOR UPDATE或者LOCK TABLE,然后回滚到保存点,但锁并没有消失,后续对该行对该表的写操作依然会被阻塞。

另一个容易忽略的是序列。保存点回滚不会把序列值恢复到之前的位置。比如你在保存点之后调用了nextval('some_seq'),回滚到保存点后再调用,序列不会重新分配曾经的编号。这对业务唯一性判断来说可能是个坑,如果依赖序列值做顺序逻辑,记得保存点回滚绕不过去。

保存点不能跨事务使用,事务结束之后所有保存点自动清空。所以在应用层设计时,不要把保存点当成一个可以长期挂起的资源来用,它的生命周期始终局限在当前事务内部。

5. 实测中容易遇到的事务问题与排查思路

这一部分要聊的不是语法,而是我在实际运维和排查过程中反复遇到的事务相关故障。事务相关的坑往往不是单条 SQL 写错,而是多个因素叠加之后表现出来的性能问题或者"假死"现象。

5.1 idle in transaction 与锁等待的排查

最常见的故障现场是:某个表突然被锁死,业务侧大量超时。打开pg_stat_activity,你会看到很多会话的状态是idle in transaction,也就是事务已经开启了,但是后面没有任何正在执行的语句,连接却一直挂着不提交也不回滚。这类会话往往持有锁,导致其他任何想修改同一行或者同一表的操作全部排队。

我曾经排查过一个案例,某服务每次处理业务前会BEGIN,中间有一段代码在请求外部接口,直到拿到外部结果才执行后续 SQL。外部接口抖动后,所有数据库会话都停在了等接口的状态,事务开着、锁没放、请求越积越多,最终把业务压垮。从那以后我对团队的要求就一条:事务区间内绝对不做数据库之外的调用,如果有外部依赖,先取结果再开事务。

知道问题之后,可以用下面这条 SQL 快速定位长时间未结束的事务:

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

再配合查看阻塞关系:

SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, blocked.query AS blocked_query FROM pg_stat_activity blocked JOIN pg_stat_activity blocking ON blocking.pid = ANY(blocked.pg_blocking_pids()) ORDER BY blocked.pid;

这两条 SQL 基本能覆盖九成的锁等待排查场景。看到结果之后,先确认阻塞会话是不是可以安全结束,如果是idle in transaction且没在跑任何查询,再决定是让应用提交回滚,还是pg_terminate_backend强制结束。

5.2 长事务与事务 ID 增长问题

长事务的危害不只是锁。PostgreSQL 的事务 ID 是 32 位整数,虽然自动回卷机制在正常维护下不会出问题,但如果数据库长时间存在一个不结束的事务,VACUUM 就没法清理那些"比该事务更新"的元组,表膨胀会越来越严重,查询性能随之下降,最终可能触发事务 ID 回卷保护机制,数据库直接拒绝执行新写入。

所以对线上库来说,一个超过十分钟还没结束的事务就应该被标记为可疑,超过一小时基本要人工介入。监控系统里建议把pg_stat_activity里事务年龄比较大的会话单独告警。PostgreSQL 也提供了参数可以减少这类事故,比如设置idle_in_transaction_session_timeout,让处在idle in transaction状态的会话超时自动断开:

ALTER DATABASE mydb SET idle_in_transaction_session_timeout = '30s'; ALTER DATABASE mydb SET statement_timeout = '60s'; ALTER DATABASE mydb SET lock_timeout = '10s';

statement_timeout控制单条 SQL 的最长执行时间,lock_timeout控制等待锁的最长时间。这几个参数不一定每个业务都适用,但对大多数联机交易系统来说,宁可超时快速失败,也不要无限期卡住拖垮整个库。

5.3 一个容易误导人的 aborted 事务案例

我再分享一个实际遇到过的场景。某开发同事在一个事务块里写了几十行 SQL,中间一行写错了字段名,导致这条语句执行失败,但事务没有回滚,后面的语句也没有再执行。由于应用代码没有主动调用 rollback,这个连接保持 aborted 状态回到了连接池。下一个请求从连接池拿到这个连接后执行 SQL,服务器端却一直报current transaction is aborted,新请求全部失败。排查半天,最终定位到是连接池复用了未回滚的连接。

这个问题的本质就是"事务状态跟着连接走"。连接池复用连接时不会自动帮你重置事务状态,所以应用代码在捕获 SQL 异常后必须显式调用回滚接口。如果你的框架层没有做这个动作,很容易踩中这种"看起来全是 SQL 报错,其实根子在残留事务状态"的坑。

5.4 两阶段提交在分布式场景下的注意点

如果你的业务涉及多个数据库节点的事务协调,PostgreSQL 还提供了两阶段提交:PREPARE TRANSACTION、COMMIT PREPARED和ROLLBACK PREPARED。这个机制把事务拆成准备和提交两个阶段,协调者先让所有参与节点把事务准备好并写入pg_prepared_xacts,再统一提交。

这个功能在单机事务里基本用不上,但分布式事务中间件通常会用到。需要注意,PREPARE TRANSACTION出来的事务会一直驻留在集群里,既持有连接资源也占用事务 ID,必须及时提交或回滚。如果协调者崩溃了,残留的 prepared 事务还需要人工处理,这个状态在pg_prepared_xacts视图里可以看到。所以生产环境使用两阶段提交,最好配套管理脚本,定期检查残留的 prepared 事务。

最后再分享一点事务使用经验

每次调试事务相关的问题,我都习惯先看一眼事务状态,再往下分析。事务的提交、回滚与保存点三件套,看起来都是单条命令,但真正工作中的核心是把事务边界、锁持有时间和异常处理路径设计得足够短、足够清晰。如果每个事务都尽量做到"开始前拿到所有外部依赖,事务内只做数据库动作,出错立刻回滚并通知框架清理连接",线上绝大多数事务故障都可以提前避免。另一个值得实践的小技巧是:在批量任务和长事务里,主动用保存点把事务切分成若干个可回滚的片段,这样即使某条数据有问题,你也只需要丢弃一小段,而不是让一整晚的批处理前功尽弃。

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

PaDiM 异常检测模型从 Python 到 C# 产线部署实战

简介&#xff1a;本资源面向具备一定 C# 与深度学习基础的开发者&#xff0c;聚焦于在 .NET 环境中部署 PaDiM 异常检测模型这一具体工程问题&#xff0c;适用于工业视觉检测、医学影像分析等需要图像异常识别的场景。压缩包共 435 个文件&#xff0c;约 793.99MB&#xff0c;包…

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

考研大数据分析系统:Spark批处理与Flink实时推荐实战

简介&#xff1a;这份资源是面向计算机专业毕业设计场景的考研大数据分析项目源码包&#xff0c;适合正在准备毕设、需要融合大数据与机器学习技术的学生参考。项目以Spark负责离线批处理与MLlib建模、Flink承担实时流数据处理、Python完成爬虫采集与数据分析可视化&#xff0c…

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

IEEE33潮流计算收敛难题:配电网仿真落地第一道门槛

简介&#xff1a;本资源是一套基于MATLAB实现的IEEE 33节点与69节点配电网潮流计算完整代码包&#xff0c;面向电力系统专业本科生、研究生及工程实践者&#xff0c;用于掌握经典配网模型建模、稳态分析与算法验证。包内共5个文件&#xff0c;含4个核心M脚本&#xff08;如IEEE…

作者头像 李华
网站建设 2026/10/11 21:18:47

基于YOLOv8的大豆叶病检测:从数据集构建到模型部署全流程

简介&#xff1a;这份资源面向深度学习入门者与计算机视觉方向的学习者&#xff0c;以大豆叶病检测为实战场景&#xff0c;帮助读者理解YOLOv8目标检测框架的整体构建流程。内容围绕数据采集与预处理、网络架构设计、基于PyTorch的训练与推理、模型评估指标监控以及部署时的模型…

作者头像 李华
网站建设 2026/10/11 21:16:00

基于YOLOv11自定义模型的人脸检测与表情识别系统实践指南

简介&#xff1a;一份面向深度学习与计算机视觉开发者的人脸检测与表情识别项目资源&#xff0c;以YOLOv11为基础&#xff0c;同时展示如何针对特定任务定制YOLO模型&#xff0c;覆盖人脸检测、关键点定位与表情分类全流程&#xff0c;适用于智能交互、安全监控、课堂考勤与用户…

作者头像 李华
网站建设 2026/10/11 21:15:50

Aras PLM权限与元数据配置实战指南

简介&#xff1a;本资源是一份面向PLM实施工程师、系统管理员及制造业数字化转型从业者的Aras PLM入门与进阶学习文档&#xff0c;聚焦产品生命周期管理平台的核心管理能力。内容覆盖用户管理&#xff08;含参与者创建、角色分级与特殊权限配置&#xff09;、细粒度权限体系&am…

作者头像 李华