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 是事务可靠性的四个保证,务必记牢:
- A - Atomicity 原子性:要么全部成功,要么全部失败。
ROLLBACK是实现机制。 - C - Consistency 一致性:事务执行前后,数据库始终满足约束(主键、外键、CHECK 等)。
- I - Isolation 隔离性:并发事务互不干扰,就像串行执行一样。这是最复杂的特性,见下节。
- D - Durability 持久性:事务 COMMIT 后,即使断电也不丢数据。Postgres 用 WAL(Write-Ahead Log,预写日志)实现。
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 不可能 不可能 不可能三个并发"异常"概念要分清:
- 脏读(Dirty Read):读到其他事务未提交的数据。Postgres 任何级别都不可能。
- 不可重复读(Non-repeatable Read):同一事务里两次读同一行结果不同(其他事务提交了 UPDATE)。
- 幻读(Phantom Read):同一事务里两次范围查询结果集大小不同(其他事务提交了 INSERT / DELETE)。
选型建议: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 内置死锁检测器,会主动杀掉其中一个事务(抛错让它重试)。
避免死锁的黄金法则:
- 固定顺序访问资源:所有事务按相同顺序更新行(如按 id 升序)。
- 缩短事务:长事务更容易冲突,业务做完立即 COMMIT。
- 使用合适的隔离级别:不要无脑 SERIALIZABLE。
- 加锁显式化:用
SELECT FOR UPDATE提前声明,避免隐式锁升级。
8. WAL:持久性的保障
WAL(Write-Ahead Log,预写日志)是 Postgres 实现持久性(Durability)的核心。原理:事务提交时,先把变更记录写入 WAL 文件(顺序写很快),再异步更新数据文件。即使断电,重启后能用 WAL 重放未完成的数据更新。
- pg_wal 目录存放 WAL 文件(默认每个 16MB)。
- synchronous_commit:是否等 WAL 落盘才返回 COMMIT 成功。关掉它能提速,但可能丢最近几毫秒事务。
- 流复制:把 WAL 实时传到备库,实现高可用。
- PITR(Point-in-Time Recovery):基于 WAL 做时间点恢复,把数据库回滚到任意历史时刻。
9. 应用层的事务最佳实践
- 事务尽量短:打开事务后立刻做事,做完立即 COMMIT。不要在事务里做耗时操作(如调外部 API、发邮件)。
- 不要在事务里 sleep:占着连接不干活,会拖垮连接池。
- 失败必须 ROLLBACK:应用捕获到异常,要确保 ROLLBACK 而不是 COMMIT 部分错误数据。
- SERIALIZABLE 要重试:使用 SERIALIZABLE 隔离级别时,应用代码要捕获
could not serialize错误并重试整个事务。 - 用连接池:PgBouncer / Pgpool 复用连接,避免每个请求建新连接。
小结
这一章你深入理解了事务的 ACID 特性、PostgreSQL 的 MVCC 实现、四种隔离级别的取舍、死锁成因与避免。下一篇进入本系列最后一章——高级查询(窗口函数 / CTE / 递归)。
← 上一篇 PostgreSQL 索引
下一篇 PostgreSQL 高级查询 →