跳转到内容
← 返回系统剖析
系统剖析当代24 分钟阅读

PostgreSQL 的 MVCC:读不阻塞写的代价

PostgreSQL MVCC: The Price of Readers Never Blocking Writers

一个再普通不过的场景:财务在跑一份要花八分钟的月度报表,与此同时,几百个用户正在下单、改地址、退款。报表读的是账户表,用户写的也是账户表。 在很多数据库的早期设计里,这两件事只能排队:要么报表把表锁住,写入全部堵在门外;要么写入拿到锁,报表被迫等待或者读到改了一半的数据。

PostgreSQL并发控制事务隔离存储引擎数据库运维

一个再普通不过的场景:财务在跑一份要花八分钟的月度报表,与此同时,几百个用户正在下单、改地址、退款。报表读的是账户表,用户写的也是账户表。

在很多数据库的早期设计里,这两件事只能排队:要么报表把表锁住,写入全部堵在门外;要么写入拿到锁,报表被迫等待或者读到改了一半的数据。

PostgreSQL 的回答是 MVCC(Multi-Version Concurrency Control,多版本并发控制):不让两边争夺同一份数据,而是让同一行数据同时存在多个版本,报表看它开始时那个版本,写入者去创造新版本。读不阻塞写,写不阻塞读。

这个回答很漂亮,但它有一笔必须有人来付的账单——而那笔账单,就是这篇文章的主题。

破除误解

误解一:MVCC 意味着 PostgreSQL 里没有锁。

不对。MVCC 消除的只是读与写之间的互斥。两个事务同时 UPDATE 同一行,第二个仍然会被行级排他锁挡住,一直等到第一个提交或回滚;ALTER TABLE 依然要拿表级的 ACCESS EXCLUSIVE 锁,把所有人挡在门外;死锁在 PostgreSQL 里照样会发生,deadlock_timeout(默认 1 秒)之后由死锁检测器杀掉其中一个事务。MVCC 只是把"读"这条路从锁的世界里搬了出去。

误解二:UPDATE 就是把那一行的字段改掉。

在存储层面完全不是。PostgreSQL 的堆表是不覆写(no-overwrite)的:一次 UPDATE 会在堆里插入一条全新的元组,然后在旧元组的头部标记"我在事务 N 的时候被作废了"。旧那条物理上还躺在原地,一个字节都没被改动。所以更新十次的一行,磁盘上可能真的躺着十一份数据。

误解三:DELETE 之后表就会变小,VACUUM 会把空间还给操作系统。

两句都错。DELETE 只是给元组盖了一个"死"的戳,文件大小不变。普通 VACUUM 之后,那些空间被登记进空闲空间映射(FSM),可以被同一张表的后续插入复用,但表文件通常不会缩小,磁盘不会还给系统。只有 VACUUM FULL(重写整表,全程持有 ACCESS EXCLUSIVE 锁)或 pg_repack 这类工具才真正把文件缩回去。运维里最常见的误判就是:"我删了一半数据,为什么磁盘一点没降?"

每行数据的身份证:xmin 与 xmax

PostgreSQL 给每个事务一个单调递增的 32 位整数编号,叫事务 ID(XID)。每一条物理行(术语叫元组,tuple)前面挂着一个 23 字节的头部,对齐后占 24 字节:

t_xmin       4B   插入这条元组的事务 ID
t_xmax       4B   删除/更新它的事务 ID(0 表示还没被作废)
t_cid        4B   事务内部的命令序号
t_ctid       6B   指向"下一个版本"的物理地址(块号+偏移)
t_infomask2  2B
t_infomask   2B   提交状态提示位、冻结位等
t_hoff       1B   数据从哪里开始
```

xminxmax 就是这一行的生卒年月:它由哪个事务生出来、被哪个事务判了死刑。 这两个数字不是元数据表里的记录,而是实实在在写在每一行数据前面的。

你可以直接把它们查出来——它们是任何表都自带的系统列:

sql
SELECT ctid, xmin, xmax, balance FROM accounts WHERE id = 7;
```

ctid 是物理地址,形如 (102,3),意思是"第 102 个 8KB 页面里的第 3 个行指针"。更新一次之后再查,你会看到 ctid 变了——这一行搬家了

快照:三个数字决定你能看见什么

事务开始查询时,PostgreSQL 给它拍一张快照(snapshot)。快照的本体只有三样东西:

  • xmin:此刻最老的仍在运行的事务 ID;
  • xmax:此刻即将分配的下一个事务 ID;
  • xip[]:介于两者之间、正在运行的事务 ID 列表。

有了它,判断"某个事务 ID 对我而言算不算已完成"就变成三条规则:小于快照 xmin 的,一定已经结束;大于等于快照 xmax 的,是我拍照之后才出现的未来,不算数;夹在中间的,去 xip[] 里查——在列表里就是还没提交,不在就是已完成。

再叠加元组头,可见性判断只剩一句话:

一条元组对我可见,当且仅当:它的 xmin 在我眼里已提交,而它的 xmax 为空、或者在我眼里尚未提交。

事务到底提交了还是回滚了,记在 pg_xact 目录下的提交日志里,每个事务只占 2 个 bit。为了不让每次读都去翻这个日志,PostgreSQL 会把结论作为提示位(hint bit)回写进元组的 t_infomask。这就解释了一个让很多人困惑的现象:一条纯 SELECT 也可能让页面变脏、产生磁盘写入——它在帮后来者省事。

隔离级别的差别,最终只落在"什么时候拍快照"上:

隔离级别快照策略典型后果
Read Committed(默认)每条语句重新拍一张同一事务里两次 SELECT 结果可能不同
Repeatable Read事务第一条语句拍一张,用到底全程一致;写冲突时报 could not serialize access
Serializable同上,外加 SSI 冲突追踪消除写偏斜,代价是更多事务被回滚重试

Repeatable Read 提供的其实就是学术上说的快照隔离(snapshot isolation)。它并不等于可串行化——著名的反例是"写偏斜":两位医生各自检查"至少还有一人值班"这个条件、各自看到的都是拍照那一刻的旧世界,于是双双请假。PostgreSQL 9.1(2011 年)引入的可串行化快照隔离(SSI,由 Kevin Grittner 与 Dan Ports 基于 Cahill 等人 2008 年的论文实现)通过追踪读写依赖环来堵住这个洞,代价是运行期记账开销和更高的回滚率。

UPDATE 的真相:插入新行,标记旧行

现在可以把 UPDATE 拆开看了。事务 500 执行 UPDATE accounts SET balance = 900 WHERE id = 7

  1. 找到旧元组,把它的 t_xmax 写成 500;
  2. 在堆里(优先是同一页)插入一条新元组t_xmin = 500t_xmax = 0
  3. 把旧元组的 t_ctid 指向新元组的地址,形成一条版本链
  4. 为新元组在每一个索引上插入索引条目。

于是几件事同时成立:

  • 事务 499 早些时候拍的快照里,500 尚未提交,它继续读旧元组——报表不会被打断,也不需要任何锁
  • 回滚几乎不要钱。 ROLLBACK 不需要撤销任何数据,只要在提交日志里把这个事务记成 aborted,它写的所有新元组自然就对所有人不可见。这是 PostgreSQL 相对于 undo 派系的一个真实优势:大事务回滚是 O(1),而不是漫长的回放。
  • 代价也随之出现:一行更新一次,堆里多一条尸体;如果这张表有 5 个索引,还要多 5 条索引条目。这就是所谓写放大

第 4 步的开销大到 PostgreSQL 必须专门优化。8.3 版本(2008 年)引入的 HOT(Heap-Only Tuple,仅堆元组)规定:如果新版本能塞进同一个 8KB 页面,并且没有修改任何被索引的列,那就完全不碰索引——索引条目继续指向该行的第一个版本,页内顺着 t_ctid 链找到最新可见的那个。HOT 链上的死元组还能在普通查询顺路做的页面剪枝(page pruning)中被回收,不必等 VACUUM

这也是为什么高频更新的表值得把 fillfactor 从默认的 100 调到 85–90:故意在每页留白,就是为了让新版本落得下去,从而走 HOT 这条便宜路。 反过来,只要你更新的那一列上建了索引,HOT 立刻失效,写放大原形毕露——Uber 工程团队 2016 年那篇引发大量争论的迁移文章,抱怨的正是这一点。

谁来打扫:VACUUM 的四份职责

死元组不会自己消失。VACUUM 是这套设计的必要配套,它干四件事:

  1. 回收死元组的空间,登记进空闲空间映射,供本表后续插入复用;
  2. 清除指向死元组的索引条目——这一步通常最贵,要扫索引;
  3. 维护可见性图(visibility map):给"整页都对所有人可见"的页面打上 all-visible 位,这是仅索引扫描(index-only scan)能成立的前提,也是 PostgreSQL 能少读堆的原因;
  4. 冻结老元组,推进 relfrozenxid——下一节的主角。

默认由 autovacuum 后台进程自动触发,阈值是"死元组数 > 50 + 20% 的表行数"(autovacuum_vacuum_thresholdautovacuum_vacuum_scale_factor)。这个百分比模型有个明显的缺陷:表越大,触发得越晚。10 亿行的表要攒够 2 亿个死元组才动手,那时清理本身已经成了灾难。所以大表的常规做法是单独把 scale_factor 调到 0.01 甚至更低。PostgreSQL 13 还补上了 autovacuum_vacuum_insert_threshold,让只插入不更新的表也能被照顾到——因为它们同样需要冻结。

膨胀失控:那条挪不动的水平线

VACUUM 有一条铁律:只有当一条死元组对所有仍在运行的事务都不可见时,才能删。 系统里最老的活跃快照,划出一条 xmin 水平线(xmin horizon),水平线之后产生的尸体,谁也不许碰。

于是最经典的生产事故长这样:某个连接执行完 BEGIN 就去干别的了,停在 idle in transaction 状态三个小时。它自己什么也没做,却把整个数据库的水平线钉死在三小时前。这三小时里所有表产生的死元组,autovacuum 一个都清不掉,只能眼睁睁看着表和索引一起膨胀。

会把水平线钉死的东西不止这一样:

原因观察位置典型处置
长事务 / idle in transactionpg_stat_activityxact_startidle_in_transaction_session_timeout
长分析查询同上的 query_start读写分离,或 statement_timeout
未被消费的复制槽pg_replication_slots.active及时删除废弃槽
备库反馈(hot_standby_feedback备库配置权衡:主库膨胀 vs 备库查询被取消
遗忘的两阶段事务pg_prepared_xactsROLLBACK PREPARED

膨胀的症状是渐进的:表文件越涨越大、缓存命中率下降、顺序扫描越来越慢、索引层数变深。等到明显感觉"数据库变慢了",往往已经膨胀了好几倍。补救手段要么是 VACUUM FULL(重写整表,全程排他锁,还需要一份等量的额外磁盘空间),要么是 pg_repack 这类在线重建工具(只在收尾时短暂加锁)。

事务 ID 回卷:32 位计数器的悬崖

现在说这套设计里最锋利的那个角。

事务 ID 只有 32 位,一共约 43 亿个。更要命的是,判断"谁在前谁在后"用的是模 2³¹ 的环形比较,因此任何时刻,一个事务只能把大约 21 亿个编号看作"过去"。

后果是致命的:如果一条元组的 xmin 比当前事务老了超过 21 亿个 XID,它会从"很久以前提交的,可见"翻转成"来自未来的,不可见"——数据不会报错,它会安静地消失。

PostgreSQL 的防御叫冻结(freeze):把足够老的元组标记为"永远可见",从此不参与年龄比较。9.4 版本起,冻结不再改写 xmin,而是在 t_infomask 上置一个冻结位,好处是原始的 xmin 被保留下来,出事时还能取证。9.6 版本给可见性图加了 all-frozen 位,让 VACUUM 可以跳过整页已冻结的部分,否则每次防回卷清理都得全表扫一遍。

围绕这条悬崖,PostgreSQL 设了一串护栏:

参数 / 阈值默认值触发时发生什么
vacuum_freeze_min_age5000 万VACUUM 顺手冻结比它老的元组
autovacuum_freeze_max_age2 亿强制启动防回卷 autovacuum,即使 autovacuum 被关掉也会跑
vacuum_failsafe_age(PG 14 起)16 亿紧急模式:跳过索引清理、无视限速,全力冻结
剩余约 4000 万 XID日志开始告警"必须在 N 个事务内完成 vacuum"
剩余约 300 万 XID数据库拒绝一切写入,只剩只读

最后那一行是运维必须理解它的全部理由:这是一次没有回滚余地的、可预测却常被忽视的停机。 症状滞后原因数周甚至数月,等到日志开始报警时,可用余量已经按小时计。真正的做法是把年龄当成常规监控指标:

sql
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;
```

正常值应当长期稳定在 2 亿附近波动(防回卷 autovacuum 会把它压回去)。一旦看到它单调爬升越过 10 亿,说明有东西挡住了冻结——通常还是上一节那张表里的某一行。

把 XID 扩到 64 位的补丁在社区讨论了很多年,难点在于元组头部寸土必争、堆页面布局要随之改动、还要处理升级路径;截至 PostgreSQL 18(2025 年 9 月发布,主打异步 I/O、UUIDv7、B 树跳跃扫描),它仍未进入正式发行版。回卷至今是一个必须靠运维纪律而不是靠版本升级解决的问题。

两条路线的分岔

把 MVCC 与传统的两阶段封锁(2PL)并排放,取舍就一目了然:

维度两阶段封锁(2PL)MVCC(快照)
读与写互相阻塞互不阻塞
读的开销加锁 / 放锁、锁表争用无锁,但每行要判可见性
写与写锁等待仍要行锁,先到先得
存储只有一份数据旧版本堆积
回滚撤销已改的数据改一个状态位即可
正确性严格 2PL 直接给出可串行化快照隔离有写偏斜,需要 SSI 补
清理不需要必须有后台回收,否则空间无界增长

而在 MVCC 阵营内部,"旧版本放哪"又分出两条路:

PostgreSQLOracle / MySQL InnoDBSQL Server(RCSI)
新版本写在哪堆里追加新元组原地更新,旧版本进 undo原地更新,旧版本进 tempdb
读旧版本直接读,成本恒定回放 undo 重建,链越长越慢读 tempdb
索引非 HOT 更新要更新全部索引索引列没变就不动索引类似
回滚近乎免费要回放 undo,大事务很慢要回放
病灶表膨胀、写放大、VACUUM 压力undo 空间膨胀、快照太旧报错tempdb 争用

没有哪一列全是优点。PostgreSQL 选择"把历史留在原地",换来了读历史版本和回滚都极快,代价是清理工作被完整地暴露给了运维。Oracle 与 InnoDB 选择"把历史搬走",换来紧凑的主表,代价是读旧数据要重建、回滚要付账。

这个分岔并非某天的临时决定。它可以一路追溯到 1980 年代中期 Michael Stonebraker 在伯克利做 POSTGRES 时的一个信念:磁盘会越来越便宜,与其费力覆写、再费力写 undo,不如永不覆写,让历史版本自然沉积——早期版本甚至据此提供过"时间旅行"式的历史查询,后来因维护成本被移除,但不覆写的存储骨架留了下来,成了今天每一次 UPDATE 的物理形状。

什么时候这套设计会失效

诚实地列出它撑不住的场景,比列优点有用得多:

  • 高频更新的小表。 计数器、会话表、任务队列表:每秒几千次 UPDATE,死元组的产生速度超过 autovacuum 的清理速度,一张只有一千行的表能膨胀到几个 GB。缓解手段是把热点计数挪去 Redis 之类的外部存储,或者用 fillfactor + 避免索引列更新来保住 HOT。
  • 被索引的列频繁更新。 HOT 直接失效,一次逻辑更新变成 1 次堆写 + N 次索引写。PostgreSQL 14 的"自底向上索引删除"缓解了索引膨胀,但没有取消这笔写入。
  • OLTP 与长分析查询混跑。 一条跑半小时的报表就能把清理停摆半小时。正解是把分析流量放到备库,并接受 hot_standby_feedback 带来的那个取舍。
  • 写偏斜类业务约束。 "至少保留一个管理员""余额之和不得为负"这类跨行不变量,快照隔离保证不了,必须上 SERIALIZABLE、显式加锁或数据库约束。
  • XID 消耗极快的系统。 每条语句都自成事务的高频写入,一天烧掉上千万 XID;如果同时又有东西挡住冻结,回卷的时钟就在滴答作响。

MVCC 真正的那句总结不是"读不阻塞写",而是:它把并发控制的成本,从查询发生的那一刻,推迟到了后台清理的那一刻。 成本没有消失,只是换了个时间、换了个人来付——通常是凌晨三点被叫醒的那个人。

跨域连接

  • 热力学定律:MVCC 把逻辑上的"原地修改"变成物理上的"只增不减",堆文件因此只会单调膨胀——这与热力学第二定律描述的过程惊人相似:系统不会自发回到紧凑有序的状态,要恢复秩序就必须从外部输入功。VACUUM 就是那份功,它消耗 I/O、CPU 与磁盘带宽,且必须持续地做;一旦停止输入,膨胀就以不可逆的方式积累。数据库的"整洁"不是一种静态属性,而是一种需要不断花钱维持的动态平衡。
  • 供应链:死元组就是在制品库存,VACUUM 就是发货,xmin 水平线就是被卡住的瓶颈工序——一个停在 idle in transaction 的连接堵住的不是它自己那条产线,而是全库所有表的出货口,这正是供应链里单点瓶颈引发全局积压的教科书形态。autovacuum 的"死元组超过 20%"阈值本质上是一条看板补货规则,而它在超大表上失灵的原因,与按固定百分比设安全库存在大批量生产中失灵的原因完全一致:真正该盯的是绝对积压量与流出速率,不是比例。
  • 安全工程:回卷防线是一套典型的分层保护设计,五道护栏依次是常规冻结、强制防回卷 autovacuum、紧急 failsafe、日志告警、只读停机——最后一道"拒绝写入"正是安全工程所说的故障安全(fail-safe):当所有前置屏障都被突破时,系统主动牺牲可用性来保住数据完整性,因为静默的数据消失比明确的停机严重得多。理解这一层的人会把告警设在第一道屏障,不理解的人会等到第五道才第一次听说 XID 这个词。
  • 认知偏误:表膨胀与 XID 老化都是慢变量,日常指标全绿、查询照常返回,问题却在后台线性累积——这正是正常化偏差与可得性偏误的温床:没出过事就被当作没有风险,于是监控只覆盖"能立刻感受到"的延迟与错误率,而不覆盖 age(datfrozenxid) 这种要提前数周才有意义的前置指标。慢性风险的治理难点从来不是技术,而是人类对"尚未造成后果的趋势"天然缺乏警觉。
  • 公地治理:可见性水平线是一份被全体连接共享的公共资源,任何一个会话只要长期持有旧快照,就单方面剥夺了所有其他人回收空间的能力——这是奥斯特罗姆意义上典型的公地问题:个体成本几乎为零,集体成本极高,纯技术手段无法解决。PostgreSQL 的应对方式恰恰是治理式的,用 idle_in_transaction_session_timeoutstatement_timeout 与复制槽清理策略给共享资源划定使用规则——把"请大家自觉不要开长事务"这样的口头约定,变成由系统强制执行的制度。

参考文献

  • Michael Stonebraker, "The Design of the POSTGRES Storage System", VLDB, 1987. —— 不覆写存储与历史版本沉积的原始设计论证。
  • Philip A. Bernstein & Nathan Goodman, "Multiversion Concurrency Control—Theory and Algorithms", ACM Transactions on Database Systems 8(4), 1983. —— 多版本并发控制的理论框架。
  • Hal Berenson, Philip Bernstein, Jim Gray, Jim Melton, Elizabeth O'Neil, Patrick O'Neil, "A Critique of ANSI SQL Isolation Levels", ACM SIGMOD, 1995. —— 快照隔离的定义与写偏斜异常的提出。
  • Michael J. Cahill, Uwe Röhm, Alan D. Fekete, "Serializable Isolation for Snapshot Databases", ACM SIGMOD, 2008. —— PostgreSQL 9.1 中 SSI 实现的直接理论来源。
  • Yingjun Wu, Joy Arulraj, Jiexi Lin, Ran Xian, Andrew Pavlo, "An Empirical Evaluation of In-Memory Multi-Version Concurrency Control", PVLDB 10(7), 2017. —— 对追加式与 undo 式版本存储的系统性实测比较。
  • PostgreSQL 官方文档《Concurrency Control》与《Routine Database Maintenance Tasks》两章(18 版,2025)。—— 隔离级别语义、VACUUM 职责与全部回卷相关参数的权威说明。

延伸阅读

  • Egor Rogov, 《PostgreSQL 14 Internals》(Postgres Professional, 2022,官方提供免费电子版)。全书前三分之一逐页讲透元组头、快照、页面剪枝与冻结,是中文读者之外最系统的 MVCC 教材。
  • Hironobu Suzuki, 《The Internals of PostgreSQL》(在线书,2015 年起持续更新)。第 5 章与第 6 章用大量图示拆解并发控制与 VACUUM,适合先建立直觉再读源码。
  • Uber Engineering, "Why Uber Engineering Switched from Postgres to MySQL" (2016) 及 PostgreSQL 社区的公开回应。一份来自反方的实战报告,把写放大与复制代价摆在真实规模下检验。