Skip to content

MVCC 多版本并发控制 ​

核心问题:PostgreSQL 的 MVCC 是如何实现的?与 MySQL 有什么区别?为什么会产生表膨胀?

1. 它解决了什么问题? ​

在没有并发控制的数据库里,读写操作会互相阻塞——有人在写数据,其他人必须等他写完才能读。这在高并发场景下是不可接受的。

MVCC(Multi-Version Concurrency Control,多版本并发控制) 的核心思想是:每次修改数据时不覆盖原值,而是保留旧版本,让读操作看到的是某个时间点的快照。这样读和写互不阻塞,大幅提升并发性能。

但代价是:旧版本行(PG 里叫 Dead Tuple)会堆积在表里。如果不清理,表文件会持续膨胀,查询越来越慢。PG 把清理工作交给了 VACUUM 机制(下一章讲)。

生活类比:图书馆(数据库)里的书(数据行)被更新后,新版本放在书架上,旧版本还占着位置(Dead Tuple)。如果不定期清理旧版本,书架越来越满,找书(查询)时需要跳过大量废弃书籍,效率越来越低。

2. PG 的 MVCC 实现原理 ​

2.1 隐藏字段:xmin / xmax ​

PG 不会显式地给每行打上"版本号",而是通过两个隐藏字段来追踪行的生命周期。

事务 ID(XID):PG 给每个写事务分配的 32 位递增编号,MVCC 靠比较它的大小判断可见性。它不会无限增长,到达上限会回卷(见 §6)。

隐藏字段含义类比
xmin创建该行的事务 ID"这本书是哪天上架的"
xmax删除/更新该行的事务 ID"这本书是哪天下架的"(0 表示还在架上)

你可以用 SELECT xmin, xmax, * FROM table_name 查看这两个隐藏字段。

2.2 写操作:不覆盖,只追加 ​

PG 的 UPDATE 操作不是"原地修改",而是两步走:

  1. 插入新行:新行的 xmin = 当前事务 ID
  2. 标记旧行过期:旧行的 xmax = 当前事务 ID

DELETE 操作类似,只是不插入新行,只标记旧行的 xmax。

sql
UPDATE users SET name = '李四' WHERE id = 1;

这条语句执行前后,表里的行版本变化:

txt
执行前:| id=1, name='张三', xmin=100, xmax=0   |  ← 活跃行
执行后:| id=1, name='张三', xmin=100, xmax=500  |  ← Dead Tuple(旧版本)
        | id=1, name='李四', xmin=500, xmax=0   |  ← 新版本

2.3 读操作:快照隔离 ​

每个事务开始时会获取一个快照(Snapshot),记录当时所有活跃事务的 ID。读数据时,通过比较行的 xmin/xmax 和快照来判断该行是否"对我可见":

  • xmin 已提交 且 xmax 为 0 → 可见(最新版本)
  • xmin 已提交 且 xmax 已提交 → 不可见(已被删除/更新)
  • xmin 是活跃事务 → 不可见(还没提交)

这就是为什么读操作不需要加锁——它看到的是某个时间点的一致性快照,不受其他事务的写操作影响。

3. 与 MySQL 的关键差异 ​

PG 和 MySQL 都实现了 MVCC,但实现方式完全不同:

对比点PostgreSQLMySQL (InnoDB)
旧版本存储位置堆表中(与新版本共存)Undo Log(独立的回滚段)
旧版本清理方式VACUUM 主动清理事务提交后自动回收
表膨胀风险有(需要 VACUUM)无(Undo Log 自动回收)
读性能无需回溯 Undo Log,稳定长事务需要回溯 Undo Log 链
长事务影响阻塞 VACUUM → 表膨胀Undo Log 膨胀 → 占用空间

为什么 PG 选择把旧版本存在堆表中? 读操作不需要去 Undo Log 中回溯旧版本,读性能更稳定。代价是需要 VACUUM 定期清理 Dead Tuple,否则表空间持续增长。这是一个读性能 vs 维护成本的取舍。

4. 表膨胀 ​

当 Dead Tuple 不断堆积、没有被及时清理时,表文件会持续增大,这就是表膨胀(Table Bloat)。

表膨胀的直接影响:

  • 查询变慢:扫描同样多的有效数据,需要读取更多的数据页
  • 索引效率下降:索引指向的页面包含大量 Dead Tuple
  • 磁盘浪费:空间被 Dead Tuple 占用,无法复用

4.1 监控表膨胀 ​

通过 pg_stat_user_tables 视图可以看到每个表的 Dead Tuple 数量:

sql
SELECT 
    schemaname,
    tablename,
    n_live_tup        AS 活跃行数,
    n_dead_tup        AS 死亡行数,
    ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS 死亡比例,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

判断标准:

  • 死亡比例 < 5%:健康
  • 死亡比例 5%~20%:需要关注
  • 死亡比例 > 20%:需要立即处理

表膨胀的详细检测与治理方法见 生产避坑 - 表膨胀检测与治理。

5. 长事务:表膨胀的元凶 ​

VACUUM 不能清理所有 Dead Tuple。它必须保留比最老活跃事务更"新"的旧版本,因为长事务可能还需要读取这些数据(MVCC 的快照隔离保证)。

txt
时间线:
事务 A 开始(读取快照)────────────────────────────────────── 还在运行!
事务 B: UPDATE → 产生 Dead Tuple 1
事务 C: UPDATE → 产生 Dead Tuple 2
VACUUM: 想清理 Dead Tuple 1 和 2,但事务 A 可能还需要它们 → 只能等!

这就是为什么长事务是表膨胀的主要原因。一个开了几天没提交的事务,会阻止整个数据库的 VACUUM 清理工作。

5.1 排查长事务 ​

sql
-- 查看是否有长时间运行的事务
SELECT 
    pid,
    usename,
    now() - pg_stat_activity.query_start AS duration,
    state,
    LEFT(query, 80) AS query
FROM pg_stat_activity
WHERE state != 'idle'
  AND now() - pg_stat_activity.query_start > interval '5 minutes'
ORDER BY duration DESC;

-- 查看最老的活跃事务(这个事务决定了 VACUUM 能清理多远)
SELECT 
    pid,
    usename,
    now() - xact_start AS xact_age,
    state,
    LEFT(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 5;

5.2 解决方案 ​

  1. 设置事务超时:SET idle_in_transaction_session_timeout = '5min'(自动终止空闲事务)
  2. 应用层控制:事务中不要调用外部接口、不要等用户输入
  3. 及时终止:发现长事务立即 pg_terminate_backend(pid)
  4. 监控告警:对超过 10 分钟的活跃事务告警

6. 事务 ID 回卷(XID Wraparound) ​

致命问题:这是 PG 最严重的生产事故之一。处理不当会导致数据库自动关机保护,极端情况下可能丢数据。

6.1 原理 ​

PG 的事务 ID 是 32 位无符号整数,范围约 42 亿。正常情况下事务 ID 会递增,但到达最大值后会回卷到 0。

问题在于:MVCC 通过比较事务 ID 大小来判断行的可见性。如果新事务的 ID(比如 100)比旧事务的 ID(比如 42 亿)还小,可见性判断就会彻底错乱——旧数据可能被错误地当作"未来数据"而不可见。

PG 的防护机制:当事务 ID 年龄接近 2^31 时,强制关闭数据库,阻止一切写操作,要求先执行 VACUUM FREEZE 冻结旧事务 ID。

6.2 监控事务 ID 年龄 ​

sql
-- 查看数据库的事务 ID 年龄
SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    2^31 - age(datfrozenxid) AS remaining
FROM pg_database
ORDER BY xid_age DESC;

健康标准:

  • xid_age < 2 亿:正常
  • xid_age 2~5 亿:需要关注
  • xid_age > 5 亿:告警,立即处理
  • xid_age > 10 亿:紧急,数据库可能随时强制关闭

6.3 关键参数 ​

参数默认值说明
autovacuum_freeze_max_age2 亿达到此值时强制触发 VACUUM FREEZE
vacuum_freeze_min_age5000 万VACUUM 时,小于此年龄的事务 ID 才会被冻结
vacuum_freeze_table_age1.5 亿超过此值时 VACUUM 扫描全表
vacuum_failsafe_age16 亿PG 14+,超过此值时跳过 IO 节流,全力冻结

6.4 紧急处理 ​

sql
-- 当事务 ID 年龄 > 5 亿时,立即执行
VACUUM FREEZE your_table;

-- 如果是整个数据库的问题
vacuumdb --freeze --all

-- 如果数据库已强制关闭,只能用单用户模式修复
postgres --single -D /var/lib/postgresql/16/main your_db
> VACUUM;

Released under the MIT License.