一、抄书的等修书的,修书的等抄书的

万卷楼是京城最大的藏书楼。三层的木楼,藏书八万六千卷,每天有几十个抄书先生进进出出——有人来誊抄古本,有人来校勘错字,有人来续写新章。

楼里有一条老规矩:一本书,同一时刻只能一个人碰。

这条规矩害死人。

去年腊月,朝中要修《皇舆总览》,十二位抄书先生同时进了万卷楼。十二个人,需要查同一套《天下郡县志》——全书六十卷,十二个人各抄不同章节。

按理说各抄各的,互不相干。但规矩说了"一本书同一时刻只能一个人碰"。第一个抄书先生抢到了第三卷,第二个只能等。第三个等第二个。第十二个坐在门槛上喝茶,等了整整一上午。

更要命的是修书的。

校勘官周大人来修《天下郡县志》第十五卷——他要改三处地名。可十五卷正被一位抄书先生抄着。周大人等。抄书先生抄完了,周大人刚要接手,又一位抄书先生抢到了十五卷——他要核对一处引用。

周大人又等。

等了三天,周大人的三处地名还没改上去。他站在万卷楼门口,对着楼主沈公发了一通火。

"你们这万卷楼,别叫藏书楼了——改叫'排队楼'。"

二、旧纸不撕,新纸另贴

沈公憋了一肚子火。但他不是那种光生气的人。他关起门来想了三天,第四天早上,他抱着一摞纸走进了万卷楼。

纸上画满了格子,每格标着数字——从一到两千。

"各位,"他把抄书先生和校勘官都叫来,"从今天起,规矩改了。"

他拿出一张空白纸,在左上角写了一个数字:一千四百二十七

"这叫'阅览号'。任何人进楼之前,先去门口领一张号牌。号牌上的数字,是你进楼的时刻——按我们墙上的漏壶走。漏壶每滴一滴水,数字就涨一号。"

抄书先生们交头接耳。

沈公接着拿起一本书,翻开一页。这一页上,居然贴着三张叠在一起的纸。

他指着最下面那张:"这张纸,是七百零三号贴的。旁边有修改记录——校勘官在七百零三号时刻,把'江陵'改成了'江宁'。"

又指着中间那张:"这张,是一千零十二号贴的——有人在上面补了一段注解。"

再指着最上面那张:"这张,是一千三百号贴的——把'江宁'又改回了'江陵'。为什么?查证后发现,改错了。"

他放下书:"有人来抄书,进楼时领的阅览号是一千一百。他能看到哪张纸?"

一个小抄书先生举手:"中间那张——一千零十二号贴的。"

"为什么?"

"因为一千零十二号是在他进楼之前贴的,他能看到。一千三百号是在他进楼之后贴的——那会儿他已经在楼里了,不该看到。"

沈公点头:"对。一人进楼的时刻,决定了他能看到哪个版本。"

"那校勘官来修书呢?"周大人问。

"修书的人不来改旧纸。他在旧纸上面,另贴一张新纸。新纸上写着一个新时刻——就是此刻漏壶的号数。旧纸不撕,留在下面。等楼里所有阅览号低于新纸的人都走了,旧纸再收。"

周大人愣住了。他活了五十年,从没听过这种管书法。

三、看书的和修书的,头一回不打仗

新规矩上线的第一天,就迎来了考验。

辰时三刻,漏壶走到了第一千四百三十滴。一位姓陈的抄书先生领了阅览号一四三零,进了楼。他要抄整套《天下郡县志》,预计要抄四个时辰。

陈先生刚摊开第一卷,校勘官周大人就来了。他领了一四三一号,也是奔《天下郡县志》来的——他要改第十五卷的三处地名。

按老规矩,周大人得等陈先生抄完十五卷——或者陈先生得等周大人改完再抄。无论如何,有一个人得等。

但新规矩下,周大人直接走到书架前,抽出了第十五卷。

陈先生抬头看了一眼,继续抄他的第一卷。

周大人在第十五卷的旧纸上,端端正正地贴了三张新纸——每一处地名改一页,每页的角上写着编号:一四三一。

陈先生抄到第十五卷的时候,已经是酉时初刻。他翻开书,看到的是旧纸——编号为一千一百的那一版。因为他的阅览号是一四三零,而周大人的修改编号是一四三一——在他之后。

"周大人,"陈先生抬起头,"您改的那几处地名,我没抄到。"

周大人走过来看了一眼:"你当然抄不到。你进来的时候我还没改。你抄的是改之前的版本。"

"那怎么办?"

沈公在旁边听见了,笑了:"好办。陈先生,你去门口换一张新号牌——号数只要比一四三一大就行。再进楼,就能看到周大人的修改了。"

陈先生去门口领了一四三五号,重新翻开第十五卷——果然,周大人的三处修改清清楚楚。

后来陈先生跟人说起这件事,用了一句话总结:

"我抄我的旧书,他修他的新版。谁也不等谁。"

那天万卷楼里,抄书的和修书的头一回没吵架。

四、没人看的旧纸,才是废纸

日子久了,书越叠越厚。

有的书页上叠了十几张纸——每一张都是一次修改。最早的那几张,压在底下,已经泛黄发脆。

沈公的徒弟小篆来问:"师父,这些旧纸什么时候清?再不清,书要合不上了。"

沈公没直接回答。他翻开万卷楼的进楼登记簿。

"你看,现在楼里最老的阅览号是多少?"

小篆翻了翻:"一四八零。有个老先生,进来抄整套《通典》,已经待了六个时辰了。"

"那就是说,比一四八零小的旧纸,都可以收了——所有进楼的人都比一四八零晚,没人能看到一四八零之前贴的旧版本了。"

小篆恍然大悟:"旧纸能不能收,不看旧纸贴了多久——看还有没有人需要看它。"

"对。"沈公说,"不清不碍事,清早了就是祸。你想象一下——老先生用阅览号一四八零在抄书,你把一四七九号的旧纸收了。他抄到一半,发现书里的内容变了。那叫什么事?"

"所以得等楼里没有人需要旧纸了,才能收?"

"正是。"沈公拍了拍那摞登记簿,"旧版本不是垃圾,是别人的记忆。等人家的记忆翻过去了,旧纸才是废纸。"

五、书的过去,也是书的一部分

半年后,万卷楼声名远播。京城的学官、史官、翰林,都来打听沈公这套"新纸贴旧纸"的法子。

有个史官问沈公:"你这套法子,说到底是靠什么?"

沈公想了想,答了三条:

"第一,改书不撕旧页。 旧页留在那儿,给需要它的人看。新来的人,看新版。"

"第二,进楼的时刻,定了你能看到什么。 你进楼之后发生的事,不打扰你正在做的事。你看到的世界,是你进门那一刻的样子。"

"第三——"沈公指着书架上那些微微鼓起的书卷,"没人看的旧纸,才是废纸。有人在看,就留着。"

史官合上笔记,忽然问了一个沈公没料到的问题:"那要是有人来查一年前的书是什么样,你还留着一年前的旧纸吗?"

沈公沉吟片刻:"……那要看楼够不够大。"

写书的人只管往前写。管书的人,得记着书走过的每一步。每一步叠一页纸,时光就有了形状。

技术解读

MVCC(Multi-Version Concurrency Control,多版本并发控制)是现代数据库实现事务隔离的核心机制。PostgreSQL、MySQL InnoDB、Oracle、SQL Server 等几乎所有主流关系型数据库,都在不同程度上依赖 MVCC 来协调读写冲突。

MVCC 的思想最早可追溯到 1981 年 Bernstein 和 Goodman 关于并发控制的论文,但真正大规模应用于数据库系统是在 1990 年代。它的核心洞见极其简洁:不为写操作加锁来阻塞读,而是保留数据的多个版本,让每个事务看到一个一致的历史快照。 这与传统的两阶段锁(2PL)形成鲜明对比——2PL 中读和写互相阻塞,而 MVCC 中读从不阻塞写,写也从不阻塞读。

核心概念回顾

概念 通俗解释
MVCC 多版本并发控制——保留数据的多个版本,让不同事务根据自己的时间戳看到对应的版本,避免读写互相阻塞
事务快照 每个事务在开始时获得一个"快照"——它能看到的所有数据版本,都是该时刻之前提交的
事务 ID 每个事务被分配一个单调递增的编号,用于判断版本可见性
可见性规则 一个事务能看到的数据版本:创建该版本的事务必须先于当前事务提交,且该版本不能已被删除
元组版本链 同一行数据的多个版本通过指针链接,形成从最新到最旧的版本链
写时复制 / 新增版本 UPDATE 不直接覆盖旧行,而是插入一个新版本(新元组),旧版本保留给需要它的事务
Vacuum / 垃圾回收 当旧版本不再被任何活跃事务需要时,后台进程将其回收,释放存储空间
读已提交 / 可重复读 不同隔离级别使用不同的快照策略:读已提交在每个语句开始时获取新快照;可重复读在整个事务中使用同一快照

故事中的隐喻对照

故事元素 映射的技术概念 解释
万卷楼 数据库 存储和管理所有数据的系统
抄书先生 读事务(Read Transaction) 读取数据,不修改。多个读事务可以同时进行
校勘官 / 修书人 写事务(Write Transaction) 修改数据。在 MVCC 中写事务创建新版本而非覆盖旧版本
漏壶的号数 事务 ID(Transaction ID / XID) 单调递增的标识符,确定事务的先后顺序
阅览号(进楼号牌) 快照时间戳(Snapshot Timestamp) 事务开始时记录当前最大事务 ID,用于后续可见性判断
旧纸上再贴新纸 INSERT 新版本元组 UPDATE 操作不修改原行,而是插入一个新行版本(新元组)
旧纸不撕,留在下面 保留旧版本 旧版本留在 undo log 或表空间中,供需要它的事务访问
"进楼的时刻决定了看到什么" 快照隔离 每个事务看到的是它开始时刻的数据库一致性快照
"一四三零号看不到一四三一号的修改" 可见性规则 事务看不到在自己开始之后才提交的修改
"去门口换一张新号牌" 开始新事务 / 刷新快照 要看到最新的修改,需要开启一个新事务(获取新的快照)
"我抄旧书,他修新版,谁也不等谁" 读不阻塞写,写不阻塞读 MVCC 的核心优势:读写互不阻塞,大幅提升并发性能
书越叠越厚,旧纸泛黄 版本膨胀(Bloat) 频繁更新会积累大量旧版本,占用额外存储空间
"没人看的旧纸才能收" Vacuum / 垃圾回收 只有当旧版本对所有活跃事务都不可见时,才能被安全回收
"旧纸清早了就是祸" Vacuum 的安全性 过早回收会导致活跃事务读到不一致的数据——这是 MVCC 垃圾回收的关键约束
"楼里最老的阅览号" 最老活跃事务 ID(Oldest XMIN) 垃圾回收进程通过追踪最老活跃事务的 ID 来判断哪些版本可以安全清理
"书的过去也是书的一部分" 时间旅行查询 MVCC 天然支持历史查询(如 PostgreSQL 的 SELECT ... AS OF TIMESTAMP

为什么这个故事对应 MVCC?

  1. "一本书同一时刻只能一个人碰"是老式锁协议的问题。 在 2PL(两阶段锁)下,读锁和写锁互斥——读写互相阻塞。这在高并发场景下意味着大量的等待和死锁。

  2. "旧纸不撕,新纸另贴"是 MVCC 最核心的设计。 UPDATE 不是修改原行,而是创建一个新版本。旧版本继续存在,服务于那些"需要看到旧世界"的事务。

  3. "阅览号决定了看到什么"对应快照隔离。 每个事务在开始时获取一个快照(Snapshot),此后的所有读取都基于这个快照——事务看到的是一个一致的、过去的数据库状态。

  4. "一四三零号看不到一四三一号"是可见性规则的精髓。 事务只能看到"在它开始之前已提交"的数据。自己开始之后的修改、未提交的修改,都不可见。

  5. "换号牌"对应开启新事务获取新快照。 在可重复读隔离级别下,同一事务内的所有查询使用同一个快照;要"刷新"视图,必须开启新事务。

  6. "没人看的旧纸才能收"精确描述了 Vacuum 的安全条件。 PostgreSQL 的 VACUUM 通过比较旧版本的事务 ID 和所有活跃事务的最小 XID 来判断哪些旧行可以安全回收。

  7. "旧纸清早了就是祸"关乎 MVCC 的根本安全保证。 如果过早删除一个旧版本,某个活跃事务可能会读到被修改的数据——破坏了快照隔离的一致性承诺。

后记:MVCC 的哲学可以用一句话概括:别删,另写。旧的不走,新的也来。 这听起来像是在制造混乱——多出来的旧版本,占地方、要清理、要看时机。但它换来的是读写之间的彻底解放:写的人不用等读的人放下书,读的人不用等写的人合上笔。下次你用 PostgreSQL 查一张正在被疯狂写入的表却丝毫不卡顿的时候,不妨想想万卷楼里那些叠在书页上的泛黄旧纸——改书的人只管往前贴新纸,旧纸守着看过它的人。等人走了,轻手轻脚地收。