-
什么是复制?
复制:在多台(通过网络连接的)机器上保留相同数据的副本。
-
为什么需要复制数据?
希望复制数据可能的原因有:
- 使得数据与用户在地理上接近(从而减少延迟)
- 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
- 扩展可以接收请求的机器的数量(从而提高吞吐量、可伸缩性)
-
几种流行的复制算法
- 单主(single leader)
- 多主(multi leader)
- 无主(leaderless)
-
进行复制时需要考虑的问题有哪些?
- 使用同步复制还是异步复制?
- 如何处理失败的副本?
- 如何解决“复制延迟”问题?
领导者与追随者
-
副本(replica):存储数据库副本的节点
-
如何确保所有数据都落在了所有副本上?
- 基于领导者的复制(主从复制)
-
-
领导者(leader): 特殊的副本
-
追随者(follower): 普通的副本
基于领导者的复制(主从复制)
- 客户端将写入请求发送给主库,主库将新数据写入其本地存储;主库将变更(复制日志)发送给从库
- 从库从主库拉取日志,并按照顺序应用所有写入,更新其本地数据库副本
- 客户端读取数据是,可以从主库或从库读取。但只有主库才能接收写操作。
同步复制与异步复制
复制是同步发生的,还是异步发生的。
-
同步:在向用户报告写入成功,并使结果对其他用户可见之前,主库需要等待从库的确认,确保从库已经收到了写入操作。
-
优点
- 从库保证有与主库一致的最新数据副本。如果主库失效,数据依然可以在从库上找到
-
缺点
-
如果从库没有响应(从库崩溃、网络故障、或其他原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。
单个从库故障,会导致整个系统无法写入。
-
-
-
异步:主库向从库发送消息,但不等待从库的响应。
-
优点
- 即使所有的从库都落后了,主库也可以继续处理写入
-
缺点
-
不能保证写入的持久(durable)。如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。
-
一般情况下,基于领导者的复制都配置为完全异步。
-
-
半同步:一个同步从库 + 多个异步从库。
设置新从库
-
何时需要设置新的从库?
- 增加副本数量
- 替换失败的节点
- …
-
如何设置新的从库?新从库如何与主库进行同步?
简单地将数据从一个节点复制到另一个节点通常不够,因为客户端会不断地向数据库写入数据,数据总是在不断变化。
如果通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,这样会违背高可用的目标。
那么要怎么做呢?
- 在某个时刻获取主库的一致性快照(如果可能),而不必锁定整个数据库。
- 将快照复制到新的从库节点。
- 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称:例如,PostgreSQL将其称为 日志序列号(log sequence number, LSN) ,MySQL将其称为 二进制日志坐标(binlog coordinates) 。
- 当从库处理完快照之后积压的数据变更,我们说它**赶上(caught up)**了主库。现在它可以继续处理主库产生的数据变化了。
处理宕机节点
宕机可能的原因:故障、维护….
处理目标:即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停机带来的影响。
如何通过基于领导者的复制实现高可用?
-
从库失效:追赶恢复
- 从库记录从主库收到的数据变更
- 从库重连后,请求从库断开期间发生的所有数据变更
-
主库失效:故障切换
故障切换(failover) :其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。
故障切换的步骤
自动故障切换的步骤:
-
确认主库失效。
如何检测主库是否失效?
- 超时(timeout) : 节点频繁地相互来回传递消息,如果一个节点在一段时间内(例如 30 秒)没有响应,就认为它挂了(因为计划内维护而故意关闭主库不算)。
-
选择一个新的主库。
如何选择新的主库?
-
选举算法
-
控制节点(controller node)
主库的最佳人选通常是拥有旧主库最新数据副本的从库(以最小化数据损失)。让所有的节点同意一个新的领导者,是一个 共识 问题。
-
-
重新配置系统以启用新的主库。
- 客户端需要将写请求发送给新的主库(请求路由)
- 系统需要保证旧主库需要意识到新主库的存在,并成为一个从库
故障切换过程中可能出错的地方
-
写入冲突。如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主库后,如果老主库重新加入集群,新主库在此期间可能会收到冲突的写入,那这些写入该如何处理?最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对于数据持久性的期望。
- 如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。
-
脑裂。发生某些故障时可能会出现两个节点都以为自己是主库的情况。这种情况称为 脑裂 (split brain) ,非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制(请参阅 “多主复制”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点 [^ii],但设计粗糙的机制可能最后会导致两个节点都被关闭。
-
超时时长难以确定
- 太长⇒恢复时间越长
- 太短⇒若网络负载较高,则出现不必要的故障切换
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障切换。
复制日志的实现
基于领导者的复制日志是如何实现的?
-
基于语句的复制:主库记录它执行的每个写入请求(语句),并将该语句日志发送给从库
-
缺点:
- 任何调用 非确定性函数(nondeterministic) 的语句,可能会在每个副本上生成不同的值。例如,使用
NOW()获取当前日期时间,或使用RAND()获取一个随机数。 - 如果语句使用了 自增列(auto increment) ,或者依赖于数据库中的现有数据(例如,
UPDATE ... WHERE <某些条件>),则必须在每个副本上按照完全相同的顺序执行它们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。 - 有副作用的语句(如:触发器、存储过程、用户定义的函数)可能会在每个副本上产生不同的副作用,除非产生的副作用是确定性的
- 任何调用 非确定性函数(nondeterministic) 的语句,可能会在每个副本上生成不同的值。例如,使用
-
-
传输预写日志(WAL): 通过使用WAL日志,从库可以构建一个与主库一模一样的数据结构拷贝。
-
缺点
- 日志记录的数据非常底层:WAL 包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数据库软件。
-
-
逻辑日志复制(基于行): 对复制和存储引擎使用不同的日志格式,这样可以将复制日志从存储引擎的内部实现中解耦出来。
关系数据库的逻辑日志通常是以行的粒度来描述对数据库表的写入记录的序列:
- 对于插入的行,日志包含所有列的新值。
- 对于删除的行,日志包含足够的信息来唯一标识被删除的行,这通常是主键,但如果表上没有主键,则需要记录所有列的旧值。
- 对于更新的行,日志包含足够的信息来唯一标识被更新的行,以及所有列的新值(或至少所有已更改的列的新值)。
优点:
- 逻辑日志与存储引擎的内部实现是解耦的,系统可以更容易地做到向后兼容,从而使主库和从库能够运行不同版本的数据库软件,或者甚至不同的存储引擎。
- 对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统,例如复制到数据仓库进行离线分析,或建立自定义索引和缓存。
-
基于触发器的复制
将复制逻辑上移到应用层。
两种方法:
- 一些工具,如 Oracle Golden Gate【19】,可以通过读取数据库日志,使得其他应用程序可以使用数据。
- 另一种方法是使用许多关系数据库自带的功能:触发器和存储过程。
触发器允许你将数据更改(写入事务)发生时自动执行的自定义应用程序代码注册在数据库系统中。
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库内置的复制更容易出错,也有很多限制。然而由于其灵活性,它仍然是很有用的。
复制延迟问题
-
什么是复制延迟?
在异步复制下,写入主库到反映至从库之间的延迟,这个延迟就是复制延迟(replication lag) 。
-
复制延迟会产生的问题
会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询操作,可能得到不同的结果,因为并非所有的写入都反映在从库中。
这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 最终一致性(eventual consistency)
复制延迟可能引发的问题及解决方法
读己之写
场景: 用户写入后从旧副本中读取数据。
解决方法:
-
**需要写后读 (read-after-write) 的一致性来防止这种异常,**也称为 **读己之写一致性(read-your-writes consistency)。**这是一个保证,如果用户重新加载页面,他们总会看到他们自己提交的任何更新。
如何在基于领导者的复制中实现写后读一致性?即如何保证写后读?
-
如果是用户特有的内容,对于用户 可能修改过 的内容,总是从主库读取;
-
如果不是,则需要使用其他标准来决定是否从主库读取。
- 例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读。还可以监控从库的复制延迟,防止向任何滞后主库超过一分钟的从库发出查询。
- 客户端可以记住最近一次写入的时间戳,系统需要确保从库在处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读取,或者等待从库追赶上来。这里的时间戳可以是逻辑时间戳(表示写入顺序的东西,例如日志序列号)或实际的系统时钟。
- 如果副本分布在多个数据中心,还会有额外的复杂性。任何需要由主库提供服务的请求都必须路由到包含该主库的数据中心。
-
单调读
场景: 用户首先从新副本读取,然后从旧副本读取。时间看上去回退了。为了防止这种异常,我们需要单调的读取。
单调读(monotonic reads) 可以保证这种异常不会发生。这是一个比 强一致性(strong consistency) 更弱,但比 最终一致性(eventual consistency) 更强的保证。当读取数据时,你可能会看到一个旧值;单调读仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间回退,也就是说,如果已经读取到较新的数据,后续的读取不会得到更旧的数据。
如何实现单调读?
- 一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取) 。例如,可以基于用户 ID 的散列来选择副本,而不是随机选择副本。但是,如果该副本出现故障,用户的查询将需要重新路由到另一个副本。
一致前缀读
场景:如果某些分区的复制速度慢于其他分区,那么观察者可能会在看到问题之前先看到答案。
这是分区或分片数据库中的一个特殊问题。
要防止这种异常,需要另一种类型的保证:一致前缀读(consistent prefix reads) 。这个保证的意思是说:如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看见它们以同样的顺序出现。
这是 分区(partitioned) 或 分片(sharded) 数据库中的一个特殊问题。如果数据库总是以相同的顺序应用写入,而读取总是看到一致的前缀,那么这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在 全局的写入顺序:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些则处于较新的状态。
如何实现一致前缀读?
- 一种解决方案是,确保任何因果相关的写入都写入相同的分区
复制延迟的解决方案
-
如果可以接受复制延迟,那很好;
-
如果不行,那么需要提供更强的保证
- 写后读、单调读、一致前缀读等
- (分布式)事物⇒性能、可用性代价
多主复制
为什么需要多主?基于领导者复制的缺点?
- 单点故障。只有一个主库,所有写入都要经过主库
什么是多主复制?
多个节点接受写入,处理写入的每个节点必须将该数据变更转发给其他所有节点。在这种情况下,每个主库同时是其他所有主库的从库。
多主复制的主要优点:
- 允许每个副本独立的接受写入
多主复制的应用场景
-
多数据中心
在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
-
需要离线操作的客户端:应用程序在断网之后仍然需要继续工作。
-
协同编辑
处理写入冲突
什么是冲突?例子:
- 两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为不同的值
- 会议室预定,同一个会议室同时创建了两个不同的预定
多主复制时的写入冲突问题: 两个人同时修改某一数据,两个人都可写入成功,但在进行异步复制的时候,两个主库同时更新同一记录引起的写入冲突
如何处理写冲突?
-
异步冲突检测😔:多个写入都是成功的,在稍后的某个时间点异步地检测冲突。
- 检测到冲突后再让用户去解决已经晚了
-
同步冲突检测😔:等待写入被复制到所有副本,然后再告诉用户写入成功。
- 失去多主复制的主要优点:允许每个副本独立地接受写入。不如直接使用单主复制。
-
避免冲突😃: 如果应用程序可以确保特定记录的所有写入都通过同一个主库,那么冲突就不会发生。
有哪些方法可以避免冲突?
- 特定用户的请求路由到同一个数据中心,并使用该数据中新的主库进行读写。从用户的角度来看,本质上就是单主配置了。
如果某个数据中心出现故障,需要将流量路由到另一个数据中心,那么冲突避免将失效,你必须处理不同主库同时写入的可能性。
-
收敛至一致的状态😃(冲突合并):每个复制方案都必须确保数据最终在所有副本中都是相同的,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。
实现冲突合并有多种方式:
-
最后写入胜利(LWW, last write wins) :给每个写入一个唯一的 ID(例如时间戳、长随机数、UUID 或者键和值的哈希),挑选最高 ID 的写入作为胜利者,并丢弃其他写入。
- 流行
- 易造成数据丢失
-
为每个副本分配一个唯一的 ID,ID 编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
-
以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们。
-
用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
-
-
自定义冲突解决逻辑😃 : 使用应用程序代码编写冲突解决逻辑。
该代码可以在写入或读取时执行:
-
写时执行
只要数据库系统检测到复制更改日志中存在冲突,就会调用冲突处理程序。例如,Bucardo 允许你为此编写一段 Perl 代码。这个处理程序通常不能提示用户 —— 它在后台进程中运行,并且必须快速执行。
-
读时执行
当检测到冲突时,所有冲突写入被存储。下一次读取数据时,会将这些多个版本的数据返回给应用程序。应用程序可以提示用户或自动解决冲突,并将结果写回数据库。例如 CouchDB 就以这种方式工作。
-
多主复制拓扑
复制拓扑(replication topology)用来描述写入操作从一个节点传播到另一个节点的通信路径。
当有两个以上的主库,多种不同的拓扑都是可能的。
-
全部到全部拓扑(all-to-all topology): 每个主库都将其写入发送给其他所有的主库
-
优点
- 容错性更好,消息可以沿着不同的路径传播,可以避免单点故障。
-
缺点
- 各个网络链接的速度不同,结果是一些复制消息可能 “超越” 其他复制消息
-
-
环型拓扑(circular topology):每个节点都从一个节点接收写入,并将这些写入(加上自己的写入)转发给另一个节点
-
问题
- 需要方式无限复制循环。为每个节点被赋予一个唯一的标识符,并且在复制日志中,每次写入都会使用其经过的所有节点的标识符进行标记
- 单点故障。如果一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信。
-
-
星型拓扑(star topology):一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。
- 问题:同环形拓扑
无主复制
单主、多主复制的思想:主库决定写入的顺序,而从库按相同顺序应用主库的写入。
无主复制思想: 放弃主库的概念,并允许任何副本直接接受来自客户端的写入。
无主复制实现:
- 由一个 协调者(coordinator) 节点代表客户端进行写入。与主库数据库不同,协调者不执行特定的写入顺序。
当节点故障时写入数据库
-
如何写入?
客户端并行发送写入到所有副本,只要收到一定数量的副本的响应后就认为写入成功。
-
如果节点故障时写入数据,需要解决以下问题:
-
如何防止客户端读到陈旧的值?
节点关闭期间发生的任何写入都不在该节点上。因此,如果你从该节点读取数据,则可能会从响应中拿到陈旧的(过时的)值。
解决方法:客户端将读请求将被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应,即来自一个节点的最新值和来自另一个节点的陈旧值。版本号将被用于确定哪个值是更新的
-
不可用节点重连之后,如何赶上它错过的写入?
可以实现如下两种机制:
-
读修复(Read repair):客户端并行读取多个节点时,它可以检测到陈旧的响应。对于陈旧响应,客户端会将新值写回该副本。
适用于读频繁的值
-
反熵(Anti-entropy process):利用后台进程,不断查找副本之间数据差异,并将缺少的数据从一个副本复制到另一个副本。
与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。
💡 请注意,如果没有反熵过程,很少被读取的值可能会从某些副本中丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复。
-
-
读写的法定人数
Q: 究竟多少个副本完成才可以认为写入成功?
如果有 n 个副本,每个写入必须由 w 个节点确认才能被认为是成功的,并且我们必须至少为每个读取查询 r 个节点。只要 w + r > n,我们可以预期在读取时能获得最新的值,因为 r 个读取中至少有一个节点是最新的。遵循这些 r 值和 w 值的读写称为 **法定人数(quorum)**的读和写。你可以认为,r 和 w 是有效读写所需的最低票数。
有时候这种法定人数被称为严格的法定人数,其相对 “宽松的法定人数” 而言。
法定人数条件 w+r>n 允许系统容忍不可用的节点,如下所示:
- 如果 w<n,当节点不可用时,我们仍然可以处理写入。
- 如果 r<n,当节点不可用时,我们仍然可以处理读取
- 通常,读取和写入操作始终并行发送到所有 n 个副本。参数 w 和 r 决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功。
法定人数一致性的局限性
如果你有 n 个副本,并且你选择了满足 w + r > n 的 w 和 r,那么你写入的节点集合和你读取的节点集合必然有重叠。也就是说,你读取的节点中必然至少有一个节点具有最新值。
通常,r 和 w 被选为多数(超过 n/2)节点,因为这确保了 w + r > n,同时仍然容忍多达 n/2 个节点故障。但是,法定人数不一定必须是大多数,重要的是读写使用的节点至少有一个节点的交集。
你也可以将 w 和 r 设置为较小的数字,以使 w+r≤n(即法定条件不满足)。在这种情况下,读取和写入操作仍将被发送到 n 个节点,但操作成功只需要少量的成功响应。
较小的 w 和 r 更有可能会读取到陈旧的数据,因为你的读取更有可能未包含具有最新值的节点。另一方面,这种配置允许更低的延迟和更高的可用性:如果存在网络中断,并且许多副本变得无法访问,则有更大的机会可以继续处理读取和写入。只有当可达副本的数量低于 w 或 r 时,数据库才变得不可写入或读取。
但是,即使在 w + r > n 的情况下,也可能存在返回陈旧值的边缘情况:
- 如果使用宽松的法定人数,w 个写入和 r 个读取有可能落在完全不同的节点上,因此 r 节点和 w 之间不再保证有重叠节点
- 如果两个写入同时发生,不清楚哪一个先发生。在这种情况下,唯一安全的解决方案是合并并发写入。如果根据时间戳(LWW)挑选一个胜者,则写入可能由于时钟偏差而丢失。
- 如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取返回的是旧值还是新值。
- 如果写操作在某些副本上成功,而在其他节点上失败(例如,因为某些节点上的磁盘已满),在小于 w 个副本上写入成功。所以整体判定写入失败,但整体写入失败并没有在写入成功的副本上回滚。这意味着一个写入虽然报告失败,后续的读取仍然可能会读取这次失败写入的值。
- 如果携带新值的节点发生故障,需要从其他带有旧值的副本进行恢复,则存储新值的副本数可能会低于 w,从而打破法定人数条件。
- 其他关于**时序(timing)**的边缘情况
因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。
监控陈旧度
监控可以帮助我们了解复制的健康状况。对于基于领导者的复制,写入是按照相同的顺序应用于主库和从库,并且每个节点对应了复制日志中的一个位置。通过从主库的当前位置中减去从库的当前位置,可以测量复制延迟的程度。
然而,在无主复制中,没有固定的写入顺序,这使监控变得更加困难。而且,如果数据库只使用读修复,那么对于一个值可能会有多陈旧其实是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。
💡 已经有一些关于衡量无主复制数据库中的复制陈旧度的研究,并根据参数 n、w 和 r 来预测陈旧读取的预期百分比。不幸的是,这还不是很常见的做法,但是将陈旧测量值包含在数据库的标准度量集中是一件好事。虽然最终一致性是一种有意模糊的保证,但是从可操作性角度来说,能够量化 “最终” 也是很重要的。宽松的法定人数与提示移交
合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障。它也可以容忍个别节点变慢,因为请求不必等待所有 n 个节点响应 —— 当 w 或 r 个节点响应时它们就可以返回。对于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景来说,这些特性使无主复制的数据库很有吸引力。
在一个大型的集群中(节点数量明显多于 n 个),网络中断期间客户端可能仍能连接到一些数据库节点,但又不足以组成一个特定的法定人数。在这种情况下,数据库设计人员需要权衡一下:
- 对于所有无法达到 w 或 r 个节点法定人数的请求,是否返回错误是更好的?
- 或者我们是否应该接受写入,然后将它们写入一些可达的节点,但不在这些值通常所存在的 n 个节点上?
后者被认为是一个 **宽松的法定人数(sloppy quorum):**写和读仍然需要 w 和 r 个成功的响应,但这些响应可能来自不在指定的 n 个 “主” 节点中的其它节点。就好比说,如果你把自己锁在房子外面了,你可能会去敲开邻居的门,问是否可以暂时呆在他们的沙发上。
一旦网络中断得到解决,一个节点代表另一个节点临时接受的任何写入都将被发送到适当的 “主” 节点。这就是所谓的 提示移交(hinted handoff) (一旦你再次找到你的房子的钥匙,你的邻居可以礼貌地要求你离开沙发回家)。
宽松的法定人数对写入可用性的提高特别有用:只要有任何 w 个节点可用,数据库就可以接受写入。然而,这意味着即使当 w + r > n 时,也不能确保读取到某个键的最新值,因为最新的值可能已经临时写入了 n 之外的某些节点
因此,在传统意义上,宽松的法定人数实际上并不是法定人数。它只是一个持久性的保证,即数据已存储在某处的 w 个节点。但不能保证 r 个节点的读取能看到它,除非提示移交已经完成。
运维多个数据中心
无主复制也适用于多数据中心操作,既然它旨在容忍冲突的并发写入、网络中断和延迟尖峰。
检测并发写入
最后写入胜利(丢弃并发写入)
每个副本只需要存储“最近”的值,并允许“更旧”的值被覆盖和抛弃。那么,需要一种方式来确定哪个写是“最近的”,并且“最近的”写入会被复制到每个副本,那么复制最终会收敛到相同的值。
既然写入是并发的,那么它们的顺序就是不确定的。
不过,即使写入没有自然的顺序,我们也可以进行强制排序。例如:
-
为每个写入附加一个时间戳,然后挑选时间最大的时间戳作为“最近的”,并丢弃较早时间戳的写入。最后写入胜利(LWW,last write wins) 。
-
LWW虽然实现了最终收敛的目标,但以持久性为代价:如果同一个键有多个并发写入,即使它们反馈给客户端的结果都是成功的(因为它们被写入 w 个副本),也只有一个写入将被保留,而其他写入将被默默地丢弃。
-
LWW 甚至可能会丢弃不是并发的写入
在类似缓存的一些情况下,写入丢失可能是可以接受的。但如果数据丢失不可接受,LWW 是解决冲突的一个很烂的选择。
在数据库中使用 LWW 的唯一安全方法是确保一个键只写入一次,然后视为不可变,从而避免对同一个键进行并发更新。例如,Cassandra 推荐使用的方法是使用 UUID 作为键,从而为每个写操作提供一个唯一的键
-
“此前发生”的关系和并发
-
“此前发生”(或 因果依赖(causally dependent) ):如果操作 B 了解操作 A,或者依赖于 A,或者以某种方式构建于操作 A 之上,则操作 A 在操作 B 之前发生(happens before)。
-
并发:如果两个操作中的任何一个都不在另一个之前发生(即,两个操作都不知道对方),那么这两个操作是并发的。
💡 为了定义并发性,确切的时间并不重要: 如果两个操作都意识不到对方的存在,就称这两个操作 并发,而不管它们实际发生的物理时间
因此,只要有两个操作 A 和 B,就有三种可能性:A 在 B 之前发生,或者 B 在 A 之前发生,或者 A 和 B 并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前,则后面的操作应该覆盖前面的操作,但是如果这些操作是并发的,则存在需要解决的冲突。
捕获“此前发生”关系
算法工作原理:
- 服务器为每个键维护一个版本号,每次写入该键时都递增版本号,并将新版本号与写入的值一起存储。
- 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须先读取。
- 当客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起(针对写入请求的响应可以像读取请求一样,返回所有当前值,这使得我们可以像购物车示例那样将多个写入串联起来)。
- 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须用更高的版本号来保存所有值(因为这些值与正在进行的其它写入是并发的)。
当一个写入包含前一次读取的版本号时,它会告诉我们的写入是基于之前的哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。
合并并发写入的值
这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:客户端随后必须合并并发写入的值。 Riak 称这些并发值为 兄弟(siblings)
如何合并?
- LWW:会丢失数据
- 做并集 + 墓碑(tombstone)
版本向量
当有多个副本但又没有主库时,算法该如何修改?
除了对每个键,我们还需要对 每个副本 使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些并发值,以及要保留哪些并发值或兄弟值。
所有副本的版本号集合称为 版本向量(version vector)。
当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。(Riak 将版本向量编码为一个字符串,并称其为 因果上下文,即 causal context)。版本向量允许数据库区分覆盖写入和并发写入。
另外,就像在单个副本中的情况一样,应用程序可能需要合并并发值。版本向量结构能够确保从一个副本读取并随后写回到另一个副本是安全的。这样做虽然可能会在其他副本上面创建数据,但只要能正确合并就不会丢失数据。