IT 论文精读 · PAPER 62
Berenson, Bernstein, Gray, Melton, O'Neil, O'Neil · SIGMOD 1995
数据库上有个旋钮叫隔离级别:它决定「同时进行的好几笔操作,彼此能干扰到什么程度」。拧紧了安全但慢,拧松了快但可能算错账。1992 年的 SQL 国际标准给这个旋钮定了四档,并用几句大白话说明每档防住了什么。1995 年,六位重量级人物(包括 SQL 标准的编辑本人,以及事务处理的奠基者 Jim Gray)写了这篇论文逐条拆穿:那几句大白话一句能读出两个意思,而且漏掉了好几种天天发生的事故。
标准里对「脏读」的说明大意是:A 改了一行还没落定,B 就把它读走了,随后 A 反悔撤销——于是 B 读到了一个从未真正存在过的数字。听上去挺合理。
但论文指出:照这个字面读法,只有 A 最后反悔才算事故。那要是 A 没反悔、一路改完落定了呢?B 照样可能在「改到一半」的账本上读到错账——转账转了一半,钱从这头扣了、还没进那头,B 这时看过去,总额凭空少了一截,按字面读法却不算违规。同一句话,严的读法把这类情形全禁掉,松的读法只禁其中一种,而标准没说清自己是哪个意思。两种读法之间,隔着「你的钱会不会被算错」。
论文接着补上标准只字未提、现实里却常见的事故。一是两个人同时改同一处,后写的把先写的悄悄抹掉——这一类连最松那一档都必须防住,否则「撤销」本身就没法做对(你不知道该恢复成谁的旧值)。二是各自读了余额、各自加钱写回,一个人的更新凭空蒸发。
最阴的是第三类:两个人分别看了同一份账,各自做出看起来都合法的决定,合起来却把规矩破了。夫妻俩各持一张卡,银行规定两个账户加起来不能欠钱;两人同时查账,都看到「一共还剩一百」,于是一个从这边取八十、一个从那边取八十——各自都不越线,合起来欠了六十。谁也没读到脏数据,账还是烂了。
论文还把一种当时已流行、却没被标准收录的做法整理清楚,正式命名为快照隔离。直觉是:每个人一进门,就先给整座图书馆拍一张照片;此后无论别人怎么搬书,你只看手里那张照片。所以读永远不必排队等人让开,也看不到别人搬到一半的样子。你的改动先记在自己名下,结账时柜台查一次:如果有人在你进门后动过同一本书、且比你先结完账,那就先结账的算数,你这一单作废重来。
这套办法又快又干净,前面的事故几乎全被挡住——但挡不住夫妻取钱那一种:两人动的是不同的书,柜台一查没撞车就都放行了,可他们依据的照片都已过期。
它把「隔离级别」从一段容易读歪的说明书,变成一份可以逐条对表的清单:每一档到底防住哪几种事故、放过哪几种,写得明明白白。今天数据库手册里的「脏读、不可重复读、幻读、丢失更新、写偏斜、快照隔离」,基本都出自这里。
标准用几句大白话定义隔离级别,既含糊(一句两读、宽严差着真金白银)又不全(漏掉真实事故)。这篇论文把每一档「到底防住什么」写成能逐条对照的清单,并给「各看各的快照、先结账者算数」这套做法起名叫快照隔离——顺带点破:它好用,却挡不住「各自都合法、合起来出事」的那一种。
想看那套精确记号、四类被漏掉的异常、以及为什么隔离级别根本排不成一条直线? → 切到精读版
1992 年的 SQL-92 标准用「脏读、不可重复读、幻读」三种现象定义了四个隔离级别,这篇论文证明这套定义既含糊又不完备:现象的英文表述至少有宽、严两种读法,只有宽读才站得住;而清单漏掉了脏写、丢失更新、读偏斜、写偏斜等真实异常。作者把每种异常改写成精确的操作序列、补齐清单、正式定义了游标稳定性与快照隔离,并给出一个结构性结论:隔离级别不是一条直线——快照隔离与 REPEATABLE READ 互不包含。
r1[x] = 事务 1 读 x,w1[x] = 写 x,c1 = 提交,a1 = 回滚,r1[P] = 按谓词 P 读一批行。作者是 Hal Berenson、Phil Bernstein、Jim Gray(事务处理理论的奠基者之一、图灵奖得主)、Jim Melton(SQL 标准的编辑)、Elizabeth O'Neil 与 Patrick O'Neil,成文于微软研究院,发表在 SIGMOD 1995。标准的执笔人自己参与了对标准的批判,这让这篇文章格外有分量。它上承 Gray 等 1976 年那套用锁定义的「一致性度(degrees of consistency)」传统与 SQL-92 文本;下启 Adya 1999 年的通用化定义、以及 2005 年之后「让快照隔离真正可串行化」的一整条研究线。
先说为什么需要隔离级别。最正确的做法当然是让所有事务排队一笔笔来(可串行化),但那样吞吐低得没法用。于是工程上做了个交易:放宽一点正确性,换回并发度。既然是交易,关键就在于——你必须说清自己放弃的到底是什么。
SQL-92 的答案是:不提锁(避免绑死实现),改用三种现象定义四档。脏读 P1(读到别人未提交的数据)、不可重复读 P2(同一行读两次不一样)、幻读 P3(同一查询条件读两次,行数变了)。READ UNCOMMITTED 三种都允许,READ COMMITTED 禁脏读,REPEATABLE READ 再禁不可重复读,SERIALIZABLE 全禁。
论文说,这个漂亮的表格有三处塌陷。其一,「实现无关」是假的:三种现象其实是从封锁实现倒推的,精确对应 Gray 1976 年那套 1/2/3 级封锁(读锁握多久、写锁握多久);换成多版本实现就对不上号——多版本系统天生看不到未提交数据,却会犯别的错。其二,英文散文有歧义(下节详述)。其三,清单不全,而漏掉的恰恰是工程师最常踩的坑。
于是真问题浮出水面:照着标准选了 READ COMMITTED 的工程师,并不知道自己放过了什么;而 DBA、厂商和标准委员会各说各话,因为读的是同一段可以两解的英文。
全篇最有价值的动作,是一个看似朴素的转换:把每一种异常从一段英文描述,改写成一族被禁止的事件序列。用上面 Glossary 里的记号,「脏读」不再是一句可以争论措辞的话,而是一个模式——凡是长成这个样子的执行,一律禁止。争论于是从「你怎么理解这句话」挪到「这段执行是否匹配这个模式」:可以逐条检验,就不能再各说各话。下面所有结论都是这个转换的产物。
拿脏读开刀。标准的英文是:T1 改了一行,T2 在 T1 提交前读了它,如果 T1 随后回滚,T2 就读到了一个从未提交过的值。照字面翻译,被禁的是这个模式(论文记作 A1):w1[x] … r2[x] … (a1 与 c2 以任意顺序)——注意 a1:非得 T1 最后回滚,才算事故。而论文主张真正该禁的是更宽的那个(P1):w1[x] … r2[x] … (c1 或 a1)——只要 T2 读了 T1 尚未提交的 x 就算,不看结局。
为什么必须取宽读?一个反例就够。设 x、y 是两个账户各 50,业务约束是 x + y = 100,T1 从 x 往 y 转 40:
不可重复读与幻读同理:字面读法都要求「同一事务再读一次、发现不一样」才算,于是只读一次就下决定的事务被坑了也不算数;宽读禁掉的则是「T1 还没结束,就有人去改它读过的东西」这件事本身。取宽读还有个决定性好处:只有在宽读下,「排除 P1、P2、P3」才等价于封锁式的可串行化;按窄读,一个排除了 A1、A2、A3 的级别并不可串行化——论文给这种名不副实的级别起了专名叫 ANOMALY SERIALIZABLE。
把记号摆开之后,缺口一目了然。
脏写 P0:w1[x] … w2[x] … (c1 或 a1)。两个未提交的事务先后写同一个数据项。标准只字未提,但论文指出每一档都必须禁止它,包括最松的 READ UNCOMMITTED。理由很硬:若允许,回滚就无从谈起——T1 回滚时该把 x 恢复成什么?旧值已被 T2 覆盖。跨行约束也会碎:设约束 x = y,T1 想把两者都写成 1、T2 想都写成 2,交错后可能落成 x=1、y=2。所以最松那一档也不是什么都不管——它至少要把写锁握到提交。
丢失更新 P4:r1[x] … w2[x] … w1[x] … c1。读余额、加钱、写回。两人同时做这套动作,T2 那笔就凭空蒸发——因为 T1 拿着过期的读数算出新值再盖上去。READ COMMITTED 挡不住(读锁一放就走人),REPEATABLE READ 能挡;这大概是应用层最常见的并发 bug。只保护「游标当前停留那一行」的廉价版本叫 P4C,对应工业界早有、标准里却没位置的游标稳定性(Cursor Stability)档。
读偏斜 A5A:r1[x] … w2[x] w2[y] c2 … r1[y]。一个事务先后读两个相关的数据项,中间被别人整体改过一轮,于是它手里的 x 是旧的、y 是新的。每个值都已提交,组合起来却是一个从未存在过的状态。
写偏斜 A5B:r1[x] r2[y] w1[y] w2[x] (c1 和 c2)。最刁钻的一种:两个事务各自读了对方即将修改的东西,各自的写单独看都合法,合起来破坏约束。
补完缺口后,论文做了第二件大事:把当时多版本数据库里流行、却没有正式名字的做法定义清楚,命名为快照隔离(Snapshot Isolation,SI)。机制分三步:① 事务开始时领一个开始时间戳,此后读到的永远是「该时间戳之前已提交」的版本(加上自己写过的)——数据库在它眼里定格成一张照片;② 它的写先存成只有自己可见的新版本;③ 提交时领一个提交时间戳,检查 [开始, 提交] 区间里有没有别的已提交事务写过我也写过的数据项,有就把我回滚——先提交者获胜(First-Committer-Wins)。
性质很漂亮:读不阻塞写、写不阻塞读;未提交版本对别人不可见,脏读脏写天然不存在;快照定格,重复读也不会变脸;先提交者获胜挡住丢失更新;一张一致的照片挡住读偏斜。
但它挡不住写偏斜。原因就在机制里:冲突检查只看「有没有写同一个数据项」,而写偏斜的两个事务写的是不同的项——它们的冲突藏在「读」上,各自依据的快照都已过时,检查看不见。所以快照隔离不是可串行化的。
这是论文最有分量的一击。把快照隔离放进标准那条阶梯,会发现它根本插不进去:REPEATABLE READ 挡得住写偏斜(读锁握到提交,别人改不动它读过的行)却挡不住幻读(它不锁谓词,别人可以插入新行);快照隔离正相反,定格的快照让它不受幻读之扰,却对写偏斜束手无策。两者互不包含——「隔离级别从松到严排成一条线」这个流传甚广的印象是错的,它是一张偏序的网。这也是「用禁止哪些异常来定义级别」的固有代价:清单能比交集,比不出全序。
这是一篇定义与论证式的论文,没有基准测试、没有性能数字。它的「结果」是三样东西:一套用事件序列写死的异常清单、一张级别对照表、以及级别之间的强弱关系(含上面那条「不可比」)。对照表可以直接当工程备忘:
论文还拿这张表去对当时的商用系统:多数系统把 READ COMMITTED 设为默认,一批系统提供游标稳定性,而 Oracle 那套多版本的「读一致性」属于快照隔离一族——它给出的并不是真正的可串行化。(后来 Oracle 的 SERIALIZABLE 模式长期就是快照隔离,正是这个判断的注脚。)
它给了整个行业一套共同的词汇。今天任何一份数据库手册里的异常清单——脏读、不可重复读、幻读、丢失更新、读偏斜、写偏斜——基本都能追到这篇。工程师终于可以不问「这一档叫什么」,而问「这一档到底排除了哪几种异常」;级别名是营销,异常清单才是合同。
快照隔离由此得名并成为主流。PostgreSQL 的 REPEATABLE READ、MySQL InnoDB 的一致性读、SQL Server 2005 加入的 SNAPSHOT 隔离,都是这一族——今天绝大多数应用其实跑在快照隔离或 READ COMMITTED 上,而不是可串行化上。
它还直接催生了一条研究线。既然快照隔离只差写偏斜那一口气,能不能补上?Fekete 等人(2005)刻画了会引发不可串行化的「危险结构」,Cahill 等人(2008)据此做出可串行化快照隔离(SSI)——追踪读写依赖、发现危险环就中止一方;PostgreSQL 9.1(2011)把它落地,成为第一个在多版本引擎上提供真正可串行化的主流数据库。这条线的起点,就是本文那句「SI 挡不住 A5B」。
① SQL-92 用三种「现象」定义四档隔离级别,本文证明这套定义既含糊又不完备,并补齐了缺口。
② 痛点与方法:标准想做到实现无关,实际是从封锁行为倒推的,工程师照它选级别并不知道自己放过了什么;论文把每种异常改写成精确的事件序列(w1[x] … r2[x] … c1),争论从措辞挪到可逐条检验的模式上。
③ 歧义:字面窄读(A1)要求 T1 最后回滚才算脏读;「转账转了一半被看到、T1 却提交了」的反例证明必须取宽读(P1)——也只有宽读下,排除三现象才等价于可串行化。
④ 缺口:脏写 P0(每一档都必须禁,否则回滚都做不对)、丢失更新 P4 与游标版 P4C、读偏斜 A5A、写偏斜 A5B——标准全没写。
⑤ 快照隔离:读定格在开始时间戳的快照上、写各存各的版本、提交时「先提交者获胜」;读写互不阻塞。但它挡不住写偏斜——冲突检查只看「写了同一项」,而写偏斜的冲突藏在读上,所以 SI 不可串行化。
⑥ 结构性结论:SI 与 REPEATABLE READ 互不包含(前者挡幻读不挡写偏斜,后者反之)——隔离级别是一张偏序的网,不是一条直线。
⑦ 影响与局限:它留下了全行业通用的异常词汇,快照隔离在此得名并成为多数数据库的实际工作档,并直接催生 Fekete(2005) → SSI(2008) → PostgreSQL 9.1;但定义仍偏锁语义(1999 年被 Adya 以串行化图通用化),清单式定义补不完,SQL 标准本身至今未改。