Loading

DDIA 学习笔记

第一章 可靠性、可扩展性、可维护性

​ 可靠性: 系统在困境(adversity)(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准。

可靠性(Reliability) 意味着即使发生故障,系统也能正常工作。故障可能发生在硬件(通常是随机的和不相关的),软件(通常是系统性的Bug,很难处理),和人类(不可避免地时不时出错)。 容错技术 可以对终端用户隐藏某些类型的故障。

​ 可扩展性: 有合理的办法应对系统的增长(数据量、流量、复杂性)

可扩展性(Scalability) 意味着即使在负载增加的情况下也有保持性能的策略。为了讨论可扩展性,我们首先需要定量描述负载和性能的方法。我们简要了解了推特主页时间线的例子,介绍描述负载的方法,并将响应时间百分位点作为衡量性能的一种方式。在可扩展的系统中可以添加 处理容量(processing capacity) 以在高负载下保持可靠。

​ 可维护性:许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)

可维护性(Maintainability) 有许多方面,但实质上是关于工程师和运维团队的生活质量的。良好的抽象可以帮助降低复杂度,并使系统易于修改和适应新的应用场景。良好的可操作性意味着对系统的健康状态具有良好的可见性,并拥有有效的管理手段。

第二章 数据模型与查询语言

文档数据库的应用场景是:数据通常是自我包含的,而且文档之间的关系非常稀少

图形数据库用于相反的场景:任意事物都可能与任何事物相关联

第三章 存储与检索

​ 在高层次上,我们看到存储引擎分为两大类:优化 事务处理(OLTP)在线分析(OLAP) 。这些用例的访问模式之间有很大的区别:

  • OLTP系统通常面向用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序通常只访问每个查询中的少部分记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。磁盘寻道时间往往是这里的瓶颈。

  • 数据仓库和类似的分析系统会低调一些,因为它们主要由业务分析人员使用,而不是由最终用户使用。它们的查询量要比OLTP系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。磁盘带宽(而不是查找时间)往往是瓶颈,列式存储是这种工作负载越来越流行的解决方案。

    第四章 编码与演化

​ Json XML 编码 --> 二进制编码的发展

​ 二进制编码技术:Apache Thrift / Protocol Buffers(protobuf)/

img

​ 服务中的数据流:REST & RPC

​ REST不是一个协议,而是一个基于HTTP原则的设计哲学。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商。与SOAP相比,REST已经越来越受欢迎,至少在跨组织服务集成的背景下,并经常与微服务相关。根据REST原则设计的API称为restful, 通常涉及较少的代码生成和自动化工具。

​ RPC调用和本地函数调用的不同:

  • 本地函数调用是可预测的,并且成功或失败,这仅取决于受您控制的参数。网络请求是不可预知的:由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在您的控制范围之内。网络问题是常见的,所以你必须预测他们,例如通过重试失败的请求。
  • 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它可能会返回没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过。
  • 如果您重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非您在协议中引入除重( 幂等(idempotence))机制。本地函数调用没有这个问题。
  • 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:在不到一毫秒的时间内它可能会完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完全一样的东西。
  • 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。没关系,如果参数是像数字或字符串这样的基本类型,但是对于较大的对象很快就会变成问题。

第五章 复制

​ 复制意味着在通过网络连接的多台机器上保留相同数据的副本,需要复制的原因:

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

​ 复制算法:单领导者(single leader)多领导者(multi leader)无领导者(leaderless)

​ 基于领导者的复制原理:

  1. 副本之一被指定为 领导者(leader),也称为 主库(master|primary) 。当客户端要向数据库写入时,它必须将请求发送给领导者,领导者会将新数据写入其本地存储。
  2. 其他副本被称为追随者(followers),亦称为只读副本(read replicas)从库(slaves)备库( sencondaries)热备(hot-standby)i。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为复制日志(replication log)记录或变更流(change stream)。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。
  3. 当客户想要从数据库中读取数据时,它可以向领导者或追随者查询。 但只有领导者才能接受写操作(从客户端的角度来看从库都是只读的)。

img

​ 同步复制和异步复制:

img

​ 同步复制的优点是,从库保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上上找到。缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。

​ 因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中一个跟随者是同步的,而其他的则是异步的。如果同步从库变得不可用或缓慢,则使一个异步从库同步。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为 半同步

​ 通常情况下,基于领导者的复制都配置为完全异步。 在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。 这意味着即使已经向客户端确认成功,写入也不能保证 持久(Durable)。 然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。

​ 如何确保新的从库拥有主库数据的精确副本?

  1. 在某个时刻获取主库的一致性快照(如果可能),而不必锁定整个数据库。大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如MySQL的innobackupex 【12】。
  2. 将快照复制到新的从库节点。
  3. 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称:例如,PostgreSQL将其称为 日志序列号(log sequence number, LSN),MySQL将其称为 二进制日志坐标(binlog coordinates)
  4. 当从库处理完快照之后积压的数据变更,我们说它赶上(caught up)了主库。现在它可以继续处理主库产生的数据变化了。

​ 节点宕机:

​ 从库失效:追赶恢复

​ 在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者,如果主库和从库之间的网络暂时中断,则比较容易恢复:从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开连接时发生的所有数据变更。当应用完所有这些变化后,它就赶上了主库,并可以像以前一样继续接收数据变更流。

​ 主库失效:故障切换

  1. 确认主库失效。有很多事情可能会出错:崩溃,停电,网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 超时(Timeout) :节点频繁地相互来回传递消息,并且如果一个节点在一段时间内(例如30秒)没有响应,就认为它挂了(因为计划内维护而故意关闭主库不算)。
  2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的控制器节点(controller node)来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(最小化数据损失)。让所有的节点同意一个新的领导者,是一个共识问题,将在第9章详细讨论。
  3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库,如果老领导回来,可能仍然认为自己是主库,没有意识到其他副本已经让它下台了。系统需要确保老领导认可新领导,成为一个从库。

故障切换的麻烦:

​ 如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作

​ 如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。

​ 发生某些故障时可能会出现两个节点都以为自己是主库的情况,这种情况称为 脑裂(split brain)。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点ii,但设计粗糙的机制可能最后会导致两个节点都被关闭。

复制日志的实现:

​ 1、基于语句的复制

​ 2、传输预写式日志(WAL)

​ 3、逻辑日志复制(基于行)

​ 另一种方法是,复制和存储引擎使用不同的日志格式,这样可以使复制日志从存储引擎内部分离出来。这种复制日志被称为逻辑日志,以将其与存储引擎的(物理)数据表示区分开来。

​ 4、基于触发器的复制

复制延迟问题

​ 1、读己之写

img

图 用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常

​ 2、单调读

img

图 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。

​ 3、一致前缀读

img

图 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。

复制延迟问题的解决方案:事务(性能和可用性代价过高)和其他替代机制

多主复制

应用场景

​ 1、运维多个数据中心

img

图 跨多个数据中心的多主复制

​ 2、需要离线的客户端

​ 考虑手机,笔记本电脑和其他设备上的日历应用。无论设备目前是否有互联网连接,你需要能随时查看你的会议(发出读取请求),输入新的会议(发出写入请求)。如果在离线状态下进行任何更改,则设备下次上线时,需要与服务器和其他设备同步。

在这种情况下,每个设备都有一个充当领导者的本地数据库(它接受写请求),并且在所有设备上的日历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几小时甚至几天,具体取决于何时可以访问互联网。

从架构的角度来看,这种设置实际上与数据中心之间的多领导者复制类似,每个设备都是一个“数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多活配好是多么困难的一件事。

​ 3、协同编辑

​ 我们通常不会将协作式编辑视为数据库复制问题,但与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时,所做的更改将立即应用到其本地副本(Web浏览器或客户端应用程序中的文档状态),并异步复制到服务器和编辑同一文档的任何其他用户。

如果要保证不会发生编辑冲突,则应用程序必须先取得文档的锁定,然后用户才能对其进行编辑。如果另一个用户想要编辑同一个文档,他们首先必须等到第一个用户提交修改并释放锁定。这种协作模式相当于在领导者上进行交易的单领导者复制。

但是,为了加速协作,您可能希望将更改的单位设置得非常小(例如,一个按键),并避免锁定。这种方法允许多个用户同时进行编辑,但同时也带来了多领导者复制的所有挑战,包括需要解决冲突。

处理写入冲突(多领导者复制的最大问题)

img

图 两个主库同时更新同一记录引起的写入冲突

无主复制

当节点故障时写入数据库

img

图5-10 仲裁写入,法定读取,并在节点中断后读修复。

检测并发写入

img

图 并发写入Dynamo风格的数据存储:没有明确定义的顺序。

捕获"此前发生"关系

​ 最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:

  1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入,服务器成功存储了它并为其分配版本号1,最后将值与版本号一起回送给客户端。
  2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2,并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值反回给客户端 2 ,并附上版本号 2 。
  3. 客户端 1 不知道客户端 2 的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端 1 提供的版本号 1 一起发送到服务器。服务器可以从版本号中知道[牛奶,面粉]的写入取代了[牛奶]的先前值,但与[鸡蛋]的值是并发的。因此,服务器将版本 3 分配给[牛奶,面粉],覆盖版本1值[牛奶],但保留版本 2 的值[蛋],并将所有的值返回给客户端 1 。
  4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉]并发,所以剩下的两个是v3 [牛奶,面粉],和v4:[鸡蛋,牛奶,火腿]
  5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。

img

第六章 分区

分区与复制

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

img

图6-1 组合使用复制和分区:每个节点充当某些分区的领导者,其他分区充当追随者。

键值数据的分区

​ 1、根据键的范围分区(字典)

​ 2、根据键的散列分区

分区与次级索引

​ 次级索引是关系型数据库的基础,并且在文档数据库中也很普遍。许多键值存储(如HBase和Volde-mort)为了减少实现的复杂度而放弃了次级索引,但是一些(如Riak)已经开始添加它们,因为它们对于数据模型实在是太有用了。并且次级索引也是Solr和Elasticsearch等搜索服务器的基石。

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

按文档的二级索引

img

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

img

分区再平衡

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

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

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

无论使用哪种分区方案,再平衡通常都要满足一些最低要求:

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

​ 平衡策略:

* 反面教材:hash mod N
* 固定数量的分区

img

* 动态分区
* 按节点比例分区

请求路由(客户端发送请求时连接数据库的那个节点?)

  1. 允许客户联系任何节点(例如,通过循环策略的负载均衡(Round-Robin Load Balancer))。如果该节点恰巧拥有请求的分区,则它可以直接处理该请求;否则,它将请求转发到适当的节点,接收回复并传递给客户端。
  2. 首先将所有来自客户端的请求发送到路由层,它决定了应该处理请求的节点,并相应地转发。此路由层本身不处理任何请求;它仅负责分区的负载均衡。
  3. 要求客户端知道分区和节点的分配。在这种情况下,客户端可以直接连接到适当的节点,而不需要任何中介。

img

​ 许多分布式数据系统都依赖于一个独立的协调服务,比如ZooKeeper来跟踪集群元数据。 下图每个节点在ZooKeeper中注册自己,ZooKeeper维护分区到节点的可靠映射。 其他参与者(如路由层或分区感知客户端)可以在ZooKeeper中订阅此信息。 只要分区分配发生的改变,或者集群中添加或删除了一个节点,ZooKeeper就会通知路由层使路由信息保持最新状态。

img

第七章 事务

ACID

原子性(Atomicity)

​ 在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的一半结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。

​ ACID原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。如果这些写操作被分组到一个原子事务中,并且该事务由于错误而不能完成(提交),则该事务将被中止,并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。

一致性(Consistency)

对数据的一组特定陈述必须始终成立。即不变量(invariants)。例如,在会计系统中,所有账户整体上必须借贷相抵。如果一个事务开始于一个满足这些不变量的有效数据库,且在事务处理期间的任何写入操作都保持这种有效性,那么可以确定,不变量总是满足的。

​ 原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。

隔离性(Isolation)

同时执行的事务是相互隔离的:它们不能相互冒犯。大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到并发问题。

img

持久性(Durability)

持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。

单对象和多对象操作

img

图 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)

​ 没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。

事务隔离级别

读已提交(Read Committed)

  1. 从数据库读时,只能看到已提交的数据(没有脏读(dirty reads))。
  2. 写入数据库时,只会覆盖已经写入的数据(没有脏写(dirty writes))。

img

图 没有脏读:用户2只有在用户1的事务已经提交后才能看到x的新值。

img

图 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起

读取偏差(不可重复读)

​ 在同一个事务中,客户端在不同的时间点会看见数据库的不同状态。快照隔离经常用于解决这个问题。快照隔离的实现通常使用写锁来防止脏写,从性能的角度来看,快照隔离的一个关键原则是:读不阻塞写,写不阻塞读。为了实现快照隔离,数据库必须可能保留一个对象的几个不同的提交版本,这种技术被称为多版本并发控制

​ 如果一个数据库只需要提供读已提交的隔离级别,而不提供快照隔离,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用MVCC来实现读已提交隔离级别。一种典型的方法是读已提交为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。

img

​ 图中,当事务12 从账户2 读取时,它会看到 $500 的余额,因为 $500 余额的删除是由事务13 完成的(根据规则3,事务12 看不到事务13 执行的删除),且400美元记录的创建也是不可见的(按照相同的规则)

图 使用多版本对象实现快照隔离

更新丢失

两个客户端同时执行**读取-修改-写入序列**。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写操作的结果。所以导致数据丢失。快照隔离的一些实现可以自动防止这种异常,而另一些实现则需要手动锁定(`SELECT FOR UPDATE`)。

幻读

​ 事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止直接的幻像读取,但是写入歪斜环境中的幻影需要特殊处理,例如索引范围锁定。

在存储过程中封装事务

​ 即使人类已经找到了关键路径,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。

在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。

出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如图所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。

img

图 交互式事务和存储过程之间的区别

存储过程与内存存储,使得在单个线程上执行所有事务变得可行。由于不需要等待I/O,且避免了并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。

可序列化快照隔离(SSI, serializable snapshot isolation)

​ 检测旧MVCC读取(读之前存在未提交的写入)

img

​ 当一个事务从MVCC数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。上图中,事务43 认为Alice的 on_call = true ,因为事务42(修改Alice的待命状态)未被提交。然而,在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43 的前提不再为真。

​ 为了防止这种异常,数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。

为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务43 ?因为如果事务43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务42 可能在事务43 被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留快照隔离对从一致快照中长时间运行的读取的支持。

​ 检测影响之前读取的写入(读之后写入)

img

​ 上图中,事务42 和43 都在班次1234 查找值班医生。如果在shift_id上有索引,则数据库可以使用索引项1234 来记录事务42 和43 读取这个数据的事实。 (如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止)之后,所有的并发事务完成之后,数据库就可以忘记它读取的数据了。

​ 当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务到其他事务完成,而是像一个引线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。

​ 上图中,事务43 通知事务42 其先前读已过时,反之亦然。事务42首先提交并成功,尽管事务43 的写影响了42 ,但因为事务43 尚未提交,所以写入尚未生效。然而当事务43 想要提交时,来自事务42 的冲突写入已经被提交,所以事务43 必须中止。

第八章 分布式系统的麻烦

部分失效是分布式系统的决定性特征。为了容忍错误,第一步是检测它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠超时来确定远程节点是否仍然可用。 一旦检测到故障,使系统容忍它也并不容易:没有全局变量,没有共享内存,没有共同的知识,或机器之间任何其他种类的共享状态。

​ 大多数非安全关键系统会选择便宜而不可靠,而不是昂贵和可靠。分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理——至少在理论上是如此。 (实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。

第九章 一致性与共识

一致性保证

​ 最终一致性:非常弱的保证

​ 线性一致性:最强一致性模型之一

​ 因果一致性

线性一致性

img

图 这个系统是非线性一致的,导致了球迷的困惑

​ 线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。

img

图 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的

​ 上图中有一些有趣的细节需要指出:

  • 第一个客户端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写操作并发(它将 x2 更新为 4 )。在没有其他请求的情况下,B的读取返回 2 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 4 ,因此不允许B读取比A更旧的值。再次,与图9-1中的Alice和Bob的情况相同。

    这就是线性一致性背后的直觉。 正式的定义【6】更准确地描述了它。 通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序,测试一个系统的行为是否线性一致性是可能的(尽管在计算上是昂贵的)【11】。

线性一致性的有效场景

​ 锁定和领导选举 (zookeeper etcd 使用一致性算法以容错方式保证)

​ 约束和唯一性保证 (用户名或电子邮件地址必须唯一标识一个用户)

​ 跨信道的时序依赖

img

图 Web服务器和图像调整器通过文件存储和消息队列进行通信,打开竞争条件的可能性

​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。

因果一致性

​ 因果性对系统中的事件施加了顺序(什么发生在什么之前,基于因与果)。与线性一致不同,线性一致性将所有操作放在单一的全序时间线中,因果一致性为我们提供了一个较弱的一致性模型:某些事件可以是并发的,所以版本历史就像是一条不断分叉与合并的时间线。因果一致性没有线性一致性的协调开销,而且对网络问题的敏感性要低得多。

分布式事务与共识

共识:所有节点一致同意所做决定,且这一决定不可撤销。

共识问题

线性一致性的CAS寄存器

	寄存器需要基于当前值是否等于操作给出的参数,原子地**决定**是否设置新值。

原子事务提交

	数据库必须**决定**是否提交或中止分布式事务。

全序广播

	消息系统必须**决定**传递消息的顺序。

锁和租约

	当几个客户端争抢锁或租约时,由锁来**决定**哪个客户端成功获得锁。

成员/协调服务

	给定某种故障检测器(例如超时),系统必须**决定**哪些节点活着,哪些节点因为会话超时需		 要被宣告死亡。

唯一性约束

	当多个事务同时尝试使用相同的键创建冲突记录时,约束必须**决定**哪一个被允许,哪些因为		 违反约束而失败。

第十章 批处理

三种系统类型

​ *服务(在线系统)*:每收到一个,服务会试图尽快处理它,并发回一个响应。响应时间通常 是服务性能的主要衡量指标,可用性通常非常重要

批处理系统(离线系统)*:大量的输入数据,跑一个作业(job)*来处理它,并生成一些输出 数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批 量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处 理特定大小的输入所需的时间).

​ *流处理系统(准实时系统)*: 介于两者之间。流处理消费输入并产生输出,在事件发生后不久就会对事件进行操作,不会等待一组固定的输入数据(批处理的特点),因此具有低延迟的特点。

UNIX 分析简单日志

cat /var/log/nginx/access.log | #1
    awk '{print $7}' | #2
    sort             | #3
    uniq -c          | #4
    sort -r -n       | #5
    head -n 5          #6
1. 读取日志文件
2. 将每一行按空格分割成不同的字段,每行只输出第七个字段,恰好是请求的URL。在我们的例子中是`/css/typography.css`。
3. 按字母顺序排列请求的URL列表。如果某个URL被请求过n次,那么排序后,文件将包含连续重复出现n次的该URL。
4. `uniq`命令通过检查两个相邻的行是否相同来过滤掉输入中的重复行。 `-c`则表示还要输出一个计数器:对于每个不同的URL,它会报告输入中出现该URL的次数。
5. 第二种排序按每行起始处的数字(`-n`)排序,这是URL的请求次数。然后逆序(`-r`)返回结果,大的数字在前。
6. 最后,只输出前五行(`-n 5`),并丢弃其余的

output:
4189 /favicon.ico
    3631 /2013/05/24/improving-security-of-ssh-private-keys.html
    2124 /2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
    1369 /
     915 /css/typography.css

​ 优点:接受任意形式输入,符合万物皆文件的概念 (进程的输出可以是下一个进程的输入)

​ 各命令之间的性能高。

​ 缺点:只能在一台机器上运行(进程到进程) ---> 引出Hadoop

MapReduce

​ MapReduce是一个编程框架,你可以使用它编写代码来处理HDFS等分布式文件系统中的大型数据集。支持多台机器上进行并行计算。Mapper和Reducer一次只能处理一条记录;它们不需要知道它们的输入来自哪里,或者输出去往什么地方,所以框架可以处理在机器之间移动数据的复杂性。

​ 要创建MapReduce作业,你需要实现两个回调函数,Mapper和Reducer。

Mapper

​ Mapper会在每条输入记录上调用一次,其工作是从输入记录中提取键值。对于每个输入,它可以生成任意数量的键值对(包括None)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。

Reducer

​ MapReduce框架拉取由Mapper生成的键值对,收集属于同一个键的所有值,并使用在这组值列表上迭代调用Reducer。 Reducer可以产生输出记录(例如相同URL的出现次数)。

分布式执行MapReduce

img

​ 每个输入文件的大小通常是数百兆字节。 MapReduce调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个Mapper,只要该机器有足够的备用RAM和CPU资源来运行Mapper任务【26】。这个原则被称为将计算放在数据附近【27】:它节省了通过网络复制输入文件的开销,减少网络负载并增加局部性。

​ 计算的Reduce端也被分区。虽然Map任务的数量由输入文件块的数量决定,但Reducer的任务的数量是由作业作者配置的(它可以不同于Map任务的数量)。为了确保具有相同键的所有键值对最终落在相同的Reducer处,框架使用键的散列值来确定哪个Reduce任务应该接收到特定的键值对。

​ 只要当Mapper读取完输入文件,并写完排序后的输出文件,MapReduce调度器就会通知Reducer可以从该Mapper开始获取输出文件。Reducer连接到每个Mapper,并下载自己相应分区的有序键值对文件。按Reducer分区,排序,从Mapper向Reducer复制分区数据,这一整个过程被称为混洗(shuffle)

​ Reduce任务从Mapper获取文件,并将它们合并在一起,并保留有序特性。因此,如果不同的Mapper生成了键相同的记录,则在Reducer的输入中,这些记录将会相邻。Reducer调用时会收到一个键,和一个迭代器作为参数,迭代器会顺序地扫过所有具有该键的记录(因为在某些情况可能无法完全放入内存中)。Reducer可以使用任意逻辑来处理这些记录,并且可以生成任意数量的输出记录。这些输出记录会写入分布式文件系统上的文件中

排序合并连接

img

​ 为了在批处理过程中实现良好的吞吐量,计算必须(尽可能)限于单台机器上进行。为待处理的每条记录发起随机访问的网络请求实在是太慢了。更好的方法是获取用户数据库的副本,并将它和用户行为日志放入同一个分布式文件系统中。

​ 当MapReduce框架通过键对Mapper输出进行分区,然后对键值对进行排序时,效果是具有相同ID的所有活动事件和用户记录在Reducer输入中彼此相邻。 Map-Reduce作业甚至可以也让这些记录排序,使Reducer总能先看到来自用户数据库的记录,紧接着是按时间戳顺序排序的活动事件 —— 这种技术被称为二次排序(secondary sort)

​ 由于Reducer一次处理一个特定用户ID的所有记录,因此一次只需要将一条用户记录保存在内存中,而不需要通过网络发出任何请求。这个算法被称为排序合并连接(sort-merge join),因为Mapper的输出是按键排序的,然后Reducer将来自连接两侧的有序记录列表合并在一起。

处理倾斜

​ 在单个Reducer中收集与某个名流相关的所有活动(例如他们发布内容的回复)可能导致严重的倾斜(也称为热点(hot spot))—— 也就是说,一个Reducer必须比其他Reducer处理更多的记录(参见“负载倾斜与消除热点“)。由于MapReduce作业只有在所有Mapper和Reducer都完成时才完成,所有后续作业必须等待最慢的Reducer才能启动。

​ Pig中的倾斜连接(skewed join)方法首先运行一个抽样作业来确定哪些键是热键。连接实际执行时,Mapper会将热键的关联记录随机(相对于传统MapReduce基于键散列的确定性方法)发送到几个Reducer之一。对于另外一侧的连接输入,与热键相关的记录需要被复制到所有处理该键的Reducer上

广播散列连接

​ 两个连接输入之一很小,所以它并没有分区,而且能被完全加载进一个哈希表中。因此,你可以为连接输入大端的每个分区启动一个Mapper,将输入小端的散列表加载到每个Mapper中,然后扫描大端,一次一条记录,并为每条记录查询散列表。

分区散列连接

​ 如果两个连接输入以相同的方式分区(使用相同的键,相同的散列函数和相同数量的分区),则可以独立地对每个分区应用散列表方法。

回调函数

​ 分布式批处理引擎有一个刻意限制的编程模型:回调函数(比如Mapper和Reducer)被假定是无状态的,而且除了指定的输出外,必须没有任何外部可见的副作用。这一限制允许框架在其抽象下隐藏一些困难的分布式系统问题:当遇到崩溃和网络问题时,任务可以安全地重试,任何失败任务的输出都被丢弃。如果某个分区的多个任务成功,则其中只有一个能使其输出实际可见。

​ 得益于这个框架,你在批处理作业中的代码无需操心实现容错机制:框架可以保证作业的最终输出与没有发生错误的情况相同,也许不得不重试各种任务。在线服务处理用户请求,并将写入数据库作为处理请求的副作用,比起在线服务,批处理提供的这种可靠性语义要强得多。

批处理的特点总结

​ 批处理作业的显著特点是,它读取一些输入数据并产生一些输出数据,但不修改输入—— 换句话说,输出是从输入衍生出的。最关键的是,输入数据是有界的(bounded):它有一个已知的,固定的大小(例如,它包含一些时间点的日志文件或数据库内容的快照)。因为它是有界的,一个作业知道自己什么时候完成了整个输入的读取,所以一个工作在做完后,最终总是会完成的。

第十一章 流处理

​ 消息代理和事件日志可以视作文件系统的流式等价物。

消息代理(消息队列)

与数据库的差异

  • 数据库通常保留数据直至显式删除,而大多数消息代理在消息成功递送给消费者时会自动删除消息。这样的消息代理不适合长期的数据存储。
  • 由于它们很快就能删除消息,大多数消息代理都认为它们的工作集相当小—— 即队列很短。如果代理需要缓冲很多消息,比如因为消费者速度较慢(如果内存装不下消息,可能会溢出到磁盘),每个消息需要更长的处理时间,整体吞吐量可能会恶化【6】。
  • 数据库通常支持二级索引和各种搜索数据的方式,而消息代理通常支持按照某种模式匹配主题,订阅其子集。机制并不一样,对于客户端选择想要了解的数据的一部分,这是两种基本的方式。
  • 查询数据库时,结果通常基于某个时间点的数据快照;如果另一个客户端随后向数据库写入一些改变了查询结果的内容,则第一个客户端不会发现其先前结果现已过期(除非它重复查询或轮询变更)。相比之下,消息代理不支持任意查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。

多个消费者

负载均衡与扇出

img

图(a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。

​ 两种模式可以组合使用:例如,两个独立的消费者组可以每组各订阅一个主题,每一组都共同收到所有消息,但在每一组内部,每条消息仅由单个节点处理。

分区日志(基于日志的消息代理)

img

图 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件

变更数据捕获(CDC)

img

图 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统

流处理的三种类型

流流连接

​ 两个输入流都由活动事件组成,而连接算子在某个时间窗口内搜索相关的事件。例如,它可能会将同一个用户30分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件,连接的两侧输入可能实际上都是同一个流(自连接(self-join))。

流表连接

​ 一个输入流由活动事件组成,另一个输入流是数据库变更日志。变更日志保证了数据库的本地副本是最新的。对于每个活动事件,连接算子将查询数据库,并输出一个扩展的活动事件。

表表连接

​ 两个输入流都是数据库变更日志。在这种情况下,一侧的每一个变化都与另一侧的最新状态相连接。结果是两表连接所得物化视图的变更流。

第十二章 数据系统的未来

​ 某些系统被指定为记录系统,而其他数据则通过转换衍生自记录系统。通过这种方式,我们可以维护索引,物化视图,机器学习模型,统计摘要等等。通过使这些衍生和转换操作异步且松散耦合,能够防止一个区域中的问题扩散到系统中不相关部分,从而增加整个系统的稳健性与容错性。

​ 将数据流表示为从一个数据集到另一个数据集的转换也有助于演化应用程序:如果你想变更其中一个处理步骤,例如变更索引或缓存的结构,则可以在整个输入数据集上重新运行新的转换代码,以便重新衍生输出。同样,出现问题时,你也可以修复代码并重新处理数据以便恢复。

​ 这些过程与数据库内部已经完成的过程非常类似,因此我们将数据流应用的概念重新改写为,分拆(unbundling) 数据库组件,并通过组合这些松散耦合的组件来构建应用程序。

​ 衍生状态可以通过观察底层数据的变更来更新。此外,衍生状态本身可以进一步被下游消费者观察。我们甚至可以将这种数据流一路传送至显示数据的终端用户设备,从而构建可动态更新以反映数据变更,并在离线时能继续工作的用户界面。

​ 接下来,我们讨论了如何确保所有这些处理在出现故障时保持正确。我们看到可扩展的强完整性保证可以通过异步事件处理来实现,通过使用端到端操作标识符使操作幂等,以及通过异步检查约束。客户端可以等到检查通过,或者不等待继续前进,但是可能会冒有违反约束需要道歉的风险。这种方法比使用分布式事务的传统方法更具可扩展性与可靠性,并且在实践中适用于很多业务流程。

​ 通过围绕数据流构建应用,并异步检查约束,我们可以避免绝大多数的协调工作,创建保证完整性且性能仍然表现良好的系统,即使在地理散布的情况下与出现故障时亦然。然后,我们对使用审计来验证数据完整性,以及损坏检测进行了一些讨论。

​ 最后,我们退后一步,审视了构建数据密集型应用的一些道德问题。我们看到,虽然数据可以用来做好事,但它也可能造成很大伤害:作出严重影响人们生活的决定却难以申诉,导致歧视与剥削,监视常态化,曝光私密信息。我们也冒着数据被泄露的风险,并且可能会发现,即使是善意地使用数据也可能会导致意想不到的后果。

​ 由于软件和数据对世界产生了如此巨大的影响,我们工程师们必须牢记,我们有责任为我们想要的那种世界而努力:一个尊重人们,尊重人性的世界。我希望我们能够一起为实现这一目标而努力。

posted @ 2022-01-12 02:47  是凉城吖  阅读(618)  评论(0编辑  收藏  举报