《设计数据密集型应用》第二部分:分布式数据 | 笔记

206 阅读1小时+

第二部分 分布式数据

第五章 复制

产生副本用于:

  • 使得数据与用户在地理上接近(从而减少延迟)
  • 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
  • 扩展可以接受读请求的机器数量(从而提高读取吞吐量)

复制的困难之处在于处理复制数据的变更(change) ,为此诞生了三种流行的变更复制算法:单领导者(single leader),多领导者(multi leader)和无领导者(leaderless)。

1 领导者和追随者

副本:存储数据库副本的每个节点。我们如何确保每次变更时数据落在了所有副本上?

基于领导者的复制(leader-based replication),也称为主动/被动、主/从复制

  1. 副本之一被指定为领导者(leader,也称为主库)。其他副本被称为追随者(followers,也称只读副本、从库、备库)
  2. 当客户端要想向数据库写入时,将请求发给领导者,领导者将新数据写入其本地存储。同时领导者将数据变更发送给所有追随者,称之为复制日志(也称变更流)。
  3. 每个追随者拉取日志并更新本地数据库副本,并执行保存。
  4. 只有领导者才能接收写操作,对于客户端来说,从库都是只读的。
领导者与追随者 - 图1

许多关系数据库都内置了主从复制,非关系数据库和某些不是数据库也有所使用(MongoDB、Kafka、RabbitMQ等)

1.1 同步复制与异步复制

  • 同步复制的优点:同步复制能保证有与主库一致的最新数据副本,如果主库突然失效,从库上也能找到数据。而异步复制不行
  • 异步复制的优点:主库发送消息时不需要等待从库的响应。同步复制时若从库没有响应,主库就无法处理写入操作,会阻止所有写入。

实际情况下通常使用半同步,将一个追随者设置为同步,其他追随者设置为异步。

1.2 设置新从库

在客户端不断向数据库写入数据时设置新从库,不能简单的复制,因为数据总是在变化。

锁定数据库会违背高可用的目标,如何不停机的拉起新从库呢:

  1. 获取主库的一致性快照,而不必锁定整个数据库。
  2. 将快照复制到新从库节点
  3. 从库连接主库,拉取快照之后的所有数据变更。这依赖于快照与主库复制日志中的位置(例如MySQL 的二进制日志坐标-binlog coordinates)。

1.3 处理节点宕机

目标:即使个别节点失效,系统也能运行,并尽可能控制节点停机带来的影响。

在以下情况时,可以用如下方法:

从库失效:追赶恢复

从库可用从日志中知道,发生故障之前处理的最后一个事务。所以从库连接回主库后,请求这些数据变更即可。

主库失效:故障切换

手动或自动进行。步骤如下:

  1. 确认主库失效。(大部分系统使用超时来确认)
  2. 选择一个新数据库。可用通过选举过程或控制器节点来指定新数据库,一般使用拥有旧主库最新数据副本的从库。
  3. 重新配置系统以启用新数据库。系统需要确保老主库回来时变成一个从库。

故障切换会有很多问题:

  • 异步复制时,老主库加入回集群后,新主库可能收到老主库宕机前还没发出去的写入操作,从而造成冲突。而丢弃掉老主库未复制的写入会打破对于持久性的期望
  • 当数据库需要和其他外部存储相协调时,丢弃写入内容是极其危险的操作。例如GitHub的一次事故中,Redis的主键比新主库的主键新,造成了一些数据的泄露。
  • 两个节点都认为自己是主库的情况称之为脑裂(split brain),十分危险。两个主库都可用接受写操作,却没有冲突解决机制,数据会造成丢失和损坏。一些系统会在检测到两个主库节点时关闭一个,诞生粗糙的设计可能导致两个主库都关闭。
  • 主库被宣告死亡时应该如何配置?超时时间设置得越长,恢复需要的时间也越久,但是设置太短会造成不必要的故障切换。

节点故障、不可靠的网络、对副本一致性,持久性,可用性和延迟的权衡 ,这些问题实际上是分布式系统中的基本问题。第8章和第9章将更深入地讨论它们。

1.4 复制日志的实现

基于语句的复制

不能使用基于语句的复制,即简单的将SQL语句记录

  • 非确定性函数会产生不同的值,例如NOW()或RAND()
  • 自增列(auto increment)使每个副本执行语句的顺序必须一致
  • 有副作用的语句(触发器、存储过程、用户定义的函数)可能在每个副本上产生不同的副作用
传输预写式日志(WAL)

在第三章中说过,写操作通常都是追加到日志中(LSM树,B树的预写式日志)。日志是包含所有数据库写入的仅追加字节序列,主库直接将日志发给从库,就可以建立与主库一模一样的数据结构的副本。

缺点:日志记录的数据非常底层,WAL包含哪些磁盘块中哪些字节发生改变,复制与存储引擎紧密耦合,这使得主库和从库的数据库版本需要一致。若非如此,就可以先升级从库,然后故障切换,从而使数据库软件不停机升级。

逻辑日志复制(基于行)

存储引擎的日志是物理表示,逻辑日志使复制日志从存储引擎内部分离出来。

逻辑日志通常以行未粒度记录序列:

  • 插入时,日志包含所有列的新值
  • 删除时,日志通过唯一标识,如果没有主键则需要记录列的所有值
  • 更新时,日志包含唯一标识和列的新值

修改多行事务会生成多个这样的日志记录,后面跟着一条记录指出事务已提交(MySQL的二进制日志用的就是这种方法)

逻辑日志更容易保持向后兼容,也能更好的被外部应用程序解析,可以更容易将数据库内容发到外部系统(数据变更捕获)

基于触发器的复制

触发器提高了复制的灵活性,我们可以只复制数据的一个子集、复制到不同种类的数据库、解决冲突逻辑。

触发器使得数据库发生数据更改时自动执行程序代码。它将更改记录到一个单独的表中,使用外部程序读取这个表,即可使用业务逻辑处理,以将数据变更复制到另一个系统中。

触发器会使得开销变大,使得复制更容易出错,也存在许多限制。

2 复制延迟问题

当客户端从异步的从库中读取数据时,由于复制延迟问题,主库和从库的数据有可能不一致,同样的语句会可能读出不同的结果。

这种不一致只是暂时的,等一段时间从库就会赶上并与主库保持一致,这种效应称为最终一致性

滞后时间太长会产生一些问题,接下来介绍几种情况和解决方法

读己之写

异步复制时,用户写入后马上查看数据,新数据有可能还没到达副本。这种情况下,我们需要读写一致性,也称读己之写一致性。

如何实现(基于领导者复制系统):

  • 读取用户可能已经修改过的内容时,都从主库读。读取自己的信息时都从主库读,读取别人信息时从从库中读。
  • 跟踪上次更新的时间,例如上次编辑完一分钟内,从主库读。
  • 客户端记住最近一次写入的时间戳,当发现获取到的数据不够新时,从另一个从库中读取,或者等待从库更新完成。、

注意i:当副本分布在多个数据中心时,复杂性会增加,任何由主库提供服务的请求都必须路由到包含主库的数据中心

当同个用户用不同设备读写时,事情变得更加复杂:

  • 记住用户上次更新的时间戳变得困难。
  • 副本分布在不同数据中心时,无法保证设备连接会路由到同一数据中心。

单调读

当用户向不同从库进行多次读取,可能会发生时光倒流:用户可能先读到一个更新完的库,然后再读到一个尚未更新的库。

单调读保证这种异常不会发生。这种保证比强一致性弱,但比最终一致性强。

实现:确保单用户总是从同一从库进行读取。可以基于用户ID的散列来选择从库,而不是随机选择。注意,当从库宕机时需要重新路由。

一致性前缀读

假设A和B数据通过不同的客户端发出,且两者存在逻辑关系,此时由于复制延迟问题,A和B可能会在某个库中打乱顺序,这样就违反了因果关系。

例如a发送了A消息给b,b用B消息回答了a,由于复制延迟问题,A消息和B消息在某个从库中被打乱,后来c要去查看群消息中a和b讨论了什么时,A消息和B消息c就看不懂了。

一致前缀读用于防止这种异常:如果一系列写入按照某个顺序发生时,任何人读取这些写入时,也会看见他们以同样的顺序出现。

这是分区数据库中的一个特殊问题,将会在第6章中讨论。

3 多主复制

多领导配置允许多个节点接受写入,同时每个领导者也是其他领导者的追随者。

3.1 多主复制的应用场景

运维多个数据中心

当只有一个数据库,且副本分散在不同的数据中心时,多领导配置可以在每个数据中心都有主库。

多主的优点

  • 性能:多主写入时间更短
  • 容忍数据中心停机:单主可以使另一个数据中心的追随者成为领导者。多主可以直接切换其他数据中心运行。
  • 容忍网络问题:单主配置对于网络连接问题非常敏感

多主的缺点:两个不同数据中心可能会同时修改同一个数据,这样会造成写冲突。

需要离线操作的客户端

当应用程序需要在断网后仍能继续工作时,如果在离线状态下进行任何更改,那么下一次上线时需要与服务器和其他设备同步。

这种情况下,每个设备都有一个充当领导者角色的本地数据库,并且在所有设备上的副本之间同步时,存在异步的多主复制过程。复制延迟取决于什么时候访问互联网,可能是几小时甚至几天。

协同编辑

多个人同时编辑同一份文档时,通常不会将其视为数据库复制问题。当用户进行协作式编辑时,更改立即应用到其本地副本,并异步复制到服务器和编辑同一文档的任何其他用户。

为了保证不发生编辑冲突,应用程序要先取得文档的锁定,即另一个用户提交时,需要等上一个用户提交修改并释放锁定。

3.2 处理写入冲突

多领导者复制的最大问题就是可能发生写冲突

同步与异步冲突检测

单主数据库中,第二个写入会被阻塞并等待第一个写入完成,或者终止第二个写入事务并强制用户重试。而多主配置中,两次的写入都是成功的。

冲突检测同步:等待写入被复制到所有副本,再告诉用户写入成功。但是这样就和单主复制没区别了。

避免冲突

应用程序可以确保同一用户始终路由到同一个数据中心,这样就不会出现冲突问题。

当数据中心出现故障,或者用户跑到离另一个数据中心更近的位置时,不同主库同时写入又有可能出现了。

收敛至一致的状态

单主库按顺序应用写操作:如果同一个字段有多个更新,则最后一个写操作将确定该字段的最终值

多主配置中,写入顺序没有定义,所以两个主库的最终结果可能不同,那么数据将处于数据不一致的状态。收敛使得所有副本必须在所有变更复制完成时收敛至一个相同的值。

实现冲突合并解决有许多种方式:

  • 最后写入胜利(LWW):给每个写入一个唯一的时间戳,挑最高的时间戳的写入作为胜利者,丢弃其他写入。
  • 以某种方式将这些值合并,然后连接它们。
  • 在保留所有信息的显示数据结构中记录冲突,并编写解决冲突的应用程序代码(可以通过提示用户的方式)。
自定义冲突解决逻辑

大多数多主复制工具允许应用程序编写冲突解决逻辑,代码可以在写或读时执行

  • 写时执行:只要数据库系统检测到复制更改日志中存在冲突时调用。
  • 读时执行:当检测到冲突时,所有冲突写入被存储。下次读取数据时,将多个版本的数据返回给应用程序,应用程序提示用户或自动解决冲突,然后将结果返回给数据库。
其他冲突

除了写冲突外还有其他更难以发现的冲突

例如商品秒杀问题,必须确保同一件商品只有一个人抢到,同时创建两个订单会发生冲突,因为下单是由两个不同的领导者进行的。

3.3 多主复制拓扑

复制拓扑描述了写入操作从一个节点传播到另一个节点的通信路径。

有两个以上的领导者时,各种不同的拓扑是可能的

多主复制 - 图3
  • 环形拓扑(MySQL):每个节点接收上个节点的写入,并转发给另一个节点
  • 星型拓扑:一个节点转发给其他所有节点。星型拓扑可以推广到树。
  • 全能拓扑:全能拓扑是更密集连接的拓扑结构,

圆形和星型拓扑中,为了防止无限循环,每个节点都需要在复制日志中写入已经通过的节点的标识符,当节点收到含有自己标识符的数据更改时将更改忽略。且当一个节点故障可能会中断消息复制,与之相比,全能拓扑的容错性更好,它允许消息沿着不同的路径传播,避免单点故障。但是,在全能拓扑中,网络连接速度可能使一些复制消息“超过”其他复制消息。(如图)

多主复制 - 图4

这是一个因果关系的问题,类似于“前缀一致读”所解决的问题。为了正确的排序,可以使用版本向量技术,本章稍后讨论。

4 无主复制

允许任何副本接收写入。也成为Dynamo风格,关系型数据库一般不用。

实现方式:

  • 客户端之间将写入发送到几个副本中
  • 由一个协调者节点代表客户端写入(与领导者不同的是,协调者不执行特定的写入顺序)

4.1 当节点故障时写入数据库

无领导配置中,故障切换不存在。如图,客户端并行发送写入到三个副本,其中一个不可用,客户收到两个确定的响应后写入是成功的。

无主复制 - 图1

但是,不可用节点的重新连接后它的数据是过时的。为了解决此问题,当客户端从数据库中读数据时,读请求会并行发送到多个节点,客户端用版本号确定哪个值比较新。

读修复和反熵

不可用节点重新连接后需要补上没更新的数据

  • 读修复(Read repair):当客户端并行读取多个节点时,检测陈旧的响应,将新值写入陈旧的副本。此方法适用于频繁读取的值,因为很少读取的值可能会丢失从而降低持久性。
  • 反熵过程(Anti-entropy process):一些数据存储具有后台进程,该进程不断查找副本之间数据的差异,并将缺少的数据添加。
读写的法定人数

副本数量为n时,每次写入需有w个节点确认才能算成功,并且读取时至少查询r个节点。n和w和r满足 w + r > n,这保证你读取的节点中至少有一个新节点,以容忍至多n/2个节点故障。遵循这条公式的读写称为法定人数的读和写。

若少于最低w或r,读或写将返回错误。因为节点不可用

这三个参数通常可以设置,通常n设为奇数,则w=r=(n+1)/2。w和r的数值影响工作负载和持久性。

4.2 仲裁一致性的局限性

即使满足w+r>n,也可能存在返回陈旧值的边缘情况

  • 写冲突。可以使用合并并发写入或最后写入胜利来避免。
  • 读写同时发生,可能有些副本还未写入,此时不确定返回是否为新值。
  • 写操作小于w时,整体判定失败,但并未对成功的副本进行回滚,这导致读取可能读到写入失败的值。
  • 有新值的节点失效后,读取了带有旧值的副本恢复时,新值的副本数可能低于w,从而打破法定人数条件。
  • 可能出现啊时序(timing)的边缘情况。

4.3 松散法定人数与提示移交

合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障和变慢,因为请求不必等待所有n个节点的响应。这可以应用于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景

然而,网络中断可能导致剩余的可用节点少于需要的节点(w和r),客户端无法到达法定人数。

在大型集群中(节点数量远大于n),网络中断期间客户端可能连接到某些数据库节点,此时应该权衡一下:

  • 将错误返回给我们无法达到w或r节点的法定数量的所有请求是否更好?
  • 我们是否应该接受写入,然后将它们写入一些可达的节点,但不在n值通常存在的n个节点之间?
松散的法定人数(sloppy quorum)
  • 上面第二条就是
  • 读写仍需要满足rw,但可能不在一开始指定的n个节点中
提示移交(hinted handoff)
  • 网络中断得到解决时,一开始指定的n个节点中的一些节点是陈旧的,那么,不在那一开始的n个节点中但是被临时写入的节点,应该将写入发送给它们

打个比方,松散的法定人数:你没带钥匙所以先在邻居家坐一会儿。提示移交:房子门开了,你邻居让你回家。

松散法定人数能提高写入的可用性:只要有任何w节点可用,数据库就可以接收写入。但这意味着即使满足读写的法定人数,也不能确定读取的某个键是最新值,因为最新值可能临时写入了n之外的节点。

运维多个数据中心

无主复制适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。

无论数据中心如何,每个来自客户端的写入都会发送到所有副本,但客户端通常只等待来自其本地数据中心内的法定节点的确认,从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步发生。

4.4 检测并发写入

Dynamo风格数据库和多领导复制都会造成写入冲突,同时Dynamo风格在“读修复”或“提示交接”时也可能冲突。

问题在于,由于网络延迟或故障,两个用户同时操作一个数据,可能导致多个副本的同一个数据不一致。如图

fig5-12

客户端A和B并发写入时,由于网络延迟,多个副本的同一个数据不一致。如何避免呢?

最后写入胜利(LWW,丢弃并发写入)

每个副本只需要存储“最近”的值,并允许“更旧”的值被覆盖。

然而,客户端并不知道哪个是“最近”的,所以我们可以为写入附加一个时间戳。

缺点:以持久性为代价,一个key有多个并发写入时,即使他们都报告为成功,也只有一个写入能存活。所以当丢失数据是不可接受的时,不能选择LWW。

“此前发生”的关系和并发

假设有A和B两个操作,那么AB有两类情况,一类是两者存在因果依赖关系,一类是AB并发。

例如:B增加的值是A插入的值,此时B因果依赖于A

而当两个操作都不在另一个之前发生,即两个操作都不知道对方,此时可以说两个操作是并发的。(不是物理时间上的并发)

如果操作并发,则存在需要解决的冲突,我们需要一个算法来判断两个操作是否并发

捕获“此前发生”关系

用于确定两个操作是否并发或具有因果依赖

服务器通过查看版本号来确定两个操作是否是并发的:

  • 服务器为每个键保留一个版本号,每次写入键时都增加版本号,并将新版本号与写入的值一起存储。
  • 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须读取。
  • 客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起。 (来自写入请求的响应可以像读取一样,返回所有当前值,这使得我们可以像购物车示例那样连接多个写入。)
  • 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须保持所有值更高版本号(因为这些值与传入的写入同时发生)。

当一个写入包含前一次读取的版本号时,它会告诉我们写入的是哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。

合并同时写入的值

通过让客户端合并并发写入的值来完成。

Riak称这些并发值为兄弟(siblings),所以也称合并兄弟值。本质上和多领导者复制中的冲突相同。

一个项目在删除时留下一个具有适合版本号的标记,以指示合并兄弟时项目已经被删除,这种删除标记称为墓碑。

通常用一些数据结构来自动执行合并。

版本向量

当有多个副本但没有领导者时,使用单个版本号来捕获操作之间的依赖关系,除了对每个键使用版本号之外,每个副本也需要使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。所有副本的版本号集合称为版本向量(version vector) 。当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。版本向量允许数据库区分覆盖写入和并发写入。

版本向量结构确保从一个副本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正确,就不会丢失数据。

5 小结

复制可以用于:提高可用性、断开连接的操作、降低延迟(数据放在离用户地理位置更近的地方)、提高可扩展性

复制主要有三种方法:单主复制、多主复制、无主复制

复制延迟会带来一系列问题,解决方式有:写后读、单调读、一致前缀读

第六章 分区

分区数据库在20世纪80年代提出。分区是将大型数据库分解成小型数据库的方式。分区主要为了可扩展性,不同分区可用放在不共享集群中的不同节点上。因此,大数据集可用分布在多个磁盘上,并且查询可以负载在多个处理器上。

对于在单个分区上运行的查询,每个节点可以独立执行对自己的查询,因此可以通过添加更多节点来扩大查询吞吐量。大型,复杂的查询可能会跨越多个节点并行处理,尽管这也带来了新的困难。

1 分区与复制

分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。

一个节点可能存储多个分区,若使用主从复制模型,则每个节点都有一个领导者和几个追随者。

大多数情况下选择和复制方案是独立的,本章中忽略复制。

image

2 键值数据的分区

假设有大量数据并且想要分区,如何决定在哪些节点上存储哪些记录呢?

分区目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分享数据和负载,那么理论上10个节点应该能够处理10倍的数据量和10倍的单个节点的读写吞吐量(暂时忽略复制)。

偏斜(skew) :分区不公平,一些分区数据更多。数据偏斜的存在使得分区效率下降。

热点(hot spot) :偏斜导致的不均衡的高负载,极端情况下所有的负载可能压在一个分区上。

避免热点:将记录随机分配给节点,在所有节点上上平均分配数据。缺点:当你读取一个特定的值时,不知道在哪个节点上,所以需要并行查询所有节点。

为了解决问题,我们可以使用简单的键值模型记录主键以访问记录。

2.1 根据键的范围分区

一种分区的方法是为每个分区指定一块连续的键范围,当我们知道范围之间的边界时,就能确定哪个分区包含哪个值(类似于B+Tree)。

分区边界可以由管理员手动选择或数据库自动选择。

每个分区中,可以按照一定的顺序保存键。可以将键作为联合索引来处理,以便一次查询多个相关记录。

缺点:某些特定的访问模式会导致热点。例如主键是时间戳时,所有写入都会放到今天的分区。

2.2 根据键的散列分区

由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。

散列函数不需要很强的加密算法,但是许多编程语言的内置hash算法不适合分区,例如Java的Object.hashCode()会导致同一个键在不同进程中有不同的哈希值。

分区边界可以是均匀间隔的,当分区边界是伪随机的时,此技术称为一致性哈希。

缺点:无法高效执行范围查询。由于顺序丢失,任何范围查询都需要发送到所有分区中(例如MongoDB)。Cassandra采取了折衷的做法,它的键中只有第一列会作为散列的依据。

2.3 负载倾斜与消除热点

哈希分区可以减少但不能完全避免热点。例如一个大v下面评论区突然开战,会导致大量写入同一个键(大v的ID等)。哈希策略不起作用,因为两个相同ID的哈希值是相同的。

大多数数据系统无法自动补偿偏斜,因此可以用应用程序减少偏斜。例如,对于少量的热点,可以在一个主键后增加随机数以取得不同的哈希值,以分散出不同的主键。

3 分片与次级索引

若只通过主键访问时,只需要确定分区并路由。但若设计次级索引,情况会变得复杂。

次级索引是关系型数据库的基础,文档数据库也用的很多,许多键值存储为了减少实现的复杂度而放弃了次级索引,但是由于他们对于数据模型很有用,一部分键值存储的数据库开始加入这个功能。

次级索引的问题是它们不能整齐地映射到分区。有两种用二级索引对数据库进行分区的方法:基于文档的分区(document-based)和基于关键词(term-based)的分区

3.1 文档分区二级索引

每个分区完全独立,每个分区维护自己的二级索引,仅覆盖该分区中的文档。所以文档分区索引也被称为本地索引

但是,当我们搜索时,我们需要将查询发送到所有分区,这种查询分区数据库的方法称为分散/聚集(scatter/gather),会使二级索引查询变得昂贵,尾部延迟大。

3.2 根据关键词(Term)的二级索引

构建一个覆盖所有分区数据的全局索引,然后将全局索引进行分区,称为关键词分区索引。

关键词来自全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。可以对关键词进行哈希分区以增加均衡负载能力。

优点:读取效率高,不需要分散/收集所有分区,只需要向包含关键词的分区发出请求

缺点:写入慢且复杂,因为写入单个文档可能影响索引的多个分区。因此在实践中,对全局二级索引的更新通常是异步的,在写入后不久读取索引。

4 分区再平衡

随着时间的推移,数据库会有各种变化。

  • 查询吞吐量增加,需要添加更多的CPU来处理负载。
  • 数据集大小增加,需要添加更多的磁盘和RAM来存储。
  • 机器出现故障,其他机器需要接管故障机器的责任。

这些更改需要数据和请求从一个节点移动到另一个节点。负载在集群的节点中移动的过程称为再平衡(reblancing)

再平衡需要满足以下要求:

  • 再平衡之后,负载(数据存储,读取和写入请求)应该在集群中的节点之间公平地共享。
  • 再平衡发生时,数据库应该继续接受读取和写入。
  • 节点之间只移动必须的数据,以便快速再平衡,并减少网络和磁盘I/O负载。

4.1 平衡策略

固定数量的分区

创建比节点更多的分区,并为每个节点分配多个分区。当一个节点加入集群时,新节点从每个节点中拿一些分区直到分区平衡。(删除则相反)

分区再平衡 - 图1

分区的数量通常在数据库第一次建立时确定,虽然原则上可以分割和合并分区,但固定数量的分区操作更简单。因此,一开始配置的分区数就是你的最大节点数量

只有分区所在的节点变了,分区的数量和键指定的分区都不变。传输过程中,原有分区仍能接受读写操作。

动态分区

固定边界的固定数量的分区出现边界错误可能导致分区数据变成空,但是手动重新配置分区边界会非常繁琐。

实现:按照键的范围进行分区的数据库会动态创建分区。当分区增长到超过配置大小时,会被分为两个分区,此时可以将一个分区转移给另一个节点。相反的,当分区数量缩小到某个阈值则会与相邻分区合并。因此,分区数量能适应总数据量。

预分割:数据集一开始很小的时候只有一个节点在处理操作,为了解决此问题,预分割允许在一个空数据库上配置一组初始分区。在键范围分区的情况下,预分割需要提前指定键是如何进行分配的。

动态分区也适用于散列分区,例如MongoDB同时支持动态分割的范围和哈希分区。

按节点比例分区

使分区数和节点数成正比,每个节点有固定数量的分区,那么每个分区的大小都于数据集大小成比例的增长,而节点数量保持不变。而当增加节点数时,分区变小。

当一个新节点加入集群时,随机选择固定数量的现有分区进行拆分,然后拿走每个选中的分区的一半。随机化会产生不公平的分割,但是平均在更大数量的分区上时,新节点会获得公平的负载份额。

4.2 运维:手动还是自动?

再平衡要手动还是自动进行呢?

全自动重新平衡很方便但不可预测。再平衡是个昂贵的操作,会使网络节点负载增加,降低其他请求的性能。

自动化再平衡和自动故障检测的结合十分危险,可能会造成级联失效(节点连锁的死亡):当发现节点过载响应慢时自动故障检测可能会认为节点已经死亡,并自动重新平衡集群,而再平衡使得其他节点负载增加并被判定为死亡。

因此,再平衡需要有人参与,虽然比自动化慢,但是可以防止运维出现意外。

5 请求路由

当客户发出请求时,如何知道连接哪个节点?这可以概括为服务发现问题。

方案:

  • 允许客户连接任何节点,如果该节点恰巧有请求的分区,则可以直接处理该请求。否则则将请求转发到适当的节点,接收回复并传递给客户端。
  • 将所有请求发送到路由层进行转发。路由层只负责分区的均衡负载。
  • 直接在客户端中配置分区和节点的路由。

请求路由 - 图1

关键问题是,做出路由决策的组件如何了解分区-节点之间的分配关系的变化?

分布式数据系统可以依赖于一个独立的协调服务,比如ZooKeeper。每个节点向ZooKeepper注册自己,路由层或客户端在ZooKeeper中订阅此信息,当分区或节点发生改变时,ZooKepper通知路由层使路由信息保持最新状态。

Cassandra和Riak在节点中使用流言协议来传播集群状态的变化。请求可以发送到任意节点,该节点会转发到目标分区的节点。这避免了对外部协调服务的依赖。

6 小结

本章讨论了大数据集怎么划分成小的子集。分区的目标是在多台机器上均匀分布数据和查询负载,避免出现热点。

分区主要有两种方法:键范围分区、散列分区。

次级索引页需要分区:按文档分区(本地索引)、按关键词分区(全局索引)。

第七章 事务

在现实中,很多事情会导致数据系统出错,而事务是保证不出错的首选项。

事务是将多个读写操作组合成一个逻辑单元的一种方式,整个事务要么成功(提交)要么失败(回滚)。事务是为了简化应用编程模型而创建的,通过事务,应用程序可以自由的忽略某些潜在错误情况和并发问题,这叫安全保证(safety guarantees)。

本章将探索数据库如何防范出错,深入并发控制领域,讨论各种可能发生的竞争条件,以及数据库如何实现已读提交快照隔离可串行化等隔离级别。

本章同时适用于单机数据库和分布式数据库。

1 事务的棘手概念

事务与其他技术设计一样,有它的优势和局限性

1.1 ACID的含义

ACID:原子性(Atomicity),一致性(Consistency),隔离性(Isolation)和持久性(Durability)

原子性(Atomicity)

原子性在不同的情况下有着不同的含义,在ACID中,原子性表示:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。

如果事务被中止(abort) ,应用程序可以确定它没有改变任何东西,所以可以安全地重试。

一致性(Consistency)

对数据的一组特定陈述必须始终成立,即不变量(invariants)。

例如,在会计系统中,所有账户整体上必须借贷相抵。

一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因为你就算违反了一致性,随便往数据库里面添加数值,数据库也无法阻止你。

隔离性(Isolation)

同时执行的事务是相互隔离的:它们不能相互冒犯。

传统来讲加可序列化(Serializability) ,每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的。

持久性(Durability)

即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。数据已被写入非易失性存储设备,或者预写日志或类似的文件。

1.2 单对象操作和多对象操作

若是想同时修改多个对象,就需要多对象事务来保持多块数据同步。

多对象事务需要某种方式来确定哪些读写操作属于同个事务。在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,BEGIN TRANSACTIONCOMMIT 语句之间的所有内容,被认为是同一事务的一部分。

单对象写入

当单个对象发生改变时,原子性和隔离性同样适用。

  • 若发送到一半断网,数据库是否保存了那残缺的数据?
  • 若数据库在覆盖磁盘中一个值时断电,是否将新旧值拼在了一起?
  • 若另一个客户端在写入时读取文档,是否会看到部分更新的值?

存储引擎的一个目标是:对单个节点上的单个对象提供原子性和隔离性。

一些数据库也提供更复杂的原子操作,例如自增、比较和设置(CAS compare-and-set)。当值没用并发被其他人修改过时,才允许执行写操作。

单对象操作可以防止多个客户端写入同个对象时丢失更新,但这不是事务。CAS被称为“轻量级事务”

多对象事务的需求

是否需要多对象事务?是否有可能只用键值模型和单对象操作来实现任何程序?

许多分布式数据库已经放弃多对象事务,因为难以跨分区实现,而且妨碍高可用和高性能。

但是,还有些场景需要多对象事务:

  • 在关系型数据模型中经常使用外键引用。多对象事务能确保这些外键始终有效
  • 文档数据模型中,缺乏连接功能的文档数据库会鼓励非规范化,当需要更新非规范化信息时,需要更新多个文档。多对象事务可以防止非规范化的数据不同步。
  • 在具有二级索引的数据库中,每次更改值时都需要更新索引。
处理错误和终止

无主复制的数据库遇到错误不会回滚

对象关系映射框架不会重试中断的事务

重试一个终止的事务会发生的事:

  • 事务实际上成功了,但是服务器向客户端确认提交成功时网络故障,那么重试事务会执行两次
  • 若错误是由于负载过大造成的,重试事务会使得负载更大。可以限制重试次数,使用指数退避算法。
  • 发生永久性错误(违反约束等)的重试是无意义的。
  • 若客户端在进程重试中失效,任何试图写入数据库的数据都将丢失。

2 弱隔离级别

2.1 读已提交

最基本的事务隔离级别是读已提交(Read Committed),它保证了:

  • 从数据库读时只能看到已提交的数据(没有脏读)
  • 写入数据库时只能覆盖已经写入的数据(没有脏写)
没有脏读

脏读:事务A读取时事务B写入了还没提交,事务A读到了事务B还没提交的值,然后事务B回滚了,此时事务A读到的值是无效的。

为什么要防止脏读:

  • 事务需要更新多个对象时,另一个事务可能只看到一部分更新。
  • 写入的内容可能回滚,另一个事务会看到稍后需要回滚的数据。
没有脏写

脏写:事务A写入时事务B还没提交,然后事务B回滚就把事务A写入的值也给回滚了。

为什么防止脏写:

  • 若事务更新多个对象,脏写会把写入的值覆盖。
  • 若发生回滚,会把脏写的数据也回滚掉。
实现读已提交

读已提交是个非常流行的隔离级别。

通常通过行锁(row-level lock)来防止脏写:当事务想要修改特定对象时,必须先获得该对象的锁。当事务完成时,将锁交给下一个事务。

读锁的效果不好,这样会使得读取需要等到事务完成,损失响应时间。

如何防止脏读:对于写入的每个脏读,数据库会记住旧的已提交值,当其他事务读取时返回此旧值。

2.2 快照隔离和可重复读

读已提交不能防止不可重复读(nonrepeatable read,读取偏差):事务内多次读取同一个数据,同时另一个事务正在修改该数据,那么第一个事务连续几次读到的数据是不一样的。

例如我从微信往银行卡里转钱,但是我在钱转到之前点开了银行卡的app,那么我会发现虽然微信扣钱了但是银行卡的钱没增加。

有些情况下不能容忍这种暂时的不一致:

  • 数据库备份:备份时数据库仍会接受写入,因此备份中会包含一些旧部分一些新部分,若从这样的备份中恢复,那么不一致就会持久化。
  • 分析查询和完整性检查:运行一个查询扫描大部分数据库时,若这些查询在不同时间点观察数据库的不同部分,那么结果会毫无意义。
快照隔离

每个事务都从数据库的一致快照中读取,每个事务只能看到该特定时间点的旧数据。

快照隔离对长时间运行的只读查询非常有用。若查询的数据在查询执行的同时发生变化,则难以理解查询的含义。

思路:通过写锁来防止脏写,但读取不做任何锁定。快照隔离的关键原则是 ”读不阻塞写,写不阻塞读“ 。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。

实现:

  • 数据库必须能保留一个对象的几个不同提交版本(多版本并发控制(MVVC)),因为各种正在进行的事务可能需要看到数据库在不同时间的工作状态。
  • 支持快照隔离的存储引擎通常也使用MVCC来实现读已提交隔离级别。一种典型的方法是读已提交为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。

需要保留几个版本的快照:若数据库只需要满足读已提交则不需要快照隔离,保留一个对象的新(未提交)旧两个版本就行

观察一致性快照的可见性规则

当一个事务从数据库中读取时,事务ID用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用呈现一致的数据库快照。工作流程:

  1. 每次事务开始时,数据库列出其他尚未完成的事务清单,即使之后提交了,这些事务的写入也将被忽略。
  2. 被中止事务所执行的任何写入都将被忽略
  3. 较晚事务所做的写入都被忽略
  4. 所有其他写入,对应用都是可见的

当以下两个条件成立时,可见一个对象:

  • 读事务开始时,创建该对象的事务已提交
  • 对象未被标记删除,或被标记删除但请求删除的事务在读事务开始时还未提交。
索引和快照隔离

索引如何在多版本数据库中工作?

法一:使索引指向对象的所有版本,使用索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除掉任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。

法二:使用B树的仅追加变体,在更新时不覆盖树页面,而是为每个修改页面创建一个副本,从父页面直到树根都会级联更新以指向他们子页面的新版本。

使用仅追加的B树时,每个写入事务都会创造一颗新B树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。

可重复读与命名混淆

许多数据库实现了它,但是名字不同,Oracle中称为可序列化(Serializable),PostgreSQL和MySQL中称为可重复读(repeatable read)。命名混淆的原因的SQL标准没有快照隔离的概念。

2.3 防止丢失更新

若两个事务同时从数据库中读一些值,修改然后写回,就有可能发生丢失更新问题。因为其中一个的修改可能会丢失,即第二个写入的内容没有包括第一个事务的修改

例如:

  • 增加计数器或更新账户余额
  • 在复杂值中进行本地修改,例如添加元素进JSON文档中的一个列表
  • 两个用户同时编辑wiki页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容
原子写(Atomic Write)

不是所有写操作都可以用原子写方式来表达,但可以用的情况下,原子写是最好的选择。

实现方法:

  • 原子操作通常通过在读取对象时,获取其上的排它锁来实现。使得更新完成之前没有其他事务可以读取它。这种技术有时被称为游标稳定性(cursor stability)
  • 另一个选择是简单地强制所有的原子操作在单一线程上执行。

然而,ORM框架经常意外地执行不安全的读取-修改-写入序列,而不是数据库提供的原子操作,这经常产出微妙的bug。

显示锁定

显示锁定也可以防止丢失更新。

让应用程序显示地锁定将要更新的对象,然后应用程序可以执行读取-修改-写入序列,当任何其他事务尝试同时读取这个对象时,需要强制等待第一个事务完成。

需要仔细考虑应用逻辑,忘记在代码某处加锁很容易引入竞争条件。

自动检测丢失的更新

允许并行执行,但当事务管理器检测到丢失更新时,中止事务并强制他们重试其读取-修改-写入序列。

优点:数据库可以结合快照隔离高效地执行此检查。

比较并设置(CAS)

CAS是一种原子操作,目的是为了比避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。否则更新不起作用且重试读取-修改-写入序列。

冲突解决和复制

在复制数据库中,防止丢失的更新需要考虑:多个节点上副本的数据可以被并发的修改,需要一些额外的步骤防止丢失更新。

基于锁和CAS的操作无法保证有一份新的数据副本,因为多节点的数据库允许多个并发写入,并异步复制到副本上。

正如在“检测并发写入”一节中写道,这种复制数据库常见的方法是允许并发写入创建多个版本冲突的值,并使用代码或特殊数据结构在事实发生之后解决合并这些版本。

原子操作可以在复制的上下文中很好地工作,尤其当他们具有可交换性时。

另一方面,最后写入为准(LWW)的冲突解决方法很容易丢失更新,然而很多数据库默认使用LWW。

2.4 写偏差与幻读

写偏差

一个事务读取一些东西,根据它所看到的值作出决定,并将决定写入数据库。但是,写入的时候,决定的前提不再是真实的。即,如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。

例如,至少需要一名医生值班,仅剩的两名医生同时点击下班,两人都下班了。

如何防止写偏差:

  • 由于涉及多个对象,单对象的原子操作不起作用。
  • 在一些快照隔离的实现中,自动检测丢失更新无法确认写偏差,所以自动防止写入偏差需要真正的可序列化隔离
  • 某些数据库允许配置约束,然后由数据库强制执行。但是为了指定至少有一名医生在线,需要一个设计多个对象的约束。大多数数据库没有内置对这种约束的支持,但是可以使用触发器或者物化视图来实现,这取决于不同的数据库。
导致写偏差的幻读

幻读:一个事务中的写入改变另一个事务的搜索查询的结果。

所有这些产生幻读的例子都遵循类似的模式:

  1. 一个SELECT查询找出符合条件的行,并检查是否符合一些要求。
  2. 按照第一个查询的结果,应用代码决定是否继续。
  3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。

例如刚刚的医生值班问题中,第一条运行的时候值还未改变,此时两个事务读取出来都是1。

快照隔离只避免了只读查询中幻读。

物化冲突

如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?

物化冲突将幻读变为数据库中一组具体行上的锁冲突。但它是最后的手段,因为有很多的缺点。在大多数情况下。可序列化(Serializable) 的隔离级别是更可取的。

3 可序列化

可序列化(Serializability) 隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止所有可能的竞争条件。

本章将讨论实现可序列化的技术,包括真的串行执行、两相锁定、可序列化的快照隔离

3.1 真正的串行

避免并发的最简单办法就是不要并发,串行直接能绕开检测/防止事务间冲突的问题,由此产生隔离。

发展过程:一开始,多线程被认为是良好性能的体现,直到07年左右,单线程循环执行事务被认为是可行的。原因有两个:RAM变便宜了,OLTP事务通常很短且只进行少量读写。

在存储过程中封装事务

在数据库早期阶段,数据库事务可以包含整个用户活动流程,然而人类做出回应的速度非常慢,事务如果需要等待人类输入才能继续运行的话,效率会很低。

因此变成了提交HTTP请求以开启事务。然而此时事务仍以交互式的客户端/服务器风格执行,一次一个语句。网络通信耗费了大量时间,若无法并发处理,吞吐量会非常糟糕,数据库会等待发出当前事务的下一个查询。

后来,事务处理系统不允许交互式的多语句事务,程序将整个事务代码交给数据库。

存储过程的优点和缺点

存储过程将事务所需的所有数据都存在内存中,可以非常快的执行,且不需要等待网络或硬盘I/O。

存储过程在关系型数据库存在了20多年,但缺点很多:

  • 每个数据库厂商都有自己的存储过程语言,但是丑陋陈旧且生态不好
  • 管理、调试、测试起来困难,版本控制和部署、收集监控等很差
  • 数据库通常比应用服务器对性能敏感得多,一个写得不好的存储过程会比在应用服务器中相同的代码造成更多麻烦。

优点:

  • 存储过程与内存存储,使得单个线程上所有事务都变得可行,且不需要等待I/O,保证了单线程的吞吐量。
分区

顺序执行使得事务并发控制变得简单,但数据库事务吞吐量被限制为单机单核的速度,对于写入吞吐量较高的应用会有性能瓶颈。

对数据集进行分区,以便事务只需要在单个分区中读写数据,这使得每个分区都能有自己独立运行的事务处理线程,为每个分区指派一个CPU核,事务的吞吐量就能和CPU核数线性扩展。

事务是否可以划分至单个分区,主要取决于应用数据的结构。键值数据很容易分区,而有多个二级索引的数据需要大量的跨分区协调。

串行执行小结
  • 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
  • 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢。
  • 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
  • 跨分区事务是可能的,但是它们的使用程度有很大的限制。

3.2 两阶段锁定(2PL)

两阶段锁定算法又成严格两阶段锁定,是一种广泛使用的序列化算法。

类似于“没有脏写”一节中:两个事务同时尝试写入时,锁确保第二个事务在第一个事务完成后继续。

2PL使锁的要求更强,只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入,就需要独占访问(exclusive access)权限:

  • 事务A读取一个对象,B想写入需要等A完成才能继续。
  • 事务A写入对象,B想读取需要等A完成才能继续。

2PL中,一个事务的读取和写入都会阻塞其他事务的读取和写入。2PL提供了可序列化的性质,防止所有竞争条件,包括丢失更新和写入偏差。

两阶段锁是一种所谓的悲观并发控制机制,即悲观锁:如果事情可能出错,最好等情况安全后再做事情。这就像互斥,用于保护多线程编程中的数据结构。

实现两阶段锁定

读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于共享模式(shared mode)独占模式(exclusive mode) 。锁使用如下:

  • 事务在读取对象前需要先以共享模式获取锁,多个事务可以同时持有共享锁。当一个事务拥有排它锁时,有共享锁的事务需要等待。
  • 事务在写入对象前需要先以独占模式获取锁,没有其他事务可以同时持有锁,所以如果对象上存在任何锁,该事务必须等待。
  • 如果事务先读取再写入对象,则将共享锁升级成独占锁锁。锁升级过程与获得排他锁相同。
  • 事务获得锁后,必须继续持有锁直到事务结束,这就是“两阶段”的由来:第一阶段:获得锁,第二阶段:释放锁。

可能发生死锁情况:事务A等待事务B释放它的锁。数据库会自动检测事务之间的死锁,并中止其中一个。被中止的事务需要由应用程序重试。

两阶段锁定的性能

性能是其巨大的缺点。一部分由于获取和释放所有这些锁的开销,更重要的是并发性的降低。

传统的关系数据库不限制事务的持续时间,因此,运行2PL的数据库有相当不稳定的延迟,若工作负载中存在争用,那么高百分点位处的响应会非常慢(参阅“描述性能”)。也就是说,一个缓慢的事务,就可以拖慢所有事务。

当事务由于死锁而被中止重试时,需要从头重做所有工作,造成巨大浪费。

谓词锁

谓词锁(predicate lock)用于防止幻读,它类似于共享/排它锁,不属于特定对象,它属于所有符合某些搜索条件的对象,例如:

 SELECT * FROM bookings
 WHERE room_id = 123 AND
       end_time > '2018-01-01 12:00' AND 
       start_time < '2018-01-01 13:00';

谓词锁限制访问:

  • 事务想要读取某些条件的对象时,需要获得查询条件上的共享谓词锁。查询条件一致的查询需要等待此锁完成。
  • 若事务A想插入、删除、更新任何对象时,需要检查旧值或新值是否与现有的谓词共享锁匹配,是则需要等该谓词锁的事务提交后事务A才能继续进行。

缺点:性能差,如果活跃事务有很多锁,检查匹配的锁会非常耗时。

索引范围锁

由于谓词锁的性能不佳,大多数使用2PL的数据库实际上实现了索引范围锁(也称间隙锁),这是一个简化版的谓词锁。

通过使谓词匹配到一个更大的集合来简化谓词锁,原理:任何满足原始谓词的写入也一定会满足这种松散的近似。

例:某房间下午被预定了,那么直接锁定其所有时间段,或者锁定所有房间的下午。

优点:能防止幻读和写入偏差,开销比谓词锁低。

缺点:没有谓词锁精确,可能会锁定更大范围的对象。

3.3 序列号快照隔离(SSI)

可序列化快照隔离(SSI, serializable snapshot isolation)提供了完整的可序列化隔离级别,且只有很小的性能损失。

悲观与乐观的并发控制

众所周知,两阶段锁是一种悲观锁,而串行执行更可以说是悲观到了极点。相比之下,序列化快照隔离就是一种乐观的并发控制技术。当事务要提交时,数据库检查隔离是否被违反,如果是则中止且必须重试。只有序列化的事务才允许被提交。

当有足够备用容量,且事务之间的争用不是很高时,乐观的并发控制技术比悲观的要好。可交换的原子操作可以减少争用。

SSI基于快照隔离,事务中的所有读取都来自数据库的一致性快照。在快照隔离的基础上,SSL添加了一种算法来检测写入之间的序列化冲突,并确定要中止哪些事务。

基于过时前提的决策

在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。

换而言之,事务基于一个前提(premise)采取行动(例如:有两个医生正在值班),之后当事务要提交时,原始数据可能已经改变:前提可能不再成立。

当应用程序进行查询时,数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能导致该事务中写入变得无效。事务中的查询与写入可能存在因果依赖,为了提供可序列化的隔离级别,若事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。

数据库如何知道查询结果可能已经改变呢?

  • 检测对旧MVCC对象版本的读取(读之前存在未提交的写入)
  • 检测影响先前读取的写入(读之后发生写入)
检测旧MVCC读取

我们知道,快照隔离通常是通过多并发版本控制(MVCC)实现的。当事务从快照中读取时,将忽略快照后其他写入。为了防止这种情况,数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。事务想提交时,数据库检查是否有被忽略的写入已经被提交,如果是,则中止事务。

可序列化 - 图2

如图,事务43读取后,42修改了43的前提,当准备提交时,检测到陈旧读取,中止事务43。

检测影响之前读取的写入

另一个事务在读取之后读取的数据被修改了。为了解决这个问题,当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务,然后通知其他事务。

可序列化的快照隔离的性能

许多工程细节会影响算法的实际表现。如果数据库能跟踪每个事务的活动(细粒度),那么可以准确地确定哪些事务需要中止,但是簿记开销可能变得很显著。简略的跟踪速度更快(粗粒度),但可能会导致更多不必要的事务中止。

事务可以读取被另一条事务覆盖的信息。这取决于发生了什么,有时可以证明执行结果无论如何都是可序列化的。

与两阶段锁定相比的优点:事务不需要阻塞等待另一个事务的锁,因此查询延迟更可预测,变量更少。只读查询可以运行在一致的快照上,而不需要任何锁定,适合负载繁重的工作。

SSL要求同时读写的事务尽量短(只读长事务没问题),长时间的读取和写入事务容易发生冲突并中止。

第八章 分布式系统的麻烦

使用分布式系统与在一台计算机上编写软件有着根本的区别,有很多新的方法可以使事情出错

1 故障与部分失效

单个计算机中,电脑崩溃会导致功能全部失效,但是在分布式系统中,系统部分失效(partial failure) 是有可能发生的

1.1 云计算与超级计算机

大型计算系统有一系列构建哲学,超级计算机中有几千个CPU,而云计算有多个数据中心,不同的哲学会导致不同的故障处理方式。

如果系统能容忍部分节点发生故障,继续保持可用,就易于操作和维护。

所以分布式系统需要接受部分故障的可能性,并在软件中建立容错机制(不论系统大小)。换句话说,我们需要从不可靠的组件构建一个可靠的系统。

2 不可靠的网络

本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器,一台机器不能直接访问另一台机器的内存和硬盘。

互联网和数据中心(通常是以太网)中的大多数内部网络都是异步分组网络(asynchronous packet networks) 。在这种网络中,不能保证节点发送数据包的到达时间和是否到达。发送者不知道数据包是否成功发送出去了,而超时检测也无法让发送者知道接收者是否收到请求。

2.1 真实世界的网络故障

网络故障是很常见的,网络的一部分会由于网络故障而被切断(这种情况也叫网络分区或网络断裂)。因此,我们需要定义网络故障的错误和处理,否则可能会发生一些严重的情况,例如死锁。

当网络遇到问题时,我们可用简单的向用户显示一条错误信息。但是我们需要知道软件如何应对网络问题,并且必须确保系统能从网络中恢复。

2.2 检测故障

许多系统需要自动检测故障节点,例如:

  • 负载平衡器需要停止向已死亡的节点转发请求(即从移出轮询列表(out of rotation) )。
  • 在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库(处理节点宕机)

然而,网络的不确定性使得很难判断一个节点是否工作,因此不能指望关于远程节点关闭的反馈:即使TCP确认已经传送了一个数据包,但是应用在处理之前可能已经崩溃。

综上,如果想确保请求是成功的,需要应用本身的响应。因此,我们通过超时响应来检测节点状态,当响应超时,证明节点已经死亡。

2.3 超时与无穷的延迟

若超时是检测故障的唯一可靠方法,那么我们应该等待多久?

时间长了会降低用户体验,用户需要一直等着。

时间短可能会导致错误地宣布节点失效,从而导致更高的风险:如果节点实际上活着并且正在执行一个工作,而我们宣布它死了,于是让另一个节点接管其工作,那么这个动作就会被执行两遍。当节点被宣告死亡后,将它的负载转移到其他节点会带来额外的负担,可能会导致级联失效(cascading failure) ,极端情况下会导致所有节点都宣告对方死亡,所有节点都停止工作。

若网络可以保证数据包的最大延迟,那么我们就可以计算出合理的超时时间,然而异步网络具有无限的延迟(尽可能快的传送数据包,但是数据包到达可能需要的时间没有上限)。

网络拥塞与排队

网络中,数据包的延迟通常是由于排队:

  • 交换机排队将数据包送入目标网络链路。若交换机队列填满,数据包会被丢弃,因此需要重新发送数据包。
  • 数据包到达目标机器时,若所有CPU内核都在繁忙,则请求会被操作系统排队。
  • 在虚拟化环境中,对CPU出现资源争用时,传入没在使用CPU的虚拟机的数据会被虚拟监视器排队。
  • TCP的流量控制(拥塞避免) ,会导致数据进入网络之前也会排队。

在高利用率的系统中,很快就能积累很长的队列。在公共云和多租户数据中心中,资源被很多客户共享,批处理工作负载(第十章)很容易让网络连接饱和。

在这种环境下,我们可以通过实验方式选择超时:测量延长的网络往返时间和多台机器的分布,以确定延迟的预期可变性。然后,考虑到应用程序的特性,可以确定故障检测延迟过早超时风险之间的适当折衷。

更好的一种做法是,系统不是使用配置的常量超时时间,而是连续测量响应时间及其变化(抖动),并根据观察到的响应时间分布自动调整超时时间。(这可以通过Phi Accrual故障检测器来完成)

2.5 同步网络vs异步网络

若我们可以依靠网络来传递一些最大延迟固定的数据包,而不是丢弃数据包。那么分布式系统就会简单很多。为什么不能在硬件层面上解决这个问题呢?

看一看移动电话网络:打电话时,会建立一个电路,两个呼叫者之间的整个路线上为呼叫分配一个固定且有保障的带宽量,这个电路会保持至通话结束。呼叫建立时,每个帧内(每个方向)分配16位空间。因此,在通话期间,每一方都保证能够每250微秒发送一个精确的16位音频数据。即使数据经过多个路由器,也不会受到排队的影响,因为呼叫的16位空间已经在网络的下一跳中保留了下来。而且由于没有排队,网络的最大端到端延迟是固定的。我们称之为有限延迟(bounded delay)

我们不能简单的使网络延迟可预测吗?

电话网络电路与TCP连接有很大不同:电路是固定的预留带宽,而TCP的数据包会使用任何可用的带宽。同时,以太网和IP是分组交换协议,这些协议可以从排队中获得,从而使网络无限延迟。

数据中心网络和互联网使用分组交换是针对突发流量(bursty traffic) 进行的优化。电话时每秒传送的比特数固定,而请求网页传输文件等没有特定的带宽要求,分组优化可以提高网络利用率。资源的动态分配可以提高资源利用率,以可变延迟为代价,这是成本和收益权衡的结果。

已经有一些尝试建立支持电路交换和分组交换的混合网络,它们在链路层实现了端到端的流量控制,从而减少了在网络中排队。

异步传输模式(Asynchronous TransferMode, ATM) 在20世纪80年代是以太网的竞争对手,但最终在电话网核心交换机之外并没有得到太多的采用。

但是,目前在多租户数据中心和公共云或通过互联网进行通信时,此类服务质量尚未启用。当前部署的技术不允许我们对网络的延迟或可靠性作出任何保证:我们必须假设网络拥塞,排队和无限的延迟总是会发生。 因此,超时时间没有“正确”的值——它需要通过实验来确定。

3 不可靠时钟

众所周知,时间和时钟是很重要的,但是在分布式系统中,由于通信的可变延迟,很难确定多台机器间发生事情的顺序。网络时间协议(NTP) 用于同步设备之间的时钟,允许根据一组服务器报告的时间来调整计算机时钟。服务器则从更精确的时间源(GPS等)获取时间。

3.1 单调钟和时钟

时钟

根据某个日历返回当前的日期和时间。例如Linux上的clock_gettime(CLOCK_REALTIME)和Java里的System.currentTimeMillis()返回自epoch(1970年1月1日 午夜 UTC,格里高利历)以来的秒数(或毫秒)。

时钟与NTP同步,然而计算机内的石英钟会自动发生漂移(drifts) 而不准,网络中提供NTP服务校准时间就会发生时钟回拨或跳跃的问题。

而与NTP同步后,机器间的时钟也会有差异,因为网络间有延迟,当网络拥塞时,误差可能超过100ms。

单调钟

单调钟保证时间是向前的,不发生时钟回拨问题,适用于测量持续时间(时间间隔),例如超时或服务的响应时间。Linux上的clock_gettime(CLOCK_MONOTONIC),和Java中的System.nanoTime()都是单调时钟。

单调钟的绝对值没有任何意义,时钟值之差为两次检测之间的时间间隔。单调钟可以在几微妙或更短时间内测量时间间隔。

分布式系统中,单调钟测量经过时间(elapsed time) 的效果很好,因为它不假定不同节点之间的时间存在同步。

3.3 依赖同步时钟

时钟有一个巨大的缺陷:一天可能不会有精确的86400秒,时钟可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。

然而,不正确的时钟很容易被视而不见,时间错误时应用似乎还是可以正常工作,数据会悄无声息的丢失而不是惊天动地的崩溃。

因此,要使用同步时钟的软件时,必须仔细监控所有机器之间的时钟偏移,时钟偏离其他时钟太远的节点应当被宣告死亡,并从集群中移除。这样的监控可以确保你在损失发生之前注意到破损的时钟。

有序事件的时间戳

依赖时钟的分布式系统,可能会出现写入时间早而时间戳更晚的情况

image-20231226221501473

如上图,客户端B的写入比客户端A的写入要晚,但是B的写入具有较早的时间戳。这样会导致最后写入胜利(LWW) (在多领导复制和无领导数据库中被广泛使用)错误的删除掉值。

此外,LWW也无法区分高频顺序写入(有因果)和真正并发写入(无因果)。而NTP同步精度本身受到网络往返时间限制,也会出现误差。

逻辑时钟可以更安全的排序事件,逻辑时钟仅测量事件的相对顺序。时钟和单调钟也成为物理时钟。

时钟读数存在置信区间

由于时钟不是准确的,所以将时钟读数视为一个时间点没有意义,它更像是一段时间范围。

不确定性界限可以根据时间源来计算。但是大多数系统不会告诉我们时间戳的预期错误。

全局快照的同步时钟

前文中提到,快照隔离是数据库中非常有用的功能,它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。

快照隔离最常见的实现需要单调递增的事务ID。如果写入比快照晚(即,写入具有比快照更大的事务ID),则该写入对于快照事务是不可见的。在单节点数据库上,一个简单的计数器就足以生成事务ID。

但是在分布式系统中,大量小规模、高频率事务的情境下,全局单调递增的事务ID很难形成。

如果有足够的同步性,我们就能用同步时钟的时间戳作为事务ID。Spanner在提交读写事务时,会故意等待置信区间长度的时间,同时,Google在每个数据中心部署了一个GPS接收器或原子钟,这两个操作保证了事务时间戳能反映因果关系。

3.4 暂停进程

一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入呢?

租约是类似带超时的锁,当一个节点获得一个租约时,它知道它在某段时间内自己是领导者,直到租约到期。为了保持领导地位,节点必须在周期性地在租约过期前续期。 如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。

然而分布式系统不能使用租约(lease)来确保节点的领导者地位:

  1. 它依赖于同步时钟,租约到期期间由另一台机器设置,却与本地时钟进行比较。
  2. 当线程在获取时间的代码行暂停时,等他好了租约可能已经过期并由另一个节点接管领导,然而它却不知道,从而继续循环。(GC机制偶尔就需要停止所有线程,类似的情况还有挂起虚拟机、停止世界、执行同步磁盘访问等待磁盘IO等)

当一台机器上编写多线程代码时,有很多工具保证线程安全,但是这些工具不能转化为分布式系统操作,因为分布式系统没有共享内存,只能通过不可靠的网络发送消息。

分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。

响应时间保证

导致暂停的原因是可以消除的,某些软件的运行环境要求很高,例如飞机、火箭、汽车上的软件。在这些系统中,软件必须有一个特定的截止时间(deadline) ,若截止时间不满足可能会导致整个系统故障。这就是所谓的硬实时(hard real-time) 系统。

例如,当车载传感器检测到当前正在经历碰撞,GC暂停就可能导致安全气囊释放系统延迟弹出。

在嵌入式系统中,实时是指系统经过精心设计和测试,以满足所有情况下的特定时间保证。提供实时保证需要各级软件栈的支持,所有这些都需要大量额外的工作,严重限制了可以使用的编程语言,库和工具的范围,所以开发费用昂贵,通常用于安全关键的嵌入式设备。

限制垃圾收集的影响

语言运行时在计划垃圾回收时具有一定的灵活性,因为它们可以跟踪对象分配的速度和随着时间的推移剩余的空闲内存。

例如可以将GC暂停视为这个节点的计划中断,让其他节点处理来自客户端的请求:运行时可以警告应用程序一个节点很快需要GC暂停,那么应用程序可以停止向该节点发送新的请求,等待它完成处理未完成的请求,然后在没有请求正在进行时执行GC。一些对延迟敏感的金融交易系统使用这种方法。

还可以只用垃圾收集器来处理短命对象(这些对象要快速收集),并定期在积累大量长寿对象(因此需要完整GC)之前重新启动进程。一次可以重新启动一个节点,在计划重新启动之前,流量可以从节点移开,就像滚动升级一样。

这些措施不能完全阻止垃圾回收暂停,但可以有效地减少它们对应用的影响。

4 知识、真相与谎言

分布式系统与运行在单台计算机上的程序的不同之处:没有共享内存,只有通过可变延迟的不可靠网络传递的消息,系统可能遭受部分失效,不可靠的时钟和处理暂停。

节点只能通过交换消息来找出另一个节点所处的状态(存储了哪些数据,是否正确运行等等)。如果远程节点没有响应,则无法知道它处于什么状态,因为网络中的问题不能可靠地与节点上的问题区分开来。

4.1 真理由多数所定义

半断开:一个节点可以接收信息,但是发送不出去,所以其他节点宣布了它的死亡。

另一种情况:节点经过了一个长时间的GC,其他节点宣布了它的死亡,但是GC结束后该节点又恢复了正常。在这个节点的角度来看,它没有经历任何时间。

以上情况说明了,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。

所以,许多分布式算法都依赖于法定人数,即在节点之间进行投票,决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。这也包括关于宣告节点死亡的决定。如果法定数量的节点宣告另一个节点已经死亡,那么即使该节点仍感觉自己活着,它也必须被认为是死的。个体节点必须遵守法定决定并下台。

最常见的法定人数是超过一半的绝对多数。多数法定人数允许系统继续工作,如果单个节点发生故障,系统仍然是安全的。

领导者与锁定

通常情况下,一些东西在一个系统中只能有一个。例如:

  • 数据库分区的领导者只能有一个节点,以避免脑裂(split brain) (参阅“处理节点宕机”)
  • 特定资源的锁或对象只允许一个事务/客户端持有,以防同时写入和损坏。
  • 一个特定的用户名只能被一个用户所注册,因为用户名必须唯一标识一个用户。

但在分布式系统中需要注意,一个节点认为它是领导者,不意味着有法定人数的节点同意,领导者节点可能在它不知道的时候被降级了(GC、网络中断等),如果不处理这种情况就会出现大问题(如图)。

image.png

这个问题就是我们先前在“进程暂停”中讨论过的一个例子:如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它不正确地认为它仍然有一个有效的租约,并继续写入文件。结果,客户的写入冲突和损坏的文件。

防护令牌

“防护”这个技术可以可以简单的解决上面这个问题。

我们假设每次锁定服务器授予锁或租约时,它还会返回一个防护令牌(fencing token) ,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的屏蔽令牌。

image.png

上图中,存储服务器记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。

4.2 拜占庭故障

屏蔽令牌可以检测和阻止无意中发生错误的节点(例如,因为它尚未发现其租约已过期)。但是,如果节点有意破坏系统的保证,则可以通过使用假屏蔽令牌发送消息来轻松完成此操作。

如果存在节点可能“撒谎”(发送任意错误或损坏的响应)的风险,则分布式系统的问题变得更困难了——例如,如果节点可能声称其实际上没有收到特定的消息。这种行为被称为拜占庭故障(Byzantine fault)在不信任的环境中达成共识的问题被称为拜占庭将军问题

当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为拜占庭容错(Byzantine fault-tolerant)

这个问题在某些场景中有意义:重要的系统(航天),多个参与组织的系统。

在大多数服务器端数据系统中,部署拜占庭容错解决方案的成本非常高。我们通常不使用拜占庭容错协议,而只是让服务器决定什么是客户端行为。在没有这种中心授权的对等网络中,拜占庭容错更为重要。

同时,拜占庭容错算法对网络安全问题没有意义,因为如果攻击者能渗透一个节点,那么它基本上就能以同样的方式渗透所有节点,因为他们的系统相同。

4.3 系统模式与现实

已经有很多算法被设计以解决分布式系统的各种故障。算法的编写方式并不过分依赖于运行的硬件和软件配置的细节。这又要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可能承担的事情。

关于定时假设,三种系统模型是常用的:

  • 同步模型(synchronous model):假设网络延迟,进程暂停和和时钟误差都是有上限的。
  • 部分同步(partial synchronous):系统在大多数情况下像一个同步系统一样运行,但有时候会超出网络延迟,进程暂停和时钟漂移的上限。
  • 异步模型:一个算法不允许对时机做任何假设——事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。

考虑节点失效时,三种最常见的模型是:

  • 崩溃-停止故障:假设节点只能因为崩溃而失效,即节点可能在任意时刻停止,然后就永远消失。
  • 崩溃-恢复故障:假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在此模型中,节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。
  • 拜占庭故障(任意故障):节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点,如上一节所述。

在真实系统的建模中,通常使用崩溃-恢复故障部分同步模型。

算法的正确性

为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。这只是定义对列表进行排序含义的一种形式方式。

同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成屏蔽令牌,我们可能要求算法具有以下属性:唯一性、单调序列、可用性(请求防护令牌且没有崩溃的节点,都会收到响应)

安全性和活性

安全性(safety)通常被非正式地定义为,没有坏事发生,而活性(safety)通常就类似:最终好事发生

活性属性通常在定义中通常包括“最终”一词。在刚刚给出的例子中,唯一性和单调序列是安全属性,但可用性是活性属性。

  • 如果安全属性被违反,我们可以指向一个特定的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌返回的特定操作) 。违反安全属性后,违规行为不能撤销——损失已经发生。
  • 活性属性反过来:在某个时间点(例如,一个节点可能发送了一个请求,但还没有收到响应),它可能不成立,但总是希望在未来(即通过接受答复)。

区分安全性和活性属性的一个优点是可以帮助我们处理困难的系统模型。

将系统模型映射到现实世界

系统模型只是对现实的简化抽象, 证明算法正确并不意味着它在真实系统上的实现必然总是正确的。算法的理论描述可以简单宣称一些事在假设上是不会发生的,但实际上我们还是需要对可能发生和不可能发生的故障做出假设,真实世界的实现,仍然会包括处理“假设上不可能”情况的代码。

这并不是说理论上抽象的系统模型是毫无价值的,恰恰相反,它们对于将实际系统的复杂性降低到一个我们可以推理的可处理的错误是非常有帮助的,以便我们能够理解这个问题,并试图系统地解决这个问题。我们可以证明算法是正确的,通过显示它们的属性总是保持在某个系统模型中。理论分析可以发现算法中的问题,这种问题可能会在现实系统中长期潜伏,直到你的假设(例如,时间)因为不寻常的情况被打破。理论分析与经验测试同样重要。

第九章 一致性与共识

正如第8章所讨论的,分布式系统中的许多事情可能会出错。处理故障最简单的方法是让整个服务失效,并向用户显示错误消息。更高级的方式是找到容错的方法,让某些内部组件即使出现故障,服务也能正常运行。

构建容错系统的最好方法,是找到一些带有实用保证的通用抽象,实现一次,然后让应用依赖这些保证。分布式系统最重要的抽象之一就是共识(consensus)就是让所有的节点对某件事达成一致。 一旦达成共识,应用可以将其用于各种目的。然而,由于网络故障和流程故障,可靠地达成共识是一个棘手问题。

在本章后面的“分布式事务和共识”中,我们将研究解决共识和相关问题的算法。但首先,我们首先需要探索可以在分布式系统中提供的保证和抽象的范围。

我们需要了解可以做什么和不可以做什么的范围:在某些情况下,系统可以容忍故障并继续工作。我们将深入研究什么可能而什么不可能的限制,既通过理论证明,也通过实际实现。我们将在本章中概述这些基本限制。

1 一致性保证

在“复制延迟问题”中,我们看到了数据库复制中发生的一些时序问题:同一时刻两个数据库副本的数据可能不一样,因为修改数据库会由于网络延迟等在不同时间到达两个数据库。大多数复制的数据库至少提供了最终一致性,也称收敛(convergence) ,所有的复本最终会收敛到相同的值。

然而,这是一个非常弱的保证,收敛的时间是不确定的,在收敛之前,读操作可能会返回任何东西或什么都没有。

在与只提供弱保证的数据库打交道时,你需要始终意识到它的局限性,错误往往是微妙的,很难找到,也很难测试。

本章将探索数据系统可能选择提供的更强一致性模型。分布式一致性模型和事务隔离级别的层次结构的不同之处在于:事务隔离主要是为了,避免由于同时执行事务而导致的竞争状态,而分布式一致性主要关于,面对延迟和故障时,如何协调副本间的状态。

2 线性一致性

最终一致的数据库,如果你在同一时刻问两个不同副本相同的问题,可能会得到两个不同的答案。但如果数据库能假装只有一个副本,那问题就不存在了。

这就是线性一致性(linearizability) 背后的想法(也称原子一致性,强一致性,立即一致性、外部一致性)。基本的想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。

在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。换句话说,线性一致性是一个新鲜度保证(recency guarantee)

在一个线性一致的系统中,如果一个客户端的读取返回新的值 ,即使写操作尚未完成,所有后续读取也必须返回新值。

2.1 什么使得系统线性一致

image-20231229205054012

图中,每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。图中C仍然在写入中,A读取到了x的值是1,那么B读取到的值必须也是1,A和B之间的箭头说明了这个时序依赖关系。

进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的:

image-20231229205420670

图中,cas(x,vold,vnew)rcas(x, v *{old}, v*{new})⇒r 表示客户端请求进行原子性的比较与设置操作(若寄存器x=v{old},x=v{new}然后返回ok,否则返回error)。图中的每个操作都在执行操作时在条柱内用竖线标出。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(每次读取都必须返回最近一次写入设置的值)。

线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,这也确保了新鲜性保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。

2.2 线性一致性和可序列化

两者是有区别的:

  • 可序列化:是事务的隔离属性,确保每个事务在下一个事务开始前完成。
  • 现象一致性:对单个对象的新鲜度保证。不会将操作组合为事务,因此也无法阻止写偏差等问题。

当数据库同时提供现象一致性和可串行性时,被称为单副本可串行性。基于”两阶段锁定“的可串行化或“实际串行执行”通常是线性一致的。但“可序列化的快照隔离”不是线性一致的,因为快照内没有比快照更新的写入。

2.3 依赖线性一致性

线性一致性在什么情况下有用?

例如锁定和领导选举

使用单主复制的系统,需要确保领导真的只有一个,否则会发生脑裂。

一种选择领导者的方法是使用锁,而这个锁就必须线性一致。如ZooKeeper和etcd之类的协调服务通常用于实现分布式锁和领导者选举,它们使用一致性算法,以容错的方式实现线性一致的操作。

约束和唯一性保证

唯一性约束在数据库中很常见。如果想要确保银行账户余额永远不会为负数,或者不会出售比仓库里的库存更多的物品,或者两个人不会都预定了航班或剧院里同一时间的同一个位置。这些约束条件都要求所有节点都同意一个最新的值(账户余额,库存水平,座位占用率)。

跨信道的时序依赖

A和B同时请求数据并获得了不一样的数据,A对B说:“咱们数据不一样!”,这就是系统中存在的额外信道。

计算机中也有可能出现这种情况,例如图像缩放器中,Web服务器通过消息队列发送,照片先写入存储服务然后再将缩放器的指令放入消息队列,而消息队列可能比存储服务内部的复制要快,所以缩放器读取图像可能读到旧版本。这个问题是由于Web服务器和缩放器之间存在两个不同信道:文件存储和消息队列,没有线性一致性的保证,两个信道就可能发生竞争。

2.4 实现线性一致的系统

我们当然不可能真的只用一个副本,这样将会没有容错。而使系统容错的最常用方法是复制,我们来回顾一下第五章的方法,看看他们是否满足线性一致性:

  • 单主复制(可能线性一致)

    • 主库具有用于写入的数据的主副本,而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据,它们可能(protential) 是线性一致性的。
    • 然而从主库读取依赖一个假设,你确定领导是谁。正如在“真理在多数人手中”中所讨论的那样,一个节点很可能会认为它是领导者,如果具有错觉的领导者继续为请求提供服务,可能违反线性一致性。
    • 使用异步复制,故障切换时甚至可能会丢失已提交的写入,这同时违反了持久性和线性一致性。
  • 共识算法(线性一致)

    • 将在本章后面讨论,与单领导者复制类似。共识协议包含防止脑裂和陈旧副本的措施。
  • 多主复制(非线性一致)

    • 它同时在多个节点上处理写入,并将其异步复制到其他节点。
  • 无主复制(也许不是线性一致的)

    • 法定人数的读写可以获得强一致性,但是非线性一致性的行为有可能发生。
    • 基于时钟的“最后写入胜利”冲突解决方法是非线性的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。松散的法定人数也破坏了线性一致的可能性。
线性一致性和法定人数

网络延迟可变导致竞争条件可能出现

image.png

2.5 线性一致性的代价

第五章说过,对多数据中心的复制而言,多主复制通常是理想的选择,但是它却无法保证线性一致性。

而单主复制可能线性一致,但是整体可用性却低:当数据中心之间断联时,连接从库数据中心的客户端没法写入,同时没法线性一致读取。

任何线性一致的数据库都有这个问题,不管它是如何实现的。问题面临的权衡如下:

  • 如果应用需要线性一致性,且某些副本因为网络问题与其他副本断开连接,那么这些副本掉线时不能处理请求。请求必须等到网络问题解决,或直接返回错误。(无论哪种方式,服务都不可用(unavailable) )。
  • 如果应用不需要线性一致性,那么某个副本即使与其他副本断开连接,也可以独立处理请求(例如多主复制)。在这种情况下,应用可以在网络问题前保持可用,但其行为不是线性一致的。

因此不需要线性一致性的应用对网络问题有更强的容错能力。这种见解通常被称为CAP定理。 CAP定理的正式定义仅限于很狭隘的范围,它只考虑了一个一致性模型和一种故障。它没有讨论任何关于网络延迟,死亡节点或其他权衡的事。 因此,尽管CAP在历史上有一些影响力,但对于设计系统而言并没有实际价值,所以最好避免使用CAP。

虽然线性一致是一个很有用的保证,但实际上,线性一致的系统惊人的少。分布式数据库为了提高性能而选择了牺牲线性一致性,而不是为了容错 —— 线性一致的速度很慢。线性读写的响应时间不可避免地会很高。更快地线性一致算法不存在,但更弱的一致性模型可以快得多,所以对延迟敏感的系统而言,这类权衡非常重要。

3 顺序保证

线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的,这个定义意味着操作是按照某种良好定义的顺序执行的。

顺序(ordering) 这一主题在本书中反复出现,这是一个重要的基础概念

  • 第五章:领导者在单主复制中的主要目的就是,在复制日志中确定写入顺序(order of write)
  • 第七章:可序列化,是关于事务表现的像按某种序列顺序(some sequential order) 执行的保证。
  • 第八章:使用时间戳和时钟是一种将顺序引入无序世界的尝试。

顺序,线性一致性和共识之间有着深刻的联系,这个概念可以明确系统的能力范围。

3.1 顺序与因果

顺序有助于保持因果关系(causality) 。因果关系对事件施加了一种顺序:因在果之前,例如消息发送在消息收取之前。一件事会导致另一件事,这些因果依赖的操作链定义了系统中的因果顺序,即什么在什么之前发生。

如果一个系统服从因果关系所规定的顺序,我们说它是因果一致(causally) 的。

因果顺序不是全序的

全序(total order) 允许任意两个元素进行比较,而偏序(partially order) 的元素无法比较。

例如,自然数集是全序的:给定两个自然数,可以告诉我哪个大哪个小。而数学集合是偏序的:{a,b}和{b,c}无法比较(除非存在包含关系)。而在线性一致的系统中,操作是全序的,我们总是能判定哪个操作先发生。

我们说过,如果两个操作都没有在彼此之前发生,那么这两个操作是并发的。换而言之,有因果相关的事件是全序的,并发事件是偏序的。因此,线性一致的数据存储中不存在并发操作,必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的。

线性一致性强于因果一致性

答案是线性一致性隐含着(implies) 因果关系:任何线性一致的系统都能正确保持因果性。线性一致性并不是保持因果性的唯一途径,一个系统可以是因果一致的,而无需承担线性一致带来的性能折损。

在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果一致性可以更高效地实现。在所有不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最强的一致性模型,而且在网络故障时仍能保持可用。

捕获因果关系

为了维持因果性,我们需要知道操作的顺序。并发操作可以以任意顺序进行,但如果一个操作发生在另一个操作之前,那它们必须在所有副本上以那个顺序被处理。因此,当一个副本处理一个操作时,它必须确保所有因果前驱的操作已经被处理。

我们需要确定因果依赖。可以推广版本向量以解决此类问题:为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。如“可序列化的快照隔离(SSI)”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。

3.2 序列号顺序

在实际场景中,跟踪所有的因果关系是不切实际的。

我们可以用序列号(sequence nunber)时间戳(timestamp) 来排序事件。时间戳可以来自逻辑时钟。这样的序列号或时间戳是紧凑的(只有几个字节大小),且提供了一个全序关系。

非因果序列号生成器

如果主库不存在(可能因为使用了多主数据库或无主数据库,或者因为使用了分区的数据库),如何为操作生成序列号就没有那么明显了。在实践中有各种各样的方法:

  • 每个节点都可以生成自己独立的一组序列号。同时确保两个节点不会生成相同的序列号(例如在二进制中预留一些位,或一个节点奇数一个节点偶数序列)。
  • 将时钟(物理时钟)时间戳附加到每个操作上。
  • 预先分配序列号区块,然后每个节点独立分配所属区块中的序列号

然而,他们都有一个问题:生成的序列号和因果不一致,他们不能正确的捕获跨节点的操作顺序:

  • 奇数节点可能落后于偶数计数器,或反之。
  • 来自物理时钟的时间戳会受到时钟偏移的影响。
  • 1到1000之间的序列号肯定是比1001到2000之间的序列号好的。
兰伯特时间戳

这是一种用来产生因果关系一致的序列号的方法。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。兰伯特时间戳就是两者的简单组合:(计数器,节点ID)(counter,nodeID)(counter, node ID)。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。

如果你有两个时间戳,则计数器值大者是更大的时间戳。如果计数器值相同,则节点ID越大的,时间戳越大。

光有时间戳排序还不够

虽然兰伯特时间戳定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常见问题。

例如,两个用户同时注册相同的用户名,那么一个应该失败,另一个应该成功。此时节点需要马上决定这个请求成功或失败,为了确保没有其他节点正在使用相同的用户名和较小的时间戳并发创建同名账户,就必须检查其他的每个节点,看看他们在做什么。

这里的矛盾是:只有收集所有操作才能出现全序,但是我们并不能将其他节点正在做的操作收集,所以无法构造构造所有操作的最终全序关系,因为来自另一个节点的未知操作可能需要被插入到全序中的不同位置。

所以,在类似这种场景中,仅有操作的全序是不够的。

3.3 全序广播

我们知道,在分布式系统中,很难让所有节点对同一个全局操作的顺序达成一致。

全序广播(total order broadcast) 又称原子广播,通常被描述为在节点间交换消息的协议,拥有两个安全属性:

  • 可靠交付:没有消息丢失。如果消息被传递到一个节点,他将被传递到所有节点。
  • 全序交付:消息以相同顺序传递给每个节点。

正确的全序广播算法必须始终保证可靠性和有序性,即使节点或网络出现故障。当然在网络中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能及时通过并按顺序送达。

使用全序广播

像ZooKeeper和etcd这样的共识服务实际上实现了全序广播,全序广播和共识之间有着紧密联系。

全序广播正是数据库复制所需的:若每个副本都按相同顺序处理相同写入,那么每个副本终将保持一致,这被称为状态机复制(state machine replication)

可以使用全序广播实现可序列化的事务。

全序广播的一个重要表现是,顺序在消息送达时被固化。

全序广播就像一种创建日志的方式,传递消息就像附加写入日志,所有节点必须以相同顺序传递相同消息,所有节点可以读取日志并看到相同的消息序列。

全序广播对于实现提供防护令牌的锁服务也很有用。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。在ZooKeeper中,这个序列号被称为zxid

使用全序广播实现线性一致的存储

全序广播是异步的:消息保证以固定顺序可靠送达,但不保证何时送达。而现象一致性保证新鲜性:读取一定能看见最新的写入值。

有了全序广播,我们可以在此基础上构建线性一致的存储。

例如,你可以确保用户名能唯一标识用户帐户。设想对于每一个可能的用户名,你都可以有一个带有CAS原子操作的线性一致寄存器。每个寄存器最初的值为空值(表示不使用用户名)。当用户想要创建一个用户名时,对该用户名的寄存器执行CAS操作,在先前寄存器值为空的条件,将其值设置为用户的账号ID。如果多个用户试图同时获取相同的用户名,则只有一个CAS操作会成功,因为其他用户会看到非空的值(由于线性一致性)。

可以将全序广播当成仅追加日志来实现这种线性一致的CAS操作:

  1. 日志中追加一条消息,试探性地指明你要声明的用户名。
  2. 读日志,并等待你所附加的信息被回送。
  3. 检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就你自己的消息,那么你就成功了。如果所需用户名的第一条消息来自其他用户,则中止操作。

由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入,则所有节点会对最先到达者达成一致。选择冲突写入中的第一个作为胜利者,并中止后来者,以此确定所有节点对某个写入是提交还是中止达成一致。类似的方法可以在一个日志的基础上实现可序列化的多对象事务。

然而,尽管这一过程保证写入是线性一致的,但它并不保证读取也是线性一致的,如果你从与日志异步更新的存储中读取数据,结果可能是陈旧的(这是顺序一致性,比线性一致性稍弱)。

为了使读取也线性一致,我们可以:

  • 通过追加一条消息,当消息回送时读取日志,执行实际的读取。
  • 若日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待直到该位置前的所有消息都传达到你,然后执行读取。
  • 从同步更新的副本中进行读取,因此可以确保结果是最新的。
使用线性一致性存储实现全序广播

最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子自增并返回操作。

该算法很简单:每个要通过全序广播发送的消息首先对线性一致寄存器执行自增返回操作,然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而收件人将按序列号连续发送消息。

与兰伯特时间戳不同,通过自增线性一致性寄存器获得的是没有间隙的序列。例如,一个节点收到了消息4和消息6,那么在传递消息6之前必须先等待消息5。事实上,这是全序广播和时间戳排序间的关键区别。

通过深入思考我们能发现,线性一致的CAS(或自增并返回)寄存器与全序广播都都等价于共识问题【28,67】。也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题的解决方案。因此,我们将在本章其余部分讨论共识问题。

4 分布式事务与共识

共识是分布式计算中最重要也是最基本的问题之一。即让几个节点达成一致,这在很多场景下很重要:

  • 领导选举:单主复制的数据库中,所有节点要对哪个节点是领导者达成一致共识,以应对故障切换。
  • 原子提交:让所有节点对事务的结果达成一致,要么全部回滚要么全部提交。

4.1 原子提交与两阶段提交(2PC)

从单节点到分布式原子提交

单个节点中,原子性通常由存储引擎实现。请求提交事务时,数据库将写入持久化后将提交记录追加到日志,若期间数据库崩溃,那么重启后事务会从日志中恢复。

但是,当事务涉及多个节点时,仅向所有节点发送提交请求并独立提交每个节点的事务是不够的,这很容易导致提交在某些节点上失败:

  • 某些节点中检测到约束冲突或冲突。
  • 某些请求在网络中丢失,最终由于超时而中止,而其他请求则通过。
  • 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而 其他节点却成功提交。

若某些节点提交了事务,其他节点却放弃了这些事务,那么这些节点就会彼此不一致。而且若事务在一些节点上提交成功却在其他节点上中止,提交也是无法撤回的。所以一旦确定事务中所有其他节点也将提交,那么节点就必须进行提交。

事务提交必须是不可撤销的,因为数据一旦被提交,其他客户端就可能会开始依赖这些数据。这个原则构成了读已提交隔离等级的基础。

两阶段提交(2PC)简介

一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。它是分布式数据库中的经典算法。下图说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。

image-20240107151350433

不要把2PC和2PL搞混了。2PC是两阶段提交提供原子提交。2PL是两阶段锁定,提供可序列化的隔离等级。

2PC使用一个新组件:协调者(coordinator) (也称事务管理器)。协调者通常在请求事务的相同应用进程中以库的形式实现(例如,嵌入在J2EE容器中),也可以是单独的进程或服务。

正常情况下,2PC事务以应用在多个数据库节点上读写数据开始,我们称这些数据库节点为参与者。协调者的工作氛围两个阶段,当应用准备提交时:

  1. 发送一个准备(prepare) 请求到每个节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应。
  2. 若所有参与者都回答“是”,则协调者发出提交(commit) 请求。否则协调者向所有节点发送中止(abort) 请求。
系统承诺

详细的分解整个过程:

  1. 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
  2. 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。
  3. 当应用准备提交时,协调者向所有参与者发送一个准备请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请求。
  4. 参与者收到准备请求时,向协调者回答是否能确保在任意情况下都可以提交事务。
  5. 协调者收到所有准备请求的答复后,根据是否所有参与者都回答“是”做出提交或中止决定,然后将决定写入磁盘上的事务日志中(提交点)。
  6. 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者将一直重试直到成功为止。

该协议包含两个关键的“不归路”:参与者回答“是”,那么它就肯定得能提交;协调者做出决定后,这个决定不能撤销。这两条保证了2PC的原子性。

协调者失效

若协调者在发送准备请求之前失效,参与者可以安全地中止事务。但是,当参与者回答“是”后,就必须等待协调者的回答,此时若协调者崩溃故障了,参与者就什么都做不了只能等待。这种事务状态称为存疑(in doubt) 或不确定。

如下图的例子,协调者决定提交,DB2收到提交请求,但是DB1没收到,此时DB1就只能干等着:如果超时而单方面中止,DB2可能执行的是提交,那么两个数据库就不一致,当然,单方面提交更寄。

image-20240107170713132

原则上,参与者可以互相沟通以达成一致,但这不是2PC协议的一部分。2PC协议只允许参与者等待协调者恢复。

协调者恢复后,日志中没有提交记录的事务都会中止,因此,2PC的提交点归结为协调者上的常规单节点原子提交。

三阶段提交

两阶段提交被称为阻塞原子提交协议。理论上,可以使一个原子提交协议变为非阻塞的,以便在节点失败时不会卡住。3PC算法可以达成非阻塞原子提交,但是它假定网络延迟有界,这在大多数无限网络延迟和进程暂停的实际系统中不能保证原子性。同时,它需要一个完美的故障检测器,在无限网络延迟的网络中,超时不是一种可靠的故障检测机制。因此,2PC算法虽然有问题但是已经是最优解。

4.2 实践中的分布式事务

分布式事务难以实现安全保证,由于运维问题容易造成性能下降,且某些实现会带来严重的性能损失(例如MySQL分布式事务比单点事务慢10倍以上)

但我们不应该直接忽视分布式事务,而应当从中汲取经验教训。

  • 数据库内的分布式事务

    • 一些分布式数据库支持数据库节点之间的内部事务,例如VoltDB和MySQL Cluster的NDB存储引擎。
  • 异构分布式事务

    • 异构事务中,参与者是两种或以上不同的技术:例如来自不同供应商的两个数据库,甚至是非数据库系统(如消息代理)。
    • 数据库内部事务不必与任何其他系统兼容,因此它们可以使用任何协议,并针对特定技术进行优化,因此数据库内部的分布式事务通常做的很好。
恰好一次的消息处理

异构的分布式事务处理能集成不同的系统:当且仅当用于处理消息的数据库事务成功提交时,消息队列中的一条消息可以被确认为已处理。这通过在同一个事务中原子提交消息确认数据库写入两个操作来实现。

若消息传递或数据库事务失败,两者都会中止,因此消息代理可以在稍后安全的重传消息。因此,通过原子提交消息处理及其副作用,即使在成功之前需要几次重试,也可以确保消息被有效地(effectively) 恰好处理一次。中止会抛弃部分完成事务所导致的任何副作用。

注意,只有当所有系统都使用同样的原子提交协议时,这样的分布式事务才是可用的。

XA事务

X/Open XA扩展架构(eXtended Architecture) 的缩写)是跨异构技术实现两阶段提交的标准。于1991年推出并得到广泛实现,许多传统关系数据库和消息代理都支持XA。

XA不是网络协议,而是用于与事务协调者连接的API。XA假定应用使用网络驱动或客户端来与参与者进行通信。若驱动支持XA,那么XA API会查明操作是否为分布式事务的一部分,若是,则将必要的信息发给数据库服务器,驱动还会向协调者暴露回调接口,协调者可以通过回调来要求参与者准备、提交或中止。

事务协调者需要实现XA API。协调者通常只是一个库而不是单独的服务,它倍加载到发起事务的应用的同一个进程中。它在事务中跟踪所有的参与者,并在要求它们准备之后通过驱动回调收集参与者的响应,最后使用本地磁盘上的日志记录每次事务的决定(提交或中止)。

若应用或机器崩溃,协调者也会失效,导致准备了但未提交事务的参与者在疑虑中卡死。由于协调程序的日志在服务器的本地磁盘上,所以必须重启该服务器,且协调程序库必须读取日志以恢复每个事务的决定,以使用数据库驱动的XA回调来要求参与者提交或中止。数据库服务器不能直接联系协调者,因为所有通信都必须通过客户端库。

怀疑时持有锁

为什么我们这么关心存疑事务?系统的其他部分就不能继续正常工作,无视那些终将被清理的存疑事务吗?

问题在于锁(locking) 。数据库事务通常获取待修改的行上的行级排他锁,以防止脏写(读已提交)。如果要使用可序列化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(2PL)。

在事务提交或中止之前,数据库不能释放这些锁。因此,使用两阶段提交时,事务必须在整个存疑期间持有这些锁,直到协调者恢复。若协调者日志丢失,那么锁将被永久持有,直到运维人员过来解决。这可能会导致大面积不可用,直到存疑事务被解决。

从协调者故障中恢复

实践中,孤立(orphaned) 的存疑事务可能出现,这会导致协调者无法确定事务的结果,从而阻塞持有锁并其他事务。

而在2PC的正确实现中,即使重启也会保留存疑事务的锁从而保持原子性。这种情况只能让运维手动决定提交还是回滚,再将结果应用于其他参与者。

许多XA有一个叫做启发式决策(heuristic decistions) 的紧急逃生舱口:允许参与者单方面决定放弃或提交一个存疑事务而无需协调者做出决定。这样会破坏原子性但总比引发灾难好。

分布式事务的限制

XA事务的优点:解决了保持多个参与者相互一致的问题;缺点:带来了严重的运维问题

事务协调者本身就是一种存储了事务结果的数据库,因此要像对其他重要数据库一样小心地打交道:

  • 如果协调者没有复制,而是只在单台机器上运行,那么它是整个系统的失效单点:它的失效会导致其他应用服务器阻塞再存疑事务持有的锁上。
  • 应用服务器本来可随意按需添加删除,但是如果有协调者就会改变部署的性质:应用服务器不再是无状态的了。
  • 由于XA需要兼容各种数据系统,因此必须是所有系统的“最小公分母“。
  • 对于数据库内部的分布式事务(不是XA)限制比较小。例如,分布式版本的SSI 是可能的。然而仍然存在问题:2PC成功提交一个事务需要所有参与者的响应,因此若系统有部分损坏,事务也会失效,从而导致扩大失效。

11和12章会教我们在没有异构分布式事务的痛苦的情况下实现几个系统的一致性。接下来我们先概况一下共识问题。

4.3 容错共识

共识即让几个节点就某事达成一致,共识算法可以用来确定这些互不相容(mutually incompatible) 的操作中,哪一个才是赢家。

共识问题中,一个或多个节点可以提议(propose) 某些值,而共识算法决定(decides) 采用其中的某个值。

在这种形式下,共识算法必须满足以下性质:这种共识的特殊形式被称为统一共识(uniform consensus) ,相当于在具有不可靠故障检测器的异步系统中的常规共识(regular consensus)

  • 一致同意:没有两个节点的决定不同。
  • 完整性:没有节点决定两次。
  • 有效性:如果一个节点决定了值 v ,则 v 由某个节点所提议。
  • 终止:由所有未崩溃的节点来最终决定值。

一致同意完整性属性定义了共识的核心思想:所有人都决定了相同的结果,一旦决定了,你就不能改变主意。有效性属性主要是为了排除平凡的解决方案:例如,无论提议了什么值,你都可以有一个始终决定值为null的算法。该算法满足一致同意和完整性属性,但不满足有效性属性。终止属性正式形成了容错的思想。实质是:一个共识算法不能等死,部分节点出现故障,其他节点也得达成项决定。

终止是一种活属性,另外三种是安全属性(参见“安全性和活性”)

共识的系统模型假设,当一个节点“崩溃”时,它会突然消失而且永远不会回来。在这个系统模型中,任何需要等待节点恢复的算法都不能满足终止属性(比如2PC)。

任何共识算法都需要至少占总体多数(majority) 的节点正确工作,以确保终止属性。多数可以安全地组成法定人数。然而即使多数节点出现故障或存在严重的网络问题,绝大多数共识的实现都能始终确保安全属性得到满足(一致同意,完整性和有效性)。

大多数共识算法假设不存在拜占庭式错误,若一个节点没有正确地遵循协议,它就可能会破坏协议的安全属性。因此只要少于一定比例的节点存在拜占庭故障,就不会破坏共识。

共识算法和全序广播

最著名的容错共识算法是视图戳复制(VSR, viewstamped replication) 、Paxos、Raft和Zab。大多数算法不直接使用形式化的模型,而是决定了顺序(sequence) ,这使它们成为全序广播算法。

所以,全序广播相当于重复进行多轮共识:

  • 一致同意:所有节点决定以相同的顺序传递相同的消息。
  • 完整性:消息不会重复。
  • 有效性:消息不会被损坏,也不能凭空编造。
  • 终止:消息不会丢失。

视图戳复制,Raft和Zab直接实现了全序广播,因为这样做比重复一次一值(one value a time) 的共识更高效。在Paxos的情况下,这种优化被称为Multi-Paxos。

单领导者复制和共识

第五章中讨论的“领导者和追随者”,实际上也是一个全序广播,为什么我们在第五章里一点都没担心过共识问题呢?这是因为主库是由运维人员手动选择和配置的,实际上是一种独裁类型的“共识算法”:只有一个节点被允许写入,若节点故障则系统无法写入,这无法满足共识的终止属性,它的恢复需要运维手动配置其他节点作为主库。

有些数据库会自动提拔一个新领导者,但是由于脑裂问题,领导者需要达成共识,这里的共识是一种全序广播算法,并且全序广播算法就像单主复制,这样就会陷入鸡生蛋的问题:要选出一个领导者,我们首先需要一个领导者。要解决共识问题,我们首先需要解决共识问题。

时代编号和法定人数

迄今为止所讨论的所有共识协议, 在内部都以某种形式使用一个领导者,但他们不能保证领导者独一无二。

相反,它们可以做出更弱的保证:协议定义了一个时代编号(epoch number)(在Paxos中称为投票编号(ballot number),视图戳复制中的视图编号(view number),以及Raft中的任期号码(term number)),并确保在每个时代中,领导者都是唯一的。

当现任领导被认为挂掉的时候,节点间投票选出一个新领导。时代编号全序递增,若领导者出现冲突,由编号更高的领导者说了算。

领导者被允许决定前,要先检查是否有更新的领导者。领导者从法定人数的节点中获取选票,对领导者想要做的每一个决定,都要将提议值发给其他节点,等法定人数的的节点响应赞成提案。在没有发现有更高的领导者的情况下,一个节点才会投同意票。

因此,有两次投票:选举(选出一位领导者)、表决(对领导者的提议进行表决)。两次投票的法定人群必须重叠,若提案的表决通过,至少得有一个投票的节点参与过选举,因为参与过选举的节点才知道有没有新的领导。

这一投票过程表面上像两阶段提交,但是2PC中协调者不是由选举产生,且2PC要求所有参与者投赞成票。容错共识还定义了一个恢复过程,节点可以在选举出新的领导者后进入一个一致状态以确保满足安全属性。

共识的局限性

共识未分布式系统带来了基础的安全属性(一致同意,完整性和有效性),同时保持容错。

但是共识算法也有局限性:

  • 节点做出决定前对提议进行投票是一种同步复制,在同步复制中发生故障切换时可能造成已提交的数据丢失(参见“同步与异步复制”)。
  • 节点至少要有三个(两个构成多数),如果网络故障切断了节点间的连接,则只有多数节点所在的网络可以继续工作,其余部分将被阻塞。
  • 大多数共识算法假定节点数是固定的,那么我们不能简单地添加或删除节点(共识算法的动态成员扩展可以解决这个问题)。
  • 共识系统依赖超时来检测失效节点,可能导致错误的认为领导者已经失效,导致性能不佳。
  • 共识算法对网络问题特别敏感,例如Raft可能因为网络问题进入领导者不断辞职然后二人转的问题。

4.4 成员与协调服务

像ZooKeeper或etcd这样的项目通常被描述为“分布式键值存储”或“协调与配置服务”。这种服务的API看起来非常像数据库:能读写遍历键值。那么是上面使他们区别于数据库呢?

ZooKeeper和etcd被设计为容纳少量可以放在内存中的数据(虽然它们仍然会写入磁盘以保证持久性),这些少量数据会通过容错的全序广播算法复制到所有节点上。

ZooKeeper模仿了Google的Chubby锁服务,实现了全序广播(因此也实现了共识)又构建了一组有趣的其他特性:

  • 线性一致性的原子操作:使用原子CAS操作实现锁,多个节点尝试相同操作时只有一个节点会成功,共识协议保证了操作的原子性和线性一致性。分布式锁通常以租约(lease) 的形式实现。
  • 操作的全序排序:某个资源受到锁或租约保护时,需要防护令牌来防止客户端在进程暂停的情况下彼此冲突(参见”领导者与锁定“)。防护令牌是每次被锁获取时单调增加的数字。ZooKeeper通过全局排序操作提供这个功能,为每个操作提供事务id(zxid)和版本号(cversion)。
  • 失效检测:客户端中在ZooKeeper服务器上维护一个长期会话,客户端和服务器周期性交换心跳包检查节点活性。若心跳停止的持续时间超出会话超时,ZooKeeper会宣告该会话已死亡。
  • 变更通知:客户端不仅可以读取其他客户端创建的锁和值,还可以监听其变更。客户端可以知道另一个客户端加入集群的时间(基于新客户端写入ZooKeeper的值),以及是否发生故障(根据其会话超时)。通过订阅通知,客户端就不用频繁轮询了。
将工作分配给节点

在ZooKeeper/Chubby模型中:

  • 若我们有几个进程实例或服务,那么需要选择其中一个实例作为主库或首选服务。如果领导者失败,其他节点之一应该接管。这个功能对单主数据库非常实用。
  • 当我们有一些分区资源并需要决定怎么分配时,当新节点加入集群时,需要将某些分区从现有节点移动到新节点以便重新平衡负载(参阅”重新平衡分区“)。

应用最初只能在单个节点上运行,但最终可能增长到数千个节点,这会导致投票变得低效。ZooKeeper在固定数量的节点上运行并执行其多数票,同时支持潜在的大量客户端。因此,ZooKeeper提供了一种将协调节点的一些工作”外包“到外部服务的方式。

服务发现

服务发现:找出你需要连接到哪个IP地址才能到达特定的服务。

ZooKeeper,etcd和Consul也经常用于服务发现。在云数据中心环境中,虚拟机连续来去常见,你通常不会事先知道服务的IP地址。通常先配置服务,使其在启动时注册服务注册表中的网络端点,然后可以由其他服务找到它们。

但是,服务发现是否需要达成共识还不太清楚。DNS是查找IP地址的传统方式,但不是线性一致的。

若共识系统已经知道领导是谁,也可以使用这些信息来帮助其他服务发现领导是谁。为此,一些共识系统支持用只读的缓存副本异步接收共识算法所有决策的日志,但不主动参与投票。因此,它们能够提供不需要线性一致性的读取请求。

成员服务

成员资格服务确定哪些节点当前处于活动状态并且是群集的活动成员。

由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。但是,如果你通过一致的方式进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。(参见第八章)

即使有成员资格服务,仍然可能让一个节点被共识误判为死亡。