知行札记
专题技术实践数据库实践PostgreSQL 实践

事务、快照与锁

用提交、隔离、锁、死锁和清理解释并发保证。

本分册采用 PostgreSQL 18 文档作为语法和行为基线。归档中 PostgreSQL 16 或其他环境的 SQL、计划与耗时保留为历史记录,2026-10-05 整理未重跑这些数据库实验。示例需在独立可丢弃数据库内使用,各章同名表可能有不同结构;先核对 DDL、角色和数据再执行。

我们的在线书店刚开业时一分钟只有几笔订单。生意好起来之后,新问题来了:一位顾客下单要连续执行好几条 SQL(扣库存、扣余额、写订单),中间程序崩溃怎么办?两位顾客同时抢购最后一本书,以谁为准?会不会有人读到别人改到一半的"半成品"数据?本章依次回答它们,对应三组关键词:**事务(Transaction)**保证一组操作"全有或全无";**隔离级别(Isolation Level)**规定并发事务彼此能看到什么;多版本并发控制(MVCC)与锁则是 PostgreSQL 同时做到"正确"与"高效"的两件工具。

5.1 为什么需要事务:一组操作必须"全有或全无"

先看一个具体的麻烦。书店为老顾客提供预存余额功能,建一张账户表:

CREATE TABLE accounts (
    id      integer PRIMARY KEY,       -- 顾客账户编号
    name    text NOT NULL,             -- 顾客姓名
    balance numeric(10,2) NOT NULL     -- 预存余额(元)
);

INSERT INTO accounts VALUES (1, '李雷', 1000.00), (2, '韩梅梅', 500.00);

书店上线"余额转赠"功能:李雷转 100 元给韩梅梅,需要两条 UPDATE:

-- 第一步:从李雷账户扣 100 元
UPDATE accounts SET balance = balance - 100.00 WHERE id = 1;

-- 第二步:给韩梅梅账户加 100 元
UPDATE accounts SET balance = balance + 100.00 WHERE id = 2;

如果两条语句之间程序崩溃、断电、磁盘写满呢?李雷的钱扣了,韩梅梅却没收到——100 元凭空消失。

事务就是为此而生:把多条 SQL 捆绑成一个"全有或全无"的操作——要么所有步骤都生效(提交),要么一步都不影响数据库(回滚)。类比签合同:双方都签字才生效,任何一方不签则合同作废。回到准确定义:事务是数据库保证作为整体执行的一组语句,任何一步失败则全部作废。

还要记住一个默认行为:PostgreSQL 把每条 SQL 语句都当作一个事务执行。不写 BEGIN 时,每条语句外面自动包一层隐式的 BEGIN 和(成功时的)COMMIT,即"自动提交(autocommit)"。上面两条 UPDATE 若直接执行,第一条成功后就永久生效,第二条出错了也拉不回它。所以凡是"必须一起成功"的操作,一定要显式写 BEGIN。

5.2 ACID:事务的四项承诺

评价事务机制的可靠性,业界用四个特性概括,合称 ACID:

  • 原子性(Atomicity):对其他事务而言,事务里的修改要么全发生、要么全不发生——扣款和入款在旁人眼里是同一瞬间完成的。
  • 一致性(Consistency):事务执行前后,数据库都满足全部业务约束(主键、外键、CHECK 等),例如余额不能为负。
  • 隔离性(Isolation):进行中的事务,其修改在完成前对其他事务不可见;一旦完成,全部修改同时可见。
  • 持久性(Durability):事务一旦提交,修改就落到永久存储——PostgreSQL 先把变更写入预写式日志(WAL,Write-Ahead Log)再报告成功,断电重启也不丢失。

记住分工:原子性与持久性管"单个事务自身可靠",隔离性管"多个事务互不干扰",一致性则是它们加上应用正确写好约束后的自然结果。本章余下主要围绕隔离性展开。

5.3 事务怎么写:BEGIN、COMMIT、ROLLBACK 与 SAVEPOINT

三件套很简单:BEGIN 开启事务,之后的所有语句同属一个事务;COMMIT 确认生效;ROLLBACK 撤销事务中迄今为止的全部更新:

BEGIN;
UPDATE accounts SET balance = balance - 100.00 WHERE id = 1;  -- 扣李雷 100
UPDATE accounts SET balance = balance + 100.00 WHERE id = 2;  -- 加韩梅梅 100
COMMIT;                                                       -- 两步同时生效

中途发现不对劲就 ROLLBACK。先给表加一条约束,让数据库替我们把关:

ALTER TABLE accounts ADD CONSTRAINT balance_nonneg CHECK (balance >= 0);

BEGIN;
UPDATE accounts SET balance = balance - 2000.00 WHERE id = 1;  -- 余额只有 1000,违反约束
-- ERROR:  new row for relation "accounts" violates check constraint "balance_nonneg"
ROLLBACK;                                                       -- 撤销一切

SELECT balance FROM accounts WHERE id = 1;   -- 1000.00,一分没动

这里有个重要的状态机:事务内任何一条语句出错后,整个事务进入 aborted(已中止)状态,后续所有命令都会被拒绝,报错 current transaction is aborted, commands ignored until end of transaction block。初学者常在此误以为"数据库坏了",其实只是事务被锁死——出路有两条:整体 ROLLBACK,或者用 SAVEPOINT 回到存档点。

SAVEPOINT(保存点)就像打游戏先存档再打 Boss:在事务里打个"存档点",冒险失败就"读档",不必整个游戏重来:

BEGIN;
UPDATE accounts SET balance = balance - 100.00 WHERE id = 1;  -- 先扣款

SAVEPOINT my_savepoint;                                       -- 存档

UPDATE accounts SET balance = balance / 0 WHERE id = 1;       -- 有 bug 的 SQL:除以零
-- ERROR:  division by zero
-- 此后再敲任何 SQL 都只会继续报 aborted 错误

ROLLBACK TO SAVEPOINT my_savepoint;                           -- 读档:撤销出错语句,事务恢复可用

UPDATE accounts SET balance = balance + 100.00 WHERE id = 2;  -- 改回正确的一步
COMMIT;

官方文档特别强调:事务出错进入 aborted 状态后,ROLLBACK TO SAVEPOINT 是重新获得事务控制权的唯一办法——这也是应用层错误处理与重试逻辑的依据。

最后提前打个预防针:序列(Sequence)不回滚。自增 ID 取值的变化立即对所有事务可见,所在事务即使回滚也不退回。因此"回滚 = 一切恢复原状"对自增 ID 不成立,订单号出现空洞是设计使然,不是 bug。

5.4 隔离级别:并发事务互相能看到什么

事务解决了"全有或全无",但多个事务同时运行时,彼此能看到对方的修改吗?先认识三种经典的读异常(read phenomenon):

  • 脏读(Dirty Read):读到别人尚未提交的数据。对方一旦回滚,你读到的就是从未存在过的值——最严重。
  • 不可重复读(Non-repeatable Read):同一事务里两次读同一行,结果不一样(别人中途改了并提交)。
  • 幻读(Phantom Read):同一事务里两次按同样条件查询,返回的行数变了(别人中途插入或删除了符合条件的行)。

PostgreSQL 默认隔离级别是读已提交(READ COMMITTED),由参数 default_transaction_isolation 控制。其语义是"语句级快照":每条语句只能看到它开始前已提交的数据,因此允许不可重复读和幻读。开两个 psql 窗口就能亲眼看到:

-- 会话 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1;    -- 1000.00

-- —— 切到会话 B ——
UPDATE accounts SET balance = balance - 100.00 WHERE id = 1;  -- 没写 BEGIN,自动提交

-- —— 回到会话 A ——
SELECT balance FROM accounts WHERE id = 1;    -- 900.00!同一事务两次读数不同
COMMIT;

这就是默认级别明确允许的"不可重复读"。想更严格,可升级到可重复读(REPEATABLE READ):PostgreSQL 用**快照隔离(Snapshot Isolation)**实现——事务执行第一条语句(BEGIN/COMMIT 等事务控制语句除外)时给数据库"拍一张快照",之后所有语句都看这张快照,别人的提交一概视而不见。这比 SQL 标准对该级别的要求还高:连幻读也不允许(标准允许实现提供更强的保证)。

-- 会话 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;    -- 900.00(拍下快照)

-- —— 会话 B ——
UPDATE accounts SET balance = balance - 100.00 WHERE id = 1;   -- 提交,实际余额已变 800

-- —— 回到会话 A ——
SELECT balance FROM accounts WHERE id = 1;    -- 仍是 900.00(只看自己的快照)

UPDATE accounts SET balance = balance - 50.00 WHERE id = 1;
-- ERROR:  could not serialize access due to concurrent update
ROLLBACK;    -- 冲突后只能回滚,由应用决定是否重试整个事务

最后那个错误(SQLSTATE 40001,序列化失败)是可重复读的代价:并发修改同一行时,数据库中止当前事务,应用需要从新的快照重试整个事务。在可重复读和可串行化级别下,应用必须准备好因序列化失败而重试事务——这是官方文档的明确要求;可重复读下纯只读的事务永远不会冲突,而可串行化下连只读事务也可能被中止,只有声明为 SERIALIZABLE READ ONLY DEFERRABLE 的事务才保证不受序列化失败影响(这是进阶选项,入门阶段知道有这回事即可)。

最严格的隔离级别是可串行化(SERIALIZABLE)。PostgreSQL 使用可串行化快照隔离(SSI,Serializable Snapshot Isolation):在快照隔离上监测并发事务的读写依赖,发现可能导致序列化异常的条件时中止事务,并返回 SQLSTATE 40001。它保证成功提交的可串行化事务具有某种串行执行的等价效果;检测采取保守策略,失败也可能发生在实际能够串行化的执行中,因此应用要能重试整个事务。

谓词锁(predicate lock)记录本事务实际读取的数据,用来识别并发写入是否可能改变这次读取的结果。锁可能覆盖元组、页或整个关系,粒度取决于执行计划和锁合并;在 pg_locks 中表现为 SIReadLock。这类锁用于依赖检测,不阻塞其他事务,也不会自行造成死锁。官方定义与运行条件见 PostgreSQL 18:可串行化隔离。设置方式:

BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ……业务 SQL……
COMMIT;

注意隔离级别必须在事务内第一条查询或数据修改语句之前设置,之后就不能改了。官方对可串行化的性能建议:尽量把事务声明为 READ ONLY、保持事务短小、限制并发连接数、避免不必要的顺序扫描。

给从其他数据库迁移过来的读者提个醒:PostgreSQL 内部实际只实现三种隔离级别,READ UNCOMMITTED 会被当作 READ COMMITTED 处理——在 PostgreSQL 里永远不可能脏读。跨库迁移务必重新核对语义。查看与设置:

SHOW transaction_isolation;           -- 当前事务的隔离级别
SHOW default_transaction_isolation;   -- 新事务的默认级别

5.5 MVCC:快照读取与并发写入

前面反复出现的"快照",就是 多版本并发控制(MVCC,Multiversion Concurrency Control) 的核心:查询按隔离级别确定快照,读取该快照下可见的行版本。为此:

  • UPDATE 写入新的行版本(row version),旧版本的可见性仍由事务和快照决定;
  • DELETE 也不立即物理删除,只是给行版本打上"已删除"标记。

旧版本让普通快照 SELECT 可以与行更新并行,读取通常无需等待写事务完成。这项性质有明确范围:SELECT 仍持有表级 ACCESS SHARE 锁,会与 ACCESS EXCLUSIVE 等锁冲突;SELECT FOR UPDATE/SHARE 会取得行锁并可能等待;并发写入也可能互相等待。MVCC 不保证所有读写操作都互不阻塞,可串行化事务还可能因依赖冲突中止。

行版本的"门牌号"藏在每张表都有的系统列里:xmin 是插入该行版本的事务 ID,xmax 是删除(或因更新而作废)它的事务 ID,0 表示尚未删除。通俗地说,xmin 是这版数据的"出生证明",xmax 是"死亡标记"。它们平时不显示,但可以直接查:

SELECT id, balance, xmin, xmax FROM accounts WHERE id = 1;
--  id | balance |  xmin  | xmax
-- ----+---------+--------+------
--   1 | 1000.00 | 120822 |    0

UPDATE accounts SET balance = balance + 1.00 WHERE id = 1;

SELECT id, balance, xmin, xmax FROM accounts WHERE id = 1;
--  id | balance |  xmin  | xmax
-- ----+---------+--------+------
--   1 | 1001.00 | 120824 |    0

xmin 从 120822 变成 120824:UPDATE 生成了新的行版本。再记一个细节:xmax 非零的行未必真的"死了"——那个删除可能尚未提交、甚至已经回滚,所以不能只看 xmax 判断可见性。

5.6 锁与死锁:当两个事务想改同一份数据

普通快照读取与行更新通常可并行。两个事务写同一行、锁定读取或修改表结构时,锁负责协调冲突操作。

表级锁按强弱分多档,先记三个代表:普通 SELECT 只加最弱的 ACCESS SHARE,几乎不与任何操作冲突;INSERT/UPDATE/DELETE 加 ROW EXCLUSIVE,彼此并不冲突(同一行的竞争交给行锁裁决);而 DROP TABLE、TRUNCATE、VACUUM FULL 这类"动结构"的操作要拿最强的 ACCESS EXCLUSIVE,阻塞对该表的一切访问,包括普通 SELECT——所有表锁模式里只有它会挡住 SELECT。所以在业务高峰跑这些命令会把整张表"锁死",维护操作请放到低峰期(例如建索引可用 CREATE INDEX CONCURRENTLY 变体避免阻塞)。

行级锁最常用的是 SELECT ... FOR UPDATE:查询的同时锁住选中的行,锁持有到事务结束(回滚到保存点会立即释放其后获取的锁)。这就是"先锁后算"的悲观并发模式:两位店员同时要调同一个账户,先到者锁行慢慢算,后到者排队等待:

-- 会话 A:先锁行,再计算、更新
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;   -- 锁住 id=1 这一行
-- ……业务计算……
UPDATE accounts SET balance = balance - 100.00 WHERE id = 1;
COMMIT;                                                  -- 事务结束,释放行锁

-- 会话 B(A 提交前执行):同样的锁定查询只能等待
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;   -- 阻塞……

-- 会话 C:普通 SELECT 完全不受影响,立即返回(MVCC 读写不互斥)
SELECT balance FROM accounts WHERE id = 1;

行锁共有四种强度,从强到弱依次为 FOR UPDATE > FOR NO KEY UPDATE > FOR SHARE > FOR KEY SHARE。

锁用多了还会遇到死锁(Deadlock):两个事务各自持有对方想要的锁,谁也走不下去。两个会话以相反顺序更新同样的两行即可复现:

-- 会话 A                               -- 会话 B
BEGIN;                                  BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
                                        UPDATE accounts SET balance = balance - 10 WHERE id = 2;
UPDATE accounts SET balance = balance - 10 WHERE id = 2;   -- 等待 B
                                        UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- 约 1 秒后 A 收到:
-- ERROR:  deadlock detected
-- DETAIL:  Process 111 waits for ShareLock on transaction 130001;
--          blocked by process 112. Process 112 waits for ShareLock on
--          transaction 130002; blocked by process 111.
ROLLBACK;                               ROLLBACK;

PostgreSQL 不会立刻发现死锁,而是等 deadlock_timeout(默认 1 秒)后才检测,一旦确认互等环就中止其中一个事务并报 deadlock detected(SQLSTATE 40P01);被牺牲的一方不可预测。双方回滚后可验证余额未被改动——死锁检测通过中止事务解除等待环;应用仍需正确处理回滚与重试。

规避死锁三板斧(官方建议):以一致的顺序锁定多个对象(例如都按 id 从小到大的顺序 UPDATE);对同一对象,在事务一开始就申请所需的最强锁模式;保持事务短小,绝不在事务里等待用户输入。再配上"对被中止的事务即时重试",系统就能健壮运行。

5.7 VACUUM、表膨胀与长事务的危害

MVCC 有存储和维护成本:旧版本留在表里,谁也不清就越攒越多。已更新、已删除留下的死元组(dead tuple)占据磁盘、拖慢扫描,表文件只增不减——这就是表膨胀(bloat)。所以 PostgreSQL 需要 VACUUM(清理)这个"垃圾回收"机制,它有四大用途:

  1. 回收已更新、已删除行的磁盘空间,供表内复用;
  2. 配合 ANALYZE 更新查询计划器依赖的统计信息;
  3. 更新可见性映射(visibility map),加速仅索引扫描(index-only scan,见第 04 章);
  4. 防止事务 ID 回卷(wraparound)造成数据丢失。
VACUUM accounts;           -- 普通清理:不阻塞读写
VACUUM ANALYZE accounts;   -- 清理的同时更新统计信息

注意一个反直觉的事实:普通 VACUUM 只把死元组空间标记为表内可复用,并不把空间还给操作系统(表尾部整页空闲的特例除外)。想真正缩小磁盘文件得用 VACUUM FULL——它重写整表、确实能"瘦身",但要拿 ACCESS EXCLUSIVE 锁阻塞一切访问,而且很慢。官方明确建议:尽量靠普通 VACUUM 维持稳态,避免 VACUUM FULL。自动清理后台进程 autovacuum 默认开启(依赖 track_counts = on),一般不要关。

比"忘了 VACUUM"更常见也更隐蔽的是长事务:只要还有一个事务"可能看到"旧版本,VACUUM 就不能回收它们。一个悬挂的旧事务会把之后所有删除、更新产生的死元组全部滞留在表里,造成持续膨胀,还会拖住事务 ID 水位线。事务 ID 只有 32 位、按 2 的 32 次方取模循环使用,约每 20 亿个事务就必须完成一次全库"冻结"清理,否则一旦回卷会导致灾难性的数据丢失——极端情况下系统会直接拒绝新的写事务来自保。

所以"事务里做慢事"是大忌:在事务中等用户输入、调外部 HTTP 接口、做长时间计算,都会制造长事务或 idle in transaction(事务中空闲)会话。查监控视图 pg_stat_activity 即可揪出它们:

SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE state <> 'idle'      -- 重点看 active 和 idle in transaction 的会话
ORDER BY xact_start        -- 事务开始得越早越靠前
LIMIT 5;

原则只有一条:把事务压到最短——BEGIN 后尽快做完该做的事就 COMMIT,发邮件、调接口、等用户都放到事务外。

常见误区

  • 忘了写 BEGIN:每条语句自动提交,中途出错时前面的语句早已生效、无法回滚,手工执行多条相关的 UPDATE 时最容易踩。
  • 出错后不回滚继续敲命令:事务进入 aborted 状态后,后续一切命令都报错,常被误以为"数据库坏了",正确做法是 ROLLBACK 或 ROLLBACK TO SAVEPOINT。
  • 以为回滚能退回序列号:自增 ID 的取值不随事务回滚,ID 有空洞是设计使然,不要试图"修复"。
  • 在可重复读/可串行化下不写重试逻辑:并发写冲突随时抛出 40001 错误,官方明确要求应用必须准备重试,一次不重试就可能丢单。
  • 事务里做慢事:等用户输入、调外部接口、长时间计算——形成长事务或 idle in transaction,堵死 VACUUM、引发表膨胀,重则拖出事务 ID 回卷风险。
  • 以为 DELETE 加 VACUUM 就能缩小磁盘文件:普通 VACUUM 只在表内标记复用;VACUUM FULL 才缩文件,但拿全表排他锁且很慢,生产上优先靠 autovacuum 稳住空间。
  • 带着其他数据库的印象理解 PG 隔离级别:PG 没有 READ UNCOMMITTED 的行为(等同 READ COMMITTED,不可能脏读);PG 的可重复读连幻读都不允许,但写冲突是直接报错而非默默阻塞。
  • 业务高峰跑 DDL 和维护命令:DROP TABLE、TRUNCATE、VACUUM FULL 会阻塞包括 SELECT 在内的一切访问;普通 CREATE INDEX 拿 SHARE 锁,虽不挡 SELECT,却会阻塞该表的所有写入。
  • 多行 UPDATE 不注意顺序导致死锁:两个事务以不同顺序更新相同的多行就会死锁;按主键等一致顺序更新,批量 FOR UPDATE 锁定同理。

参考来源

最后更新于

本页目录