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 的锁类型和优先级,以及如何快速定位和解决死锁。