第 23 章 一致性模型——1979年,为什么写入已经成功,另一台计算机却仍然读到了旧数据?标记本章已读大字阅读打印本章回到上次阅读位置交互模块加载失败。正文仍可阅读,请刷新重试。正在加载交互实验…第一季 · 人类如何驯服数据来源历史主线:1979—2008年 · Lamport、顺序一致性、线性一致性、因果一致性、最终一致性与 CAP1979年9月。一位计算机科学家在 IEEE Transactions on Computers 上发表了一篇只有两页的论文。题目很长:How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs《如何让多处理器计算机正确执行多处理器程序》。作者是我们已经多次见过的 Leslie Lamport。这篇论文讨论了一个看似微不足道的问题。假设一台计算机有两个处理器。两个处理器都需要访问同一个内存变量:text复制代码balance = 1000处理器 A 修改余额:text复制代码balance = 900处理器 B 随后读取它。你当然希望 B 读到900。可是现实中的处理器为了加速运行,可能分别保存自己的缓存副本。A 修改了自己的缓存,并不意味着 B 在同一个时刻已经看到了相同的值。于是 Lamport 追问:一台多处理器计算机,究竟必须满足什么条件,才能让程序员认为它正确地执行了并发程序?他的答案之一,就是后来著名的:Sequential Consistency——顺序一致性。这篇论文发表于1979年9月,第690—691页,DOI 为 10.1109/TC.1979.1675439。[IEEE Xplore][Microsoft Research]不过,我们必须先纠正一个容易出现的历史误解。Lamport 1979年的论文研究对象主要是多处理器共享内存,而不是两台远程数据库的复制。后来,计算机科学家把这些关于操作顺序、可见性和正确性的思想进一步发展,广泛运用到并发对象、分布式存储和数据库系统中。因此,这一章虽然从一份1979年的论文开始,但它真正要讲述的是一个更大的故事:当多个计算机都保存着同一份数据时,什么才叫“它们的数据是一致的”?第一幕:两台数据库,两个不同的答案先考虑一个现代系统。某个账户最初余额1000元。数据库在两台服务器上保存副本:A 和 B。数据库 A¥1,000主服务区域数据库 B¥1,000远程副本现在客户端向 A 发送:sql复制代码UPDATE accounts SET balance = 900 WHERE account_id = 1;A 执行成功。返回:text复制代码OK随后客户端向 B 查询:sql复制代码SELECT balance FROM accounts WHERE account_id = 1;B 返回:text复制代码1000究竟谁错了?答案取决于系统承诺了什么。如果系统承诺所有成功写入都必须立即对后来开始的读取可见,那么这种结果违反了承诺。但如果系统明确允许异步副本滞后,并且客户端选择的是可能返回旧值的读取方式,那么 B 返回1000不一定意味着数据库发生了实现错误。这就是本章的核心:一致性不是一个只有“有”或“没有”的单一属性,而是一组需要严格定义的可观察行为保证。而且还需要区分两个词。数据副本暂时不相同,属于副本状态问题。读写历史是否满足某种一致性模型,属于对外行为的正确性问题。两者相关,但不完全等价。第二幕:Lamport 意识到,处理器看到的世界可能不一样来源Leslie Lamport1941年出生 · 2013年度 ACM 图灵奖得主他的研究涵盖逻辑时钟、并发程序正确性、拜占庭容错、Paxos 和形式化系统设计。在 Lamport 后来公开的个人论文回顾中,他解释了1979年那项研究的动机。他想到:多处理器同步算法往往假定不同处理器访问的是同一块内存。但在真实机器里,每个处理器可能访问自己缓存中的副本。这显然可能产生问题。这段自述很重要,因为它提醒我们:顺序一致性最初关注的,不是什么抽象的“全球数据库”。而是现代计算机体系结构中最实际的困难之一:处理器为了运行得更快,会改变内存操作在外部看来发生的顺序。 [Microsoft Research]一个看起来不应该出现的执行结果初始变量:text复制代码x = 0 y = 0两个处理器运行:处理器 P1text复制代码 x = 1 r1 = y处理器 P2text复制代码 y = 1 r2 = x假设内存操作满足顺序一致性。每个处理器内部的程序顺序必须得到保持。也就是说:P1 必须先写 x,再读 y。P2 必须先写 y,再读 x。现在问题来了:能否同时得到:text复制代码r1 = 0 r2 = 0如果每个内存操作都被放进一个统一的总顺序,而且这个顺序还保持各处理器自身的程序顺序,那么答案是:不可以。为什么?如果 P1 读到 y=0,说明在统一顺序中,P1 的读 y 发生在 P2 写 y 之前。同理,如果 P2 读到 x=0,说明 P2 的读 x 发生在 P1 写 x 之前。结合程序顺序:text复制代码P1 写 x ↓ P1 读 y ↓ P2 写 y ↓ P2 读 x ↓ P1 写 x得到一个不可能成立的循环先后关系。这意味着:不存在一个合法的单一操作总顺序来解释该结果。这正是顺序一致性的力量:它把多处理器的执行历史,限制为能够被某个单线程式交错执行所解释的结果。但要注意,这里讨论的是抽象的共享内存模型;真实语言与 CPU 还存在弱内存模型、数据竞争规则和同步原语要求。不应直接把这个例子当作没有同步的 C++ 程序一定会怎样运行的承诺。第三幕:顺序一致性——允许重新排列,但不能破坏程序自己的顺序Lamport 1979年提出的顺序一致性,可以用一句话概括:所有处理器的操作必须能够解释为某一种统一的顺序执行,并且这个顺序必须保留每个处理器内部原本的程序顺序。这里有两个要求。第一,能够构造一条所有人共享的逻辑操作序列。第二,单个参与者内部的操作不能被颠倒。但它没有额外要求:不同参与者之间,真实时间上谁先完成,逻辑排序里谁就必须在前。这就产生了一个非常反直觉的现象。一个已经完成的写入,为什么仍然可以排在读取之后?假设数据库中的变量:x=0x=0x=0客户端 Alice 执行:text复制代码write(x, 1)服务器返回:text复制代码成功然后,另一个客户端 Bob 才开始读取:text复制代码read(x)结果却得到:text复制代码0现实时间线是:实际发生的顺序AliceBobwrite(x,1)read(x) → 0先完成Alice 的写入已经结束,Bob 才开始读取查看 SVG 配图原件23-004.svg · 点击查看大图对于线性一致性,这显然有问题。但对只有这两个操作、分属两个不同参与者的历史来说,顺序一致性可以作出一种解释:在逻辑执行顺序中,先发生 Bob 的读取,再发生 Alice 的写入。text复制代码逻辑顺序: read(x) → 0 write(x, 1)这个排序虽然不符合我们现实中观察到的完成时间先后关系,却没有颠倒 Alice 或 Bob 各自内部的程序顺序。因为 Alice 只执行了一次写。Bob 只执行了一次读。因此,这一小段历史仍然可能满足顺序一致性。顺序一致性要求共同的逻辑顺序,但不要求保留不同参与者之间的全部真实时间顺序。 [jepsen.io]实验一:同一个旧值,什么时候允许返回?一致性模型判定实验室固定真实时间:写先完成初始 x=0。write(x,1) 已经返回成功,之后 read(x) 才开始。没有其他写入。读写由谁发起?不同客户端同一客户端读取返回什么?旧值 0新值 1是否符合顺序一致性✓可以是否符合线性一致性◇不可以不同客户端只有各自的一次操作。若忽略跨客户端真实时间,逻辑上可以将读0排在写1之前,所以这段历史可能满足顺序一致性,但不满足线性一致性。请注意这个实验的限定:它只检查一个只有两次操作的完整历史。现实系统是否满足顺序一致性,不能只靠挑选其中两条操作。必须检查全部相关操作,是否存在同一个合法总顺序。第四幕:1990年,Herlihy 和 Wing 让“立即生效”有了数学定义来源Maurice Herlihy并发算法与共享对象研究者来源Jeannette M. Wing程序语言、形式化方法与计算机科学研究者1980年代,分布式与并发系统的研究逐渐深入。研究者发现:即使所有操作能够排成一个合法的顺序,程序员有时仍然不能满意。因为人们在使用一个共享对象时,往往会有更强的直觉:如果我已经看到一个操作完成,之后开始的操作就不应该装作它还没有发生。1986年,Herlihy 和 Wing 已经公开了相关研究的早期版本。1987年,他们在 POPL 会议发表了关于并发对象公理的论文。1990年7月,两人正式发表:Linearizability: A Correctness Condition for Concurrent Objects《线性一致性:并发对象的一项正确性条件》。论文刊登于 ACM Transactions on Programming Languages and Systems,第12卷第3期,第463—492页。[CMU School of Computer Science][CMU School of Computer Science]他们提出的概念叫作:Linearizability,线性一致性,也常译作线性化一致性。一个操作,仿佛在某个瞬间完成假设客户端发起一次写入。操作开始于时间 t1t_1t1。返回成功于时间 t2t_2t2。真实写入过程可能涉及网络传输、日志刷盘和副本确认。但线性一致性要求,我们能够在:[t1,t2][t_1,t_2][t1,t2]之间找到某个逻辑时刻,使这个操作看起来在那一瞬间原子地生效。这个逻辑时刻称作:Linearization Point,线性化点。对于另一项操作:如果它在前一项操作返回之后才开始,那么它的逻辑位置必须排在前一项之后。如果两项操作的执行时间发生重叠,则它们的相对顺序通常可以由实现选择,只要最终历史符合对象的顺序规范。线性一致性的时间约束写入读取write(x, 1)read(x) → 1线性化点操作完成后才开始的读取,不能返回更旧版本这就解释了为什么:如果写入 x=1 已经返回成功,且不存在其他写入,那么后来开始的读不能返回旧值0。这不是因为所有存储副本都必须在物理上同时改写。而是因为系统必须确保:对外可观察的完整操作历史,能够满足这种实时顺序要求。 [jepsen.io]顺序一致性与线性一致性的根本区别问题顺序一致性线性一致性是否存在合法的统一操作顺序必须必须是否保持各参与者的程序顺序必须必须是否尊重不重叠操作的真实时间先后不一定必须写入成功后,另一客户端再读取旧值某些历史可能允许不允许,若无其他写入通常哪个更强相对较弱相对较强在标准单对象模型中,线性一致性蕴含顺序一致性。但反过来不成立。还有一个历史上十分重要的性质:线性一致性具有局部性(Locality)。如果一个系统中每个对象各自的操作历史都满足线性一致性,那么整体历史也可以满足由这些对象构成的线性一致性要求。Herlihy 与 Wing 的论文专门讨论了这种可组合的正确性性质。[CMU School of Computer Science]不过,这并不意味着分别对两个账户进行线性一致读写,就自动获得跨账户的原子转账。跨对象事务,是另一个层面的保证。第五幕:1995年,研究者发现另一种顺序——不必所有事件都排队,但原因必须先于结果如果线性一致性要求太强,能不能放宽一点?例如,全球用户正在同一款社交应用里发布内容。Alice 在东京发了一条消息:“我找到了一家很好的餐厅。”Bob 看见这条消息,回复:“是哪一家?”现在服务器需要把两条消息传播到世界各地。对于某个远程用户,允许两条消息晚一点出现,也许可以接受。但如果先看见:“是哪一家?”之后才看到:“我找到了一家很好的餐厅。”体验就很奇怪。因为第二条消息是在回复第一条。它们之间存在因果关系。这就引出了:Causal Consistency——因果一致性。两种不同的顺序假设有两个事件:A=Alice 发布原始消息A=\text{Alice 发布原始消息}A=Alice 发布原始消息B=Bob 看见消息后回复B=\text{Bob 看见消息后回复}B=Bob 看见消息后回复那么:A→BA\rightarrow BA→B表示 A 在因果上先于 B。任何遵守因果一致性的副本,都不应该先应用 B,却尚未应用它所依赖的 A。但假设另一个用户 Carol 独立发布了一条天气消息:C=Carol 发布天气C=\text{Carol 发布天气}C=Carol 发布天气如果 C 与 A、B 没有因果依赖,那么不同副本可以选择不同的合法显示顺序。例如:副本1:text复制代码A → B → C副本2:text复制代码C → A → B都没有颠倒 A 与 B 的因果顺序。这就是因果一致性的关键:不要求所有相互独立的操作具有相同的全局排序,但必须尊重已发生的因果依赖。研究史:从 Lamport 的逻辑时钟到因果内存1978年,Lamport 在论文 Time, Clocks, and the Ordering of Events in a Distributed System 中研究了 happened-before 关系,为描述分布式事件之间的潜在因果关系提供了经典工具。1995年,Mustaque Ahamad、Gil Neiger、James E. Burns、Prince Kohli 和 Phillip W. Hutto 在期刊 Distributed Computing 发表:Causal Memory: Definitions, Implementation, and Programming。这篇论文对因果内存给出了形式化定义,并讨论了实现及编程模型。[XDN][ACM Digital Library]注意一个重要差别:因果一致性是关于操作可见顺序的模型。它并不自动规定对彼此独立、并发修改的冲突应该怎样合并。如果两个用户同时修改同一个字段,而且不存在因果关系,那么仅靠“尊重因果顺序”并不能自动决定最终应该采用谁的值。还需要额外的冲突解决规则,或者采用更强的并发控制机制。实验二:让消息按不同顺序传播因果一致性实验室A → B 是因果依赖先显示独立的 C,再显示 A、B先显示 A,再显示独立的 C,最后显示 B先显示回复 B,最后才显示被回复的 A1Carol:今天气温很舒服。2Alice:我找到了一家好餐厅。3Bob:是哪一家?✓符合这个因果依赖独立事件 C 的位置可以改变,只要 A 始终先于依赖它的 B。本实验只检查三条消息之间的指定因果关系,不是对某个具体社交平台消息协议的行为保证。这里有一个非常重要的区别。“事情发生得早”与“事情在因果上影响另一件事”,不是同一个概念。如果两个人互不知情,在不同城市独立修改数据,那么即使其中一项在真实时间上早几毫秒,也未必形成可被系统观察到的因果依赖。这也是因果一致性通常比线性一致性允许更多并发的原因。第六幕:1994年,Xerox PARC 发现——用户首先需要的是“别让我忘记自己的操作”来源到了1990年代,移动计算开始进入研究者视野。笔记本电脑可能经常断开网络。不同办公室、城市之间的数据复制可能并不及时。用户在一台设备修改了文档,随后换到另一台设备。如果新设备尚未接收到修改,用户就会发现:“我刚才明明保存了,为什么又变回旧版本?”1994年,Xerox PARC 的 Douglas Terry、Alan Demers、Karin Petersen、Mike Spreitzer、Marvin Theimer 和 Brent Welch 发表:Session Guarantees for Weakly Consistent Replicated Data《弱一致性复制数据的会话保证》。这篇论文提出四种可以为用户会话提供的保证。[IEEE Xplore]它们很值得记住。Read Your Writes——读己之写用户已经完成自己的写入之后,在同一受保证会话里进行后续读取,应当看到自己的写入效果,或者由系统按模型要求处理无法满足保证的情况。直观地说:“我刚刚改过的内容,不能在我的下一次读取里平白消失。”Monotonic Reads——单调读如果用户已经看见某个较新的版本,之后的读取不应该倒退到更旧的状态。例如:第一次读取看见版本5。第二次却因为被路由到滞后副本,看见版本3。这就违反了单调读保证。Monotonic Writes——单调写同一会话里的写入应当按照正确的顺序传播和生效,不能让后一个写入在前一个尚未得到适当处理时造成错误重排。Writes Follow Reads——写跟随读一项写入如果依赖用户之前已经读到的数据,那么系统需要尊重相应依赖关系。这与因果一致性的思想非常接近。这四种会话保证不要求系统在任何时刻把所有副本都完全同步。而是优先保护同一用户已经观察或完成的操作之间的关系。它们是针对弱一致性复制系统的一种很有价值的增强方式。[康奈尔计算机科学系]用户体验不一定需要全世界同时看见最新内容,但用户通常不能接受自己刚刚完成的操作在下一次读取中消失。这就是为什么现实系统经常组合:副本异步传播。会话令牌。版本号。最低可接受的读取版本。以及必要时等待副本追上或转向更新的副本。第七幕:2007年,Amazon 面对一个选择——是让所有副本同步,还是让服务尽量不停?来源2007年,Amazon 的研究人员发表了我们在第十五章已经认识过的论文:Dynamo: Amazon's Highly Available Key-value Store。当时,大型互联网服务需要在大量服务器上保存数据。服务器会发生故障。网络连接也可能暂时中断。如果任何一次写入都必须等待全部副本确认,系统可能因为一小部分节点不可达而停止接收请求。Amazon 的研究者希望某些服务可以在故障情况下继续工作。于是,Dynamo 在特定故障场景下允许不同副本暂时保存不同版本,利用对象版本管理、反熵同步以及应用参与的冲突解决机制实现高可用性。[Amazon Science]这项设计与 Eventual Consistency——最终一致性 密切相关。最终一致性究竟是什么?它并不是说:“数据库偶尔不一致也无所谓。”更准确地说:在规定的复制、恢复与冲突收敛条件下,如果不再发生新的更新,经过一段时间后,各副本最终会收敛到一致的结果。关键条件是:不再发生新的更新。通信最终能够恢复。复制与冲突处理机制能够持续推进。否则,不能无条件声称副本一定收敛。一次写入在副本之间逐渐传播假设有三个副本:A、B、C。初始值:x=1000x=1000x=1000客户端向 A 写入:x=900x=900x=900系统采用允许异步传播的设计。因此,A 已经接受并确认这次写入,但 B、C 可能暂时没有得到更新。写入传播过程中,副本可能短暂不同A900已更新B1000未收到更新C1000未收到更新这是一个简化的异步复制示例,不代表所有最终一致性系统都必须单主写入。稍后,B 收到更新。再稍后,C 也收到更新。最终:text复制代码A = 900 B = 900 C = 900三个副本收敛。实验三:模拟副本复制滞后副本同步实验室异步传播A 已接受写入 x=900,随后更新依次传播给 B、C。节点 A900最新版本节点 B1000旧版本节点 C1000旧版本从哪台服务器读取?读取 A读取 B读取 C当前读取结果1000当前所选副本仍返回旧值。这种读取违反完成写之后的线性一致性预期,但可由允许异步滞后读取的系统提供。传播下一份副本更新 →↶2008年,Amazon 的 Werner Vogels 又发表文章 Eventually Consistent,专门讨论互联网规模系统中一致性与可用性的权衡。他强调,开发者应该了解自己使用的数据存储所提供的具体一致性保证,而不是简单地把所有分布式数据库都当成传统的单机存储来使用。[queue.acm.org][Revisited]最终一致性还有一个非常容易被忽视的问题假设 A、B 两台机器在网络中断期间,都接受了修改。A 保存:text复制代码status = "approved"B 保存:text复制代码status = "rejected"网络恢复后,系统应该采用哪个状态?仅仅把数据传播给对方,并不能自动解决这个冲突。系统可能需要:版本向量。基于时间戳的选择规则。应用层冲突合并。或者具备明确合并语义的数据类型。如果使用最后写入胜出(LWW)策略,也必须清楚“最后”由什么机制决定,例如物理时间戳可能存在时钟偏差。因此:最终一致性不是不要处理冲突,而是把副本收敛和冲突处理设计成明确的系统责任。第八幕:2000—2002年,CAP 定理为什么让分布式系统工程师感到为难?来源2000年,计算机科学家 Eric Brewer 在 ACM PODC 会议上提出一个具有巨大影响力的观点。分布式服务希望同时拥有:Consistency(一致性)。Availability(可用性)。Partition Tolerance(网络分区容忍性)。但是,在存在某些网络故障的情况下,这些要求不可能全部得到满足。2002年,Seth Gilbert 与 Nancy Lynch 发表论文:Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services。他们在异步网络模型中形式化了相关要求,并给出了不可能性证明。[普林斯顿大学计算机科学系][APICe]这就是著名的:CAP Theorem,CAP 定理。但 CAP 经常被错误解释最常见的说法是:“任何分布式数据库只能在 C、A、P 三者中选两个。”这句话非常容易误导。因为现实中,网络分区并不是一种可以靠配置开关关闭的物理现象。如果网络发生分区,一些节点之间无法通信,系统仍然必须面对这一事实。真正需要思考的是:在存在网络分区时,如果仍然要求每个未故障节点的请求都必须获得符合定义的响应,那么是否还能保证所有读写都具备线性一致性?在 CAP 的经典异步模型下,答案是否定的。假设只有两台服务器A 和 B 保存同一个变量:x=0x=0x=0现在两台机器之间的网络断开。客户端 Alice 连接 A。客户端 Bob 连接 B。Alice 向 A 写入:x=1x=1x=1如果 A 立即返回成功,B 却完全收不到这次写入。那么 Bob 随后连接 B 读取时,B 仍然只知道旧值0。如果 B 必须立即回答,就有可能违反线性一致性。反过来,如果 B 坚持在不确定是否存在更新的情况下拒绝或者等待,就不能满足 CAP 所定义的总可用性要求。于是出现了无法同时满足的要求。实验四:网络分区后,你会做出什么选择?CAP 网络分区实验室A 与 B 无法通信服务器 AAlice 请求写入 x=1服务器 BBob 随后请求读取 x◇网络分区:A 无法把更新发送给 B保住线性一致性:必要时拒绝或等待部分请求让分区两侧都继续响应:允许返回滞后值◇ 优先保持强一致性当 B 无法可靠判断是否有更晚且已完成的写入时,可以拒绝或等待需要最新值保证的读取,避免返回无法证明正确的旧值。代价是分区中的部分请求不可完成。这是 CAP 不可能性证明的简化直觉模型。正式定义还涉及通信、终止、操作历史与故障假设。两个特别重要的工程结论第一,CAP 的 C 通常指接近原子寄存器的强一致性,即线性一致性,而不是上一章 ACID 中的“Consistency”约束一致性。第二,CAP 不等于数据库平时总是在“正确性与可用性”之间做二选一。在没有分区的正常情况下,系统仍然会权衡延迟、性能、复制成本和一致性要求。CAP 特别强调的是网络分区等条件下的理论限制。因此,理解 CAP 时,不应只背诵三个字母。应该先明确:在哪一种故障模型下,我们要求每一个操作得到什么样的保证?第九幕:数据库事务的一致性,与分布式副本一致性是同一件事吗?现在,我们把第二十章《数据库事务》和这一章连接起来。上一章讲到:ACID 中的 Isolation(隔离性)。Serializable(可串行化)。而这一章讲到:Sequential Consistency(顺序一致性)。Linearizability(线性一致性)。它们的名称很相似,但研究范围不同。Serializable:多条事务能否解释为某种串行执行?假设有两笔事务:T1:从 A 向 B 转账100元。T2:读取 A、B 的余额并计算总额。可串行化要求:成功提交的并发事务历史,在效果上等价于这些事务以某种串行顺序执行。但这个串行顺序不一定保持真实时间的先后关系。Linearizability:单个操作是否尊重真实时间?线性一致性通常定义于并发对象的操作历史。如果一次写入已经完成,那么后来才开始的读取,必须按照该对象的顺序语义看到相应的效果。它特别强调操作调用和完成之间的实时约束。Strict Serializability:两者的组合如果系统支持跨多个对象的事务,并且要求:事务效果等价于某种串行执行顺序。同时,这个顺序也保持不重叠事务之间的真实时间先后关系。那么对应的强一致性模型是:Strict Serializability——严格可串行化。这比单纯的 Serializable 增加了真实时间要求。[jepsen.io][jepsen.io]模型主要讨论对象特别关注Sequential Consistency多参与者的操作历史统一顺序与各参与者程序顺序Linearizability并发对象操作原子效果与真实时间顺序Serializability多操作事务是否等价于事务串行执行Strict Serializability多操作事务串行效果加真实时间顺序Causal Consistency分布式操作传播不颠倒因果依赖事务隔离级别与副本一致性模型是不同的设计维度。例如,数据库在主节点上正确提交了一个可串行化事务,并不自动保证某台异步只读副本已经重放这笔事务。所以,不能仅凭“数据库开启了 SERIALIZABLE”就断言所有远程读取都能立即看到最新值。第十幕:Raft 已经达成多数派共识,为什么 Follower 仍然可能读到旧数据?在第十六章,我们学习了 Raft。假设一个三节点集群:A 是 Leader。B、C 是 Followers。A 收到写入:text复制代码x = 900随后日志已经在多数派满足提交条件。此时,这项写入可能已经提交。但问题来了:B 是否已经把它应用到自己的状态机中?不一定。Raft 中至少存在两个需要区分的概念:日志已经复制。日志已经提交。日志已经被某个副本应用到状态机。这些状态不是同一个瞬间自动完成。因此,即使一次写入已经成功提交,某个落后的 Follower 仍然可能持有旧状态。如果系统直接让客户端读取这个落后副本,就可能得到旧值。那 Raft 不就无法提供线性一致性了吗?不能这样下结论。Raft 可以作为构建线性一致服务的重要基础。但读取路径必须遵守适当的协议。例如,需要确保提供读取的节点具有足够新且有效的状态,并正确处理领导权变化。一些实现会通过 Leader、ReadIndex、Leader Lease 或其他具有相应安全条件的读取机制保证线性一致性。而如果实现明确允许从滞后副本读取,就可能提供较弱的读取语义。因此,Raft 的日志共识正确,不代表应用任意选择一个副本读取就自动获得线性一致性。这是分布式系统设计里非常重要的一层区别。共识协议负责共同决定操作顺序。读取协议负责确保客户端观察到符合要求的状态。第十一幕:PostgreSQL 中,“提交成功”和“副本可见”到底隔着多远?现在来看一个非常实际的数据库问题。PostgreSQL 支持主库与备用库之间的流复制。备用库通过接收并重放 WAL 来保持数据更新。在常见的异步复制配置中,主库完成一次事务提交,并不要求每一个备用库都已经收到和应用对应的 WAL。因此,主库提交成功后,备用库可能短时间返回旧数据。PostgreSQL 官方文档明确承认这种复制延迟。[PostgreSQL][PostgreSQL]这和我们整章讨论的读写可见性问题直接相关。PostgreSQL 的 synchronous_commit 有不同等待级别模式关键含义off不等待本地 WAL 持久化便可确认事务local等待本地 WAL 持久化,不等待同步备用库remote_write在配置同步备用库时,等待其写入操作系统文件系统on在配置同步备用库时,等待其将相关 WAL 刷至持久存储remote_apply在配置同步备用库时,等待其重放相关事务,使其对备用库查询可见这些模式还有本地提交行为、适用前提和故障保证上的细节,不能只看名称推断。特别值得注意的是:remote_apply 需要等待指定的当前同步备用库确认重放,而不只是接收日志。PostgreSQL 官方文档明确指出,这可以帮助保证相应事务已经对那些同步备用库的查询可见。[PostgreSQL]但不能因此推导:设置 remote_apply 后,整个网络上的所有副本、所有缓存或任何外部系统都会同步完成。它保证的范围,取决于被选定的同步备用库、复制拓扑和具体配置。如何观察实际复制延迟?在 PostgreSQL 主库上,可以查看:sql复制代码SELECT application_name, state, sync_state, sent_lsn, write_lsn, flush_lsn, replay_lsn FROM pg_stat_replication;这里几种 LSN 非常重要。sent_lsn:已发送的 WAL 位置。write_lsn:备用库报告已写入的位置。flush_lsn:备用库报告已刷盘的位置。replay_lsn:备用库报告已重放的位置。这些指标可以帮助判断复制进度。但是,LSN 之间的差距不能简单、无条件地等同于准确的“落后多少秒”。也不能仅凭主库上的一个统计快照,就证明所有客户端后续读取都满足线性一致性。真正做读写分离时,应该怎样设计?例如,你在 ERP 系统里刚刚保存一份单据。用户立即打开详情页,必须看到自己刚保存的结果。可以考虑:让关键的写后读取继续走主库。或者使用与本次写入关联的版本或日志位置,等到目标副本达到所需进度后再读取。对于报表、历史统计或明确允许几秒滞后的查询,则可以适当使用异步只读副本。这种分流策略往往比要求所有业务查询都具有同样强的一致性,更符合实际性能和正确性要求。第十二幕:用 Python 检查一次读写历史是否符合线性一致性一致性研究的一个有趣特点是:我们不只可以讨论算法,还可以研究它们实际产生的历史记录。假设系统输出如下日志:text复制代码write(1) started at 10 write(1) finished at 20 read() started at 30 read() finished at 40, returned 0初始值为0。写入1已经在时间20结束。读取在时间30才开始。因此,读取返回0违反线性一致性。我们先实现一个极小的判定实验。python复制代码def check_single_write_read( initial_value, written_value, write_start, write_end, read_start, read_end, read_value, ): if write_start > write_end: raise ValueError("invalid write interval") if read_start > read_end: raise ValueError("invalid read interval") # 仅处理一写一读、无其他操作的例子 if write_end < read_start: # 写入已经完成,读取才开始 return read_value == written_value if read_end < write_start: # 读取已经完成,写入才开始 return read_value == initial_value # 两项操作重叠。 # 在这个简化模型里,读取旧值或新值都可能合法。 return read_value in ( initial_value, written_value, ) print(check_single_write_read( initial_value=0, written_value=1, write_start=10, write_end=20, read_start=30, read_end=40, read_value=0, )) # False把最后一项改成:python复制代码read_value=1结果为:text复制代码True但这个函数并不是完整的线性一致性验证器。它只适用于一个寄存器、一次写入、一次读取,而且这两个操作都已经完成的特殊场景。真实系统需要处理:多个并发客户端。很多次读写。多种对象操作。未完成操作。超时与不确定结果。以及整个历史中是否存在共同的合法线性化顺序。因此,专业一致性测试通常需要进行更复杂的历史搜索或模型检查。这也说明:验证分布式系统是否正确,往往比只检查某条 SQL 是否执行成功复杂得多。第十三幕:为什么一致性越强,不一定意味着系统越好?我们已经学习了多种模型。现在应该问一个工程问题:是否所有业务都应该选择线性一致性,甚至严格可串行化?不一定。因为系统需求不同。◇账户资金与库存扣减对于直接改变资金或关键库存约束的操作,通常需要很强的事务保证。必须防止重复扣款、丢失更新与错误并发执行。◇用户资料修改用户通常希望自己修改后立即可见。Read Your Writes 可以成为重要的最低体验要求;其他用户看到更新是否允许稍晚,取决于具体业务。◇社交消息与评论因果顺序通常很有价值,因为回复不应该先于它依赖的消息出现。不同、独立消息未必需要统一的全球顺序。◇报表与分析如果业务允许明确的数据延迟,异步副本或周期性快照可以减少主系统负载。但报表需要标明数据截至时间,并避免把不同时间点的统计当成同一事务快照。真正成熟的系统设计,不是简单给整个数据库贴上一个“强一致”或者“弱一致”的标签。而是逐一明确:写入成功究竟代表什么?下一次读取至少需要看到哪个版本?允许落后多久?遇到网络分区时,是返回错误、等待,还是允许旧数据?并发写入发生冲突时,如何合并?是否需要跨多个对象的原子事务?这些问题的答案,才真正定义了一个系统对用户作出的承诺。终章挑战:写入成功后立刻读取旧值,到底违反了什么?假设系统只有一个寄存器:text复制代码x = 100客户端 A 执行:text复制代码write(x, 200)操作已经返回成功。一秒钟后,客户端 B 开始:text复制代码read(x)返回:text复制代码100期间没有其他写入。这违反线性一致性,但这两条操作组成的历史仍可能满足顺序一致性这必然同时违反顺序一致性与线性一致性只要最后收敛到200,就自动满足线性一致性这只能说明违反了 ACID 原子性尾声:数据是否正确,不只取决于它保存了什么,还取决于用户何时看到了什么1979年,Leslie Lamport 研究多处理器缓存和并发操作的正确执行,提出了顺序一致性的经典定义。1990年,Maurice Herlihy 与 Jeannette Wing 正式发表线性一致性理论,强调并发对象操作的原子效果与真实时间约束。1994年,Xerox PARC 的研究者提出会话保证,让弱一致性复制系统也能维护用户自己已经观察和完成的操作关系。1995年,Ahamad 等人正式研究因果内存的定义与实现。2002年,Gilbert 与 Lynch 给出 CAP 定理的经典形式化证明。2007—2008年,Amazon Dynamo 与 Werner Vogels 关于最终一致性的研究,进一步展示了大规模互联网服务如何在故障、延迟与可用性之间作出工程取舍。现在回顾这一段历史,我们会发现一个很深刻的变化。早期程序员主要关心:“内存中的数值是否正确?”分布式系统则要求回答:“哪个副本的数值正确?”“什么时候正确?”“谁能够看见?”“如果不同用户看到不同历史,这些历史还能否被某个统一的规则解释?”这就是一致性模型真正研究的对象。分布式系统中的正确性,不仅是数据值本身,还包括数据值对不同参与者可见的顺序。第二十三章知识总结概念核心思想Sequential Consistency存在保持各参与者程序顺序的统一操作序列Linearizability在统一顺序之外,还尊重非重叠操作的真实时间先后Linearization Point一个操作在抽象上瞬间生效的位置Causal Consistency保证因果相关操作不会被错误颠倒Eventual Consistency在相应条件下停止更新后,副本最终收敛Read Your Writes同一会话中的后续读取体现此前写入Monotonic Reads后续读取不倒退到更旧版本Serializable并发事务效果等价于某种串行执行Strict Serializability可串行化再加真实时间约束CAP网络分区中强一致性与总可用性存在不可兼得的要求Replication Lag副本接收或应用更新的滞后Consensus共同决定日志或操作顺序,不自动保证任意副本读取都最新本章原始研究资料Lamport(1979):《How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs》Herlihy、Wing(1990):《Linearizability》完整论文 PDFTerry 等(1994):《Session Guarantees for Weakly Consistent Replicated Data》因果一致性的形式化定义与 Ahamad 等人1995年原始研究Gilbert、Lynch(2002):CAP 定理原始论文 PDFAmazon(2007):《Dynamo: Amazon's Highly Available Key-value Store》Werner Vogels(2008):《Eventually Consistent》PostgreSQL 官方文档:同步提交、remote_apply 与复制可见性下一章预告第二十四章:CRDT——2011年,为什么两台断网的计算机可以各自修改数据,最后却不需要冲突解决会议?2011年,一组分布式系统研究者系统整理了一类极其有趣的数据类型:Conflict-Free Replicated Data Types,CRDT。其中的重要研究者包括 Marc Shapiro、Nuno Preguiça、Carlos Baquero 和 Marek Zawirski。假设你有一个计数器。服务器 A 和 B 暂时断网。A 把计数器增加3。B 把计数器增加5。网络恢复后,应该怎么办?如果简单采用最后写入胜出,可能丢失其中一部分增量。如果要求两台机器每次操作前先达成共识,断网时又无法继续工作。CRDT 提出了一种非常特殊的解决思路:设计一种数据结构,使不同副本的修改可以按照特定的数学合并规则收敛,而不必把每一次更新都交给中心协调者决定。下一章,我们将研究 G-Counter、PN-Counter、OR-Set、交换律、结合律和幂等性,以及这些数学性质为什么能够让分布式副本在乱序、重复或延迟接收更新之后仍然收敛。最令人惊讶的问题是:为什么只要设计好数据结构本身,网络上的消息就算重复、乱序到达,结果仍然有可能完全正确?上一章第二十二章:垃圾回收——1960年,为什么让计算机自动释放内存,反而创造了更复杂的算法?下一章第二十四章:CRDT——2011年,为什么两台断网的计算机,最后竟然可以自动合并各自的修改?