《数据密集型应用系统设计》读书笔记——第二部分 分布式数据系统
作者:互联网
第二部分 分布式数据系统
前面我们讨论了数据系统的各个⽅方⾯面,但仅限于数据存储在单台机器上的情况。现在我们到了第二部分,进⼊更高的层次,并提出⼀个问题:如果多台机器参与数据的存储和检索,会发⽣什么?
你可能会出于各种各样的原因,希望将数据库分布到多台机器上:
可扩展性
如果你的数据量、读取负载、写入负载超出单台机器的处理能力,可以将负载分散到多台计算机上。
容错与高可⽤性
如果你的应用需要在单台机器(或多台机器,网络或整个数据中⼼)出现故障的情况下仍然能继续工作,则可使用多台机器,以提供冗余。⼀台故障时,另一台可以接管。
延迟
如果在世界各地都有用户,你也许会考虑在全球范围部署多个服务器,从⽽每个用户可以从地理上最近的数据中心获取服务,避免了等待网络数据包穿越半个世界。
系统扩展能力
当负载增加需要更强的处理能力时,最简单的方法就是购买更强大的机器(有时称为垂直扩展或向上扩展(scale up))。许多处理器,内存和磁盘可以在同一个操作系统下相互连接,快速的相互连接允许任意处理器访问内存或磁盘的任意部分。在这种共享内存架构 (shared-memory architecture)中,所有的组件都可以看作⼀台单独的机器。
共享内存⽅法的问题在于,成本增长速度快于线性增⻓:一台有着双倍处理器数量,双倍内存⼤小,双倍磁盘容量的机器,通常成本会远超过原来的两倍。⽽且可能因为存在瓶颈,并不足以处理双倍的载荷。
共享内存架构可以提供有限的容错能力,高端机器可以使⽤热插拔的组件(不关机更换磁盘,内存模块,甚⾄处理器)——但它必然囿于单个地理位置的桎梏。
另一种⽅法是共享磁盘架构(shared-disk architecture),它使⽤多台具有独立处理器和内存的机器,但将数据存储在机器之间共享的磁盘阵列上,这些磁盘通过快速⽹连接。这种架构多适用于数据仓库等负载,但竞争和锁定的开销限制了共享磁盘方法的可扩展性。
无共享架构
相⽐之下,无共享架构(shared-nothing architecture)(有时称为水平扩展(horizontal scale))或向外扩展(scale out))已经相当普及。在这种架构中,运⾏数据库软件的每台机器/虚拟机都称为节点(node)。每个节点只使用各自的处理器,内存和磁盘。节点之间的任何协调,都是在软件层⾯使用传统网络实现的。
无共享系统不需要使用特殊的硬件,所以可以用任意机器——比如性价比最好的机器。也可以跨多个地理区域分布数据从而减少用户延迟,或者在损失一整个数据中心的情况下幸免于难。随着云计算拟机部署的出现,即使是小公司,也可以实现异地分布式架构。
虽然分布式无共享架构有许多优点,但它通常也会给应用带来额外的复杂度,有时也会限制可⽤数据模型的表达力。
复制与分区
将数据分布在多个节点时有两种常见的方式:
- 复制:在多个节点上保存相同数据的副本,每个副本具体的存储位置可能不尽相同。复制方法可以提供冗余:如果某些节点发生不可用,则可以通过其他节点继续提供服务。复制也能提供系统性能。
- 分区:将一个大块头的数据库拆分为多个较小的子集即分区。不同的分区分配给不同的节点(也成为分片)。
这些是不同的数据分布机制,但经常被放在一起组合使用。
第5章 数据复制
复制主要指通过互联网络在多台机器上保存相同数据的副本。通过数据复制方案,人们通常希望达到以下目的:
- 使数据在地里位置上更接近用户,从而降低访问延迟
- 当部分组件出现故障时,系统依然可以正常的工作,从而提高可用性
- 扩展至多台机器以同时提供数据访问服务,从而提高吞吐量。
主节点与从节点
每个保存数据库完整数据集的节点称之为副本。对于每一笔数据的写入,所有副本都需要随之更新;否则,某些副本将出现不一致。最常见的解决方案是基于主节点的复制(也称为主动/被动,或主从复制)。如下图所示:
同步复制与异步复制
数据复制通常分为同步复制与异步复制两种。
同步复制的优点是,从库保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上找到。缺点是,如果同步从库没有响应(⽐比如它已经崩溃,或者出现⽹络故障,或其它任何原因),主库就⽆法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可⽤。
因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中一个跟随者是同步的,而其他的则是异步的。如果同步从库变得不可⽤或缓慢,则使一个异步从库同步。这保证至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为半同步。
主从复制还经常被配置为全异步模式。 在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写⼊都会丢失。 这意味着即使已经向客户端确认成功,写入也不能保证持久 。 然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。
配置新的从节点
简单地将数据文件从一个节点复制到另⼀个节点通常是不够的:客户端不断向数据库写入数据,数据总是在不断变化,标准的数据副本会在不同的时间点总是不一样。复制的结果可能没有任何意义。
可以通过锁定数据库(使其不可⽤于写入)来使磁盘上的文件保持⼀一致,但是这会违背⾼可用的目标。
幸运的是,拉起新的从库通常并不需要停机。从概念上讲,过程如下所示:
- 在某个时刻获取主库的⼀致性快照,而不必锁定整个数据库。大多数据库都具有这个功能,因为它是备份必需的。
- 将快照复制到新的从库节点。
- 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。
- 当从库处理完快照之后积压的数据变更,我们说它赶上了主库。现在它可以继续处理主库产生的数据变化了。
处理失效节点
从节点失效:追赶式恢复
在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者,如果主库和从库之间的⽹络暂时中断,则比较容易恢复:从库可以从⽇志中知道,在发⽣故障之前处理的最后⼀一个事务。因此,从库可以连接到主库,并请求在从库断开连接时发⽣的所有数据变更。当应用完所有这些变化后,它就赶上了主库,并可以像以前⼀样继续接收数据变更流。
主节点失效:节点切换
主库失效处理起来相当棘⼿:其中⼀个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为故障切换 (failover)。
故障切换会出现很多麻烦:
- 如果使⽤异步复制,则新主库可能没有收到⽼主库宕机前最后的写入操作。在选出新主库后,如果老主库重新加入集群,新主库在此期间可能会收到冲突的写⼊,那这些写⼊该如何处理?最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对于数据持久性的期望。
- 如果数据库需要和其他外部存储相协调,那么丢弃写⼊内容是极其危险的操作。例如在GitHub的一场事故中,⼀个过时的MySQL从库被提升为主库。数据库使⽤用⾃增ID作为主键,因为新主库的计数器落后于老主库的计数器,所以新主库重新分配了一些已经被老主库分配掉的ID作为主键。这些主键也在Redis中使用,主键重用使得MySQL和Redis中数据产⽣不一致,最后导致⼀些私有数据泄漏到错误的⽤户手中。
- 发⽣某些故障时可能会出现两个节点都以为⾃己是主库的情况。这种情况称为脑裂,⾮危险:如果两个主库都可以接受写操作,却没有冲突解决机制,那么数据就可能丢失或损坏。
- 主库被宣告死亡之前的正确超时应该怎么配置?在主库失效的情况下,超时时间越长,意味着恢复时间也越⻓。但是如果超时设置太短,⼜可能会出现不必要的故障切换。
复制滞后问题
基于主库的复制要求所有写入都由主节点处理,但只读查询可以由任何副本处理理。
不幸的是,当应⽤程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执⾏相同的查询,可能得到不同的结果,因为并非所有的写⼊都反映在从库中。这种不一致只是⼀个暂时的状态——如果停⽌写入数据库并等待一段时间,从库最终会赶上并与主库保持⼀致。出于这个原因,这种效应被称为最终一致性(eventually consistency)。
读自己的写
如果⽤户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户⽽言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。
在这种情况下,我们需要读写⼀致性,也称为读己之写一致性。这是⼀个保证,如果⽤户重新加载⻚面,他们总会看到他们⾃己提交的任何更新。它不会对其他用户的写⼊做出承诺。
基于主从复制的系统实现读写一致性,有多种可行的方案,常见如下:
- 读用户可能已经修改过的内容时,都从主库读;
- 如果应用中的⼤部分内容都可能被用户编辑,那这种方法就没⽤了,因为大部分内容都必须从主库读取(扩容读就没效果了)。在这种情况下可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的⼀分钟内,从主库读。
- 还可以监控从库的复制延迟,防⽌向任何滞后超过一分钟的从库发出查询。
- 客户端可以记住最近⼀次写⼊的时间戳,系统需要确保从库为该用户提供任何查询时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另⼀个从库读,或者等待从库追赶上来。时间戳可以是逻辑时间戳(指示写⼊顺序的东⻄,例如⽇志序列号)或实际系统时钟(在这种情况 下,时钟同步变得⾄关重要)。
- 如果副本分布在多个数据中⼼(出于可用性⽬的与⽤户尽量在地理上接近),则会增加复杂性。任何需要由主节点提供服务的请求都必须路由到包含主库的数据中⼼。
另⼀种复杂的情况是:如果同⼀个用户从多个设备请求服务,例如桌⾯浏览器和移动APP。这种情况下可能就需要提供跨设备的写后读一致性:如果用户在某个设备上输⼊了一些信息,然后在另⼀个设备上查看,则应该看到他们刚输入的信息。
在这种情况下,还有一些需要考虑的问题:
- 记住⽤户上次更新时间戳的方法变得更加困难,因为一台设备上运⾏的程序不知道另⼀台设备上发生了什么。此时元数据必须做到全局共享。
- 如果副本分布在不同的数据中心,很难保证来⾃不同设备的连接会路由到同一数据中心。 (例如,用户的台式计算机使⽤家庭宽带连接,而移动设备使⽤蜂窝数据⽹络,则设备的⽹络路线可能完全不同)。如果你的方法需要读主库,可能首先需要把来自同⼀用户的请求路由到同⼀一个数据中心。
单调读
单调读一致性保证当读取数据时,如果某个用户一次进行多次读取,则他绝不会看到回滚现象,即在读取较新值之后又发生读旧值的情况。这是一个比强一致性更弱,但⽐最终一致性更强的保证。单调读取仅意味着如果一个⽤户顺序地进行多次读取,则他们不会看到时间后退, 即,如果先前读取到较新的数据,后续读取不会得到更旧的数据。
实现单调读取的⼀种⽅式是确保每个⽤户总是从同一个副本进⾏读取(不同的⽤户可以从不同的副本读取)。例如,可以基于用户ID的散列来选择副本,⽽不是随机选择副本。但是,如果该副本失败,用户的查询将需要重新路由到另⼀个副本。
前缀一致读
前缀一致读保证:如果一系列写入按某个顺序发生,那么任何⼈读取这些写⼊时,也会看见它们以同样的顺序出现。
这是分区(分⽚)数据库中的⼀个特殊问题。如果数据库总是以相同的顺序应用写入,则读取总是会看到⼀致的前缀,所以这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运⾏行,因此不存在全局写入顺序:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些处于较新的状态。
一种解决方案是,确保任何因果相关的写入都写入相同的分区,但该方案真实实现效率会大打折扣。
多主节点复制
基于主节点的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它。如果出于任何原因 (例如和主库之间的⽹络连接中断)无法连接到主库, 就无法向数据库写⼊。
基于主节点的复制模型的⾃然延伸是允许多个节点接受写入。 复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据更改转发给所有其他节点。 称之为多主配置(也称多主、多活复制)。 在这种情况下,每个主节点同时扮演其他领导者的追随者。
适用场景
在单个数据中⼼内部使用多个主库很少是有意义的,因为好处很少超过复杂性的代价。 但在一些情况 下,多活配置是也合理的。
多数据中心
假如你有⼀个数据库,副本分散在好几个不同的数据中⼼(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于主节点的复制设置,主库必须位于其中⼀个数据中⼼心,且所有写入都必须经过该数据中心。
多主配置中可以在每个数据中⼼都有主库。 在每个数据中心内使⽤常规的主从复制:在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
可以对比一下在多数据中心环境下,部署单主节点的主从复制方案与多主复制方案之间的差异:
- 性能
在单主节点配置中,每个写入都必须穿过广域网,进⼊主库所在的数据中心。这可能会增加写入时间,并可能违背了设置多个数据中⼼的初心(就近访问)。在多主配置中,每个写操作都可以在本地数据中心进⾏处理,并与其他数据中心异步复制。因此,数据中心之间的网络延迟对⽤户来说是透明的,这意味着感觉到的性能
可能会更更好。 - 容忍数据中⼼心停机
在单主配置中,如果主库所在的数据中心发生故障,故障切换可以使另一个数据中⼼里的追随者成为领导者。在多主配置中,每个数据中⼼可以独立于其他数据中⼼心继运行,并且当发生故障的数据中⼼恢复时,复制会⾃动赶上。 - 容忍⽹网络问题
数据中⼼之间的通信通常穿过广域网,这可能不如数据中心内的本地网络可靠。单主配置对这数据中⼼间的连接问题⾮常敏感,因为通过这个连接进行的写操作是同步的。采⽤异步复制功能的多主配置通常能更好地承受⽹络问题:临时的⽹络中断并不会妨碍正在处理的写⼊。
尽管多主复制有这些优势,但也有⼀个很大的缺点:两个不同的数据中⼼可能会同时修改相同的数据, 写冲突是必须解决的。
离线操作的客户端
多主复制的另⼀种适用场景是:应⽤程序在断⽹之后仍然需要继续工作。例如,考虑手机,笔记本电脑和其他设备上的⽇历应用。无论设备⽬前是否有互联网连接,你需要能随时查看你的会议(发出读取请求),输入新的会议(发出写入请求)。如果在离线状态下进⾏任何更更改,则设备下次上线时,需要与服务器和其他设备同步。
在这种情况下,每个设备都有⼀个充当主节点的本地数据库(它接受写请求),并且在所有设备上的⽇历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几⼩时甚⾄几天,具体取决于何时可以访问互联网。
从架构的⻆度来看,这种设置实际上与数据中⼼之间的多主复制类似,每个设备都是⼀个“数据中心”,而它们之间的⽹络连接是极度不可靠的。
协同编辑
实时协作编辑应用程序允许多个⼈同时编辑⽂档。我们通常不会将协作式编辑视为数据库复制问题,但与前面提到的离线编辑⽤例有许多相似之处。当一个用户编辑文档时,所做的更改将立即应用到其本地副本(Web浏览器或客户端应⽤程序中的文档状态),并异步复制到服务器和编辑同一⽂档的任何其他⽤户。
如果要保证不会发生编辑冲突,则应⽤程序必须先取得文档的锁定,然后用户才能对其进行编辑。如果另⼀个用户想要编辑同⼀个文档,他们首先必须等到第⼀个⽤用户提交修改并释放锁定。这种协作模式相当于在主节点上进行交易的单主复制。
但是,为了加速协作,可能希望将更改的单位设置得⾮常⼩小(例如,⼀个按键),并避免锁定。这种方法允许多个用户同时进⾏编辑,但同时也带来了多领导者复制的所有挑战,包括需要解决冲突。
处理写冲突
同步与异步冲突检测
在单主数据库中,第二个写⼊将被阻塞,并等待第⼀个写入完成,或中止第二个写入事务,强制⽤户重试。另⼀方面,在多主配置中,两个写⼊都是成功的,并且在稍后的时间点仅异步地检测到冲突。那时要求⽤户解决冲突可能为时已晚。
原则上,可以使冲突检测同步,即等待写入被复制到所有副本,然后再告诉⽤户写入成功。但是,通过这样做,将失去多主复制的主要优点:允许每个副本独立接受写⼊。如果想要同步冲突检测,那么或许可以使用单节点复制。
避免冲突
处理冲突的最简单的策略就是避免它们:如果应⽤程序可以确保特定记录的所有写入都通过同一个领导者,那么冲突就不会发生。由于多主复制处理的许多实现冲突相当不好,避免冲突是一个经常推荐的方法。
但是,有时可能需要更改指定的记录的主库——可能是因为⼀个数据中心出现故障,需要将流量重新路由到另⼀个数据中心,或者可能是因为用户已经迁移到另⼀个位置,现在更接近不同的数据中心。 在这种情况下,冲突避免会中断,必须处不不同主库同时写⼊的可能性。
收敛⾄一致的状态
单主数据库按顺序应用写操作:如果同⼀个字段有多个更新,则最后⼀个写操作将确定该字段的最终值。
在多主配置中,写入顺序没有定义,所以最终值应该是什么并不清楚。如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态,这是不可接受的,每个复制⽅案都必须确保数据在所有副本中最终都是相同的。 因此,数据库必须以一种收敛的⽅式解决冲突,这意味着所有副本必须在所有变更复 制完成时收敛⾄一个相同的最终值。
实现冲突合并解决有多种途径:
- 给每个写入一个唯⼀的ID(例如,⼀个时间戳,⼀个长的随机数,一个UUID或者一个键和值的哈希),挑选最⾼ID的写入作为胜利利者,并丢弃其他写入。如果使用时间戳,这种技术被称为最后写入胜利(LWW, last write wins)。虽然这种⽅法很流行,但是很容易造成数据丢失。
- 为每个副本分配一个唯⼀的ID,ID编号更高的写⼊具有更高的优先级。这种方法也意味着数据丢失。
- 以某种⽅式将这些值合并在一起,例如,按字母顺序排序,然后连接它们。
- 在保留所有信息的显式数据结构中记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
自定义冲突解决逻辑
作为解决冲突最合适的⽅法可能取决于应用程序,⼤多数多主复制工具允许使用应⽤程序代码编写冲突解决逻辑。该代码可以在写入或读取时执行:
- 写时执⾏
只要数据库系统检测到复制更改⽇志中存在冲突,就会调用冲突处理程序。这个处理程序通常不能提示⽤户——它在后台进程中运行,并且必须快速执行。 - 读时执行
当检测到冲突时,所有冲突写⼊被存储。下⼀次读取数据时,会将这些多个版本的数据返回给应用程序。应⽤程序可能会提示用户或⾃动解决冲突,并将结果写回数据库。
冲突解决通常适用于单个行或文档层面,⽽不是整个事务。因此,如果有一个事务会原⼦性地进⾏⼏次不同的写⼊,则对于冲突解决⽽言,每个写入仍需分开单独考虑。
无主节点复制
到目前为止所讨论的复制方法 ——单主复制、多主复制——都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。
一些数据存储系统采⽤不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。
在⼀些无主的实现中,客户端直接将写⼊发送到几个副本中,而另⼀些情况下,⼀个协调者 节点代表客户端进⾏写入。但与主节点数据库不同,协调者不执行特定的写入顺序。
节点失效时写入数据库
在无主配置中,故障切换不存在。下图显示了发⽣了什么事情:客户端(用户1234)并行发送写⼊到所有三个副本,并且两个可⽤副本接受写入,但是不可⽤用副本错过了它。假设三个副本中的两个承认写入是足够的:在⽤户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
现在想象一下,不可⽤的节点重新联机,客户端开始读取它。节点关闭时发生的任何写入都从该节点丢失。因此,如果从该节点读取数据,则可能会将陈旧值视为响应。
为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅发送它的请求到⼀个副本:读请求也被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应。即来自一个节点的最新值和来自另一个节点的陈旧值。版本号用于确定哪个值更新。
读修复与反熵
复制方案应确保最终将所有数据复制到每个副本。在一个不可用的节点重新联机之后,它如何赶上错过的写入?经常使⽤如下两种机制:
- 读修复(Read repair)
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。客户端发现某副本具有陈旧值时,并将新值写回该副本。这种⽅法适⽤于频繁读取的值。 - 反熵过程(Anti-entropy process)
此外,⼀些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从⼀个副本复制到另⼀一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显着的延迟。
请注意,如果没有反熵过程, 某些副本中很少读取的值可能会丢失,从而降低了了持久性,因为只有在应用程序读取值时才执行读修复。
读写quorum
一般地说,如果有n个副本,每个写入必须由w节点确认才能被认为是成功的,并且我们必须至少为每个读取查询r个节点。 只要w + r > n
,读取中就至少有⼀个节点是最新的。遵循这些r值,w值的读写称为法定⼈数(quorum) 的读和写。可以认为,r和w是有效读写所需的最低票数。
仲裁条件w + r > n
定义了允许系统容忍不可⽤的节点,如下所示:
- 如果
w < n
,如果一个节点不可用,我们仍然可以处理写入。 - 如果
r < n
,如果一个节点不可用,我们仍然可以处理读取。 - 对于
n = 3,w = 2,r = 2
,我们可以容忍一个不可用的节点。 - 对于
n = 5,w = 3,r = 3
,我们可以容忍两个不可用的节点。 - 通常,读取和写⼊操作始终并⾏发送到所有n个副本。 参数w和r决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功。
如果少于所需的w或r节点可用,则写⼊或读取将返回错误。 由于许多原因,节点可能不可用:因为由于执行操作的错误(由于磁盘已满⽽⽆法写⼊)导致节点关闭(崩溃,关闭电源),由于客户端和服务器之间的⽹络中断,或任何其他原因。
Quorum一致性的局限性
如果有n个副本,并且选择w和r,使得w + r > n
,通常可以期望每个读取返回为⼀个键写的最近的值。情况就是这样,因为写的节点集合和读取的节点集合必然重叠。也就是说,读取的节点中必须至少有一个具有最新值的节点。
通常,r和w被选为多数(超过n/2)节点,因为这确保了w + r > n
,同时仍然容忍多达n/2个节点故障。但是,法定⼈数不一定必须是大多数,只要求读写使用的节点交集⾄少需要包括一个节点。其他法定人数的配置是可能的,这使得分布式算法的设计有一定的灵活性。
也可以将w和r设置为较⼩的数字,以使w + r ≤ n
(即法定条件不满足)。在这种情况下,读取和写入操作仍将被发送到n个节点,但操作成功只需要少量的成功响应。
较的w和r更有可能会读取过时的数据,因为读取更有可能不包含具有最新值的节点。另⼀⽅面,这种配置允许更低的延迟和更高的可⽤性:如果存在⽹络中断,并且许多副本变得⽆法访问,则可以继续处理读取和写入的机会更大。只有当可达副本的数量低于w或r时,数据库才分别变得不可⽤于写入或读取。
但是,即使在w + r > n
的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
- 如果使用松散的法定人数(见“宽松的quorum与数据回传”),w个写入和r个读取落在完全不同的节点上,因此r节点和w之间不再保证有重叠节点。
- 如果两个写入同时发⽣生,不清楚哪一个先发生。在这种情况下,唯⼀安全的解决⽅案是合并并发写入。如果根据时间戳(最后写入胜利)挑选出胜者,则由于 时钟偏差,写⼊可能会丢失。
- 如果写操作与读操作同时生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取是返回旧值还是新值。
- 如果写操作在某些副本上成功,⽽在其他节点上失败(例如,因为某些节点上的磁盘已满),在⼩于w个副本上写⼊成功。所以整体判定写入失败,但整体写⼊失败并没有在写入成功的副本上回滚。这意味着如果⼀个写⼊虽然报告失败,后续的读取仍然可能会读取这次失败写入的值。
- 如果携带新值的节点失败,需要读取其他带有旧值的副本。并且其数据从带有旧值的副本中恢复,则存储新值的副本数可能会低于w,从而打破法定⼈数条件。
宽松的quorum与数据回传
法定人数(如迄今为⽌所描述)并不像它们可能的那样具有容错性。⽹络中断可以很容易地将客户端从⼤量的数据库节点上切断。虽然这些节点是活着的,而其他客户端可能能够连接到它们,但是从数据库节点切断的客户端,它们也可能已经死亡。在这种情况下,剩余的可用节点可能会少于可⽤节点,因此客户端可能无法达到法定⼈数。
在⼀个大型的群集中(节点数量明显多于n个),网络中断期间客户端可能连接到某些数据库节点,⽽不是为了特定值组成法定人数的节点们。在这种情况下,数据库设计⼈员需要权衡⼀一下:
- 无法达到w或r节点的法定数量的所有请求将错误明确的返回给客户端?
- 或者我们是否应该接受写入,然后将它们写⼊一些可达的节点(这个节点并不在n个节点集合中)?
后者被认为是⼀个松散的法定⼈数(sloppy quorum):写和读仍然需要w和r成功的响应,但包含了哪些并在先前指定的n个节点。⽐方说,如果你把⾃己锁在房⼦子外⾯,你可能会敲开邻居的门,问你是否可以暂时停留在沙发上。
一旦⽹络中断得到解决,临时节点需要把接受到的所有写入全部发送到原始主节点上。这就是所谓的数据回传。 (一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家。)
松散法定⼈数对写⼊可用性的提高特别有⽤:只要有任何w节点可用,数据库就可以接受写入。然而, 这意味着即使当w + r > n
时,也不能确定读取某个键的最新值,因为最新的值可能已经临时写⼊了n 之外的某些节点。
检测并发写
为了最终达成一致,副本应该收敛于相同的值。常见有如下几种方法:
最后写入者获胜(丢弃并发写入)
实现最终收敛的⼀种方法是声明每个副本只需要存储最“最近”的值,并允许“更旧”的值被覆盖和抛弃。 然后,只要我们有一种明确的⽅式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本, 那么复制最终会收敛到相同的值。
正如“最近”的引号所表明的,这个想法其实颇具误导性。事实上,说“发生”是没有 意义的:我们说写⼊是并发的,所以它们的顺序是不确定的。
即使写⼊没有自然的排序,我们也可以强制对齐排序。例如,可以为每个写入附加⼀个时间戳,挑选“最近”即的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为最后写⼊胜利(LWW, last write wins)。
LWW实现了最终收敛的目标,但以持久性为代价:如果同⼀个Key有多个并发写⼊入,即使它们都向客户端返回成功(因为它们被写⼊w个副本),但只有最近⼀个写⼊将存活,而其他写⼊将被静默丢弃。
有⼀些情况,如缓存,其中丢失的写入可能是可以接受的。如果丢失数据不可接受,LWW是解决冲突的一个很烂的选择。
与LWW一起使用数据库的唯一安全⽅法是确保一个键只写⼊一次,然后视为不可变,从⽽避免对同⼀个密钥进⾏并发更新。例如,Cassandra推荐使⽤的方法是使用UUID作为键,从而为每个写操作提供⼀个唯一的键。
合并同时写入的值
合并本质上是与多领导者复制中的冲突解决相同的问题。⼀个简单的方法是根据版本号或时间戳(最后写⼊胜利)选择⼀个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更多的事情。
以购物⻋为例,一种合理的合并方法就是集合求并。在上图中,最后的两个兄弟是[⽜奶,面粉,鸡蛋,熏⾁]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现了两次,即使他们每个只写一次。合并的最终值应该[牛奶,⾯粉,鸡蛋,培根,火腿],没有重复。
然⽽,如果你想让人们也可以从他们的⼿推车中删除东西,⽽不是仅仅添加东⻄,那么把并发至都合并起来可能不会产⽣正确的结果:如果你合并了两个客户端中的值,并且只在其中一个里里删掉了它,那么被删除的项目会重新出现在并集中。为了防止这个问题,项⽬在删除时不能简单地从数据库中删除:相反,系统必须留下一个具有合适版本号的标记,以指示合并时该项目已被删除。这种删除标记被称为墓碑。
版本矢量
当多个副本并发接受写入时,使用单个版本号来捕获操作之间的依赖关系是不够的。相反,除了对每个键使⽤版本号之外,还需要在每个副本中版本号。每个副本在处理写入时增加⾃己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值。所有副本的版本号集合称为版本向量。
当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 版本向量使数据库可以区分覆盖写入和并发写入。
另外,就像在单个副本的例子中一张,应⽤程序仍然需要执行合并操作。版本向量量结构确保从一个副本读取并随后写回到另⼀个副本是安全的。这样做可能会创建新的“兄弟”值,但至少不会发生数据丢失,且可以正确合并所有并发值。
第6章 数据分区
数据分区与数据复制
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。 这意味着,即使每条记录属于⼀个分区,它仍然可以存储在多个不同的节点上以获得容错能⼒力。
一个节点可能存储多个分区。 如果使⽤主从复制模型,则分区和复制的组合如下图所示。 每个分区都有自己的主副本,例如被分配给某个节点,而从副本被分配给其他节点。 每个节点可能是某些分区的主副本,同时是其他分区的从副本。
键-值数据的分区
分区⽬标是将数据和查询负载均匀分布在各个节点上。如果每个节点平均分担负载,那么理论上10个节点应该能够处理10倍的数据量和10倍的单个节点的读写吞吐量(暂时忽略略复制)。
如果分区是不均匀的,一些分区比其他分区有更多的数据或查询,我们称之为倾斜(skew)。数据倾斜的存在使分区效率下降很多。在极端的情况下,所有的负载可能压在一个分区上,其余节点都是空闲的,瓶颈落在这⼀个繁忙的节点上。不均衡导致的⾼负载的分区被称为热点。
避免热点最简单的⽅法是将记录随机分配给节点。这将在所有节点上平均分配数据,但是它有⼀个很⼤的缺点:当你试图读取一个特定的值时,你⽆法知道它在哪个节点上,所以你必须并行地查询所有的节点。
基于关键字区间分区
⼀种分区的⽅法是为每个分区指定⼀块连续的键范围(从最小值到最⼤值)。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果还知道分区所在的节点,那么可以直接向相应的节点发出请求。
键的范围不一定均匀分布,因为数据也很可能不均匀分布。
在每个分区中,我们可以按照一定的顺序保存键。好处是进⾏范围扫描⾮非常简单,可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录。例如,假设我们有⼀个程序来存储传感器网络的数据,其中主键是测量的时间戳。范围扫描在这种情况下⾮常有用,因为我们可以轻松获取某个⽉份的所有数据。
然而,关键字区间分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳,则分区对应于时间范围,例如,给每天分配一个分区。 不幸的是,由于我们在测量发⽣时将数据从传感器写入数据库, 因此所有写⼊操作都会转到同一个分区(即今天的分区),这样分区可能会因写入而过载,而其他分区则处于空闲状态。
为了避免传感器数据库中的这个问题,需要使⽤除了时间戳以外的其他东西作为主键的第一个部分。 例如,可以在每个时间戳前添加传感器名称,这样会首先按传感器名称,然后按时间进⾏分区。 假设有多个传感器同时运行,写入负载将最终均匀分布在不同分区上。 现在,当想要在⼀个时间范围内获取多个传感器的值时,需要为每个传感器名称执⾏一个单独的范围查询。
基于关键字哈希值分区
由于倾斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。
一个好的散列函数可以将倾斜的数据均匀分布。假设你有⼀个32位散列函数,⽆无论何时给定一个新的字符串输入,它将返回一个0到2^32-1之间的"随机"数。即使输⼊的字符串非常相似,它们的散列也会均匀分布在这个数字范围内。
一旦你有⼀个合适的键散列函数,你可以为每个分区分配⼀个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如下图所示。
这种技术擅⻓在分区之间分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为一致性哈希)。
不幸的是,通过使⽤关键字散列进⾏分区,我们失去了键范围分区的一个很好的属性:高效执行范围查询的能力。曾经相邻的密钥现在分散在所有分区中,所以它们之间的顺序就丢失了。
负载倾斜与热点
如前所述,哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同⼀个键的,所有的请求都会被路由到同一个分区。
这种场景也许并不常见,但并非闻所未闻:例如,在社交媒体⽹站上,⼀个拥有数百万粉丝的名⼈用户在做某事时可能会引发⼀场⻛暴。这个事件可能导致⼤量写⼊入同⼀个键(键可能是名人的⽤户ID,或者人们正在评论的动作的ID)。哈希策略略不起作⽤,因为两个相同ID的哈希值仍然是相同的。
如今,⼤多数数据系统⽆法⾃动处理这种⾼度倾斜的负载,因此应⽤程序有责任减少倾斜。例如,如果一个主键被认为是非常火爆的,一个简单的⽅法是在主键的开始或结尾添加⼀个随机数。只要一个两位数的十进制随机数就可以将主键分散为100种不同的主键,从⽽存储在不同的分区中。
然⽽,将主键进⾏分割之后,任何读取都必须要做额外的⼯作,因为他们必须从所有100个主键分区中读取数据并将其合并。因此通常只需要对少量热点附加随机数;对于写⼊吞吐量低的绝大多数主键来是不必要的开销。此外,还需要额外的元数据来标记哪些关键字进行了特殊处理。
分区与二级索引
二级索引是关系型数据库的基础,并且在文档数据库中也很普遍。许多键值存储为了减少实现的复杂度而放弃了二级索引。并且二级索引也是全文索引服务器的基⽯石。
二级索引的问题是它们不能整齐地映射到分区。有两种⽤二级索引对数据库进行分区的⽅法:基于⽂档的分区和基于词条的分区。
基于文档分区的二级索引(本地索引)
假设你正在经营⼀个销售⼆⼿车的网站,如下图所示。 每个列表都有⼀个唯⼀一的ID——称之为文档 ID——并且⽤文档ID对数据库进⾏分区。
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要⼀个在颜色和厂商上的二级索引。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条⽬color:red
的⽂档ID列表中。
在这种索引方法中,每个分区是完全独⽴的:每个分区维护⾃己的二级索引,仅覆盖该分区中的⽂档。 它不关心存储在其他分区的数据。无论何时需要写入数据库(添加,删除或更新文档),只需处理包含正在编写的⽂档ID的分区即可。出于这个原因,文档分区索引也被称为本地索引。
但是,从⽂档分区索引中读取需要注意:除⾮对文档ID做了特别的处理,否则不太可能将所有具有特定颜色或特定品牌的汽⻋放在同⼀个分区中。在图6-4中,红⾊色汽⻋车出现在分区0和分区1中。因此,如果要搜索红⾊汽车,则需要将查询发送到所有分区,并合并所有返回的结果。
这种查询分区数据库的⽅法有时被称为分散/聚集,并且可能会使⼆级索引上的读 取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放⼤。大多数据库供应商建议用户最忌构建一个能从单个分区提供⼆级索引查询的分区方案,但这并不总是可⾏,尤其是当在单个查询中使用多个⼆级索引时(例如同时需要按颜⾊和制造商查询)。
基于词条分区的二级索引(全局索引)
我们可以构建⼀个覆盖所有分区数据的全局索引,⽽不是给每个分区创建⾃己的二级索引。但是,我们不能只把这个索引存储在⼀个节点上,因为它可能会成为瓶颈,违背了分区的目的。 全局索引也必须进⾏分区,但可以采⽤与主键不同的分区⽅式。
下图描述了这可能是什么样⼦:来⾃所有分区的红⾊汽⻋在红⾊索引中,并且索引是分区的,⾸字母从 a 到 r 的颜色在分区0中, s 到 z 的在分区1。汽车制造商的索引也与之类似(分区边界在 f 和 h 之间)。
我们将这种索引称为词条分区(term-partitioned),因为我们寻找的词条(关键字)决定了索引的分区⽅式。词条来源于来⾃全⽂搜索引(一种特殊的二级索引),指⽂档中出现的所有单词。
和之前⼀样,我们可以通过关键词本身或者它的散列进行索引分区。根据它本身分区对于范围扫描⾮常有⽤,⽽对关键词的哈希分区提供了负载均衡的能力。
词条分区的全局索引优于⽂档分区索引的地方点是它可以使读取更有效率:不需要分散/收集所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个⽂档现在可能会影响索引的多个分区(文档中的每个词条可能位于不同的分区或者不同的节点上)。
理想情况下,索引总是最新的,写入数据库的每个⽂档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都⽀持。
在实践中,对全局二级索引的更新通常是异步的(也就是说,如果在写入之后不久读取索引,刚才所做的更改可能尚未反映在索引中)。
分区再平衡
随着时间的推移,数据库会有各种变化。
- 查询吞吐量增加,需要添加更多的CPU来处理负载。
- 数据集⼤小增加,需要添加更多的磁盘和RAM来存储它。
- 机器出现故障,其他机器需要接管故障机器的责任。
所有这些更改都需要数据和请求从⼀个节点移动到另一个节点。 将负载从集群中的一个节点向另⼀个节点移动的过程称为再平衡(reblancing)。
无论使用哪种分区⽅案,再平衡通常都要满⾜一些最低要求:
- 再平衡之后,负载(数据存储,读取和写⼊请求)应该在集群中的节点之间更均匀的分布。
- 再平衡发⽣时,数据库应该继续接受读取和写入。
- 节点之间只移动必须的数据,以便快速再平衡,并减少网络和磁盘I/O负载。
动态再均衡的策略
为什么不用取模
也许你想知道为什么我们不使⽤mod(许多编程语⾔中的%运算符)。例如, hash(key) mod 10
会返回⼀个介于0和9之间的数字。如果我们有10个节点,编号为0到9,这似乎是将每个键分配给一个节点的简单⽅法。
模N⽅法的问题是,如果节点数量N发生变化,大多数关键字将需要从一个节点移动到另⼀个节点。这种频繁的举动使得重新平衡过于昂贵。
我们需要⼀种只移动必需数据的⽅方法。
固定数量的分区
幸运的是,有⼀个相当简单的解决⽅案:创建⽐节点更多的分区,并为每个节点分配多个分区。例如, 运行在10个节点的集群上的数据库可能会从⼀开始就被拆分为1000个分区,因此⼤约有100个分区被分配给每个节点。
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中匀走一些分区,直到分区再次均匀分配。这个过程如下图所示。如果从集群中删除⼀个节点,则会发⽣相反的情况。
只有分区在节点之间的移动。分区的数量不会改变,键所指定的分区也不会改变。唯一改变的是分区所在的节点。这种变更并不是即时的——在⽹络上传输⼤量的数据需要一些时间——所以在传输过程中,原有分区仍然会接受读写操作。
原则上,甚⾄可以解决集群中的硬件不匹配问题:通过为更强⼤的节点分配更多的分区,可以强制这些节点承载更多的负载。
在这种配置中,分区的数量通常在数据库第⼀次建立时确定,之后不会改变。虽然原则上可以分割和合并分区,但固定数量的分区在操作上更简单,因此许多固定分区数据库选择不实施分区分割。因此,一开始配置的分区数就是可以拥有的最大节点数量,所以需要选择⾜够多的分区以适应未来的增⻓。但是,每个分区也有管理理开销,所以选择太⼤的数字会适得其反。
如果数据集的总⼤小难以预估(例如,如果它开始很小,但随着时间的推移可能会变得更大),选择正确的分区数是困难的。由于每个分区包含了总数据量固定⽐率的数据,因此每个分区的⼤小与集群中的数据总量成⽐例增长。如果分区非常⼤,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。当分区⼤小“恰到好处”的时候才能获得很好的性能,如果分区数量固定,但数据量量变动很⼤,则难以达到最佳性能。
动态分区
对于使用键范围分区的数据库,具有固定边界的固定数量的分区将⾮常不便: 如果边界划分不合理,则可能会导致数据挤在⼀个分区中,其他分区中的基本为空。⼿动重新配置分区边界将⾮常繁琐。
出于这个原因,按键的范围进行分区的数据库(如HBase)会动态创建分区。当分区增⻓到超过配置的⼤小时,会被分成两个分区,每个分区约占⼀半的数据。与之相反,如果⼤量数据被删除并且分区缩⼩到某个阈值以下,则可以将其与相邻分区合并。
每个分区分配给一个节点,每个节点可以处理多个分区,就像固定数量的分区⼀样。⼤分区拆分后, 可以将其中的⼀半转移到另⼀个节点,以平衡负载。在HBase中,分区⽂件的传输通过HDFS来实现。
动态分区的⼀个优点是分区数量适应总数据量。如果只有少量的数据,少量的分区就⾜够了,所以开销很⼩;如果有⼤量的数据,每个分区的⼤小被限制在一个可配置的最大值。
需要注意的是,⼀个空的数据库从⼀个分区开始,因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小,直到达到第⼀个分区的分割点,所有写入操作都必须由单个节点处理,而其他节点则处于空闲状态。为了解决这个问题,HBase和MongoDB允许在⼀个空的数据库上配置一组初始分区(这被称为预分区)。在键范围分区的情况中,预分区需要提前知道键是如何进行分配的。
动态分区不仅适⽤于数据的范围分区,而且也适⽤于散列分区。
按节点比例分区
通过动态分区,分区的数量与数据集的⼤小成正比,因为拆分和合并过程将每个分区的⼤小保持在固定的最小值和最大值之间。另⼀方面,对于固定数量的分区,每个分区的⼤小与数据集的⼤小成正比。在这两种情况下,分区的数量都与节点的数量无关。
Cassandra使⽤的第三种方法是使分区数与节点数成正⽐——换句话说,每个节点具有固定数量的分区。在这种情况下,每个分区的⼤小与数据集⼤小成⽐例地增⻓,而节点数量保持不变,但是当增加节点数时,分区将再次变小。由于较大的数据量通常需要较大数量的节点进⾏存储,因此这种⽅法也使每个分区的⼤小较为稳定。
当一个新节点加⼊集群时,它随机选择固定数量的现有分区进⾏拆分,然后占有这些拆分区中每个分区的一半,同时将每个分区的另⼀半留在原地。随机化可能会产⽣不均匀的分割,但是当平均分区数量较大时,新节点最终从现有节点获得均匀的负载份额。
随机选择分区边界要求使用基于散列的分区(可以从散列函数产⽣的数字范围中挑选边界)。实际上, 这种方法最符合⼀致性哈希的原始定义。
自动与手动再平衡操作
关于再平衡有⼀个重要问题:自动还是⼿动进行?
在全自动重新平衡(系统自动决定何时将分区从⼀个节点移动到另一个节点,无须⼈工干预)和完全⼿动(分区指派给节点由管理员明确配置,仅在管理员明确重新配置时才会更改)之间有⼀个权衡。
全⾃动重新平衡可以很⽅便,因为正常维护的操作工作较少。但是,这可能导致不可预测的影响。再平衡是一个昂贵的操作,因为它需要重新路由请求并将⼤量数据从⼀个节点移动到另一个节点。如果没有做好,这个过程可能会使网络或节点负载过重,降低其他请求的性能。
这种⾃动化与自动故障检测相结合可能⼗分危险。例如,假设一个节点过载,并且对请求的响应暂时很慢。其他节点得出结论;过载的节点已经死亡,并⾃动重新平衡集群,使负载离开它。这会对已经超负荷的节点,其他节点和网络造成额外的负载,从⽽使情况变得更糟,并可能导致级联失败。
出于这个原因,再平衡的过程中有人参与是一件好事。这比完全⾃动的过程慢,但可以帮助防止运维意外。
请求路由
现在我们已经将数据集分割到多个机器上运行的多个节点上。但是仍然存在一个悬而未决的问题:当客户想要发出请求时,如何知道要连接哪个节点?随着分区重新平衡,分区对节点的分配也发生变化。为了回答这个问题,需要有人知晓这些变化:如果我想读或写键“foo”,需要连接哪个IP地址和端⼝号?
这个问题可以概括为服务发现,它不仅限于数据库。任何可通过⽹络访问的软件 都有这个问题,特别是如果它的⽬标是高可用性。许多公司已经编写了⾃己的内部服务发现⼯具,其中许多已经作为开源发布。
概括来说,这个问题有几种不同的⽅方案:
- 允许客户联系任何节点。 如果该节点恰巧拥有请求的分区,则它可以直接处理理该请求;否则,它将请求转发到适当的节点, 接收回复并传递给客户端。
- ⾸先将所有来⾃客户端的请求发送到路由层,它决定了应该处理请求的节点,并相应地转发。此路由层本身不处理任何请求,它仅负责分区的负载均衡。
- 要求客户端知道分区和节点的分配。在这种情况下,客户端可以直接连接到适当的节点,⽽不需要任何中介。
以上所有情况中的关键问题是:作出路由决策的组件(可能是节点之一,路由层或客户端)如何了解分区-节点之间的分配关系变化?
这是⼀个具有挑战性的问题,因为重要的是所有参与者都同意——否则请求将被发送到错误的节点,而不是正确处理。 在分布式系统中有达成共识的协议,但很难正确地实现。
许多分布式数据系统都依赖于一个独⽴的协调服务,比如ZooKeeper,来跟踪集群元数据。每个节点在ZooKeeper中注册⾃己,ZooKeeper维护分区到节点的可靠映射。 其他参与者可以在ZooKeeper中订阅此信息。只要分区分配发⽣了改变,或者集群中添加或删除了一个节点,ZooKeeper就会通知路由层使路由信息保持最新状态。
Cassandra采取不同的⽅法:他们在节点之间使用流⾔协议(gossip protocol) 来传播群集状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点。这个模型在数据库节点中增加了更多的复杂性,但是避免了对像ZooKeeper这样的外部协调服务的依赖。
第7章 事务
深入理解事务
现今,⼏乎所有的关系型数据库和⼀些非关系数据库都支持事务。事务概念从诞生至今,尽管一些实现细节发⽣了变化,但总体思路大同⼩异。
21世纪以后,非关系(NoSQL)数据库开始普及。它们的⽬标是通过提供新的数据模型选择,并通过默认包含复制和分区来改善关系现状。事务是这种运动的主要受害者: 这些新一代数据库中的许多数据库完全放弃了事务,或者重新定义了这个词,即替换为比以前弱得多的保证。
随着这种新型分布式数据库的炒作,⼈们普遍认为事务是可扩展性的对⽴面,任何大型系统都必须放弃事务以保持良好的性能和高可用性。另一⽅面,数据库厂商有时将事务保证作为“重要应用”和“有价值数据”的基本要求。这两种观点都是纯粹的夸张。
事实并非如此简单:与其他技术设计选择⼀样,事务有其优势和局限性。
ACID的含义
事务所提供的安全保证,通常由众所周知的首字⺟缩略词ACID来描述,ACID代表原子性 (Atomicity),一致性(Consistency),隔离性(Isolation)和持久性(Durability)。
但实际上,不同数据库的ACID实现并不相同。例如,围绕着隔离性的含义有许多含糊不清的争议。想法非常美好,细节方见真章。今天,当⼀个系统声称⾃己“符合ACID”时,实际上能期待的是什么保证并不清楚。不幸的是,ACID现在几乎已经变成了一个营销术语。
不符合ACID标准的系统有时被称为BASE,它代表基本可⽤性(Basically Available),软状态 (Soft State)和最终⼀致性(Eventual consistency),这⽐ACID的定义更加模糊。BASE唯一可以确定的是“它不是ACID”,此外它几乎没有承诺任何东西。
原子性
⼀般来说,原子是指不能分解成更小部分的东西。这个词在计算的不同分支中意味着相似但⼜微妙不同的东西。例如,在多线程编程中,如果⼀个线程执行一个原⼦操作,这意味着另⼀个线程无法看到该操作的中间结果。系统只能处于操作之前或操作之后的状态,⽽不是介于两者之间的状态。
相比之下,ACID的原⼦性并不是关于并发的。它并不是在描述如果⼏个进程试图同时访问相同的数据会发⽣什么情况,这种情况实际是ACID的隔离性所描述的。
ACID原子性其实描述了当客户发起了一个包含多个写操作的请求时可能发生的情况,例如在完成一部分写入后,系统发生了故障,包括进程崩溃,网络连接中断,磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到⼀个原子事务 中,并且该事务由于错误⽽不能完成,则该事务将被中⽌,并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。
原⼦性使得应⽤程序可以确定它没有改变任何东西,所以可以安全地重试。
ACID原⼦性的定义特征是:能够在错误时中止事务,丢弃该事务进⾏的所有写⼊入变更的能⼒。 或许可中止性是更好的术语。
一致性
ACID⼀致性主要是指对数据有特定的预期状态。例如,在会计系统中,所有账户整体上必须借贷相抵。如果一个事务开始于⼀个有效状态,且在事务处理期间的任何写⼊操作都没有违背约束,那么最终的结果依然符合有效状态。
这种一致性本质上要求应用层来维护状态一致(或者恒等),应⽤程序负责正确定义它的事务,并保持⼀致性。这并不是数据库可以保证的事情:如果提交的数据修改违背了恒等条件,数据库很难检测进而阻⽌该操作。 (⼀些特定类型的恒等约束可由数据库检查,例如外键约束或唯⼀约束,但是⼀般来说,是应用程序来定义 什么样的数据是有效的,什么样是⽆效的。据库只管存储。)
原⼦性,隔离性和持久性是数据库的属性,⽽一致性(在ACID意义上)是应⽤程序的属性。应⽤可能依赖数据库的原子性和隔离属性来实现⼀致性,但这并不仅取决于数据库。因此,字⺟C其实并不应该属于ACID。
隔离性
ACID意义上的隔离性意味着,同时执⾏的事务是相互隔离的:它们不能相互交叉。传统的数据库教科书将隔离性形式化为可序列化(Serializability),这意味着每个事务可以假装它是唯⼀在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行是一样的,尽管实际上它们可能是并发运⾏的。
然⽽实践中很少会使用可序列化隔离,因为它有性能损失。⼀些流⾏的数据库如Oracle 11g,甚至没有实现它。在Oracle中有一个名为“可序列化”的隔离级别,但实际上它实现了一种叫做快照隔离的功能,这是⼀种比可序列化更弱的保证。
持久性
数据库系统的⽬的是,提供一个安全的地⽅存储数据,⽽不用担心丢失。持久性 是一个承诺,即⼀旦事务成功完成,即使发生硬件故障或数据库崩溃,写⼊的任何数据也不会丢失。
在单节点数据库中,持久性通常意味着数据已被写⼊非易失性存储设备,如硬盘或SSD。它通常还包括预写日志或类似的文件,以便在磁盘上的数据结构损坏时进⾏行恢复。在带复制的数据库中,持久性可能意味着数据已成功复制到多个节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
如“可靠性”一节所述,完美的持久性是不存在的:如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。
单对象与多对象事务操作
略~
弱隔离级别
读-提交
最基本的事务隔离级别是读-提交(Read Committed),它提供了两个保证:
- 从数据库读时,只能看到已提交的数据(防止脏读)。
- 写⼊数据库时,只会覆盖已经写入的数据(防止脏写)。
防止脏读
假定一个事务已经将⼀些数据写⼊数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做脏读。
在读已提交隔离级别运⾏的事务必须防⽌脏读。这意味着事务的任何写⼊操作只有在该事务提交时才能被其他人看到(并且所有的写全部可见)。
为什什么要防止脏读,有几个原因:
- 如果事务需要更新多个对象,脏读意味着另一个事务可能会只看到一部分更新。看到处于部分更新状态的数据库会让⽤户感到困惑,并可能导致其他事务做出错误的决定。
- 如果事务中⽌,则所有写⼊操作都需要回滚。如果数据库允许脏读,那就意味着 一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。
防止脏写
如果两个事务同时尝试更新数据库中的相同对象,会发⽣什么情况?我们不知道写⼊的顺序是怎样的,但是我们通常认为后⾯的写入会覆盖前面的写入。
但是,如果先前的写入是尚未提交事务的一部分,⼜会发⽣什么情况,后⾯的写⼊入会覆盖一个尚未提交的值?这被称作脏写。在读已提交的隔离级别上运⾏的事务必须防⽌脏写,通常是延迟第二次写入,直到第⼀次写⼊事务提交或中⽌为⽌止。
通过防⽌止脏写,这个隔离级别避免了⼀些并发问题:
- 如果事务更新多个对象,脏写会导致非预期的错误结果。例如销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了Alice(因为她成功更新了了发票表)。 读已提交会阻止这样的事故。
- 但是,提交读取并不能防⽌两个计数器增量之间的竞争状态。在这种情况下,第二次写⼊发⽣在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的。
实现读-提交
读-提交是⼀个⾮常流行的隔离级别。它是许多数据库的默认设置。
最常⻅的情况是,数据库通过使⽤行锁来防⽌脏写:当事务想要修改特定对象时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有⼀个事务可持有任何给定对象的锁;如果另⼀个事务要写⼊同⼀个对象,则必须等到第⼀个事务提交或中⽌后,才能获取该锁并继续。这种锁定是读-提交模式的数据库⾃动完成的。
如何防⽌止脏读?⼀种选择是使⽤相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后⽴即再次释放该锁。这能确保不会读取进行时,对象不会在脏的状态,有未提交的值。
但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会损失只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。
出于这个原因,大多数数据库如下方式防止脏读:对于写⼊的每个对象,数据库都会记住旧的已提交值,和由当前持有写⼊锁的事务设置的新值。 当事务正在进⾏行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
快照级别隔离与可重复读
在使⽤读-提交隔离级别时,仍然有很多地⽅可能会产⽣并发错误。例如下图说明了读-提交时可能发生的问题。
Alice在银行有1000美元的储蓄,分为两个账户,每个500美元。现在一笔事务从她的⼀个账户中转移了100美元到另⼀个账户。如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完成前看到收款账户余额(余额为500美元),⽽在转账完成后看到另一个转出账户(已经转出100美元,余额 400美元)。对Alice来说,现在她的账户似乎只有900美元——看起来100美元已经消失了。
这种异常被称为不可重复读:如果Alice在事务结束时再次读取账户1的余额,她将看到与她之前的查询中看到不同的值(600美元)。在读已提交的隔离条件下,不不可重复读被认为是可接受的:Alice看到的帐户余额时确实在阅读时已经提交了了。
快照隔离是这个问题最常见的解决方案。想法是,每个事务都从数据库的一致快照中读取——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另⼀个事务更改,每个事务也只能看到该特定时间点的旧数据。
快照隔离对长时间运行的只读查询(如备份和分析)非常有⽤用。如果查询的数据在查询执行的同时发⽣变化,则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的⼀致快照,理解起来就很容易了。
实现快照级别隔离
与读-提交的隔离类似,快照隔离的实现通常使⽤写锁来防⽌脏写,这意味着进⾏写入的事务会阻⽌另一个事务修改同⼀个对象。但是读取不需要任何锁定。从性能的⻆度来看,快照隔离的⼀个关键原则是:读不阻塞写,写不阻塞读。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用。
为了实现快照隔离,数据库可能保留⼀个对象的几个不同的提交版本,因为各种正在进⾏的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为多版本并发控制(MVCC, multi- version concurrentcy control)。
如果⼀个数据库只需要提供读已提交的隔离级别,⽽不提供快照隔离,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。⽀持快照隔离的存储引擎通常也使用MVCC来实现读-提交隔离级别。一种典型的⽅法是读-提交为每个查询使用单独的快照,而快照隔离对整个事务使⽤相同的快照。
表中的每⼀一行都有⼀个created_by
字段,其中包含将该行插入到表中的事务ID。此外,每⾏都有一个deleted_by
字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将deleted_by
字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾回收过程会将所有带有删除标记的⾏移除,并释放其空间。
一致性快照的可见性规则
当⼀个事务从数据库中读取时,事务ID用于决定它可以看⻅哪些对象,看不见哪些对象。通过仔细定义可⻅性规则,数据库可以向应用程序呈现一致的数据库快照。例如:
- 在每次事务开始时,数据库列出当时所有其他(尚未提交或中⽌)的事务清单,即使之后提交了,这些事务的写入也都会被忽略略,即不可见。
- 被中止事务所执⾏的任何写⼊都将被忽略。
- 由具有较晚事务ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
- 所有其他写⼊,对应⽤都是可见的。
换句话说,如果以下两个条件都成立,则可见一个对象:
- 读事务开始时,创建该对象的事务已经提交。
- 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
防止更新丢失
到⽬前为⽌已经讨论的读-提交和快照隔离级别,主要保证了只读事务在并发写⼊时可以看到什么。却忽略了两个事务并发写入的问题——我们只讨论了脏写,⼀种特定类型的写-写冲突是可能出现的。
并发的写⼊事务之间还有其他⼏种有趣的冲突。其中最着名的是更新丢失问题。
如果应⽤从数据库中读取⼀些值,修改它并写回修改的值,则可能会发⽣更新丢失的问题。如果两个事务同时执行,则其中⼀个的修改可能会丢失,因为第二个写⼊入的内容并没有包括第一个事务的修改。这种模式发⽣在各种不同的情况下:
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 在复杂值中进⾏本地修改:例如,将元素添加到JSON文档中的一个列表(需要解析文档,进⾏更改并写回修改的文档)
- 两个⽤户同时编辑wiki⻚面,每个用户通过将整个⻚面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
这是⼀个普遍的问题,所以已经开发了各种解决方案。
原子写操作
许多数据库提供了原⼦更新操作,从而消除了在应⽤程序代码中执行读取-修改-写回
的操作。如果支持的话,那这通常是最好的解决⽅案。例如,下面的指令在⼤多数关系数据库中是并发安全的:
UPDATE counters SET value = value + 1 WHERE key = 'foo'
类似地,像MongoDB这样的⽂档数据库提供了对JSON文档的一部分进行本地修改的原子操作,Redis提供了修改数据结构(如优先级队列)的原⼦操作。并不是所有的写操作都可以用原⼦操作的方式来表达,例如维基⻚面的更新涉及到任意⽂本编辑,但是在可以使用原子操作的情况下,它们通常是最好的选择。
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为游标稳定性。另⼀个选择是简单地强制所有的原子操作在单⼀线程上执行。
显示加锁
如果数据库的内置原子操作没有提供必要的功能,防止更新丢失的另一个选择是让应⽤程序显式地锁定将要更新的对象。然后应用程序可以执行读取-修改-写回
操作,如果任何其他事务尝试同时读取同⼀个对象,则强制等待,直到第一个操作完成。
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
-- 检查玩家的操作是否有效,然后更更新先前SELECT返回棋⼦子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
FOR UPDATE
⼦句告诉数据库应该对该查询返回的所有行加锁。
这是有效的,但要做对,需要仔细考虑应用逻辑。忘记在代码某处加锁很容易引入竞争条件。
自动检测更新丢失
原⼦操作和锁是通过强制读取-修改-写回
操作串行执行来防⽌更新丢失的⽅法。另一⽅方法是允许它们并⾏执行,如果事务管理理器检测到更新丢失,则中止事务并强制回到安全的读取-修改-写回
操作。
这种⽅法的⼀个优点是,数据库可以结合快照隔离⾼效地执⾏此检查。丢失更新检测是⼀个很好的功能,因为它不需要应⽤代码使⽤任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从⽽引入错误;但更新丢失的检测是⾃动发生的,因此不太容易出错。
原子比较和设置(CAS)
在不提供事务的数据库中,有时会发现一种原子操作:比较并设置(CAS, Compare And Set)。此操作的⽬的是为了避免更新丢失:只有当前值从上次读取时一直未改变, 才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写回
操作。
例如,为了防⽌两个用户同时更新同一个wiki⻚面,可以尝试类似这样的方式,只有当用户开始编辑⻚面内容时,才会发生更新:
-- 根据数据库的实现情况,这可能也可能不安全
UPDATE wiki_pages SET content = '新内容'
WHERE id = 1234 AND content = '旧内容';
如果内容已经更改并且不再与“旧内容”相匹配,则此更新将不起作用,因此您需要检查更新是否生效,必要时重试。但是,如果数据库允许WHERE ⼦句从旧快照中读取,则此语句可能⽆法防止丢失更新,因为即使发⽣了另一个并发写入,WHERE条件也可能为真。在依赖数据库的CAS操作前要检查其是否安全。
冲突解决与复制
在多副本数据库中,防止更新丢失需要考虑另⼀个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防⽌更新丢失。
锁和CAS操作假定有一个最新的数据副本。但是多主或⽆主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此会出现多个最新的数据副本。所以基于锁或CAS操作的技术不适⽤于这 种情况。
这种多副本数据库中的一种常⻅方法是允许并发写⼊创建多个冲突版本的值,并使用应⽤代码或特殊数据结构在事实发⽣之后解决和合并这些版本。
写倾斜与幻读
并发写⼊间可能发生的竞争条件还没有完。在本节中,我们将看到一些更微妙的冲突例子。
⾸先,想象⼀下这个例子:你正在为医院写⼀个医⽣轮班管理程序。医院通常会同时要求几位医⽣待命,但底线是⾄少有一位医⽣在待命。医生可以放弃他们的班次(例如,如果他们⾃己生病了),只要至少有一个同事在这一班中继续工作。
现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同⼀时间点击按钮下班。
在两个事务中,应⽤首先检查是否有两个或以上的医⽣正在值班;如果是的话,它就假定⼀名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回2 ,所以两个事务都进⼊下一个阶段。Alice更新⾃己的记录休班了,而Bob也做了⼀样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。
定义写倾斜
这种异常称为写倾斜。它既不是脏写,也不是丢失更新,因为这两个事务正在更新两个不同的对象(Alice和Bob各自的待命记录)。在这里发生的冲突并不是那么明显,但是这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个医生就不能歇班了。异常行为只有在事务并发进行时才有可能。
可以将写⼊倾斜视为更新丢失问题的⼀般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写⼊倾斜。在多个事务更新同⼀个对象的特殊情况下,就会发生脏写或更新丢失(取决于时机)。
有多种不同的⽅法来防⽌更新丢失。随着写倾斜,我们的选择更受限制:
- 由于涉及多个对象,单对象的原⼦操作不起作用。
- 不幸的是,在⼀些快照隔离的实现中,⾃动检测更新丢失对此并没有帮助。自动防止写⼊倾斜需要真正的可序列化隔离
- 某些数据库允许配置约束,然后由数据库强制执⾏(例如,唯一性,外键约束或特定值限制)。但是为了指定⾄少有一名医⽣必须在线,需要⼀个涉及多个对象的约束。⼤多数据库没有内置对这种约束的支持,但是可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库。
- 如果⽆法使⽤可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。
为何产生写倾斜
写倾斜都遵循类似的模式:
- ⼀个SELECT查询找出符合条件的行,并检查是否符合一些要求。(例如:至少有两名医⽣生在值班;不存在对该会议室同一时段的预定;棋盘上的位置没有被其他棋⼦占据;用户名还没有被抢注;账户⾥还有足够余额)
- 按照第⼀个查询的结果,应⽤代码决定是否继续。(可能会继续操作,也可能中⽌并报错)
- 如果应⽤决定继续操作,就执⾏写入(插⼊、更新或删除),并提交事务。
这个写⼊的效果改变了步骤2中的先决条件。换句话说,如果在提交写入后,重复执⾏一次步骤1的SELECT查询,将会得到不同的结果。因为写入改变了符合搜索条件的行集。
在医生值班的例子中,在步骤3中修改的行,是步骤1中返回的⾏之一,所以我们可以通过锁定步骤1中的行(SELECT FOR UPDATE)来使事务安全并避免写倾斜。如果步骤1中的查询没有返回任何行,则SELECT FOR UPDATE锁不了任何东⻄西。
这种效应:一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读。快照隔离避免了只读查询中幻读,但是在像我们讨论的例⼦那样的读写事务中,幻影会导致特别棘⼿的写倾斜情况。
实体化冲突
如果幻读的问题是没有对象可以加锁,也许可以⼈为地在数据库中引⼊一个锁对象?
例如,在会议室预订的场景中,可以想象创建⼀个时间-房间表。此表中的每⼀行对应于特定时间段(例如15分钟)的特定房间。可以提前插⼊房间和时间的所有可能组合⾏(例如接下来的六个月)。
现在,要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是⽤来存储预订相关的信息——它完全就是一组锁,⽤用于防止同时修改同一房间和时间范围内的预订。
这种⽅法被称为实体化冲突,因为它将幻读变为数据库中一组具体行上的锁冲突。不幸的是,弄清楚如何实体化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应⽤数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,实体化冲突应被视为最后的⼿段。在大多数情况下。可序列化的隔离级别是更可取的。
串行化
串行化隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终 的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确——换句话说,数据库可以防止所有可能的竞争条件。
实际串行执行
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执⾏一个事务。这样做就完全绕开了检测/防⽌事务间冲突的问题,由此产⽣的隔离,正是串行化的定义。
尽管这似乎是⼀个明显的主意,但数据库设计⼈只是在2007年左右才决定,单线程循环执⾏事务是可行的。如果多线程并发在过去的30年中被认为是获得良好性能的关键所在,那么究竟是什么改变致使单线程执⾏变为可能呢?
两个进展引发了这个反思:
- RAM足够便便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
- 数据库设计⼈员意识到OLTP事务通常很短,而且只进行少量的读写操作。相⽐比之下,长时间运⾏的分析查询通常是只读的,因此它们可以在串行执⾏循环之外的一致快照上运行。
分区
顺序执⾏所有事务使并发控制简单多了,但数据库的事务吞吐量被限制为单机单核的速度。只读事务可以使⽤快照隔离在其它地⽅执行,但对于写入吞吐量较高的应⽤,单线程事务处理器可能成为一个严重的瓶颈。
为了扩展到多个CPU核心和多个节点,可以对数据进⾏分区。 如果可以找到一种对数据集进⾏分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个 分区就可以拥有⾃己独立运行的事务处理线程。在这种情况下可以为每个分区指派⼀个独立的CPU核, 事务吞吐量就可以与CPU核数保持线性扩展。
但是,对于需要访问多个分区的任何事务,数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。
由于跨分区事务具有额外的协调开销,所以它们⽐单分区事务慢得多。
事务是否可以划分⾄单个分区很⼤程度上取决于应⽤数据的结构。简单的键值数据通常可以⾮常容易地进⾏分区,但是具有多个二级索引的数据可能需要⼤量的跨分区协调。
串行执行小结
在特定约束条件下,真的串行执行事务,已经成为一种实现可序列化隔离等级的可行办法:
- 每个事务都必须⼩而快,只要有⼀个缓慢的事务,就会拖慢所有事务处理。
- 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得⾮常慢。
- 写⼊吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
- 跨分区事务是可能的,但是它们的使用程度有很大的限制。
两阶段加锁(2PL)
至今为止,在数据库中只有一种⼴泛使用的序列化算法:两阶段】加定(2PL,two-phase locking)
之前我们看到锁通常⽤于防⽌脏写:如果两个事务同时尝试写⼊同一个对象, 则锁可确保第⼆个写入必须等到第一个写入完成事务,然后才能继续。
两阶段加锁类似,但使锁的要求更强。只要没有写入,就允许多个事务同时读取同⼀个对象。但对象只要有写⼊,就需要独占访问权限。
在2PL中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得读不阻塞写,写也不阻塞读,这是2PL和快照隔离之间的关键区别。另⼀方面,因为2PL提供了可序列化的性质,它可以防止早先讨论的所有竞争条件,包括更新丢失和写⼊倾斜。
实现两阶段加锁
2PL⽤于MySQL(InnoDB)和SQL Server中的可序列列化隔离级别,以及DB2中的可重复读隔离级别。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于共享模式或 独占模式。锁使用如下:
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另⼀个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写⼊一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁,所以如果对象上存在任何锁,该事务必须等待。
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的⼯作与直接获得排他锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束。这就是“两阶段”这个名字的来 源:第⼀阶段(当事务正在执⾏时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务A等待事务B释放它的锁,反之亦然。这种情况叫做死锁。数据库会⾃动检测事务之间的死锁,并中⽌其中一个,以便另⼀个继续执行。被中⽌的事务需要由应用程序重试。
两阶段加锁的性能
两阶段锁定的巨大缺点,是其性能问题。两阶段锁的事务吞吐量与查询响应时间要⽐弱隔离级别下要差得多。
这⼀部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。
传统的关系数据库不限制事务的持续时间,因为它们是为等待⼈类输⼊的交互式应⽤而设计的。因此,当一个事务需要等待另⼀个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同⼀个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。
因此,运行2PL的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争⽤,那么可能⾼百分位点处的响应会⾮常的慢。可能只需要一个缓慢的事务,或者⼀个访问⼤量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚⾄迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
基于锁实现的读-提交隔离级别可能发⽣死锁,但在基于2PL实现的串行化隔离级别中,它们会出现的频繁的多((决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中⽌并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨⼤的浪费。
谓词锁
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在写倾斜中,我们讨论了幻读的问题。即一个事务改变另一个事务的搜索查询的结果。具有可串行化隔离级别的数据库必须防⽌幻读。
从概念上讲,我们需要一个谓词锁。它类似于前⾯描述的共享/排它锁,但不属于特定的对象(例如,表中的⼀⾏),它属于所有符合某些搜索条件的对象。
谓词锁限制访问,如下所示:
- 如果事务A想要读取匹配某些条件的对象,就像在SELECT查询中那样,它必须获取查询条件上的共享谓词锁。如果另一个事务B持有任何满⾜这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进⾏查询。
- 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中⽌后才能继续。
这里的关键思想是,谓词锁甚⾄适用于数据库中尚不存在,但将来可能会添加的对象。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写倾斜和其他竞争条件,因此其隔离实现了可串行化。
索引区间锁
不幸的是谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。因此,⼤多数使⽤2PL的数据库实际上实现了索引范围锁(也称为间隙锁(next-key locking)),这是⼀个简化的近似版谓词锁。
通过使谓词匹配到⼀个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午1点之间预订123号房间的谓词锁,则锁定123号房间的所有时间段,或者锁定12:00~13:00时间段的所有房间是一个安全的近似,因为任何满⾜原始谓词的写⼊也⼀定会满⾜这种更松散的近似。
在房间预订数据库中,您可能会在 room_id列上有⼀个索引,并且/或者在 start_time 和 end_time 上有索引:
假设索引位于room_id上,并且数据库使⽤此索引查找123号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索123号房间⽤用于预订。 或者,如果数据库使⽤基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的⼀系列值,指示事务已经将12:00~13:00时间段标记为用于预定。
⽆论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插⼊,更新或删除同⼀个房间和/或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
这种方法能够有效防⽌幻读和写倾斜。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
如果没有可以挂载间隙锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻⽌所有其他事务写入表格,但这是一个安全的回退位置。
可串行化的快照隔离
⼀方⾯,我们实现了性能不好(2PL)或者扩展性不好(串行执⾏)的可串行化隔离级别。另⼀方面,我们有性能良好的弱隔离级别,但容易出现各种竞争条件 。序列化的隔离级别和⾼性能是从根本上相互⽭盾的吗?
也许不是:⼀个称为可序列化快照隔离(SSI, serializable snapshot isolation) 的算法是⾮常有前途的。它提供了完整的可序列化隔离级别,但与快照隔离相比只有只有很⼩的性能损失。SSI是相当新的:它在2008年首次被提出。
由于SSI与其他并发控制相⽐还很年轻,还处于在实践中证明⾃己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
悲观与乐观的并发控制
两阶段锁是一种所谓的悲观并发机制,它是基于这样的原则:如果有事情可能出 错,最好等到情况安全后再做任何事情。这就像互斥,⽤于保护多线程编程中的数据结构。
从某种意义上说,串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有排它锁,作为对悲观的补偿,我们让每笔事务执行得⾮常快,所以只需要短时间持有“锁”。
相⽐之下,序化快照隔离是⼀种乐观的并发控制技术。在这种情况下,乐观意味着, 如果存在潜在的危险也不阻止事务,而是继续执⾏事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发⽣(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可序列化的事务才被允许提交。
乐观并发控制是一个古⽼的想法,其优点和缺点已经争论了很⻓时间。如果存在很多争用(很多事务试图访问相同的对象),则表现不佳,因为这会导致很⼤一部分事务需要中止。如果系统已经接近最大吞吐量,来⾃重试事务的额外负载可能会使性能变差。
但是,如果有足够的备⽤容量,并且事务之间的争⽤不是太高,乐观的并发控制技术往往比悲观的要好。可交换的原子操作可以减少争用:例如,如果多个事务同时要增加一个计数器,那么应用增量的顺序(只要计数器不在同⼀个事务中读取)就⽆关紧要了,所以并发增量可以全部应⽤且无需冲突。
顾名思义,SSI基于快照隔离——也就是说,事务中的所有读取都是来自数据库的一致性快照。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上,SSI添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。
基于过期条件做决定
先前讨论了快照隔离中的写入偏差时,我们观察到一个循环模式:事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。但是, 在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同⼀时间被修改。
换句话说,事务基于一个前提采取⾏动(事务开始时候的事实,例如:“目前有两名医⽣正在值班”)。之后当事务要提交时,原始数据可能已经改变——前提可能不再成立。
当应⽤程序进行查询时,数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变 得无效。 换⽽言之,事务中的查询与写⼊可能存在因果依赖。为了提供可序列化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中⽌止事务。
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
- 读取是否作用于一个(即将)过期的MVCC对象(在读取之前已经有未提交的写入)
- 检测写入是否影响即将完成的读取(读取之后,又有新的写入)。
检测是否读取了过期的MVCC对象
快照隔离通常是通过多版本并发控制来实现的。当⼀个事务从MVCC 数据库中的⼀致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在下图中,事务43认为Alice的on_call = true ,因为事务42未被提交。然而,在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经⽣效,事务43的前提不再为真。
为了防止这种异常,数据库需要跟踪⼀个事务由于MVCC可⻅性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中⽌事务43?因为如果事务43是只读事务,则不需要中止,因为没有写倾斜的风险。当事务43进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务42可能在事务43被提交的时候中止或者可能仍然未被提交,因此读取可能不是陈旧的。通过避免不必要的中止,SSI可以高效支持哪些需要在一致性快照中运行很长时间的读事务。
检测写是否影响了之前的读
第⼆种情况要考虑的是另⼀个事务在读取数据之后修改数据。这种情况如下图所示。
在两阶段锁定的上下文中,我们讨论了索引范围锁,它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如WHERE shift_id = 1234
。可以在这里使用类似的技术,除了 SSI锁不会阻塞其他事务。
在上图中,事务42和43都在班次1234 查找值班医⽣生。如果在shift_id上有索引,则数据库可以使用索引项1234来记录事务42和43读取这个数据的事实。(如果没有索引,这个信息可以在表级别进⾏跟踪)。这个信息只需要保留一段时间:在⼀个事务完成之后,所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务到其他事务完成,⽽是像一个引线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
在上图中,事务43通知事务42其先前读已过时,反之亦然。事务42首先提交并成功,尽管事务43的写影响了42 ,但因为事务43尚未提交,所以写⼊尚未生效。然⽽当事务43想要提交时,来自事务42的冲突写入已经被提交,所以事务43必须中止。
可串行化快照隔离的性能
与两阶段锁定相比,可串行化快照隔离的最大优点是一个事务不需要阻塞等待另⼀个事务所持有的锁。
就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,变量更少。
特别是,只读查询可以运⾏在一致的快照上,⽽不需要任何锁定,这对于读取繁重的⼯作负载⾮常有吸引力。
与串行执行相比,可串行化快照隔离并不局限于单个CPU核的吞吐量。
中⽌率显着影响SSI的整体表现。例如,长时间读取和写⼊数据的事务很可能会发生冲突并中止,因此 SSI要求同时读写的事务尽量短(只读长事务可能没问题)。对于慢事务,SSI可能⽐两阶段锁定或串⾏执行更不敏感。
第8章 分布式系统的挑战
故障与部分失效
当你在⼀台计算机上编写一个程序时,它通常会以一种确定的方式运⾏:⽆论是⼯作还是不工作。充满错误的软件可能会让人觉得电脑有时候是“糟糕的一天”(这个问题通常是重新启动的问题), 但这主要是软件写得不好的结果。
单个计算机上的软件没有根本性的不可靠原因:当硬件正常⼯作时,相同的操作总是产⽣相同的结果 (这是确定性的)。如果存在硬件问题(例如,内存损坏或连接器松动),其后果通常是整个系统故障 (例如,内核崩溃,“蓝屏死机”,启动失败)。装有良好软件的个人计算机通常要么功能完好,要么完全失效,⽽不是介于两者之间。
这是计算机设计中的⼀个慎重的选择:如果发⽣内部错误,我们宁愿电脑完全崩溃,⽽不是返回错误的结果,因为错误的结果很难处理。因为计算机隐藏了模糊不不清的物理实现,并呈现出⼀个理想化的系统模型,并以数学一样的完美的方式运作。 CPU指令总是做同样的事情;如果将一些数据写⼊内存或磁盘,那么这些数据将保持不变,并且不会被随机破坏。
当你编写运⾏在多台计算机上的软件时,情况有本质上的区别。在分布式系统中,我们不再处于理想化的系统模型中,我们别无选择,只能面对现实世界的混乱现实。而在现实世界中,各种各样的事情都可能会出现问题。
在分布式系统中,尽管系统的其他部分工作正常,但系统的某些部分可能会以某种不可预知的⽅式被破坏。这被称为部分失效。难点在于部分失效是不确定性的 :如果你试图做任何涉及多个节点和⽹络的事情,它有时可能会⼯作,有时会出现不可预知的失败。正如我们将要看到的,你甚⾄不知道是否成功了,因为消息通过⽹网络传播的时间也是不确定的。
这种不确定性和部分失效的可能性,使得分布式系统难以⼯作。
云计算与超算
关于如何构建⼤型计算系统有⼀系列的哲学:
- 规模的⼀端是⾼性能计算(HPC)领域。具有数千个CPU的超级计算机通常用于计算密集型科学计算任务,如天⽓预报或分子动力学。
- 另⼀个极端是云计算,云计算并不是⼀个良好定义的概念,但通常有一下特点:多租户数据中心,通用计算机,用IP以太网链接,弹性/按需资源分配,并按需收费。
- 传统企业数据中⼼位于这两个极端之间。
不同的哲学会导致不同的故障处理方式。在超级计算机中,作业通常会不时地会将计算的状态存盘到持久存储中。如果一个节点出现故障,通常的解决⽅案是简单地停⽌整个集群的工作负载。故障节点修复后,计算从上一个检查点重新开始。因此,超级计算机更像是⼀个单节点计算机⽽不是分布式系统:通过让部分失败升级为完全失败来处理部分失败——如果系统的任何部分发⽣故障,只是让所有的东⻄西都崩溃(就像单台机器上的内核崩溃一样)。
我们将重点放在实现互联⽹服务的系统上,这些系统通常与超级计算机看起来有很⼤不同: - 许多与互联⽹有关的应⽤程序都是24小时在线的,因为它们需要能够随时以低延迟服务⽤户。使服务不可用是不可接受的。相比之下,像天⽓模拟这样的离线工作可以停止并重新启动,影响相当小。
- 超级计算机通常由专用硬件构建⽽成,每个节点相当可靠,节点通过共享内存和远程直接内存访问 进行通信。另⼀⽅面,云服务中的节点是由商品机器构建而成的,由于规模经济,可以较低的成本提供相同的性能,⽽且具有较⾼的故障率。
- 大型数据中⼼网络通常基于IP和以太网,以闭合拓扑排列,以提供更高的二等分带宽。超级计算机通常使用专⻔的⽹络拓扑结构,这为具有已知通信模式的HPC⼯工作负载提供了更好的性能。
系统越大,其组件之一就越有可能发生变化。随着时间的推移,破碎的东西得到修复,新的东西被破坏,但是在一个有成千上万个节点的系统中,有理由认为总是有⼀些东西被破坏。当错误处理策略由简单的放弃组成时,⼀个大的系统最终会花费⼤量时间从错误中恢复,⽽不是做有用的工作。 - 如果系统可以容忍发生故障的节点,并继续保持整体⼯作状态,那么这对于操作和维护非常有用::例如,可以执⾏滚动升级,一次重新启动一个节点,而服务继续服务⽤户不中断。 在云环境中,如果⼀台虚拟机运⾏不佳,可以杀死它并请求⼀台新的虚拟机。
- 在地理位置分散的部署中(保持数据在地理位置上接近⽤户以减少访问延迟),通信很可能通过互联⽹进行,与本地⽹络相比,通信速度缓慢且不可靠。超级计算机通常假设它们的所有节点都靠近在一起。
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建⽴容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统。
即使在只有少数节点的小型系统中,考虑部分故障也是很重要的。在一个小系统中,很可能大部分组件在⼤部分时间都正常工作。然⽽,迟早会有⼀部分系统出现故障,软件必须以某种方式处理。故障处理必须是软件设计的⼀部分,并且作为软件的运维,需要知道在发生故障的情况下,软件可能会表现出怎样的行为。
简单地假设缺陷很罕见,只是希望始终保持最好的状况是不明智的。考虑⼀系列可能的错误(甚⾄是不太可能的错误),并在测试环境中⼈为地创建这些情况来查看会发⽣什么是⾮常重要的。在分布式系统中,怀疑,悲观和偏执狂才能生存。
不可靠的网络
我们关注的分布式系统是无共享的系统,即通过⽹络连接的一堆机器。⽹络是这些机器可以通信的唯⼀途径——我们假设每台机器都有⾃己的内存和磁盘, 一台机器不能访问另⼀台机器的内存或磁盘(除了通过⽹络向服务器发出请求)。
⽆共享并不是构建系统的唯一方式,但它已经成为构建互联⽹服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进⾏冗余,可以实现高可靠性。
互联网和数据中心(通常是以太⽹)中的⼤多数内部网络都是异步分组⽹络。在这种⽹络中,一个节点可以向另⼀个节点发送⼀个消息,但是网络不能保证它什么时候到达,或者是否到达。如果发送请求并期待响应,则很多事情可能会出错:
- 请求可能已经丢失(可能有⼈拔掉了网线)。
- 请求可能正在排队,稍后将交付(也许⽹络或收件人超载)。
- 远程节点可能已经失效(可能是崩溃或关机)。
- 远程节点可能暂时停⽌了响应(可能会遇到⻓时间的垃圾回收暂停),但稍后会
再次响应。 - 远程节点可能已经处理了请求,但是⽹络上的响应已经丢失(可能是网络交换机配置错误)。
- 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是⽹络或者你⾃己的机器过载)。
发送者甚⾄不能分辨数据包是否被发送:唯一的选择是让接收者发送响应消息,这可能会丢失或延迟。
这些问题在异步⽹络中难以区分:所拥有的唯⼀信息是,尚未收到响应。如果向另⼀个节点发送请求并且没有收到响应,则⽆法说明原因。
处理这个问题的通常⽅法是超时:在⼀段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时,仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发件人已经放弃了该请求,仍然可能会将其发送给收件⼈人)。
检测故障
许多系统需要自动检测故障节点。例如:
- 负载平衡器需要停止向已死亡的节点转发请求
- 在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库。
不幸的是,⽹络的不确定性使得很难判断⼀个节点是否工作。在某些特定的情况下,你可能会收到一些反馈信息,明确告诉您某些事情没有成功:
- 如果你可以登录运⾏节点的机器,但没有进程正在侦听⽬标端口(例如,因为进程崩溃),操作系统将通过发送FIN或RST来关闭并重⽤TCP连接。但是,如果节点在处理请求时发生崩溃,则⽆法知道远程节点实际处理了多少数据。
- 如果节点进程崩溃(或被管理员杀死),但节点的操作系统仍在运行,则脚本可以通知其他节点有关该崩溃的信息,以便另⼀个节点可以快速接管,⽽无需等待超时到期。
- 如果你有权访问数据中⼼网络交换机的管理界面,则可以查询它们以检测硬件级别的链路故障(例如,远程机器是否关闭电源)。如果你通过互联⽹连接,或者如果你处于共享数据中心⽽无法访问交换机,或者由于网络问题⽽无法访问管理界⾯,则排除此选项。
- 如果路由器确认你尝试连接的IP地址不可用,则可能会使用ICMP目标不可达数据包回复你。但是,路由器不具备神奇的故障检测能⼒——它受到与⽹络其他参与者相同的限制。
关于远程节点关闭的快速反馈很有用,但不是万能的。即使TCP确认已经传送了一个数据包,应⽤程序在处理之前可能已经崩溃。如果你想确保⼀个请求是成功的,就需要应用级别的回复。
相反,如果出了什么问题,你可能会在堆栈的某个层次上得到⼀个错误响应,但总的来说,你必须假设你根本就没有得到任何回应。你可以重试⼏次(TCP重试是透明的,但是你也可以在应⽤程序级别重试),等待超时过期,并且如果在超时时间内没有收到响应,则最终声明节点已经死亡。
超时与无限期的延迟
如果超时是检测故障的唯⼀可靠⽅方法,那么超时应该等待多久?不幸的是没有简单的答案。
⻓时间的超时意味着⻓时间等待,直到⼀个节点被宣告死亡(在这段时间内,⽤户可能不得不等待,或者看到错误信息)。短暂的超时可以更快地检测到故障,但是实际上它只是经历了暂时的性能波动(例如,由于节点或⽹络上的负载峰值)而导致错误地宣布节点失效的⻛险更高。
过早地声明⼀个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送⼀封电子邮件),⽽另一个节点接管,那么这个动作可能会最终执行两次。
当⼀个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和⽹络带来额外的负担。如果系统已经处于⾼负荷状态,则过早宣告节点死亡会使问题更严重。尤其是可能发生,节点实际上并没有死亡,⽽是由于过载导致响应缓慢;将其负载转移到其他节点可能会导致级联失效,在极端情况下,所有节点都宣告对⽅死亡,并且所有节点都停⽌工作。
我们所使用的大多数系统都没有确定时间的保证:异步网络具有⽆限的延迟(即尽可能快地传送数据包,但数据包到达可能需要的时间没有上限),并且⼤多数服务器实现并不能保证它们可以在一定的最⼤时间内处理请求。对于故障检测,系统部分时间快速运⾏是不够的: 如果你的超时时间很短,往返时间只需要一个瞬时尖峰就可以使系统失衡。
网络拥塞与排队
在驾驶汽车时,由于交通拥堵,道路交通网络的通⾏时间往往不尽相同。同样,计算机⽹络上数据包延迟的可变性通常是由于排队:
- 如果多个不同的节点同时尝试将数据包发送到同⼀目的地,则网络交换机必须将它们排队并将它们逐个送⼊目标⽹络链路。
- 在繁忙的⽹络链路上,数据包可能需要等待⼀段时间才能获得一个插槽(这称为⽹络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃, 因此需要重新发送数据包——即使⽹络运行良好。
- 当数据包到达⽬标机器时,如果所有CPU内核当前都处于繁忙状态,则来自⽹络的传⼊请求将被操作系统排队,直到应⽤程序准备好处理它为止。根据机器上的负载,这可能需要⼀段任意的时间。
- 在虚拟化环境中,CPU核会切换虚拟机,正在运行的操作系统会暂停⼏十毫秒。在这段时间内,虚拟机不能从⽹络中接收任何数据,所以传入的数据被虚拟机管理器排队缓冲,进一步增加了网络延迟的可变性。
- TCP执行流量控制,其中节点限制⾃己的发送速率以避免⽹络链路或接收节点过载。这意味着甚至在数据至进入网络之前,在发送者处就需要进行额外的排队。
如果TCP在某个超时时间内没有被确认(这是根据观察的往返时间计算的),则认为数据包丢失,丢失的数据包将自动重新发送。尽管应⽤程序没有看到数据包丢失和重新传输,但它看到了延迟(等待超时到期,然后等待重新传输的数据包得到确认)。
更好的一种做法是,系统不是使⽤配置的常量超时,而是连续测量响应时间及其变化,并根据观察到的响应时间分布自动调整超时。
不可靠的时钟
在分布式系统中,时间是一件棘手的事情,因为通信不是即时的:消息通过⽹络从⼀台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间,但是由于⽹络中的可变延迟,精确测量面临很多挑战。这个事实有时很难确定在涉及多台机器时发生事情的顺序。
⽽且,网络上的每台机器都有⾃己的时钟,这是一个实际的硬件设备:通常是石英晶体振荡器。这些设备不是完全准确的,所以每台机器都有⾃己的时间概念,可能比其他机器稍快或更慢。可以在一定程度上同步时钟:最常⽤的机制是网络时间协议(NTP),它允许根据一组服务器报告的时间来调整计算机时钟。服务器则从更更确的时间源(如GPS接收机)获取时间。
单调时钟与墙上时钟
现代计算机⾄少有两种不同的时钟:墙上时钟和单调时钟。尽管它们都衡量时间,但区分这两者很重要,因为它们有不同的⽬目的。
墙上时钟
墙上时钟是你直观地了解时钟的依据:它根据某个日历返回当前日期和时间。例如,Linux上的clock_gettime(CLOCK_REALTIME)
和Java中的
System.currentTimeMillis()
返回⾃epoch(1970年年1月1日 午夜 UTC,格里⾼利历)以来的秒数(或毫秒),根据公历⽇历,不包括闰秒。有些系统使用其他日期作为参考点。
时钟通常与NTP同步,这意味着来⾃一台机器的时间戳(理想情况下)与另一台机器上的时间戳相同。但是时钟也具有各种各样的奇特之处。特别是,如果本地时钟在NTP服务器之前太远,则它可能会被强制重置,看上去好像跳回了先前的时间点。这些跳跃以及他们经常忽略闰秒的事实,使时钟不能⽤于测量时间间隔。
时钟还具有相当粗略的精度,例如,在较早的Windows系统上以10毫秒为单位前进。在最近的系统中这已经不是一个问题了。
单调时钟
单调时钟适⽤于测量持续时间(时间间隔),例如超时或服务的响应时间:Linux上的clock_gettime(CLOCK_MONOTONIC)
,和Java中的 System.nanoTime()
都是单调时钟。这个名字来源于他们保证总是前进的事实(而时钟可以及时跳回)。
你可以在某个时间点检查单调时钟的值,做一些事情,且稍后再次检查它。这两个值之间的差异告诉你两次检查之间经过了多长时间。但单调时钟的绝对值是毫无意义的:它可能是计算机启动以来的纳秒数,或类似的任意值。特别是⽐较来⾃两台不同计算机的单调钟的值是没有意义的,因为它们并不是一回事。
如果NTP协议检测到计算机的本地石英钟⽐NTP服务器要更快或更慢,则可以调整单调钟向前走的频率 。默认情况下,NTP允许时钟速率增加或减慢最高至0.05%,但NTP不能使单调时钟向前或向后跳转。单调时钟的精度通常相当好:在⼤多数系统中,它们能在⼏微秒或更短的时间内测量时间间隔。
在分布式系统中,使⽤单调时钟测量经过时间通常很好,因为它不假定不同节点的时钟之间存在任何同步,并且对测量的轻微不准确性不敏感。
依赖同步的时钟
时钟的问题在于,虽然它们看起来简单易用,但却具有令⼈惊讶的缺陷:一天可能不会有精确的86400秒,时钟可能会前后跳跃,⽽一个节点上的时间可能与另一个节点上的时间完全不同。
前面我们讨论了网络丢包和任意延迟包的问题。尽管网络在大多数情况下表现良好,但软件的设计必须假定⽹络偶尔会出现故障,⽽软件必须正常处理这些故障。时钟也是如此:尽管大多数时间都工作得很好,但需要准备健壮的软件来处理不正确的时钟。
时间戳与事件顺序
让我们考虑一个特别的情况,⼀件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进⾏行排序。 例如,如果两个客户端写⼊分布式数据库,谁先到达? 哪一个更近?
尽管通过保留“最近”的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近”的定义取决于本地的时钟,这很可能是不正确的。即使用频繁同步的NTP时钟,⼀个数据包也可能在时间戳100毫秒(根据发送者的时钟)时发送,并在时间戳99毫秒(根据接收者的时钟)处到达——看起来好像数据包在发送之前已经到达,这是不可能的。
NTP同步是否能⾜够准确,以⾄于这种不正确的排序不会发生?也许不能,因为NTP的同步精度本身受到网络往返时间的限制,除了石英钟漂移这类误差源之外。为了进行正确的排序,你需要⼀个⽐测量对象(即⽹络延迟)要精确得多的时钟。
所谓的逻辑时钟是基于递增计数器而不是振荡⽯英晶体,对于排序事件来说是更安全的选择。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序。相反,用来测量实际经过时间的时钟和单调时钟也被称为物理时钟。
时间的置信区间
你可能够以微秒或甚⾄纳秒的精度读取机器的时钟。但即使可以得到如此细致的测量结果,这并不意味着这个值对于这样的精度实际上是准确的。实际上,如前所述,即使你每分钟与本地网络上的NTP服务器进行同步,很可能也不会像前面提到的那样,在不精确的石英时钟上漂移几毫秒。使⽤公共互联网上的NTP服务器,最好的准确度可能达到⼏十毫秒,而且当网络拥塞时,误差可能会超过100毫秒 。
因此,将时钟读数视为一个时间点是没有意义的——它更像是一段时间范围:例如,⼀个系统可能以 95%的置信度认为当前时间处于本分钟内的第10.3秒和10.5秒之间,它可能没法⽐这更精确了。
不确定性界限可以根据你的时间源来计算。如果你的GPS接收器或原子(铯)时钟直接连接到您的计算 机上,预期的错误范围由制造商报告。如果从服务器获得时间,则不确定性取决于自上次与服务器同步以来的⽯英钟漂移的期望值,加上NTP服务器的不确定性,再加上到服务器的网络往返时间。
进程暂停
在分布式系统中危险使用时钟的另一个例子:假设你有一个数据库,每个分区只有一个领导者。只有领导被允许接受写⼊。一个节点如何知道它仍然是领导者,并
且它可以安全地接受写⼊?
一种选择是领导者从其他节点获得⼀个租约,类似⼀个带超时的锁。任⼀时刻只有⼀个节点可以持有租约——因此,当⼀个节点获得⼀个租约时,它知道它在某段时间内⾃己是领导者,直到租约到期。为了保持领导地位,节点必须周期性地在租约过期前续期。
如果节点发生故障,就会停⽌续期,所以当租约过期时,另一个节点可以接管。
分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在⼀个函数的中间。
在暂停期间,世界的其它部分在继续运转,甚至可能因为该节点没有响应,而宣告暂停节点的死亡。最终暂停的节点可能会继续运行,在再次检查⾃己的时钟之前,甚⾄可能不会意识到⾃己进⼊了休眠。
知识,真相与谎言
真相由多数决定
设想⼀个具有不对称故障的⽹络:一个节点能够接收发送给它的所有消息,但是来⾃该节点的任何传出消息被丢弃或延迟。即使该节点运⾏良好,并且正在接收来⾃自其他节点的请求,其他节点也无法听到其响应。经过⼀段时间后,其他节点宣布它已经死亡,因为他们没有听到节点的消息。
另一种情况,想象一个经历了长时间STW垃圾收集暂停(stop-the-world GC Pause)的节点。节点的所有线程被GC抢占并暂停一分钟,因此没有请求被处理理,也没有响应被发送。其他节点等待,重试,不耐烦,并最终宣布节点死亡。最后,GC完成,节点的线程继续,好像什么也没有发⽣生。其他节点感到惊讶,因为所谓的死亡节点突然从棺材中抬起头来,身体健康,开始和旁观者高兴地聊天。GC后的节点最初甚至没有意识到已经过了整一分钟,⽽且⾃已被宣告死亡。 从它⾃己的⻆度来看,从最后一次与其他节点交谈以来,⼏乎没有经过任何时间。
节点不一定能相信⾃己对于情况的判断。分布式系统不能完全依赖单个节点,因为 节点可能随时失效,可能会使系统卡死,⽆法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进⾏投票:决策需要来⾃多个节点的最小投票数,以减少对于某个特定节点的依赖。这也包括关于宣告节点死亡的决定。如果法定数量的节点宣告另⼀个节点已经死亡,那么即使该节点仍感觉⾃己活着,它也必须被认为是死的。个体节点必须遵守法定决定并下线。
主节点与锁
通常情况下,⼀些东西在一个系统中只能有一个。例如:
- 数据库分区的领导者只能有一个节点,以避免脑裂。
- 特定资源的锁或对象只允许⼀个事务/客户端持有,以防同时写入和损坏。
- 一个特定的⽤户名只能被⼀个⽤户所注册,因为⽤户名必须唯一标识一个用户。
在分布式系统中实现这⼀点需要注意:即使⼀个节点认为它是“唯一的那个”,但这并不一定意味着有法定⼈数的节点同意!一个节点可能以前是领导者,但是如果其他节点在此期间宣布它死亡,则它可能已被降级,且另一个领导者可能已经当选。
如果一个节点继续表现为“唯一的那个”,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以⾃己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。
这个问题就是我们先前讨论过的⼀个例子:如果持有租约的客户端暂停太久,它的租约将到期。另⼀个客户端可以获得同⼀⽂件的租约,并开始写⼊文件。当暂停的客户端回来时,它认为它仍然有一个有效的租约,并继续写⼊文件。结果,导致客户的文件写入被破坏。
Fencing令牌
当使⽤锁或租约来保护对某些资源的访问时,需要确保⼀个被误认为⾃己是“唯一的那个”的节点不能影响系统的其它正常部分。实现这一目标的⼀个相当简单的技术就是使用防护 (fencing)令牌。
我们假设每次锁定服务器授予锁或租约时,它还会返回一个防护令牌,这个数字在 每次授予锁定时都会增加。然后,我们可以要求客户端每次向存储服务发送写⼊请求时,都必须包含当前的防护令牌。
如果将ZooKeeper⽤作锁定服务,则可将事务标识 zxid 或节点版本 cversion ⽤作防护令牌。由于它们保证单调递增,因此它们具有所需的属性。
请注意,这种机制要求资源本身在检查令牌⽅面发挥积极作用,如果发现已处理过更新的令牌,拒绝使用旧的令牌——仅仅依靠客户端检查⾃己的锁状态是不够的。对于不明确支持防护令牌的资源,可能仍然可以解决此问题(例如,在文件存储服务的情况下,可以将防护令牌包含在文件名 中)。总之,为了避免在锁的保护之外理请求,需要进⾏某种检查。
在服务器端检查一个令牌可能看起来像是一个缺点,但这可以说是⼀件好事:一个服务假定它的客户总是守规矩并不明智,因为使用客户端的人与运行服务的⼈优先级⾮常不一样。因此,任何服务保护⾃己免受意外客户的滥⽤是一个好主意。
拜占庭故障
常见的分布式系统中并不存在拜占庭故障(节点伪造消息),略~
第9章 一致性与共识
一致性保证
⼤多数复制的数据库至少提供了最终一致性,这意味着如果你停⽌向数据库写入数据并等待⼀段不确定的时间,那么最终所有的读取请求都会返回相同的值。换句话说,不一致性是暂时的,最终会⾃行解决(假设⽹络中的任何故障最终都会被修复)。最终一致性的⼀个更好的名字可能是收敛,因为我们预计所有的副本最终会收敛到相同的值。
然⽽,这是⼀个非常弱的保证——它并没有说什么时候副本会收敛。在收敛之前,读操作可能会返回任何东⻄或什么都没有。
在与只提供弱保证的数据库打交道时,你需要始终意识到它的局限性,⽽不是意外地作出太多假设。错误往往是微妙的,很难找到,也很难测试,因为应⽤可能在⼤多数情况下运行良好。当系统出现故障或高并发时,最终一致性的边缘情况才会显现出来。
本章将探索数据系统可能选择提供的更强⼀致性模型。它不是免费的:具有较强保证的系统可能会比保证较差的系统具有更差的性能或更少的容错性。尽管如此,更强的保证可以更吸引人,因为它们更容易用对。
可线性化
在最终⼀致的数据库,同时查询两个不同的副本可能会得到两个不同的答案。这会使应用层感到困惑。如果数据库可以提供只有一个副本的假象(即,只有一个数据副本),那么事情就简单太多了。
这就是可线性化(也称原子一致性,强一致性等)背后的思想。基本的想法是让⼀个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本,应用也不需要担⼼它们。
在⼀个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚写入的值。维护数据单个副本的错觉是指,系统能保障读到的值是最近的,最新的,⽽不是来⾃陈旧的缓存或副本。换句话说,可线性化是一种就近的保证。
线性化的依赖条件
线性⼀致性在什么情况下有用?观看体育比赛的最后得分可能是一个轻率的例⼦子:过了几秒钟的结果不可能在这种情况下造成任何真正的伤害。然而对于少数领域,线性⼀致性是系统正确⼯作的⼀个重要条件。
加锁与主节点选举
主从复制的系统,需要确保主节点只有一个,否则会产生脑裂。⼀种选择主节点的⽅法是使用锁:每个节点在启动时尝试获取锁,成功者成为主节点。不管这个锁是如何实现的,它必须是线性一致的:所有节点必须就哪个节点拥有锁达成一致,否则就没⽤了。
约束与唯一性保证
唯⼀性约束在数据库中很常⻅:例如,用户名或电子邮件地址必须唯一标识一个⽤户,⽽在⽂件存储服务中,不能有两个具有相同路径和文件名的文件。如果要在写⼊数据时强制执⾏此约束,则需要线性一致性。
这种情况实际上类似于一个锁:当⼀个用户注册你的服务时,可以认为他们获得了了所选用户名的“锁定”。该操作与原子性的⽐较与设置非常相似:将⽤户名赋予声明它的用户,前提是用户名尚未被使用。
跨通道的时间依赖
考虑一个例子,假设有一个网站,用户可以上传照片,一个后台进程会调整照⽚大⼩,降低分辨率以加快下载速度。该系统的架构和数据流如下图所示。
图像缩放器需要明确的指令来执⾏尺寸缩放作业,指令是Web服务器通过消息队列列发送的。 Web服务器不会将整个照⽚放在队列中,因为⼤多数消息代理都是针对较短的消息⽽设计的,⽽一张照⽚的空间占⽤可能达到⼏兆字节。取⽽代之的是,⾸先将照⽚写⼊文件存储服务,写⼊完成后再将缩放器的指令放⼊消息队列。
如果⽂件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列可能⽐存储服务内部的复制更快。在这种情况下,当缩放器读取图像时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产⽣了永久性的不一致。
出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性化的就近性保证,这两个信道之间的竞争条件是可能的。
实现线性化系统
线性化的代价
考虑这样⼀种情况:如果两个数据中⼼之间发⽣网络中断会发⽣什么?我们假设每个数据中心内的网络正在⼯作,客户端可以访问数据中心,但数据中⼼之间彼此⽆法互相连接。
使⽤多主数据库,每个数据中⼼都可以继续正常运⾏:由于在⼀个数据中⼼写入的数据是异步复制到另一个数据中心的,所以在恢复网络连接前,写入操作暂存在本地队列。
另⼀方面,如果使⽤单主复制,则主库必须位于其中⼀个数据中⼼。任何写入和任何线性一致的读取请求都必须发送给该主库,因此对于连接到从库所在数据中心的客户端,这些读取和写⼊请求必须通过网络同步发送到主库所在的数据中心。
在单主配置的条件下,如果数据中心之间的⽹络被中断,则连接到从库数据中心的客户端⽆法联系到主库,因此它们无法对数据库执⾏任何写入,也不能执行任何线性一致的读取。它们仍能从库读取,但结果可能是陈旧的。如果应用需要线性一致的读写,却⼜位于与主库网络中断的数据中心,则网络中断将导致这些应用不可用。
如果客户端可以直接连接到主库所在的数据中心,这就不是问题了,那些应用可以继续正常工作。但直到⽹络链接修复之前,只能访问从库数据中心的客户端会中断运⾏。
CAP理论
这个问题不仅仅存在于单主复制和多主复制系统中:任何线性一致的数据库都有这个问题,不管它是如何实现的。这个问题也不仅仅局限于多数据中⼼部署,而可能发⽣在任何不可靠的网络上,即使在同一个数据中⼼内也是如此。问题面临的权衡如下:
- 如果应⽤需要线性一致性,且某些副本因为⽹络问题与其他副本断开连接,那么这些副本掉线时不能处理请求。请求必须等到网络问题解决,或直接返回错误。(无论哪种⽅式,服务都不可⽤)。
- 如果应⽤不需要线性一致性,那么某个副本即使与其他副本断开连接,也可以处理请求。在这种情况下,应⽤可以在⽹络问题恢复前保持可用,但其行为不是线性⼀致的。
因此不需要线性⼀致性的应⽤对网络问题有更强的容错能力。这种⻅解通常被称为CAP定理。
CAP最初是作为⼀个经验法则提出的,没有准确的定义,目的是讨论数据库的权衡。那时候许多分布式数据库侧重于在共享存储的集群上提供线性一致性的语义,CAP定理鼓励数据库⼯程师向分布式⽆无共享系统的设计领域深⼊探索,这类架构更适合实现大规模的⽹络服务。 对于这种⽂化上的转变,CAP值得赞扬——它⻅证了自2000年以来新数据库的技术爆炸(即NoSQL)。
CAP理论是否有用
CAP有时以这种⾯目出现:⼀致性,可用性和分区可用性,三者只能择其二。不不幸的是这种说法很有误导性,因为⽹络分区是⼀种错误,所以它并不是一个选项:不管你喜不喜欢它都会发⽣。
在网络正常工作的时候,系统可以提供⼀致性和整体可用性。发⽣网络故障时, 你必须在线性⼀致性和整体可⽤性之间做出选择。因此,一个更好的表达CAP的⽅方法可以是“在网络分区的情况下,选择一致还是可用”。一个更可靠的网络需要减少这个选择,但是在某些时候选择是不可避免的。
总⽽言之,围绕着CAP有很多误解和困惑,并不能帮助我们更好地理解系统,所以最好避免使⽤CAP。
CAP定理的正式定义仅限于很狭隘的范围,它只考虑了一个一致性模型(即线性⼀一致性)和⼀种故障(网络分区,或活跃但彼此断开的节点)。它没有讨论任何关于⽹络延迟,死亡节点或其他权衡的事。 因此,尽管CAP在历史上有一些影响⼒力,但对于设计系统⽽言并没有实际价值。 在分布式系统中有更多有趣的“不可能”的结果,且CAP定理现在已经被更精确的结果取代,所以它现在基本上成了历史古迹了。
可线性化与网络延迟
虽然可线性化是⼀个很有⽤的保证,但实际上,线性一致的系统惊⼈的少。例如,现代多核CPU上的内存甚至都不是线性⼀致的:如果一个CPU核上运行的线程写入某个内存地址,而另⼀个CPU核上运行的线程不久之后读取相同的地址,并不能保证一定能读到第一个线程写⼊的值(除非使⽤了内存屏障或fence指令)。
这种⾏为的原因是每个CPU核都有⾃己的内存缓存和存储缓冲区。默认情况下,内存访问⾸先⾛缓存,任何变更会异步写入主存。因为缓存访问比主存要快得多,所以这个特性对于现代CPU的良好性能表现至关重要。但是现在就有⼏个数据副本(一个在主存中,也许还有几个在不同缓存中的其他副本),而且这些副本是异步更新的,所以就失去了线性一致性。
为什要做这个权衡?对多核内存⼀致性模型⽽言,CAP定理是没有意义的:在同⼀台计算机中,我们通常假定通信都是可靠的。并且我们并不指望一个CPU核能在脱离计算机其他部分的条件下继续正常⼯作。牺牲线性一致性的原因是性能,而不是容错。
许多分布式数据库也是如此:它们是为了提高性能⽽选择了牺牲线性⼀致性,而不是为了容错。 线性一致的速度很慢——这始终是事实,⽽不仅是⽹络故障期间。
能找到一个更高效的线性⼀致存储实现吗?看起来答案是否定的:Attiya和Welch 证明,如果你想要线性⼀致性,读写请求的响应时间⾄少与网络延迟的不确定性成正比。在像大多数计算机⽹络一样具有高度可变延迟的网络中,线性读写的响应时间不可避免地会很高。更快地线性一致算法不存在,但更弱的⼀致性模型可以快得多,所以对延迟敏感的系统⽽言,这类权衡⾮常重要。
顺序保证
之前说过,线性一致寄存器的⾏为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的⽅式生效的。这个定义意味着操作是按照某种良好定义的顺序执⾏的。
事实证明,顺序,线性一致性和共识之间有着深刻的联系。
顺序与因果关系
顺序反复出现有几个原因,其中一个原因是,它有助于保持因果关系。
因果关系对事件施加了一种顺序:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样, 一件事会导致另⼀件事:某个节点读取了一些数据然后写⼊入一些结果,另一个节点读取其写入的内容, 并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。
如果一个系统服从因果关系所规定的顺序,我们说它是因果⼀致的。例如,快照隔离提供了因果⼀致性:当你从数据库中读取到⼀些数据时,你⼀定还能够看到其因果前驱(假设在此期间这些数据还没有被删除)。
因果顺序并非全序
全序允许任意两个元素进⾏比较,所以如果有两个元素,你总是可以说出哪个更大, 哪个更小。例如,⾃然数集是全序的:给定两个自然数,比如说5和13,那么你可以告诉我,13大于 5。
然而数学集合并不完全是全序的: {a, b} 比 {b, c} 更⼤吗?好吧,你没法真正⽐较它们,因为二者都不是对方的子集。我们说它们是无法⽐较的,因此数学集合是偏序的:在某些情况下,可以说一个集合⼤于另一个(如果⼀个集合包含另一个集合的所有元素),但在其他情况下它们是无法比较的。 全序和偏序之间的差异反映在不同的数据库一致性模型中:
- 线性⼀致性
在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。 - 因果性
我们说过,如果两个操作都没有在彼此之前发生,那么这两个操作是并发的。换句话说,如果两个事件是因果相关的(一个发⽣在另⼀个事件之前),则它们之间是有序的,但如果它们是并发的,则它们之间的顺序是⽆法比较的。这意味着因果关系定义了一个偏序,⽽不是一个全序:一些操作相互之间是有顺序的,但有些则是无法⽐较的。
因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有⼀条时间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但是数据存储确保了每个请求都是在唯一时间线上的某个时间点⾃动处理的,不存在任何并发。
可线性化强于因果一致性
那么因果顺序和线性⼀致性之间的关系是什么?答案是线性一致性一定意味着因果关系:任何线性一致的系统都能正确保持因果性。特别是,如果系统中有多个通信通道(比如消息队列和⽂件存储服务),线性⼀致性可以⾃动保证因果性,系统⽆无需任何特殊操作(如在不同组件间传递时间戳)。
线性⼀致性确保因果性的事实使线性⼀致系统变得简单易懂,更有吸引力。然⽽,使系统线性⼀致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下。出于这个原因,⼀些分布式数据系统已经放弃了线性一致性,从而获得更更好的性能,但它们用起来也更为困难。
好消息是存在折衷的可能性。线性⼀致性并不是保持因果性的唯一途径——还有其他方法。⼀个系统可以是因果一致的,⽽无需承担线性⼀致带来的性能折损。实际上在所有的不会被网络延迟拖慢的一致性模型中,因果一致性是可⾏的最强的一致性模型。而且在网络故障时仍能保持可⽤。
在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果⼀致性可以更高效地实现。
序列号排序
虽然因果是一个重要的理论概念,但实际上跟踪所有的因果关系是不切实际的。在许多应用中,客户端在写入内容之前会先读取⼤量数据,我们无法弄清写入因果依赖于先前全部的读取内容,还是仅包括其中一部分。显式跟踪所有已读数据意味着巨大的额外开销。
但还有一个更好的方法:我们可以使用序列号或时间戳来排序事件。时间戳不一定来⾃墙上时钟。它可以来⾃一个逻辑时钟,这是一个⽤来⽣成标识操作的数字序列的算法,典型实现是使用一个每次操作自增的计数器。
这样的序列号或时间戳是紧凑的(只有几个字节⼤小),它提供了一个全序关系:也就是说每次操作都有一个唯⼀的序列号,而且总是可以⽐较两个序列号,确定哪一个更大(即哪些操作后发生)。
特别是,我们可以使⽤与因果一致的全序来生成序列号:我们保证,如果操作A因果后继于操作B,那么在这个全序中A在B前(A具有比B更小的序列号)。并行操作之间可以任意排序。这样一个全序关系捕获了所有关于因果的信息,但也施加了⼀个比因果性要求更为严格的顺序。
在单主复制的数据库中,复制⽇志定义了与因果⼀致的写操作。主库可以简单地为每个操作⾃增一个计数器,从而为复制日志中的每个操作分配一个单调递增的序列号。如果一个从库按照它们在复制日志中出现的顺序来应⽤写操作,那么从库的状态始终是因果⼀致的(即使它落后于领导者)。
非因果序列发生器
如果主库不存在(可能因为使⽤了多主数据库或无主数据库,或者因为使⽤了分区的数据库),如何为操作生成序列号就没有那么明显了了。在实践中有各种各样的⽅法:
- 每个节点都可以⽣成⾃己独立的⼀组序列号。例如有两个节点,⼀个节点只能⽣成奇数,而另一个节点只能⽣成偶数。通常,可以在序列号的二进制表示中预留一些位,用于唯一的节点标识符,这样可以确保两个不同的节点永远不会生成相同的序列号。
- 可以将墙上时钟时间戳附加到每个操作上。这种时间戳并不连续,但是如果它具 有⾜够⾼的分辨率,那也许足以提供一个操作的全序关系。这一事实应⽤于最后写⼊为准的冲突解决方法中。
- 可以预先分配序列号区块。例如,节点A可能要求从序列号1到1000区块的所有权,⽽节点B可能要求序列号1001到2000区块的所有权。然后每个节点可以独立分配所属区块中的序列号,并在序列号告急时请求分配一个新的区块。
这三个选项都⽐单一主库的自增计数器表现要好,并且更具可扩展性。它们为每个操作⽣成一个唯一的,近似⾃增的序列号。
然而它们都有同⼀个问题:生成的序列号与因果不一致。
因为这些序列号⽣成器不能正确地捕获跨节点的操作顺序,所以会出现因果关系的问题:
- 每个节点每秒可以处理不同数量的操作。因此,如果一个节点产⽣偶数序列号⽽另一个产⽣奇数序列号,则偶数计数器可能落后于奇数计数器,反之亦然。如果你有⼀一奇数编号的操作和⼀个偶数编号的操作,你⽆法准确地说出哪⼀个操作在因果上先发生。
- 来⾃物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。因果上晚发⽣的操作,却被分配了一个更早的时间戳。
- 在分配区块的情况下,某个操作可能会被赋予一个范围在1001到2000内的序列号,然⽽一个因果上更晚的操作可能被赋予一个范围在1到1000之间的数字。这里序列号与因果关系也是不一致的。
Lamport时间戳
尽管刚才描述的三个序列号生成器与因果不一致,但实际上有⼀个简单的⽅法来产⽣与因果关系一致的序列号。它被称为兰伯特时间戳。
下图说明了兰伯特时间戳的应用。每个节点都有⼀个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。
兰伯特时间戳与物理时间时钟没有任何关系,但是它提供了一个全序:如果你有两个时间戳,则计数器值大者是更大的时间戳。如果计数器值相同,则节点ID越大的,时间戳越大。
迄今,这个描述与上节所述的奇偶计数器基本类似。使兰伯特时间戳因果⼀致的关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最⼤计数器值,并在每个请求中包含这个最⼤计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将⾃己的计数器设置为这个最⼤值。
只要每一个操作都携带着最⼤计数器值,这个方案确保兰伯特时间戳的排序与因果一致,而请求的因果依赖性一定会保证后发生的请求得到更大的时间戳。
时间戳排序依然不够
虽然兰伯特时间戳定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常⻅问题。
例如,考虑一个需要确保⽤户名能唯一标识用户帐户的系统。如果两个⽤户同时尝试使⽤相同的⽤户名创建帐户,则其中⼀个应该成功,另一个应该失败。
乍看之下,似乎操作的全序关系⾜以解决这⼀问题(例如使⽤用兰伯特时间戳):如果创建了两个具有相同⽤户名的帐户,选择时间戳较小的那个作为胜者,并让带有更大时间戳者失败。由于时间戳上有全序关系,所以这个⽐较总是可⾏的。
这种⽅法适⽤于事后确定胜利者:一旦你收集了系统中的所有用户名创建操作,就可以⽐较它们的时间戳。然而当某个节点需要实时处理用户创建⽤户名的请求时,这样的方法就⽆法满⾜了。节点需要⻢上决定这个请求是成功还是失败。在那个时刻,节点并不知道是否存其他节点正在并发执行创建同样用户名的操作。
为了确保没有其他节点正在使⽤相同的⽤户名和较小的时间戳并发创建同名账户,你必须检查其它每个节点,看看它在做什么。如果其中一个节点由于网络问题出现故障或不可达,则整个系统可能被拖至停机。这不是我们需要的那种容错系统。
这里的问题是,只有在所有的操作都被收集之后,操作的全序才会出现。如果另⼀个节点已经产⽣了一些操作,但你还不知道那些操作是什么,那就⽆法构造所有操作最终的全序关系:来自另一个节点的未知操作可能需要被插⼊到全序中的不同位置。
总之:为了实诸如⽤户名上的唯一约束这种东西,仅有操作的全序是不够的,你还需要知道这些操作是否发生、合适确定。如果你有⼀个创建用户名的操作,并且确定在全序中,没有其他节点正在执行相同用户名的创建,那么你就可以安全地宣告操作执⾏成功。
想知道什么时候全序关系已经确定就需要“全序关系广播”了。
全序关系广播
全序⼴播通常被描述为在节点间交换消息的协议。 非正式地讲,它要满足两个安全属性:
- 可靠交付,没有消息丢失
如果消息被传递到⼀个节点,它将被传递到所有节点。 - 全序交付,消息以相同的顺序传递给每个节点。
正确的全序广播算法必须始终保证可靠性和有序性,即使节点或⽹络出现故障。当然在⽹络中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能及时通过并送达(当然它们必须仍然按照正确的顺序传递)。
采用全序关系广播实现线性化存储
在线性⼀致的系统中,存在操作的全序。这是否意味着线性一致与全序⼴播一样?不尽然,但两者之间有者密切的联系。
全序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息何时被送达(所以一个接收者可能落后于其他接收者)。相比之下,线性⼀致性是就近性的保证:读取一定能看⻅最新的写⼊值。
但如果有了全序广播,你就可以在此基础上构建线性一致的存储。例如,你可以确保⽤户名能唯一标识用户帐户。
设想对于每⼀个可能的⽤户名,你都可以有⼀个带有CAS原子操作的线性一致寄存器。每个寄存器最初的值为空值。当⽤户想要创建一个用户名时,对该⽤户名的寄存器执行CAS操作,在先前寄存器值为空的条件,将其值设置为⽤户的账号ID。如果多个⽤户试图同时获取相同的⽤户名,则只有⼀个CAS操作会成功,因为其他⽤用户会看到非空的值(由于线性一致性)。
你可以通过将全序广播当成追加日志的⽅式来实现这种线性一致的CAS操作:
- 在日志中追加一条消息,试探性地指明你要创建的用户名。
- 读日志,将其广播给所有节点,并等待回复。
- 检查是否有任何消息声称目标⽤户名的所有权。如果这些消息中的第一条就你⾃己的消息,那么你就成功了:你可以提交声称的⽤户名。如果所需⽤户名的第一条消息来⾃其他用户,则中止操作。
由于⽇志项是以相同顺序送达⾄所有节点,因此如果有多个并发写入,则所有节点会对最先到达者达成一致。选择冲突写⼊中的第一个作为胜利者,并中止后来者,以此确定所有节点对某个写入是提交还是中⽌达成一致。
尽管这⼀过程保证写入是线性一致的,但它并不保证读取也是线性一致的——如果你从与⽇志异步更新的存储中读取数据,结果可能是陈旧的。为了使读取也线性⼀致,有几个选项:
- 可以采用追加的方式把读请求排序、广播,然后各个节点获取该日志,当本节点收到消息时才执行真正的读操作。消息在日志中的位置已经决定了读取发生时间点。etcd的quorum读取和这个思路有相似之处。
- 如果⽇志允许以线性⼀致的⽅式获取最新⽇志消息的位置,则可以查询该位置,等待直到该位置前的所有消息都传达到你,然后执⾏行读取。 这是Zookeeper sync() 操作背后的思想。
- 你可以从同步更新的副本中进⾏读取,因此可以确保结果是最新的。
采用线性化存储实现全序关系广播
上一节介绍了如何从全序广播构建一个线性一致的CAS操作。我们也可以把它反过来,假设我们有线性一致的存储,接下来会展示如何在此基础上构建全序广播。
最简单的方法是假设你有一个线性⼀致的寄存器来存储一个整数,并且有一个原⼦子CAS(或自增)操作。
该算法很简单:每个要通过全序⼴播发送的消息⾸先对线性一致寄存器执行⾃增并返回操作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而接收方将按序列号连续发送息。
请注意,与兰伯特时间戳不同,通过自增线性⼀致性寄存器获得的数字形式上是一个没有间隙的序列。 因此,如果一个节点已经发送了消息4并且接收到序列号为6的传入消息,则它知道它在传递消息6之前必须等待消息5。兰伯特时间戳则与之不同——事实上,这是全序⼴播和时间戳排序间的关键区别。
实现一个带有原⼦性⾃增并返回操作的线性⼀致寄存器有多困难?像往常一样,如果事情从来不出差错,那很容易:你可以简单地把它保存在单个节点内的变量中。问题在于处理当该节点的⽹络连接中断时的情况,并在该节点失效时能恢复这个值。一般来说,如果你对线性⼀致性的序列号生成器进行过⾜够深入的思考,你不可避免地会得出一个共识算法。
这并⾮巧合:可以证明,线性一致的CAS(或⾃增)寄存器与全序⼴播都等价于共识问题。也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题的解决方案。
分布式事务与共识
共识是分布式计算中最重要也是最基本的问题之一。从表面上看似乎很简单:非正式地讲,⽬标只是让几个节点达成一致。你也许会认为这不会太难。不幸的是,许多出故障的系统都是因为错误地轻信这个问题很容易解决。
有很多重要的场景都需要集群节点达成某种一致,例如:
- 领导选举
在单主复制的数据库中,所有节点需要就哪个节点是领导者达成一致。如果一些节点由于网络故障⽽无法与其他节点通信,则可能会对领导权的归属引起争议。在这种情况下,共识对于避免错误的故障切换非常重要。错误的故障切换会导致两个节点都认为⾃己是领导者。如果有两个领导者,它们都会接受写入,它们的数据会发生分歧,从而导致不一致和数据丢失。 - 原子提交
在支持跨多节点或跨多分区事务的数据库中,⼀个事务可能在某些节点上失败,但在其他节点上成功。 如果我们想要维护事务的原⼦性,我们必须让所有节点对事务的结果达成一致:要么全部中⽌/回滚,要么它们全部提交。
原子提交与两阶段提交
从单节点到分布式的原子提交
对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端请求数据库节点提交事务时,数据库将使事务的写入持久化(通常在预写式⽇志中),然后将提交记录追加到磁盘中的⽇志里。如果数据库在这个过程中间崩溃,当节点重启时,事务会从日志中恢复:如果提交记录在崩溃之前成功地写⼊磁盘,则认为事务被提交;否则来自该事务的任何写⼊都被回滚。
因此,在单个节点上,事务的提交主要取决于数据持久化落盘的顺序:首先是数据,然后是提交记录。事务提交或终⽌的关键决定时刻是磁盘完成写入提交记录的时刻:在此之前,仍有可能中⽌,但在此之后,事务已经提交(即使数据库崩溃)。因此,是单一的设备使得提交具有原子性。
但是,如果一个事务中涉及多个节点呢?在这些情况下,仅向所有节点发送提交请求并独立提交每个节点的事务是不够的。这样很容易发⽣违反原子性的情况:提交在某些节点上成功,而在其他节点上失败。
如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中⽌了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进⾏提交。
事务提交必须是不可撤销的——事务提交之后,你不能改变主意,并追溯性地中⽌事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了读-提交隔离等级的基础。如果⼀个事务在提交后被允许中止,所有那些读取了已提交却⼜被追溯声明不存在数据的事务也必须回滚。提交事务的结果有可能通过事后执⾏另一个补偿事务来取消,但从数据库的⻆度来看,这是⼀个独立的事务,因此任何关于跨事务正确性的保证都是应用⾃己的问题。
两阶段提交
两阶段提交(two-phase commit,2PC)是一种用于实现跨多个节点的原⼦事务提交的算法,即确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法。
下图说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此⽽得名),而不是单节点事务中的单个提交请求。
2PC使⽤一个通常不会出现在单节点事务中的新组件:协调者(也称为事务管理器)。协调者通常在请求事务的相同应⽤进程中以共享库的形式实现,但也可以是单独的进程或服务。
正常情况下,2PC事务以应用在多个数据库节点上读写数据开始。我们称这些数据库节点为参与者。当应⽤准备提交时,协调者开始阶段1:它发送一个准备请求到每个节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应:
- 如果所有参与者都回答“是”,表示它们已经准备好提交,那么协调者在阶段 2 发出提交请求,然后提交真正发⽣。
- 如果任意⼀个参与者回复了“否”,则协调者在阶段2中向所有节点发送中⽌请求。
系统的承诺
为了理解它的工作原理,我们必须更详细地分解这个过程:
- 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
- 应⽤在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。所有的读写都是在这些单节点事务中各⾃完成的。如果在这个阶段出现任何问题(例如,节点崩溃或请求超时),则协调者或任何参与者都可以中止。
- 当应⽤准备提交时,协调者向所有参与者发送⼀个准备请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中⽌请求。
- 参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。这包括将所有事务数据写⼊磁盘(出现故障,电源故障,或硬盘空间不足都不能是稍后拒绝提交的理由)以及检查是否存在任何冲突或违反约束。通过向协调者回答“是”,节点承诺,只要请求,这个事务⼀定可以不出差错地提交。换句话说,参与者放弃了了中⽌事务的权利,但没有实际提交。
- 当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定(只有在所有参与者投赞成票的情况下才会提交)。协调者必须把这个决定写到磁盘上的事务⽇志中,如果它随后就崩溃,恢复后也能知道⾃己所做的决定。这被称为提交点。
- ⼀旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者必须永远保持重试,直到成功为止。没有回头路:如果已经做出决定,不管需要多少次重试它都必须被执行。如果参与者在此期间崩溃,事务将在其恢复后提交——由于参与者投了赞成,因此恢复后它不能拒绝提交。
因此,该协议包含两个关键的“不归路”点:当参与者投票“是”时,它承诺它稍后肯定能够提交。⼀旦协调者做出决定,这⼀决定是不不可撤销的。这些承诺保证了2PC的原⼦性。 (单节点原子提交将这两个事件合二为一,写入事务日志即提交)。
协调者发生故障
我们已经讨论了在2PC期间,如果参与者之一或⽹络发生故障时会发⽣什么情况:如果任何一个准备请求失败或者超时,协调者就会中止事务。如果任何提交或中⽌止请求失败,协调者将⽆条件重试。但是如果协调者崩溃,会发⽣什么情况就不太清楚了。
如果协调者在发送准备请求之前失败,参与者可以安全地中⽌事务。但是,一旦参与者收到了准备请求并投了“是”,就不能再单⽅面放弃——必须等待协调者回答事务是否已经提交或中⽌。如果此时协调者崩溃或网络出现故障,参与者什么也做不不了只能等待。
情况如下图所示。在这个特定的例子中,协调者实际上决定提交,数据库2收到提交请求。但是,协调者在将提交请求发送到数据库1之前发⽣崩溃,因此数据库1 不知道是否提交或中⽌。即使超时在这里也没有帮助:如果数据库1在超时后单⽅方⾯中止,它将最终与执⾏提交的数据库2不一致。同样,单⽅面提交也是不安全的,因为另⼀个参与者可能已经中⽌了。
没有协调者的消息,参与者⽆法知道是提交还是放弃。原则上参与者可以相互沟通,找出每个参与者是如何投票的,并达成⼀致,但这不是2PC协议的⼀部分。
可以完成2PC的唯⼀方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交或中⽌请求之前,将其提交或中止决定写⼊磁盘上的事务⽇志:协调者恢复后,通过读取其事务日志来确定所有存疑事务的状态。任何在协调者日志中没有提交记录的事务都会中止。因此,2PC的提交点归结为协调者上的常规单节点原⼦子提交。
三阶段提交
两阶段提交被称为阻塞原子提交协议,因为存在2PC可能卡住并等待协调者恢复的情况。 理论上,可以使一个原子提交协议变为非阻塞的,以便在节点失败时不会卡住。但是 让这个协议能在实践中工作并没有那么简单。
作为2PC的替代⽅案,已经提出了一种称为三阶段提交(3PC)的算法。然而,3PC假定⽹络延迟有界,节点响应时间有限;在⼤多数具有无限网络延迟和进程暂停的实际系统中,它并不能保证原子性。
通常,非阻塞原⼦提交需要一个完美的故障检测器——即⼀个可靠的机制来判断⼀个节点是否已经崩溃。在具有⽆限延迟的⽹络中,超时并不是一种可靠的故障检 测机制,因为即使没有节点崩溃,请求也可能由于网络问题⽽超时。出于这个原因,2PC仍然被使⽤,尽管⼤家都清楚可能存在协调者故障的问题。
实践中的分布式事务
分布式事务的某些实现会带来严重的性能损失——例如据报告称,MySQL中的分布式事务⽐单节点事务慢10倍以上,所以当⼈们建议不要使⽤用它们时就不足为奇了。两阶段提交所固有的性能成本, 大部分是由于崩溃恢复所需的额外强制刷盘以及额外的⽹络往返。
但我们不应该直接忽视分布式事务,而应当更加仔细地审视这些事务,因为从中可以汲取重要的经验教训。首先,我们应该精确地说明“分布式事务”的含义。两种截然不同的分布式事务类型经常被混淆:
- 数据库内部的分布式事务
⼀些分布式数据库(即在其标准配置中使用复制和分区的数据库)支持数据库节点之间的内部事务。例如,VoltDB和MySQL Cluster的NDB存储引擎就有这样的内部事务支持。在这种情况下,所有参与事务的节点都运行相同的数据库软件。 - 异构分布式事务
在异构事务中,参与者是两种或以上不同技术:例如来⾃不同供应商的两个数据 库,甚⾄是非数据库系统(如消息代理)。跨系统的分布式事务必须确保原子提交,尽管系统可能完全不同。
数据库内部事务不必与任何其他系统兼容,因此它们可以使⽤任何协议,并能针对特定技术进行特定的优化。因此数据库内部的分布式事务通常⼯作地很好。另⼀方面,跨异构技术的事务则更有挑战性。
Exactly-once消息处理
异构的分布式事务处理能够以强大的方式集成不同的系统。例如:消息队列中的⼀条消息可以被确认为已处理,当且仅当⽤于处理消息的数据库事务成功提交。这是通过在同一个事务中原⼦提交消息确认和数据库写入两个操作来实现的。藉由分布式事务的支持,即使消息代理和数据库是在不同机器上运行的两种不相关的技术,这种操作也是可能的。
如果消息传递或数据库事务任意一者失败,两者都会中⽌,因此消息代理可能会在稍后安全地重传消息。因此,通过原⼦提交消息处理及其副作用,即使在成功之前需要几次重试,也可以确保消息被有效地恰好处理一次。中⽌会抛弃部分完成事务所导致的任何副作⽤。
然⽽,只有当所有受事务影响的系统都使⽤同样的原⼦提交协议时,这样的分布式事务才是可能的。例如,假设处理消息的副作用是发送⼀封邮件,而邮件服务器并不支持两阶段提交:如果消息处理失败并重试,则可能会发送两次或更多次的邮件。但如果处理理消息的所有副作⽤都可以在事务中⽌时回滚,那么这样的处理流程就可以安全地重试,就好像什么都没有发生过一样。
停顿时仍持有锁
为什么我们这么关心陷入停顿的参与者节点?难道系统不能选择忽略(并最终处理)这些节点,这样系统不就可以继续工作吗?
问题在于锁。正如在“读-提交”中所讨论的那样,数据库事务通常获取待修改的行上的⾏级排他锁,以防⽌脏写。此外,如果要使⽤可序列化的隔离等级,则使⽤两阶段锁定的数据库也必须为事务所读取的⾏加上共享锁。
在事务提交或中⽌之前,数据库不能释放这些锁。因此,在使⽤两阶段提交时,事务必须在整个停顿期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有——或至少在管理员手动解决该情况之前。
当这些锁被持有时,其他事务不能修改这些行。根据数据库的不同,其他事务甚⾄至可能因为读取这些⾏而被阻塞。因此,其他事务没法儿简单地继续它们的业务了——如果它们要访问同样的数据,就会被阻塞。这可能会导致应⽤大⾯积进入不可用状态,直到出于停顿状态的事务被解决。
从协调者故障中恢复
理论上,如果协调者崩溃并重新启动,它应该⼲净地从日志中恢复其状态,并解决任何存疑事务。然⽽在实践中,孤⽴的存疑事务确实会出现,即⽆论出于何种理由,协调者⽆法确定事务的结果(例如事务⽇志已经由于软件错误丢失或损坏)。这些事务⽆法⾃动解决,所以它们永远待在数据库中,持有锁并阻塞其他事务。
即使重启数据库服务器也无法解决这个问题,因为在2PC的正确实现中,即使重启也必须保留存疑事务的锁。
唯⼀的出路是让管理员手动决定提交还是回滚事务。管理员必须检查每个存疑事务的参与者,确定是否有任何参与者已经提交或中⽌止,然后将相同的结果应用于其他参与者。
许多XA的实现都有⼀个叫做启发式决策的紧急逃⽣舱口:允许参与者单⽅面决定放弃或提交一个停顿事务,⽽无需协调者做出最终决定。要清楚的是,这⾥启发式是可能破坏原子性的委婉说法,因为它违背了两阶段提交的系统承诺。因此,启发式决策只是为了逃出灾难性的情况而准备的,⽽不是为了日常使用的。
分布式事务的限制
XA事务解决了保持多个参与者(数据系统)相互一致的现实的重要问题,但正如我们所看到的那样,它也引⼊了严重的运维问题。特别来讲,这⾥的核⼼认识是:事务协调者本身就是一种数据库(存储了事务的结果),因此需要像其他重要数据库⼀样⼩心地打交道:
- 如果协调者没有复制,⽽是只在单台机器上运行,那么它是整个系统的失效单点(因为它的失效会导致其他应⽤服务器阻塞在存疑事务持有的锁上)。
- 许多服务器端应⽤都是使⽤无状态模式开发的(受HTTP的青睐),所有持久状态都存储在数据库中,因此具有应用服务器可随意按需添加删除的优点。但是,当协调者成为应⽤服务器的⼀部分时,它会改变部署的性质。突然间,协调者的日志成为持久系统状态的关键部分——与数据库本身一样重要,因为协调者日志是为了在崩溃后恢复存疑事务所必需的。这样的应⽤服务器不再是⽆状态的了。
- 由于XA需要兼容各种数据系统,因此它必须是所有系统的最低标准。
- 对于数据库内部的分布式事务,限制没有这么⼤。 然⽽仍然存在问题:2PC成功提交⼀个事务需要所有参与者的响应。因此,如果系统的任何部分损坏,事务也会失败。因此,分布式事务又有扩⼤失效的趋势,这又与我们构建容错系统的目标背道而驰。
支持容错的共识
⾮正式地,共识意味着让几个节点就某事达成⼀致。
共识问题通常形式化如下:一个或多个节点可以提议某些值,而共识算法决定采⽤其中的某个值。
在这种形式下,共识算法必须满⾜以下性质:
- 协商一致性(Uniform agreement)
所有的节点都接受相同的决议 - 诚实性(Integrity)
没有节点决定两次。 - 合法性(Validity)
如果一个节点决定了值 v ,则v由某个节点所提议。 - 可终止性(Termination))
节点如果不崩溃则最终一定可以达成决议
协商一致性和诚实性属性定义了共识的核⼼思想:所有人都决定了相同的结果,⼀旦决定了,你就不能改变主意。有效性属性主要是为了排除无效的解决⽅案:例如,⽆论提议了什么值,你都可以有⼀个始终决定值为null的算法。该算法满⾜协商一致性和诚实性属性,但不满⾜合法性属性。
如果你不关⼼容错,那么满⾜前三个属性很容易:你可以将一个节点硬编码为“独裁者”,并让该节点做出所有的决定。但如果该节点失效,那么系统就无法再做出任何决定。事实上,这就是我们在两阶段提交的情况中所看到的:如果协调者失效,那么存疑的参与者就⽆法决定提交还是中止。
终⽌属性正式形成了容错的思想。它实质上说的是,⼀个共识算法不能简单地永远闲坐着等死——换句话说,它必须取得进展。即使部分节点出现故障,其他节点也必须达成一项决定。
可终⽌性是⼀种活性属性,而另外三种是安全属性。
共识算法与全序广播
最著名的容错共识算法是视图戳复制(VSR, viewstamped replication),Paxos ,Raft以及 Zab。这些算法之间有不少相似之处,但它们并不相同。
大多数这些算法实际上并不直接使用这里描述的形式化模型(提议与决定单个值,同事满足上面4个属性)。取⽽代之的是,它们决定了一系列值,然后采用全序广播算法。
全序⼴播要求将消息按照相同的顺序,恰好传递一次,准确传送到所有节点。如果仔细思考, 这相当于进⾏了几轮共识:在每⼀轮中,节点提议下一条要发送的消息,然后决定在全序中下一条要发送的消息。
所以,全序⼴播相当于重复进⾏多轮共识(每次共识决定与一次消息传递相对应):
- 由于协商一致性属性,所有节点决定以相同的顺序传递相同的消息。
- 由于诚实性属性,消息不会重复。
- 由于合法性属性,消息不会被损坏,也不能凭空编造。
- 由于可终止性属性,消息不会丢失。
Epoch和Quorum
迄今为⽌所讨论的所有共识协议,在内部都以某种形式使⽤一个主节点,它不是固定的。相反,它们可以做出更弱的保证:协议定义了一个世代编号(epoch number),并确保在每个世代中,主节点都是唯一的。
每次当现任主节点被认为挂掉的时候,节点间就会开始⼀场投票,以选出一个新主节点。这次选举被赋予⼀个递增的世代编号,因此世代编号是全序且单调递增的。如果两个不同的世代的主节点之间出现冲突(也许是因为前任主节点实际上并未死亡),那么带有更高世代编号的主节点说了算。
在任何主节点被允许决定任何事情之前,必须先检查是否存在其他带有更高世代编号的节点,它们可能会做出相互冲突的决定。主节点如何知道⾃自己没有被另⼀个节点赶下台?回想⼀下在“真理由多数决定”中提到的:一个节点不一定能相信自⼰的判断——因为只有节点⾃己认为⾃己是主节点,并不一定意味着其他节点接受它作为它们的领导者。
相反,它必须从法定⼈数的节点中获取选票。对主节点想要做出的每一个决定,都必须将提议值发送给其他节点,并等待法定⼈数的节点响应并赞成提案。法定人数通常(但不总是)由多数节点组成。只有在没有意识到任何带有更高世代编号的主节点的情况 下,⼀个节点才会投票赞成提议。
因此,我们有两轮投票:第⼀次是为了选出主节点,第⼆次是对主节点的提议进⾏表决。关键在于,这两次投票的法定⼈群必须相互重叠:如果一个提案的表决通过,则⾄少得有⼀个参与投票的节点也必须参加过最近的主节点选举。因此,如果在一个提案的表决过程中没有出现更高的世代编号。那么当前主节点就可以得出这样的结论:没有发生过更高时代的主节点选举,因此可以确定⾃己仍然在领导。然后它就可以安全地对提议值做出决定。
这一投票过程表⾯上看起来很像两阶段提交。最大的区别在于,2PC中协调者不是由选举产生的,而且2PC则要求所有参与者都投赞成票,而容错共识算法只需要多数节点的投票。而且,共识算法还定义了一个恢复过程,出现故障后,通过该过程可以选举出新的主节点然后进入一致的状态,确保总是能够满足安全属性。这些区别正是共识算法正确性和容错性的关键。
共识的局限性
共识算法对于分布式系统来说是一个巨⼤的突破:它为其他充满不确定性的系统带来了基础的安全属性 (一致同意,完整性和有效性),它们还能保持容错(只要多数节点正常工作且可达,就能继续工作)。它们提供了全序广播,因此也可以以⼀种容错的⽅式实现线性⼀致的原子操作。
尽管如此,也不是所有的系统都采用了共识,因为好处总是有代价的。
- 节点在做出决定之前对提议进行投票的过程是⼀种同步复制。通常数据库会配置为异步复制模式。在这种配置中发生故障切换时,一些已经提交的数据可能会丢失 ——但是为了获得更好的性能,许多人选择接受这种风险。
- 共识系统总是需要严格多数来运转。这意味着你至少需要三个节点才能容忍单节点故障,或者⾄少有五个节点来容忍两个节点发生故障。如果网络故障切断了某 些节点同其他节点的连接,则只有多数节点所在的⽹络可以继续⼯作,其余部分将被阻塞。
- 大多数共识算法假定参与投票的节点是固定的集合,这意味着你不能简单的在集群中添加或删除节点。 共识算法的动态成员扩展允许集群中的节点集随时间推移⽽变化,但是它们⽐静态成员算法要难理解得多。
- 共识系统通常依靠超时来检测失效的节点。在网络延迟⾼度变化的环境中,特别是在地理上散布的系统中,经常发⽣一个节点由于暂时的网络问题,错误地认为领导者已经失效。虽然这种错误不会损害安全属性,但频繁的领导者选举会导致糟糕的性能表现,系统最终会花费更多的时间和资源在选举主节点上而不是原本的服务任务。
成员与协调服务
像ZooKeeper或etcd这样的项目通常被描述为“分布式键值存储”或“协调与配置服务”。这种服务的API看起来非常像数据库:你可以读写给定键的值,并遍历键。所以如果它们基本上算是数据库的话,为什它们要把⼯夫全花在实现⼀个共识算法上呢?是什么使它们区别于其他任意类型的数据库?
ZooKeeper和etcd被设计为容纳少量完全可以放在内存中的数据(虽然它们仍然会写⼊磁盘以保证持久性),所以你不会想着把所有应⽤数据放到这里。这些少量数据会通过容错的全序⼴播算法复制到所有节点上。正如前面所讨论的那样,数据库复制需要的就是全序广播:如果每条消息代表对数据库的写入,则以相同的顺序应⽤相同的写⼊操作可以使副本之间保持一致。
ZooKeeper模仿了Google的Chubby锁服务,不仅实现了全序⼴播(因此也实现了共识), ⽽且还构建了一组有趣的其他特性,这些特性在构建分布式系统时变得特别有⽤:
- 线性⼀致性的原子操作
使⽤原⼦CAS操作可以实现锁:如果多个节点同时尝试执行相同的操作,只有一个节点会成功。共识协议保证了操作的原子性和线性一致性,即使节点发生故障或⽹络在任意时刻中断。分布式锁通常以租约的形式实现,租约有⼀个到期时间,以便在客户端失效的情况下最终能被释放。 - 操作的全序排序
当某个资源受到锁或租约的保护时,你需要⼀个防护令牌来防⽌客户端在进 暂停的情况下彼此冲突。防护令牌是每次锁被获取时单调增加的数字。 ZooKeeper通过全局排序操作来提供这个功能,它为每个操作提供⼀个单调递增的事务ID(zxid)和版本号(cversion )。 - 失效检测
客户端在ZooKeeper服务器上维护⼀个长期会话,客户端和服务器周期性地交换⼼跳包来检查节点是否还活着。即使连接暂时中断,或者ZooKeeper节点失效,会话仍保持在活跃状态。但如果⼼跳停止的持续时间超出会话超时,ZooKeeper会宣告该会话已死亡。当会话超时时,会话持有的任何锁都可以配置为自动释放(ZooKeeper称之为临时节点(ephemeral nodes))。 - 变更更通知
客户端不仅可以读取其他客户端创建的锁和值,还可以监听它们的变更。因此,客户端可以知道另⼀个客户端何时加⼊集群,或发⽣故障(因其会话超时,⽽其临时节点消失)。通过订阅通知,客户端不用再通过频繁轮询的⽅式来找出变更。
在这些功能中,只有线性一致的原子操作才真的需要共识。但正是这些功能的组合,使得像ZooKeeper这样的系统在分布式协调中⾮常有用。
节点任务分配
ZooKeeper/Chubby模型运⾏良好的⼀个例子是,如果你有几个进程实例或服务,需要选择其中⼀个实例作为主库或⾸选服务。如果领导者失败,其他节点之一应该接管。这对单主数据库当然非常实⽤,但对作业调度程序和类似的有状态系统也很好⽤。
另一个例子是,当你有一些分区资源(数据库,消息流,文件存储,分布式Actor系统等),并需要决定将哪个分区分配给哪个节点时。当新节点加⼊集群时,需要将某些分区从现有节点移动到新节点,以便重新平衡负载。当节点被移除或失效时,其他节点需要接管失效节点的⼯作。这类任务可以通过在ZooKeeper中使⽤原⼦操作,临时节点与通知来实现。如果设计得当,这种方法允许应⽤自动从故障中恢复⽽无需⼈工干预。不过这并不容易,尽管已经有不少在ZooKeeper客户端API基础之上提供更高层⼯具的库,例如Apache Curator。但它仍然要比尝试从头实现必要的共识算法要好得多,这样的尝试鲜有成功记录。
应用最初可能在单个节点上运⾏,但最终可能会增长到数千个节点。试图在如此之多的节点上进⾏多数投票将是⾮常低效的。相反,ZooKeeper在固定数量的节点(通常是三到五个)上运行,并在这些节点之间执⾏其多数票,同时支持潜在的⼤量客户端。因此,ZooKeeper提供了一种将协调节点(共识,操作排序和故障检测)的⼀些工作“外包”到外部服务的方式。
通常,由ZooKeeper管理的数据的类型变化十分缓慢,信息可能会在⼏分钟或几⼩时的时间内发⽣变化。它不是用来存储应⽤的运⾏时状态的,他们每秒可能会改变数千甚⾄数百万次。
服务发现
ZooKeeper,etcd和Consul也经常用于服务发现——也就是找出你需要连接到哪个IP地址才能到达特定的服务。在云数据中心环境中,虚拟机可能会起起停停,你通常不会事先知道服务的IP地址。相反,你可以配置你的服务,使其在启动时将其网络端口信息想ZooKeeper等服务注册,然后其他人只需向ZooKeeper的注册表中询问即可。
但是,服务发现是否需要共识还缺乏统一认识。 DNS是查找服务名称的IP地址的传统方式,它使⽤多层缓存来实现良好的性能和可用性。从DNS读取是绝对不满足线性一致性的,如果DNS查询的结果有点陈旧, 通常不会有问题。 DNS对于⽹络中断时服务的可用性和可靠性更为重要。
尽管服务发现并不需要共识,但主节点选举则肯定需要。因此,如果你的共识系统已经知道主节点是谁,那么也可以使用这些信息来帮助其他次级服务发现各自的主节点。为此,⼀些共识系统⽀持只读缓存副本。这些副本异步接收共识算法所有决策的⽇志,但不主动参与投票。因此,它们能够提供不需要线性一致性的读取请求。
成员服务
ZooKeeper可以看作是成员服务研究的悠久历史的一部分,这个历史可以追溯到20世纪80年代,并且对建立高度可靠的系统(例如空中交通管制)⾮常重要。
成员资格服务确定哪些节点当前处于活动状态并且是群集的有效成员。由于无限的网络延迟,无法可靠地检测到另⼀个节点是否发⽣故障。但是,如果你通过一致的⽅式进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。
即使它确实存在,仍然可能发⽣一个节点被共识错误地宣告死亡。即使这样,系统就成员资格问题的决定是全体一致的。例如,选择主节点可能意味着简单地选择当前成员中编号最小的成员,但如果不同的节点对当前包含哪些成员有不同意⻅,则这种方法将不起作用。
标签:事务,读书笔记,数据库,写入,分区,如果,数据系统,节点,分布式 来源: https://blog.csdn.net/Frank19910526/article/details/111060322