PostgreSQL 事务与 MVCC

事务是数据库可靠性的核心。它把一组 SQL 打包成"不可分割"的单元,要么全部成功,要么全部撤销——这是金融系统、订单系统、计费系统不能出错的根本保证。PostgreSQL 通过 MVCC(多版本并发控制)实现高效的事务隔离,本篇讲透 ACID、隔离级别、MVCC 原理、死锁与避免方法。

1. 为什么需要事务?

想象一个转账场景:从账户 A 扣 100 元,加到账户 B。这两步必须同时成功或同时失败——不能扣了钱却没加上,也不能加了钱却没扣。如果中间任何一步出错(断电、网络故障、约束违反),数据库必须有办法把状态恢复到"什么都没发生过"。

这就是事务存在的意义。事务(Transaction)是一组 SQL 的逻辑单元,具备 ACID 四个特性(下面会展开)。

2. 事务的基本用法

BEGIN 开启事务,用 COMMIT 提交或 ROLLBACK 回滚。

-- 事务:把一组 SQL 打包成一个"不可分割"的单元
-- 要么全部成功(COMMIT),要么全部撤销(ROLLBACK)

-- 经典案例:转账
BEGIN;    -- 开启事务(等价于 START TRANSACTION)

UPDATE accounts SET balance = balance - 100 WHERE id = 1;   -- 扣钱
UPDATE accounts SET balance = balance + 100 WHERE id = 2;   -- 加钱

-- 检查余额是否充足
SELECT balance FROM accounts WHERE id = 1;
-- 假设发现扣成负数,撤销一切
ROLLBACK;

-- 如果一切正常,提交(改动才真正持久化)
-- COMMIT;

-- === 三大控制语句 ===
BEGIN;       -- 开启事务
COMMIT;      -- 提交(全部生效,不可撤销)
ROLLBACK;    -- 回滚(全部撤销,像没发生过)

-- 等价写法
START TRANSACTION;
BEGIN WORK;
BEGIN ISOLATION LEVEL SERIALIZABLE;  -- 指定隔离级别

自动提交(autocommit):Postgres 默认每条 SQL 自动包裹一个事务并立即提交。所以你平时写 SELECT 1; 不需要显式 BEGIN。只有在多条 SQL 需要"原子性"时才手动开启事务。

3. ACID 四特性

ACID 是事务可靠性的四个保证,务必记牢:

4. 隔离级别(Isolation Level)

隔离级别控制并发事务之间的可见性。级别越高越安全,但性能越低。SQL 标准定义了四种,Postgres 实际支持三种:

-- === 四种事务隔离级别(SQL 标准)===
-- Postgres 实际只有三种(READ UNCOMMITTED 被当作 READ COMMITTED 处理)

-- 1. READ COMMITTED(默认,大多数场景够用)
--   SELECT 看到的是"语句开始时"已提交的数据
--   同一事务里两次 SELECT 可能看到不同结果(其他事务提交了)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 2. REPEATABLE READ(Postgres 的实现已避免幻读)
--   SELECT 看到的是"事务开始时"的快照
--   同一事务里两次 SELECT 结果一致(即使其他事务提交了)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 3. SERIALIZABLE(最严格,Postgres 用 SSI 实现)
--   完全等价于"事务串行执行"
--   并发冲突时会抛错,应用需重试
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 用法:在 BEGIN 后立即设置
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT ...;
COMMIT;

-- === 各级别能解决的并发问题 ===
--                  脏读    不可重复读   幻读
-- READ UNCOMMITTED  可能     可能        可能   (Postgres 视为 RC)
-- READ COMMITTED    不可能   可能        可能   (Postgres 默认)
-- REPEATABLE READ   不可能   不可能      不可能 (Postgres 已防幻读)
-- SERIALIZABLE      不可能   不可能      不可能

三个并发"异常"概念要分清:

选型建议:99% 场景用默认的 READ COMMITTED。需要"事务内一致性快照"时用 REPEATABLE READ。严格金融场景才用 SERIALIZABLE(注意要写重试逻辑)。

5. MVCC:多版本并发控制

MVCC 是 Postgres 实现 ACID 的核心机制,也是它性能优秀的根本原因。

核心思想:更新数据时不覆盖旧版本,而是新建一个版本。这样读操作读旧版本,写操作写新版本,两者不冲突——读写互不阻塞

-- === MVCC:多版本并发控制(Postgres 的灵魂)==

-- 核心思想:更新数据时不覆盖旧版本,而是新建一个版本
-- 这样读操作不会被写操作阻塞,反之亦然

-- 例子:用户 A 在读取 id=1 的行,用户 B 想更新 id=1
-- 传统数据库(锁机制):B 必须等 A 读完
-- MVCC 数据库:B 直接写入"新版本",A 继续读"旧版本",互不干扰

-- 验证 MVCC:更新后行数不变,但 xmin(创建事务 ID)变了
CREATE TABLE demo (id INT, name TEXT);
INSERT INTO demo VALUES (1, 'old');
SELECT xmin, * FROM demo;   -- xmin = 12345(创建事务 ID)
UPDATE demo SET name = 'new' WHERE id = 1;
SELECT xmin, * FROM demo;   -- xmin = 12346(新事务 ID,新行版本!)

-- === MVCC 的代价:死元组(dead tuples)==
-- 旧版本留在磁盘上,需要 VACUUM 清理
-- 不清理会导致表膨胀、查询变慢

-- 查看表的死元组数
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'demo';

-- VACUUM:清理死元组(不锁表)
VACUUM demo;

-- VACUUM FULL:重写表,回收空间(锁整张表!)
VACUUM FULL demo;     -- 谨慎!会阻塞所有读写

-- AUTOVACUUM:Postgres 自动定期跑 VACUUM
-- 默认开启,生产环境一般不需要手动 VACUUM,但大表建议监控

MVCC 的代价:旧版本(dead tuple)留在磁盘上,需要 VACUUM 清理。如果不清理,表会"膨胀"——占用越来越多空间,查询变慢。Postgres 默认开启 autovacuum 自动清理,但大表仍需要监控。

6. SAVEPOINT:事务内的书签

正常事务里任何一条 SQL 出错,整个事务会标记为 abort,后续语句都报错。SAVEPOINT 让你只回滚到某个点,不放弃整个事务。

-- SAVEPOINT:在事务内部设"书签",支持部分回滚
-- 不必因一处错误就放弃整个事务

BEGIN;

INSERT INTO orders (user_id, total) VALUES (1, 100) RETURNING id;
-- 假设返回 id = 10

SAVEPOINT sp_insert_items;

INSERT INTO order_items (order_id, product_id, qty)
VALUES (10, 1, 2);

-- 这条失败(比如 product_id 不存在)
INSERT INTO order_items (order_id, product_id, qty)
VALUES (10, 999, 1);
-- ERROR: foreign key violation

-- 不放弃整个事务,只回滚到 savepoint
ROLLBACK TO sp_insert_items;

-- 重新尝试正确的插入
INSERT INTO order_items (order_id, product_id, qty)
VALUES (10, 2, 1);

COMMIT;   -- 整个事务(包括 orders 和正确的 order_items)都生效

-- === PostgreSQL 自动保存点 ===
-- 实际上,Postgres 在事务中遇到错误时整个事务会标记为 abort
-- 后续任何语句都报错,直到 ROLLBACK
-- 解决方法:用 SAVEPOINT 或开启 ON_ERROR_ROLLBACK(psql 专有)

实战应用:批量导入数据时,某一行出错不想放弃整批——用 SAVEPOINT,失败时回滚到书签,继续处理下一行。

7. 锁与死锁

当多个事务同时修改同一行时,数据库用保证一致性。Postgres 主要用行锁(不阻塞整个表),但死锁仍可能发生。

-- === 锁与死锁 ===

-- 1. 行锁(隐式获得):UPDATE / DELETE / SELECT FOR UPDATE 自动加行锁
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 此时 id=1 的行被锁住,其他事务的 UPDATE 会等待
-- 直到本事务 COMMIT 或 ROLLBACK

COMMIT;

-- 2. 显式行锁:SELECT FOR UPDATE
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 先锁住这行,再决定怎么更新,避免并发覆盖
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- 3. FOR UPDATE vs FOR SHARE
--   FOR UPDATE   : 排他锁(其他人不能读改)
--   FOR SHARE    : 共享读锁(其他人能读不能改)
--   FOR NO KEY UPDATE : 弱排他锁(允许其他人 FOR SHARE)
--   FOR KEY SHARE     : 最弱共享锁(只锁主键)

-- 4. 表锁(显式)
LOCK TABLE accounts IN ACCESS EXCLUSIVE MODE;
-- 最严格的表锁,阻塞一切读写

-- === 死锁:两个事务互相等待 ===
-- 事务 A:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;   -- 锁 id=1
UPDATE accounts SET balance = balance + 100 WHERE id = 2;   -- 等 id=2

-- 同时事务 B:
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;    -- 锁 id=2
UPDATE accounts SET balance = balance + 50 WHERE id = 1;    -- 等 id=1
-- 死锁!Postgres 检测到后,会主动杀掉其中一个事务

-- ERROR: deadlock detected
-- DETAIL: Process ... waits for ShareLock on transaction ...;
--         blocked by process ....

-- 避免死锁的黄金法则:所有事务按相同顺序访问资源
-- 比如转账永远"先锁 id 较小的账户,再锁 id 较大的"

死锁是两个事务互相等待对方持有的锁——双方都动不了。Postgres 内置死锁检测器,会主动杀掉其中一个事务(抛错让它重试)。

避免死锁的黄金法则:

8. WAL:持久性的保障

WAL(Write-Ahead Log,预写日志)是 Postgres 实现持久性(Durability)的核心。原理:事务提交时,先把变更记录写入 WAL 文件(顺序写很快),再异步更新数据文件。即使断电,重启后能用 WAL 重放未完成的数据更新。

9. 应用层的事务最佳实践

小结

这一章你深入理解了事务的 ACID 特性、PostgreSQL 的 MVCC 实现、四种隔离级别的取舍、死锁成因与避免。下一篇进入本系列最后一章——高级查询(窗口函数 / CTE / 递归)

← 上一篇 PostgreSQL 索引

下一篇 PostgreSQL 高级查询

✈️💬