MVCC 与事务隔离级别
High Contrast
Dark Mode
Light Mode
Sepia
Forest
2 min read354 words

MVCC 与事务隔离级别

PostgreSQL 能在高并发下保持数据一致性,靠的是 MVCC(多版本并发控制)机制。理解 MVCC 和事务隔离级别,能帮你设计正确的并发策略——既不过度加锁拖慢系统,也不因为隔离级别不够出现脏读、幻读等并发 bug。


MVCC 的核心原理

MVCC 的关键思想:每次写操作不覆盖旧数据,而是创建新版本。读操作看到的是"快照",不需要等待写操作完成。

传统数据库(无 MVCC):
Reader ─────── 等待 ──────→ 读到数据
↑ 阻塞
Writer ──── 写入(加锁)──→ 释放锁
PostgreSQL MVCC:
Reader ─────────────────→ 读到历史版本快照(无需等待)
Writer ──── 写入新版本 ──→ 完成
-- 演示 MVCC 快照:事务开始后看到的是快照
-- 会话 A:
BEGIN;
SELECT * FROM products WHERE id = 1;
-- 看到:price = 6999
-- 会话 B:同时更新
UPDATE products SET price = 7999 WHERE id = 1;
COMMIT;
-- 会话 A 继续(事务还未结束):
SELECT * FROM products WHERE id = 1;
-- 仍然看到:price = 6999 ← 读取的是事务开始时的快照
COMMIT;  -- 事务结束后才能看到 7999

四个标准隔离级别

隔离级别 脏读 不可重复读 幻读 PostgreSQL 实现
READ UNCOMMITTED 可能 可能 可能 PG 实际等同于 READ COMMITTED
READ COMMITTED 不可能 可能 可能 PG 默认级别
REPEATABLE READ 不可能 不可能 可能 PG 中也防幻读
SERIALIZABLE 不可能 不可能 不可能 全序列化(最严格)
-- 设置隔离级别
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;   -- 默认
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

READ COMMITTED(默认):语句级快照

-- 每条 SQL 语句开始时重新获取快照
-- 同一事务内,两次相同查询可能看到不同结果(不可重复读)
-- 会话 A:
BEGIN;  -- 使用默认 READ COMMITTED
-- 第一次查询
SELECT balance FROM accounts WHERE user_id = 1;
-- 结果:1000
-- 会话 B:此时提交了 UPDATE
-- UPDATE accounts SET balance = 500 WHERE user_id = 1;
-- COMMIT;
-- 会话 A 继续(同一事务内):
SELECT balance FROM accounts WHERE user_id = 1;
-- 结果:500 ← 和第一次不同!这是"不可重复读"
COMMIT;
-- ✅ 适合大多数 OLTP 场景
-- ❌ 不适合需要在事务内保证一致视图的场景(如报表)

REPEATABLE READ:事务级快照

-- 整个事务期间,所有读操作使用同一个快照
-- PostgreSQL 的 REPEATABLE READ 还额外防止幻读(比 SQL 标准更严格)
-- 会话 A:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE user_id = 1;
-- 结果:1000
-- 会话 B:提交 UPDATE,余额改为 500
-- 会话 A:
SELECT balance FROM accounts WHERE user_id = 1;
-- 结果:1000 ← 仍然是事务开始时的值,不受其他事务影响
-- 但如果会话 A 尝试 UPDATE:
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
-- 错误!可能遇到:
-- ERROR: could not serialize access due to concurrent update
-- 这是 PostgreSQL 保护数据一致性的机制
ROLLBACK;
-- 重试整个事务
-- ✅ 适合:长时间报表查询、需要事务内一致视图的业务逻辑
-- ❌ 适合:写冲突频繁的场景(需要应用层重试)

SERIALIZABLE:可序列化隔离

-- 最高隔离级别:确保并发事务的结果等同于某种串行执行顺序
-- PostgreSQL 使用 SSI(Serializable Snapshot Isolation)实现
-- 经典场景:双重检查 + 写入(Check-then-Act 模式)
-- 假设:每个用户每天只能下一个订单
-- ❌ 用 READ COMMITTED 时,两个并发事务都通过了检查,都成功创建了订单
-- (幻读问题)
-- ✅ 用 SERIALIZABLE:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 检查今天是否已有订单
SELECT COUNT(*) FROM orders
WHERE user_id = 101 AND created_at::date = CURRENT_DATE;
-- 结果:0
-- 另一个并发事务也做了同样的检查,也得到 0
-- 创建订单
INSERT INTO orders (user_id, total_amount) VALUES (101, 299.00);
COMMIT;
-- 如果存在序列化冲突,会报错:
-- ERROR: could not serialize access due to concurrent update
-- 应用层捕获此错误并重试
-- ✅ 适合:金融交易、库存扣减、唯一性约束无法覆盖的业务规则
-- ❌ 不适合:高并发写入场景(大量重试开销)

实用:选择隔离级别的决策流程

graph TD A["新业务逻辑"] --> B{"需要事务内\n一致视图?"} B --> |否| C["READ COMMITTED\n(默认,不改)"] B --> |是| D{"涉及写操作?"} D --> |只读报表| E["REPEATABLE READ\n或 READ COMMITTED\n(快照读)"] D --> |有写操作| F{"写操作依赖\n读取结果?"} F --> |否| G["REPEATABLE READ\n(谨慎处理冲突错误)"] F --> |是(Check-then-Act)| H["SERIALIZABLE\n(应用层重试)"]

事务最佳实践

-- ✅ 事务要短:减少锁持有时间
BEGIN;
-- 快速执行
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- ❌ 事务中不要有网络 I/O 或用户交互
BEGIN;
SELECT * FROM orders WHERE id = 1;
-- 等待用户确认... ← 这期间持有锁!可能几秒甚至几分钟
UPDATE orders SET status = 'confirmed' WHERE id = 1;
COMMIT;
-- ✅ 正确做法:先读取,在应用层处理,再用短事务写入
-- SELECT → 应用层处理 → BEGIN; UPDATE; COMMIT;
-- ✅ 设置事务超时,防止意外长事务
SET lock_timeout = '5s';     -- 等锁超过 5 秒就报错
SET statement_timeout = '30s'; -- 单条语句超过 30 秒就报错
-- 查看当前长事务
SELECT
pid,
now() - xact_start AS duration,
state,
LEFT(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > INTERVAL '5 minutes'
ORDER BY duration DESC;

VACUUM 与事务 ID 回绕

MVCC 的代价是旧版本数据的积累,VACUUM 负责清理:

-- 查看表的"膨胀"情况
SELECT
schemaname,
tablename,
n_dead_tup AS dead_rows,         -- 待清理的旧版本行数
n_live_tup AS live_rows,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
-- 手动触发 VACUUM(不锁表)
VACUUM products;
-- VACUUM ANALYZE:同时更新统计信息
VACUUM ANALYZE orders;
-- 查看事务 ID 消耗情况(防止事务 ID 回绕)
SELECT
datname,
age(datfrozenxid) AS xid_age,
2^31 - age(datfrozenxid) AS xids_remaining
FROM pg_database
ORDER BY xid_age DESC;
-- xid_age 接近 2^31(约 21 亿)时需要紧急 VACUUM FREEZE

下一节锁机制与死锁排查——表锁、行锁、Advisory Lock——PostgreSQL 的锁类型和优先级,以及如何快速定位和解决死锁。