Go 进阶 · 分布式爬虫实战day09-分布式数据一致性与故障容错

193 阅读11分钟

微服务可以分散到多个机器中,它本身是分布式架构的一种特例,所以自然也面临着和分布式架构同样的问题。除了我们之前介绍的可观测性等问题之外,微服务还面临着分布式架构所面临的核心难题:数据一致性和可用性问题。

数据一致性的诞生背景

在微服务架构中,服务一般被细粒度地拆分为无状态的服务。无状态服务(stateless service)指的是当前的请求不依赖其他请求,服务本身不存储任何信息,处理一次请求所需的全部信息要么都包含在这个请求里,要么可以从外部(例如缓存、数据库)获取。这样,每一个服务看起来都是完全相同的。这种设计能够在业务量上涨时快速实现服务水平的扩容,并且非常容易排查问题。然而我们也需要看到,这种无状态的设计其实依托了第三方服务,比较典型的就是数据库。

以关系型数据库 MySQL 为例,在实践中,随着我们业务量的上涨,一般会经历下面几个阶段。

  1. 硬件的提升:选择更强的 CPU、更大的内存、更快的存储设备。
  2. 设计优化:通过增加缓存层减轻数据库的压力、利用合适的索引设计快速查找数据、使用监控慢查询日志优化不合理的业务 SQL 语句。
  3. 服务拆分:拆分后,子系统配置单独的数据库服务器。
  4. 分库分表:通过 ID 取余或者一致性哈希策略将请求分摊到不同的数据库和表中。
  5. 数据备份:例如,将存储 1 年以上的数据转存到其他数据库中。
  6. 主从复制与读写分离:将 Leader 节点数据同步到 Follower 节点中,一般只有一个 Leader 节点可以处理写请求,其余 Follower 节点处理读请求,这样可以提高数据库的并发访问。

从上面的优化中我们可以看到,拆分是解决大规模数据量的利器。但是,当数据分散到更多的机器,或者当我们希望通过主从复制实现可用性和读写分离时,我们也面临着新的问题:数据一致性。 数据一致性问题,本质上是分布式架构相比单个程序而言有着巨大的不确定性。在分布式系统中会遇到下面这些问题。

  • 网络延迟:消息到达的时候有时延,而且不确定。
  • 网络分区:网络可能被分割为多个互不连通的区域。
  • 系统故障:硬件问题、断电、内核崩溃导致部分机器故障,当机器数量越来越多时,机器故障也变成了大概率事件。
  • 不可靠的时钟:这意味着我们无法依靠绝对的时钟来确定操作的顺序。

前面我们举的那个主从复制的问题,本质上是网络延迟导致的。当网络恢复时,数据是能够完整同步到 Follower B 的,因此我们把这样的一致性称为最终一致性。最终一致性指的是从长远来看,数据最终能够到达一致的状态,但过程中可能会读到过时的数据。 也就是说,数据的一致性有多种维度去衡量,当我们在设计分布式架构时,我们的场景能够容忍哪一种类型的数据不一致,通常是决定我们架构设计和技术选型的重要因素。

比最终一致性更严格的一致性保证被称为线性一致性。在这里,我无意陷入到讨论学术概念的旋涡中,因为系统论述线性一致性是一个比较复杂的话题。这里我想说的是,线性一致性能够推导出我们更加常见的概念:强一致性。即在更新完成之后,任何后续的访问都会返回更新的值。 如果我们上面案例的数据库遵循了线性一致性,那么就不会出现读出过时数据的情况。那么问题来了,我们要怎么设计架构,才能让系统有更强的数据一致性保证呢?

在上面的主从复制架构中,我们可以强制让读写都通过 Leader 节点实现,或者强制要求 Leader 节点复制到 Follower 节点之后,才能完成后续操作。但我们很快又会发现新的难题:可用性问题。例如,如果我们有一个 Follower 节点崩溃,那么系统是不是需要一直陷入等待,变得不可用了呢?很显然,分布式数据一致性其实是一种权衡。当我们希望保证更强一致性的时候,也必须要牺牲一些东西,这就是有名的 CAP 定理告诉我们的内容。

CAP 定理

CAP 定理的三个属性具体来说分别是:

  • C(Consistency),线性一致性;
  • A(Availability),可用性,意思是即便有失败节点不可用,其他节点仍然能正常工作,并对每一个接收到的请求给出响应;
  • P (Partition tolerance),分区容忍度,指能够容忍任意数量的消息丢失。 CAP 理论证明,在异步网络中,这三个属性不能同时获得。这三种属性排列组合,可以得到 CP、AP、CA 三种类型系统。但由于分布式系统无法保证网络的可靠性,因此我们实际面临的是 CP 系统或者 AP 系统的选择,即在线性一致性与可用性之间进行权衡。不过其实在引入 CAP 定理时,我们就凭直觉点出了这一点。

CAP 理论论证过程中的条件是非常严格的:必须保证一致性是线性一致性;可用性指的是所有的请求都需要有回应;分区容忍只考虑了网络分区,没有考虑其他故障。另外还要强调的是,一个系统不可能同时拥有 CAP 三种属性,但这并不意味着放弃了其中一个属性,就一定会有另外两种属性。也难怪在《Designing Data-Intensive Applications》一书中也提到,尽管 CAP 在历史上具有影响力,但它对于设计系统的实用价值不大。

在分布式系统的设计中,线性一致性与可用性之间需要进行一些妥协。在实践中,很少有系统实现了真正的线性一致性,这是因为在可信的网络中,异常和网络延迟等情况其实是可控的。而要保证线性一致性,系统在正常情况下也要付出许多性能上的代价。

而对于可用性来讲,我们还需要考虑系统在异常情况下的故障容错性,保证服务正确且可用。虽然 CAP 理论中的 P 只考虑了网络分区的容错,但其实正如我们之前提到的,系统还可能遇到网络延迟、系统故障等问题。仍然以之前提到的主从复制案例为例,这个场景在正常情况下工作良好,也能够实现最终的数据一致性。但是如果 Leader 节点挂了怎么办? 如果挂掉的节点没来得及将数据同步到 Follower 节点,当其中一个 Follower 节点提升为 Leader 节点时,这些没来得及同步的数据就会丢失。要解决这些问题,就需要依靠共识算法来保证了。

共识算法

共识算法保证系统中的大部分节点能够就同一个意见达成一致,只有这样才能在小部分节点“失联”的时候,保证大部分节点可用,同时保证数据的正确性。

要考虑到各种可能的异常情况,还要兼顾并发的读写,达成共识并不是一件容易的事情。其中比较有名的共识算法是:Paxos、Raft、Zab。下节课,我们还会深入讲解介绍这些算法。

分布式协调服务

分布式容错和数据的一致性实现起来很困难,在实践中,我们也很少自己去实现分布式算法,因为即便是最简单的 Raft 算法,要保证其正确性,或者要排查问题都异常艰辛。通常,我们会借助那些设计优秀、经过了检验的系统,帮助我们更容易地实现分布式服务之间的协调。这种系统被称作分布式协调服务,其中比较熟知的开源项目有 ZooKeeper、etcd。

这些服务通常具有友好的 API 设计,在这里我以 ZooKeeper 为例来说明分布式协调服务的使用场景。如下图,ZooKeeper 的数据模型类似于 Unix 文件系统,其中,Znode 是客户端通过 ZooKeeper API 处理的数据对象,Znode 以路径命名,通过分层的名称空间进行组织。

  • Znode 包含应用程序的元数据(配置信息、时间戳、版本号),它有两种类型:
  • Regular(常规的),客户端通过显式创建和删除来操作常规 Znode;Ephemeral(临时的),此类 Znode 要么被显式删除,要么被自动删除(系统检测到会话中止时)。

为了引用给定的 Znode,我们使用标准的 Unix 符号表示文件系统路径。例如,我们使用 /A/B/C 表示 Znode C 的路径,其中 C 的父节点为 B,B 的父节点为 A,除 Ephemeral 节点外,所有节点都可以有子节点。Znode 命名规则为:name + 序列号。一个新 Znode 的序列号永远不会小于其父 Znode 之下的其他 Znode 的序列号。

image.png ZooKeeper 提供了对用户友好的 API 用于操作 Znode,这些 API 包括了:


create(path, data, flags)
delete(path, version) 
exists(path, watch)
getData(path, watch)
setData(path, data, version) 
getChildren(path, watch)
sync()
  • ZooKeeper 对数据的一致性有一些重要的保证:
  • ZooKeeper 进行的所有写操作都是线性一致的,可以保证优先顺序;
  • 每一个客户端的操作都是 FIFO 顺序执行的。

对 ZooKeeper 进行读操作时,因为可以直接在 Follower 中执行,所以确实有可能读到过时的数据。针对这个问题,ZooKeeper 提供了 sync() 方法来实现读的线性一致性。此外,通过允许读取操作返回过时数据,ZooKeeper 可以实现每秒数十万次操作,适用于多读而少写的场景。

基于分布式协调服务的特性,我们可以在应用服务中构建分布式锁,进行配置管理,并完成服务发现的工作。

分布式锁

基于 ZooKeeper 可以实现分布式锁,这是基于写操作的线性一致性保证。它的基本思想是每个客户端都创建一个 Znode,所有的 Znode 形成一个单调有序的队列,而排在队列前面的客户端能够优先获得锁,其余客户端陷入等待。当锁释放时,下一个序号最低的 Znode 能够获得锁,这种机制还能够避免惊群效应,其伪代码如下所示:


acquire lock:
     n = create("app/lock/request-", "", empheral|sequential)
   retry:
     requests = getChildren(l, false)
     if n is lowest znode in requests:
       return
     p = "request-%d" % n - 1
     if exists(p, watch = True)
       goto retry

    watch_event:
       goto retry

配置管理

分布式协调服务也可以实现分布式系统中的动态配置。当服务启动时,连接 ZooKeeper 获取配置信息,让 ZooKeeper 与服务保持连接。当配置发生变更时,通知所有连接的进程,获取最新的配置信息。

服务发现

在分布式系统中,服务可能随时扩容、重建或者销毁。因此,当服务启动时,需要自动注册自己的 IP 等信息到注册中心。这样客户端可以获取最新的服务信息,并采取负载均衡策略将请求均匀打到下游服务。服务发现的另一个场景是监听服务的变化。例如调度器为了实现合理的调度,会监控 Worker 服务数量的变化,并及时调整任务的分配。这样,当一个 Worker 崩溃时,就能够及时将 Worker 上的任务转移到其他 Worker 中了。