想象你们办公室有一块白板,上面写着会议室预约记录。运营要改一个预约、销售要新增一条记录、老板又要核对历史安排——如果只能“先到先得”地抢笔改字,所有人都会被卡住。PostgreSQL 采用的 MVCC(Multi-Version Concurrency Control,多版本并发控制)提供了一种更优雅的玩法:不擦旧字,直接贴新便签。要修改一行数据,就在上面盖一张新便签,旧版本原封不动压在底下;每个进来办事的人,进门时用特制眼镜拍一张“快照”,之后只看得到自己进门时的那个版本。这样读者永远不用等写者,写者也永远不用等读者。 本文要讲的就是这套“便签白板”系统的内幕:便签上的两枚印章(xmin/xmax)代表什么、什么叫快照、裁判如何判断哪张便签“可见”,以及深夜来撕旧便签的保洁阿姨 VACUUM 的工作原理。读完你就能理解 PostgreSQL 高并发背后的核心闭环,也懂得如何排查表膨胀、长事务等疑难杂症。
对 AI 从业者来说,PostgreSQL 是特征存储、元数据中心、AI 应用后端的高频数据库。理解 MVCC,你才能在高并发读写、数据一致性与训练快照之间做出正确取舍。
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
MVCC核心思想
还是回到那块办公室白板。传统数据库用“锁”来保护数据:一个人改的时候,其他人要么等着,要么干脆看不到。这就像白板前站着个保安——谁要写字,就得先排队,大家的时间都被浪费了。MVCC 把规则改成:别擦了,往上贴便签。每个“写者”都只需要在属于自己的那张新便签上写,旧便签留给旧读者,新便签服务新读者,互不干扰。
这个“一次更新生成新版本”的设计,就是多版本存储(Multi-Version Storage)。每一行逻辑数据,在物理上可能躺着好几个版本,通过版本链串起来。所谓快照隔离,是指每个事务在开始时取一个快照,它看到的世界就像按下快门的一瞬间,之后别人怎么贴便签都与它无关。
在 PostgreSQL 里,一行被 UPDATE 后,旧版本不会立刻消失,而是以“死元组(dead tuple)”的名义留在页面里,新版本通过 t_ctid 指向构成链条。不同版本允许同时存在的好处是:读操作永远不需要等写操作释放锁,写操作也只需要锁住“行”这一个点。付出的代价是:旧版本需要 VACUUM 回收,否则表会越来越大。
整个核心思想就是一句话:白板不是用来擦的,而是用来叠的。读旧、写新,各看各的版本,这就是 MVCC 的底层哲学。
- R₀最早版本:首次 INSERT 写入的物理元组,它是整条链的起点,此时 xmax 通常为 0。
- t_ctid版本链胶水:每个版本的 t_ctid 指向下一个版本的位置,像便签之间的胶带把新旧版本连成串。
- Rₙ最新版本:最近一次 UPDATE 产生的新版,读者沿链从新到旧寻找第一条对自己可见的版本。
事务机制基础
为了判断哪张便签“跟我有关”,每个进办公室的人要领一个工牌号——在 PG 里对应事务 ID(xid)。事务 ID 单调递增:谁来得早,谁的号就小。办公室前台(事务管理器)维护三样东西:当前未离场的人员清单、已走人员清单、被开除人员的清单,这正是“事务状态”的日常版本。
每个事务最终落在三种状态之一:进行中(in progress)、已提交(committed)、已回滚(aborted)。PostgreSQL 不会在每条元组里写死状态,而是把状态集中记在同一份“签到本”——CLOG(提交日志)里。判断“这个 xid 提交了吗”就是查 CLOG。
做可见性判断前,事务要先拍快照。快照由三部分构成:xmin(下界,小于它的都已结束)、xmax(上界,大于等于它的都是看不见的)、xip_list(活动事务清单,落在 xmin 与 xmax 之间的“还没走的人”)。在“读已提交”级别下,每一条 SQL 都会重新拍;在“可重复读”级别下,整个事务只拍一次。
前台手中的三份名单,决定了你能看到哪些便签。快照的“眼界”由此划定,接下来的一切判断都以它为基准。
- xmin下界:快照时刻小于 xmin 的事务都已尘埃落定(提交或回滚),它们写的版本可以放心看。
- xmax上界:大于等于 xmax 的事务还没开始,它们产生的任何版本对当前事务都不可见。
- xip_list活动清单:落在 xmin 与 xmax 之间仍在进行的事务,它们写的版本同样不可见。
行版本字段
每张便签打印出来时,都会盖两枚章:左边一枚“出生章”,盖着写者工号(xmin);右边一枚“作废章”,等着盖删除者的工号(xmax)。便签刚贴上去时作废章是空的;一旦有人把它替换掉,就在上面盖下那个人的工号。裁判只看这两枚章,就能判断“这张便签你该不该看”。
在 PG 中,每个元组分为两部分:元组头(Header)和数据区(Data)。元组头里就藏着 xmin、xmax、t_ctid、infomask 等信息。infomask 是位掩码,记录一些“结论缓存”,例如“这个 xmin 已验证为已提交”,下次不必再查 CLOG,直接看标记,这就是 Hint Bits 的加速原理。
xmin 负责回答“谁生的我”;xmax 负责回答“谁杀了我”;t_ctid 负责把新旧版本串成链;infomask 负责把重复劳动的结果记下来。旧版本数据本身仍留在堆表里,直到 VACUUM 来清。
有了这两枚章,每张便签都自带着完整的身世:什么时候生的、被谁杀的。读者眨一下眼睛,就能从一堆便签中认出属于自己的那张。
- xmin 出生戳记录插入该元组的事务 ID,是可见性判断的第一道门槛。
- xmax 死亡戳记录删除或更新该元组的事务 ID,0 表示未被删除。
- t_ctid 指针指向同一逻辑行的下一个版本,把新旧版本串成链条。
- infomask 标记位缓存 CLOG 的查询结果,让可见性判断免于重复查日志。
可见性判断
现在一名员工看白板上的某一“行”时,发现上面叠了三四张便签。他该信谁?裁判的规则非常机械:自上而下翻,找到第一张“出生章在我熟悉范围内、且作废章没有生效”的便签,那就是他该看的内容。
形式化地说,一个元组版本 t 对快照 S 可见,需要同时满足两个条件:第一,xmin 在快照 S 之前的已提交集合里(出生已兑现);第二,xmax 要么为 0(没人杀它),要么其所属事务在快照 S 时还没有提交(死亡未兑现,即删除者未生效)。
只要 xmin 未提交或已回滚,版本直接不可见;只要 xmax 所属事务已提交,版本就“已死亡”。剩余的边缘情形——xmax 事务仍在进行、或已回滚——都按可见处理。为了避免每次判断都查 CLOG,PG 顺手把结果写进 infomask(Hint Bits),第一次判完,后面都是 O(1) 的读标记。
(其中 $C_S$ 表示快照 S 形成时已提交的事务集合)
裁判不关心便签上的字写得对不对,只认两枚章。这机械的规则保证了任何时刻、任何事务看到的世界,都在逻辑上一致自洽。
- xmin ∈ C_S出生戳所在事务必须已提交,且发生在快照之前,版本才算真正出生。
- xmax = 0死亡戳为空,说明这个版本从出生至今没人杀它。
- xmax ∉ C_S杀死它的事务要么进行中、要么已回滚,死亡尚未生效。
- ∧ 组合判断出生已生效且死亡未生效,两者同时成立版本才可见。
事务隔离级别
快照就是员工戴的眼镜。四种隔离级别,本质上是“快门”按下的时机不同:有的人进门按一次快门,全场用一张照片;有的人每看一句话就重新拍一张。快门按得越频繁,越能看到“最新鲜”的画面,但也越可能看到前后不一致的画面。
PostgreSQL 支持四种隔离级别:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)、可串行化(Serializable)。有意思的是,PG 中读未提交实际上表现与读已提交相同——因为 MVCC 天生不会脏读,想读未提交都读不到。
读已提交在每个语句开始前拍快照,因此同一事务内两次 SELECT 可能看到不同结果(不可重复读)。可重复读则在事务开始时拍一次,之后所有语句沿用同一快照。PG 的可重复读还能彻底避免幻读,代价是检测到写冲突时直接抛序列化错误。可串行化在可重复读之上叠加 SSI(串行化快照隔离),进一步保证事务可以等价于“串行”执行。
眼镜不影响你的视力,只影响快门。选哪种隔离级别,本质上就是在“新鲜度”与“一致性”之间做权衡。
- 语句开始快照读已提交下每条 SQL 重新拍,看到的是“这条语句发出的瞬间”。
- 事务开始快照可重复读与可串行化在事务起点定格,之后一直沿用同一张快照。
- 选型权衡快照越新越实时,越旧越一致——新鲜度与一致性不可兼得。
更新删除流程
更新一块白板上的内容,内部发生了什么?员工掏出新便签写下最新值,啪地贴上去,然后在旧便签上盖一个自己的作废章。删除则更简单:找到当前可见的便签,直接盖作废章,不贴新的。
UPDATE:先定位目标行,加行锁,生成新版本(xmin=当前事务),并把旧版本 xmax 指向当前事务。DELETE:加行锁,把旧版本 xmax 指向当前事务,不产生新版本。行锁的用意是防止两个事务同时改同一行——MVCC 只解决读写互斥,写写依然要排队。
频繁 UPDATE 会让元组链变长,每次可见性判断都要沿链回看。HOT(Heap-Only Tuple)技术登场:如果更新的列不是索引列,就在原页面内追加新版本,不新增索引项,让索引始终指向链头,大幅降低索引维护成本。链太长或跨页更新时,HOT 会退化为普通更新。
更新就是“贴新签+盖旧章”两步连招,删除则是孤零零的一枚作废章。行锁只是便签板前的短暂排队,挡住的是两个写者,而不是读者。
- 旧版本盖死亡戳被更新的旧元组把 xmax 指向当前事务,表示“我不再代表最新值”。
- 新版本盖出生戳新元组以当前事务为 xmin,替代旧版本成为链上最新一员。
- 行锁保驾护航更新前对目标行加行锁,防止两个写者同时改同一行造成版本混乱。
VACUUM清理
白板上贴的便签越来越多,总得有人收拾。VACUUM 就是每晚来办公室的保洁阿姨:她把所有“已经不可能被任何事务看见”的旧便签撕下来,把位置腾出来给新便签用。但要注意:她只负责撕和腾,不负责把垃圾桶倒到楼下——空间是否归还操作系统是另一回事。
一个元组成为死元组(dead tuple)的充分条件是:它的版本已被更新或删除,且删除它的事务已提交,同时没有任何活跃事务的快照还能看到它。VACUUM 遍历表页,去掉这些死元组,更新可见性映射、维护统计信息。PG 提供 autovacuum 在后台自动执行这一过程。
事务 ID 是 32 位有符号整数,终究会用完。xid 回卷(wraparound)是 PG 最危险的场景之一:一旦用尽,数据库会强制停摆保护。解决办法是冻结(freeze):把足够老的元组 xmin 标记为 Frozen,使其对所有事务都可见,从而避免参与 xid 比较。autovacuum 的一项重要职责就是赶在回卷之前把旧事务 ID 冻结。
保洁阿姨不是天天来,而是按“垃圾量”动态上门。调低阈值等于让阿姨来得更勤、但更打扰;调高则省资源、但表更容易胖。
- N_dead 死元组数表中不可见且可回收的元组数量,是垃圾量的直接度量。
- threshold 基础阈值autovacuum_vacuum_threshold 默认 50,小表至少攒 50 个垃圾才触发。
- scale_factor 比例因子autovacuum_vacuum_scale_factor 默认 0.2,大表按活元组数的 20% 动态加码。
- 触发条件垃圾量超过静态与动态阈值之和,保洁阿姨开始上班。
MVCC实践优化
便签制度再好,如果大家贴完就走、保洁跟不上,办公室迟早变成废纸堆。实践中的 MVCC 优化,就是一套“卫生管理制度”:监控垃圾量、约束长驻者、定期大扫除。
表膨胀(bloat)指的是表实际占用空间远大于逻辑数据量。死元组若长期得不到回收、或 VACUUM 只腾出页内碎片却无法压缩页面,表就会越来越胖。最彻底的解决方案是 VACUUM FULL——表重写,把活元组复制进新文件,但会长时间锁表。
治理表膨胀的关键指标包括:n_dead_tup(死元组数)、last_vacuum、last_autovacuum,以及 pg_stat_user_tables 视图。常见的组合拳包括:合理设置 autovacuum 阈值与并发度;把大表按访问模式分区;坚决打击超长事务——长事务会让旧版本永远“有人看得见”,保洁阿姨永远没法动手。必要时在业务低峰期用 pg_repack 在线重写表,替代会锁表的 VACUUM FULL。
好的办公室,靠的是每一位员工及时贴签、保洁阿姨按量到岗、“长驻会议”及时散场。MVCC 的优化没有银弹,唯有读懂快照与版本,才能在高并发的钢丝上走得稳。
- N_total 总元组数页面内存在的全部元组,活元组加上死元组的总和。
- N_live 活元组数当前所有事务都能看到或可能看到的有效元组。
- BloatRatio 膨胀率死元组占总元组的比例,是衡量表健康度的核心指标。
AI 应用场景
高并发特征存储的读写隔离
阶段:数据管道构建与实时特征服务
解决的问题:特征更新任务频繁写入特征表,在线推理服务要持续读取;传统行锁会让写入阻塞线上推理请求。MVCC 让写者贴新版本、读者读旧快照,读写互不等待,推理服务永远拿到一致快照。
具体案例:某推荐系统把用户特征存 PG,每 5 分钟一批 ETL 更新特征列,同时推理服务每秒并发读数千次。设置“读已提交”级别后,读请求立刻返回一致数据,写任务不再阻塞读路径,P99 延迟从 800ms 降到 120ms。
训练数据一致性快照
阶段:训练数据准备与导出
解决的问题:训练集抽取与业务写入互不干扰;可重复读事务的一次快照能保证一个训练批次看到完全一致的过去,不受导出期间新写入的影响。
具体案例:NLP 团队要导出词频统计表参与模型训练,同时线上正在写入新语料。开启可重复读事务后,整个导出过程锁定同一张快照:导出一小时前的完整数据,导完也不受新写入影响,避免“边导边变”的数据漂移。
报表分析与在线业务共存
阶段:业务数据分析与指标监控
解决的问题:财务对账、运营报表这类长查询最怕被写入阻塞;MVCC 让长查询与在线事务并存,报表读取旧快照,业务照常更新,互不抢锁。
具体案例:某 SaaS 公司每天凌晨跑全量收入报表(查询耗时 20 分钟),同一时段用户订单仍在高频写入。得益于 MVCC 快照隔离,报表结果保持一致,订单写入零阻塞,DBA 不再需要为报表单独搭建只读从库。
Python 演示
class Tx: def __init__(self, tid): self.tid = tid self.state = 'active' def commit(self): self.state = 'committed' def abort(self): self.state = 'aborted' class Tuple: def __init__(self, xmin, xmax=0): self.xmin = xmin self.xmax = xmax self.chain = [self] def new_version(self, tid): self.xmax = tid new_t = Tuple(tid) self.chain.append(new_t) return new_t class MVCCStore: def __init__(self): self.rows = {} self.txs = {} def new_tx(self): tid = len(self.txs) + 1 self.txs[tid] = Tx(tid) return tid def commit(self, tid): self.txs[tid].commit() def abort(self, tid): self.txs[tid].abort() def visible(self, t): if self.txs[t.xmin].state != 'committed': return False if t.xmax != 0 and self.txs[t.xmax].state == 'committed': return False return True def read(self, key): for v in reversed(self.rows[key].chain): if self.visible(v): return v return None def main(): store = MVCCStore() t1 = store.new_tx() store.rows['room_a'] = Tuple(xmin=t1) store.commit(t1) t2 = store.new_tx() store.rows['room_a'].new_version(t2) print('t2 未提交时读取:', store.read('room_a').xmin) store.commit(t2) print('t2 提交后读取:', store.read('room_a').xmin) main()
self.xmax = tid给旧版本盖上“死亡戳”,表示被当前事务替换,对应 UPDATE 的双章操作。def visible(self, t):裁判函数,逐条复刻“出生戳已提交且死亡戳未生效”的核心可见性规则。if self.txs[t.xmin].state != 'committed':出生戳未提交则不可见,等价于“便签的出生章无效”。if t.xmax != 0 and self.txs[t.xmax].state == 'committed':死亡戳已提交则不可见,等价于“作废章已盖实”。for v in reversed(self.rows[key].chain):从最新版本往回找第一个可见版本,模拟版本链的回溯查找。store.commit(t2)事务提交后,新版本才对后续读者可见,旧版本彻底变为死元组。
常见误区
误区MVCC 多版本并发嘛,那是不是读写全都不加锁?我更新一行的时候,别人怎么更新同一行都没问题吧?
正解不对。MVCC 化解的是读写互斥,但写写之间依然要串行:UPDATE 时先对目标行加行锁,两个事务同时改同一行,后者会等待前者提交或回滚。就像白板上两个人同时伸手贴便签,总得有个先来后到,贴重了会乱套。
为何常见:“读写互不阻塞”这句宣传语太响亮,初学者容易把“读写”错听成“所有并发”,忽略了写写仍要排队的行锁机制。
误区xmin 是行的创建时间,xmax 是删除时间吧?那我看两个时间差就知道版本新旧了。
正解xmin/xmax 存的是事务 ID 而非时间戳。判断可见性时,PG 要和快照中的事务集合比较,而不是看墙上挂钟。事务 ID 单调递增,才使得“数字大小”可以间接表示先后顺序。
为何常见:字段名里的 min/max 容易被直觉理解成“最小时间/最大时间”,数据库教材画图又常把版本按时间轴排列,进一步误导读者。
误区反正有 autovacuum 自动跑,死元组会被清掉,那表大小就不会无限涨吧?
正解VACUUM 回收的是页内可复用空间,不一定把空间归还操作系统,文件可能依然很大。要真正“减肥”得靠 VACUUM FULL 重写表或 pg_repack(会锁表或独占资源)。而且长事务会让 VACUUM 无法推进,死元组照样堆积。
为何常见:“清理”这个词让人以为就是删文件,而表膨胀问题暴露得很慢,往往等磁盘告警才意识到 VACUUM 也有力所不及之处。
误区可重复读嘛,同一个事务两次查询可能看到不同的行——幻读嘛,MySQL 也这样,PostgreSQL 肯定也一样。
正解PostgreSQL 的可重复读基于快照隔离,整个事务只用一张快照,因此不会出现幻读。它的代价是另一种:当检测到“可串行化冲突”时,事务会直接报错终止,需要应用重试。这是 PG 与 MySQL 在 RR 级别上一个显著区别。
为何常见:网上大量 MySQL 资料把“可重复读仍会幻读”写得顺理成章,读者直接套用到 PG 上就张冠李戴了。
面试题
Q1. PostgreSQL MVCC 的核心思想是什么?
- A.通过读写锁让读等待写
- B.保存多版本读写互不阻塞← 正确答案
- C.复制多份数据到多台机器
- D.强制所有读写串行执行
答案:B。A 描述的是传统锁机制而非 MVCC;B 正确描述了“多版本并存、读写互不阻塞”的核心思想;C 是分布式复制;D 是极端串行化策略,都不是 MVCC 的本质。
Q2. PostgreSQL 中元组的 xmin 字段表示什么?
- A.产生该行版本的事务 ID← 正确答案
- B.删除该行版本的事务 ID
- C.该行数据最后一次修改时间
- D.该版本在数据页中的物理位置
答案:A。xmin 是“出生戳”,记录插入该元组的事务 ID;xmax 才是“死亡戳”,记录删除者的事务 ID。时间戳和物理位置分别由其他字段记录,本题选项 A 准确。
Q3. 事务快照中的 xip_list 是什么?
- A.已经提交事务的 ID 集合
- B.正在进行事务的 ID 集合← 正确答案
- C.已经回滚事务的 ID 集合
- D.全部历史事务的 ID 集合
答案:B。xip_list 是快照时刻仍在进行的事务 ID 列表,落在 xmin 与 xmax 之间的“没走的人”。已提交和已回滚事务通过 CLOG 判断,无需常驻快照;全部历史事务更不是快照的内容。
Q4. 一个元组的 xmin 已提交、xmax 指向的事务未提交,该元组对当前快照可见吗?
- A.可见,因为死亡戳未生效← 正确答案
- B.不可见,因为已被删除
- C.可见性由存储引擎决定
- D.会直接抛并发冲突异常
答案:A。出生戳已提交表示“版本已生效”,xmax 事务未提交表示“死亡尚未生效”,两者都满足可见条件。就像便签的出生章有效、作废章没盖,读者自然该看到这张便签。
Q5. 下列哪些情况会导致元组对当前事务不可见?
- A.xmin 所属事务还未提交← 正确答案
- B.xmin 所属事务已经回滚← 正确答案
- C.xmax 所属事务已经提交← 正确答案
- D.xmax 字段的值为零
答案:ABC。xmin 未提交或已回滚,版本“没出生”或“出生无效”,不可见;xmax 已提交说明删除者已生效,版本已死亡,不可见;xmax 为 0 表示没人杀它,反而是可见的。因此选 A、B、C。
Q6. PostgreSQL 中“读已提交”与“可重复读”的核心区别是什么?
- A.读写操作是否加表锁
- B.事务快照的获取时机← 正确答案
- C.是否使用索引访问
- D.是否启用自动提交
答案:B。读已提交每条语句重新拍快照,可重复读整个事务只拍一次,两者差异就是快照时机。锁策略和索引使用不是两者的核心区别,自动提交则与隔离级别无关。
Q7. 关于 PostgreSQL 的 UPDATE 操作,以下说法正确的是?
- A.直接原地修改元组内容
- B.新增版本并标记旧版 xmax← 正确答案
- C.删除整行后再插入新行
- D.必须获取表级排它锁
答案:B。PG 的 UPDATE 是“标记旧版 + 生成新版”:旧元组 xmax 指向当前事务,新元组以当前事务为 xmin。原地修改是错误的;DELETE 才只做标记;行级锁足够,不需要表级排它锁。
Q8. 导致 PostgreSQL 表膨胀的原因有哪些?
- A.频繁更新删除产生死元组← 正确答案
- B.长事务阻碍 VACUUM 回收← 正确答案
- C.清理后空间未归还操作系统← 正确答案
- D.索引过多导致写入变慢
答案:ABC。频繁 UPDATE/DELETE 产生死元组是膨胀的直接来源;长事务让 VACUUM 无法推进回收;VACUUM 只回收页内空间、不归还操作系统,文件保持大。索引过多只是写入开销,不是膨胀原因。
Q9. 什么是死元组(dead tuple)?
- A.被删除但尚未清理的元组← 正确答案
- B.因磁盘坏道损坏的数据
- C.事务刚写入未提交的元组
- D.索引中已经失效的指针
答案:A。死元组是被 DELETE 或 UPDATE 留下、且删除者已提交、已无人可见的旧版本,等待 VACUUM 回收。损坏数据、未提交的新数据、失效索引指针都不是“死元组”的定义。
Q10. 如何有效缓解 PostgreSQL 的表膨胀?
- A.用 VACUUM FULL 重写表← 正确答案
- B.调低 autovacuum 触发阈值← 正确答案
- C.压缩长事务的执行时长← 正确答案
- D.关闭后台自动清理功能
答案:ABC。VACUUM FULL 重写表可彻底归还空间;调低阈值让清理更频繁;压缩长事务能让回收推进更快。关闭 autovacuum 只会让死元组越积越多、膨胀加剧,因此 D 错误。