DDIA_Chapter9 学习笔记
分布式系统最重要的抽象之一就是共识(consensus):就是让所有的节点对某件事达成一致。
一致性保证:
具有较强一致性的系统可能会比一致性较差的系统具有更差的性能或更少的容错性,需要根据业务进行取舍。
线性一致性(Linearizability):一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。
下面提供一个非线性一致性的例子(最终一致性)
下图中有一些有趣的细节需要指出:
-
第一个客户端B发送一个读取
x
的请求,然后客户端D发送一个请求将x
设置为0
,然后客户端A发送请求将x
设置为1
。尽管如此,返回到B的读取值为1
(由A写入的值)。这是可以的:这意味着数据库首先处理D的写入,然后是A的写入,最后是B的读取。虽然这不是请求发送的顺序,但这是一个可以接受的顺序,因为这三个请求是并发的。也许B的读请求在网络上略有延迟,所以它在两次写入之后才到达数据库。 -
在客户端A从数据库收到响应之前,客户端B的读取返回
1
,表示写入值1
已成功。这也是可以的:这并不意味着在写之前读到了值,这只是意味着从数据库到客户端A的正确响应在网络中略有延迟。 -
此模型不假设有任何事务隔离:另一个客户端可能随时更改值。例如,C首先读取
1
,然后读取2
,因为两次读取之间的值由B更改。可以使用原子比较并设置(cas)操作来检查该值是否未被另一客户端同时更改:B和C的cas请求成功,但是D的cas请求失败(在数据库处理它时,x
的值不再是0
)。 -
客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的cas写操作并发(它将
x
从2
更新为4
)。在没有其他请求的情况下,B的读取返回2
是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值4
,因此不允许B读取比A更旧的值。再次,与图9-1中的Alice和Bob的情况相同。
需要一致性保证的场景:
分布式锁和选主:保证锁和Leader的唯一性。
一种选主的方法是使用锁:每个节点在启动时尝试获取锁,成功者成为Leader。不管这个锁是如何实现的,它必须是线性一致的:所有节点必须就哪个节点拥有锁达成一致(保证获得锁的结点只有一个,也就是保证锁的唯一性)。
唯一性约束:唯一索引
跨信道的时序依赖:跨系统
例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如图所示。
图像缩放器需要明确的指令来执行尺寸缩放作业,指令是Web服务器通过消息队列发送的(参阅第11章)。 Web服务器不会将整个照片放在队列中,因为大多数消息代理都是针对较短的消息而设计的,而一张照片的空间占用可能达到几兆字节。取而代之的是,首先将照片写入文件存储服务,写入完成后再将缩放器的指令放入消息队列。
如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列(图中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。
出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。
顺序和因果:
(yb:有时我们的系统并不需要全局的绝对时间,而仅仅是需要操作间的相对时间——操作顺序,即因果性)。
全序(Total Order):系统中任意两个操作都有绝对的顺序,在线性一致的系统中,操作是全序的。
允许任意两个元素进行比较,所以如果有两个元素,你总是可以说出哪个更大,哪个更小。例如,自然数集是全序的:给定两个自然数,比如说5和13,那么你可以告诉我,13大于5。
偏序(Patial Order):系统中有些操作没有绝对的先后顺序,因为很多操作是并发的。所谓并发,代表着操作A执行的时候,同时有其他操作也在执行,且A无法查看到这些操作的结果。因此,无法明确地比较哪个操作在前,哪个操作在后。
因果性:
兰伯特时间戳(Lamport Timestamp):
兰伯特时间戳实现规则:
- 每个事件对应一个Lamport时间戳,初始值为0
- 如果事件在节点内发生,本地进程中的时间戳加1
- 如果事件属于发送事件,本地进程中的时间戳加1并在消息中带上该时间戳
- 如果事件属于接收事件,本地进程中的时间戳 = Max(本地时间戳,消息中的时间戳) + 1
根据上图,我们可以判断出操作顺序如下:C1(1)->B1(2)->B2(3)->A1(4)->B3(4)->A2(5)->C2(5)->B4(6)->C3(6)->A3(7)->B5(7)->C4(8)->C5(9)->A4(10)
二阶段提交(2PC):yb:事务的隔离性是如何保证的?如何保证各节点在提交(Commit)的过程中其他事务无法读取到脏数据?悲观锁?
2PC使用一个通常不会出现在单节点事务中的新组件:协调者(coordinator)(也称为事务管理器(transaction manager))。协调者通常在请求事务的相同应用进程中以库的形式实现(例如,嵌入在Java EE容器中),但也可以是单独的进程或服务。这种协调者的例子包括Narayana,JOTM,BTM或MSDTC。
正常情况下,2PC事务以应用在多个数据库节点上读写数据开始。我们称这些数据库节点为参与者(participants)。当应用准备提交时,协调者开始阶段 1 :它发送一个准备(prepare)请求到每个节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应:
- 如果所有参与者都回答“是”,表示它们已经准备好提交,那么协调者在阶段 2 发出提交(commit)请求,然后提交真正发生。
- 如果任意一个参与者回复了“否”,则协调者在阶段2 中向所有节点发送中止(abort)请求。
这个过程有点像西方传统婚姻仪式:司仪分别询问新娘和新郎是否要结婚,通常是从两方都收到“我愿意”的答复。收到两者的回复后,司仪宣布这对情侣成为夫妻:事务就提交了,这一幸福事实会广播至所有的参与者中。如果新娘与新郎之一没有回复”我愿意“,婚礼就会中止。
实践中的分布式事务:
怀疑时持有锁:
在事务提交或中止之前,数据库不能释放这些锁(如图9-9中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。
当这些锁被持有时,其他事务不能修改这些行。根据数据库的不同,其他事务甚至可能因为读取这些行而被阻塞。因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。
从协调者故障中恢复:
理论上,如果协调者崩溃并重新启动,它应该干净地从日志中恢复其状态,并解决任何存疑事务。然而在实践中,**孤立(orphaned)**的存疑事务确实会出现【89,90】,即无论出于何种理由,协调者无法确定事务的结果(例如事务日志已经由于软件错误丢失或损坏)。这些事务无法自动解决,所以它们永远待在数据库中,持有锁并阻塞其他事务。
即使重启数据库服务器也无法解决这个问题,因为在2PC的正确实现中,即使重启也必须保留存疑事务的锁(否则就会冒有违反原子性保证的风险)。这是一种棘手的情况。
唯一的出路是让管理员手动决定提交还是回滚事务。管理员必须检查每个存疑事务的参与者,确定是否有任何参与者已经提交或中止,然后将相同的结果应用于其他参与者。解决这个问题潜在地需要大量的人力,并且可能发生在严重的生产中断期间(不然为什么协调者处于这种糟糕的状态),并很可能要在巨大精神压力和时间压力下完成。