神一样的CAP理论被应用在何方

25,660 阅读23分钟

对于开发或设计分布式系统的架构师工程师来说,CAP是必须要掌握的理论。

(but:这个文章的重点并不是讨论CAP理论和细节,重点是说说CAP在微服务中的开发怎么起到一个指引作用,会通过几个微服务开发的例子说说明,尽量的去贴近开发)

CAP定理又被成为布鲁尔定理,是加州大学计算机科学家埃里克·布鲁尔提出来的猜想,后来被证明成为分布式计算领域公认的定理。不过布鲁尔在出来CAP的时候并没有对CAP三者(Consistency,Availability,Partition tolerance)进行详细的定义,所以在网上也出现了不少对CAP不同解读的声音。

CAP 定理

CAP定理在发展中存在过两个版本,我们以第二个版本为准

在一个分布式系统中(指互相连接并共享数据的节点集合)中,当涉及到读写操作时,只能保证一致性(Consistence)、可用性(Availability)、分区容错性(Partition Tolerance)三者中的两个,另外一个必须被牺牲。

image

这个版本的CAP理论在探讨分布式系统,更加强调两点是互联和共享数据,其实也是理清楚了第一个版本中三选二的一些缺陷,分布式系统不一定都存在互联和共享数据,例如memcached集群相互间就没有存在连接和共享数据,所以memcached集群这类的分布式系统并不在CAP理论讨论的范围,而想Mysql集群就是互联和数据共享复制,因此mysql集群式属于CAP理论讨论的对象。

一致性(Consistency)

一致性意思就是写操作之后进行读操作无论在哪个节点都需要返回写操作的值

可用性(Availability)

非故障的节点在合理的时间内返回合理的响应

分区容错性(Partition Tolerance)

当网络出现分区后,系统依然能够继续旅行社职责

在分布式的环境下,网络无法做到100%可靠,有可能出现故障,因此分区是一个必须的选项,如果选择了CA而放弃了P,若发生分区现象,为了保证C,系统需要禁止写入,此时就与A发生冲突,如果是为了保证A,则会出现正常的分区可以写入数据,有故障的分区不能写入数据,则与C就冲突了。因此分布式系统理论上不可能选择CA架构,而必须选择CP或AP架构。

分布式事务BASE理论

BASE理论是对CAP的延伸和补充,是对CAP中的AP方案的一个补充,即使在选择AP方案的情况下,如何更好的最终达到C。

BASE是基本可用,柔性状态,最终一致性三个短语的缩写,核心的思想是即使无法做到强一致性,但应用可以采用适合的方式达到最终一致性。

CAP在服务中实际的应用例子

image

理解貌似讲多了,项目的CAP可以参考下李运华的《从零开始学架构》的书,里面的21,22章比较详细的描绘了CAP的理论细节和CAP的版本演化过程。

这里着重的讲解的是神一样的CAP在我们的微服务中怎么去指导和应用起来,大概会举几个平时常见的例子

image

服务注册中心,是选择AP还是选择CP ?

服务注册中心解决的问题

在讨论CAP之前先明确下服务注册中心主要是解决什么问题:一个是服务注册,一个是服务发现。

  • 服务注册:实例将自身服务信息注册到注册中心,这部分信息包括服务的主机IP和服务的Port,以及暴露服务自身状态和访问协议信息等。

  • 服务发现:实例请求注册中心所依赖的服务信息,服务实例通过注册中心,获取到注册到其中的服务实例的信息,通过这些信息去请求它们提供的服务。

image

目前作为注册中心的一些组件大致有:dubbo的zookeeper,springcloud的eureka,consul,rocketMq的nameServer,hdfs的nameNode。目前微服务主流是dubbo和springcloud,使用最多是zookeeper和eureka,我们就来看看应该根据CAP理论应该怎么去选择注册中心。(springcloud也可以用zk,不过不是主流不讨论)。

zookeeper选择CP

zookeep保证CP,即任何时刻对zookeeper的访问请求能得到一致性的数据结果,同时系统对网络分割具备容错性,但是它不能保证每次服务的可用性。从实际情况来分析,在使用zookeeper获取服务列表时,如果zk正在选举或者zk集群中半数以上的机器不可用,那么将无法获取数据。所以说,zk不能保证服务可用性。

eureka选择AP

eureka保证AP,eureka在设计时优先保证可用性,每一个节点都是平等的,一部分节点挂掉不会影响到正常节点的工作,不会出现类似zk的选举leader的过程,客户端发现向某个节点注册或连接失败,会自动切换到其他的节点,只要有一台eureka存在,就可以保证整个服务处在可用状态,只不过有可能这个服务上的信息并不是最新的信息。

zookeeper和eureka的数据一致性问题

先要明确一点,eureka的创建初心就是为一个注册中心,但是zk更多是作为分布式协调服务的存在,只不过因为它的特性被dubbo赋予了注册中心,它的职责更多是保证数据(配置数据,状态数据)在管辖下的所有服务之间保持一致,所有这个就不难理解为何zk被设计成CP而不是AP,zk最核心的算法ZAB,就是为了解决分布式系统下数据在多个服务之间一致同步的问题。

更深层的原因,zookeeper是按照CP原则构建,也就是说它必须保持每一个节点的数据都保持一致,如果zookeeper下节点断开或者集群中出现网络分割(例如交换机的子网间不能互访),那么zk会将它们从自己的管理范围中剔除,外界不能访问这些节点,即使这些节点是健康的可以提供正常的服务,所以导致这些节点请求都会丢失。

而eureka则完全没有这方面的顾虑,它的节点都是相对独立,不需要考虑数据一致性的问题,这个应该是eureka的诞生就是为了注册中心而设计,相对zk来说剔除了leader节点选取和事务日志极致,这样更有利于维护和保证eureka在运行的健壮性。

image

再来看看,数据不一致性在注册服务中中会给eureka带来什么问题,无非就是某一个节点被注册的服务多,某个节点注册的服务少,在某一个瞬间可能导致某些ip节点被调用数少,某些ip节点调用数少的问题。也有可能存在一些本应该被删除而没被删除的脏数据。

image

小结:服务注册应该选择AP还是CP

对于服务注册来说,针对同一个服务,即使注册中心的不同节点保存的服务注册信息不相同,也并不会造成灾难性的后果,对于服务消费者来说,能消费才是最重要的,就算拿到的数据不是最新的数据,消费者本身也可以进行尝试失败重试。总比为了追求数据的一致性而获取不到实例信息整个服务不可用要好。

所以,对于服务注册来说,可用性比数据一致性更加的重要,选择AP。

分布式锁,是选择AP还是选择CP ?

这里实现分布式锁的方式选取了三种:

  • 基于数据库实现分布式锁
  • 基于redis实现分布式锁
  • 基于zookeeper实现分布式锁

基于数据库实现分布式锁

构建表结构

image

利用表的 UNIQUE KEY idx_lock (method_lock) 作为唯一主键,当进行上锁时进行insert动作,数据库成功录入则以为上锁成功,当数据库报出 Duplicate entry 则表示无法获取该锁。

image

不过这种方式对于单主却无法自动切换主从的mysql来说,基本就无法现实P分区容错性,(Mysql自动主从切换在目前并没有十分完美的解决方案)。可以说这种方式强依赖于数据库的可用性,数据库写操作是一个单点,一旦数据库挂掉,就导致锁的不可用。这种方式基本不在CAP的一个讨论范围。

基于redis实现分布式锁

redis单线程串行处理天然就是解决串行化问题,用来解决分布式锁是再适合不过。

实现方式:

setnx key value Expire_time
获取到锁 返回 1 , 获取失败 返回 0

为了解决数据库锁的无主从切换的问题,可以选择redis集群,或者是 sentinel 哨兵模式,实现主从故障转移,当master节点出现故障,哨兵会从slave中选取节点,重新变成新的master节点。

image

哨兵模式故障转移是由sentinel集群进行监控判断,当maser出现异常即复制中止,重新推选新slave成为master,sentinel在重新进行选举并不在意主从数据是否复制完毕具备一致性。

所以redis的复制模式是属于AP的模式。保证可用性,在主从复制中“主”有数据,但是可能“从”还没有数据,这个时候,一旦主挂掉或者网络抖动等各种原因,可能会切换到“从”节点,这个时候可能会导致两个业务县城同时获取得两把锁

image

这个过程如下:

  1. 业务线程-1 向主节点请求锁
  2. 业务线程-1 获取锁
  3. 业务线程-1 获取到锁并开始执行业务
  4. 这个时候redis刚生成的锁在主从之间还未进行同步
  5. redis这时候主节点挂掉了
  6. redis的从节点升级为主节点
  7. 业务线程-2 想新的主节点请求锁
  8. 业务线程-2 获取到新的主节点返回的锁
  9. 业务线程-2 获取到锁开始执行业务
  10. 这个时候 业务线程-1 和 业务线程-2 同时在执行任务

上述的问题其实并不是redis的缺陷,只是redis采用了AP模型,它本身无法确保我们对一致性的要求。redis官方推荐redlock算法来保证,问题是redlock至少需要三个redis主从实例来实现,维护成本比较高,相当于redlock使用三个redis集群实现了自己的另一套一致性算法,比较繁琐,在业界也使用得比较少。

能否使用redis作为分布式锁?

能不能使用redis作为分布式锁,这个本身就不是redis的问题,还是取决于业务场景,我们先要自己确认我们的场景是适合 AP 还是 CP , 如果在社交发帖等场景下,我们并没有非常强的事务一致性问题,redis提供给我们高性能的AP模型是非常适合的,但如果是交易类型,对数据一致性非常敏感的场景,我们可能要寻在一种更加适合的 CP 模型

基于zookeeper实现分布式锁

刚刚也分析过,redis其实无法确保数据的一致性,先来看zookeeper是否合适作为我们需要的分布式锁,首先zk的模式是CP模型,也就是说,当zk锁提供给我们进行访问的时候,在zk集群中能确保这把锁在zk的每一个节点都存在。

image

(这个实际上是zk的leader通过二阶段提交写请求来保证的,这个也是zk的集群规模大了的一个瓶颈点)

zk锁实现的原理

说zk的锁问题之前先看看zookeeper中几个特性,这几个特性构建了zk的一把分布式锁

特性:

  • 有序节点

当在一个父目录下如 /lock 下创建 有序节点,节点会按照严格的先后顺序创建出自节点 lock000001,lock000002,lock0000003,以此类推,有序节点能严格保证各个自节点按照排序命名生成。

  • 临时节点

客户端建立了一个临时节点,在客户端的会话结束或会话超时,zookepper会自动删除该解ID那。

  • 事件监听

在读取数据时,我们可以对节点设置监听,当节点的数据发生变化(1 节点创建 2 节点删除 3 节点数据变成 4 自节点变成)时,zookeeper会通知客户端。

结合这几个特点,来看下zk是怎么组合分布式锁。

image

  1. 业务线程-1 业务线程-2 分别向zk的/lock目录下,申请创建有序的临时节点
  2. 业务线程-1 抢到/lock0001 的文件,也就是在整个目录下最小序的节点,也就是线程-1获取到了锁
  3. 业务线程-2 只能抢到/lock0002的文件,并不是最小序的节点,线程2未能获取锁
  4. 业务线程-1 与 lock0001 建立了连接,并维持了心跳,维持的心跳也就是这把锁的租期
  5. 当业务线程-1 完成了业务,将释放掉与zk的连接,也就是释放了这把锁
zk分布式锁的代码实现

zk官方提供的客户端并不支持分布式锁的直接实现,我们需要自己写代码去利用zk的这几个特性去进行实现。

image

小结:究竟该用CP还是AP的分布式锁

首先得了解清楚我们使用分布式锁的场景,为何使用分布式锁,用它来帮我们解决什么问题,先聊场景后聊分布式锁的技术选型。

无论是redis,zk,例如redis的AP模型会限制很多使用场景,但它却拥有了几者中最高的性能,zookeeper的分布式锁要比redis可靠很多,但他繁琐的实现机制导致了它的性能不如redis,而且zk会随着集群的扩大而性能更加下降。

简单来说,先了解业务场景,后进行技术选型。

分布式事务,是怎么从ACID解脱,投身CAP/BASE

如果说到事务,ACID是传统数据库常用的设计理念,追求强一致性模型,关系数据库的ACID模型拥有高一致性+可用性,所以很难进行分区,所以在微服务中ACID已经是无法支持,我们还是回到CAP去寻求解决方案,不过根据上面的讨论,CAP定理中,要么只能CP,要么只能AP,如果我们追求数据的一致性而忽略可用性这个在微服务中肯定是行不通的,如果我们追求可用性而忽略一致性,那么在一些重要的数据(例如支付,金额)肯定出现漏洞百出,这个也是无法接受。所以我们既要一致性,也要可用性。

image

都要是无法实现的,但我们能不能在一致性上作出一些妥协,不追求强一致性,转而追求最终一致性,所以引入BASE理论,在分布式事务中,BASE最重要是为CAP提出了最终一致性的解决方案,BASE强调牺牲高一致性,从而获取肯用性,数据允许在一段时间内不一致,只要保证最终一致性就可以了。

实现最终一致性

弱一致性:系统不能保证后续访问返回更新的值。需要在一些条件满足之后,更新的值才能返回。从更新操作开始,到系统保证任何观察者总是看到更新的值的这期间被称为不一致窗口。

最终一致性:这是弱一致性的特殊形式;存储系统保证如果没有对某个对象的新更新操作,最终所有的访问将返回这个对象的最后更新的值。

BASE模型

BASE模型是传统ACID模型的反面,不同与ACID,BASE强调牺牲高一致性,从而获得可用性,数据允许在一段时间内的不一致,只要保证最终一致就可以了。

BASE模型反ACID模型,完全不同ACID模型,牺牲高一致性,获得可用性或可靠性: Basically Available基本可用。支持分区失败(e.g. sharding碎片划分数据库) Soft state软状态 状态可以有一段时间不同步,异步。 Eventually consistent最终一致,最终数据是一致的就可以了,而不是时时一致。

分布式事务

在分布式系统中,要实现分布式事务,无外乎几种解决方案。方案各有不同,不过其实都是遵循BASE理论,是最终一致性模型。

  • 两阶段提交(2PC)
  • 补偿事务(TCC)
  • 本地消息表
  • MQ事务消息

两阶段提交(2PC)

其实还有一个数据库的XA事务,不过目前在真正的互联网中实际的应用基本很少,两阶段提交就是使用XA原理。

image

在 XA 协议中分为两阶段:

  1. 事务管理器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交。
  2. 事务协调器要求每个数据库提交数据,或者回滚数据。

说一下,为何在互联网的系统中没被改造过的两阶段提交基本很少被业界应用,最最大的缺点就是同步阻塞问题,在资源准备就绪之后,资源管理器中的资源就一直处于阻塞,直到提交完成之后,才进行资源释放。这个在互联网高并发大数据的今天,两阶段的提交是不能满足现在互联网的发展。

还有就是两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,例如:

比如在第二阶段中,假设协调者发出了事务 Commit 的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了 Commit 操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。

补偿事务(TCC)

TCC是服务化的两阶段变成模型,每个业务服务都必须实现 try,confirm,calcel三个方法,这三个方式可以对应到SQL事务中Lock,Commit,Rollback。

image

相比两阶段提交,TCC解决了几个问题

同步阻塞,引入了超时机制,超时后进行补偿,并不会像两阶段提交锁定了整个资源,将资源转换为业务逻辑形式,粒度变小。 因为有了补偿机制,可以由业务活动管理器进行控制,保证数据一致性。

1). try阶段

try只是一个初步的操作,进行初步的确认,它的主要职责是完成所有业务的检查,预留业务资源

2). confirm阶段

confirm是在try阶段检查执行完毕后,继续执行的确认操作,必须满足幂等性操作,如果confirm中执行失败,会有事务协调器触发不断的执行,直到满足为止

3). cancel是取消执行,在try没通过并释放掉try阶段预留的资源,也必须满足幂等性,跟confirm一样有可能被不断执行

一个下订单,生成订单扣库存的例子:

image

接下来看看,我们的下单扣减库存的流程怎么加入TCC

image

在try的时候,会让库存服务预留n个库存给这个订单使用,让订单服务产生一个“未确认”订单,同时产生这两个预留的资源, 在confirm的时候,会使用在try预留的资源,在TCC事务机制中认为,如果在try阶段能正常预留的资源,那么在confirm一定能完整的提交

image

在try的时候,有任务一方为执行失败,则会执行cancel的接口操作,将在try阶段预留的资源进行释放。

这个并不是重点要论tcc事务是怎么实现,重点还是讨论分布式事务在CAP+BASE理论的应用。实现可以参考:github.com/changmingxi…

本地消息表

本地消息表这个方案最初是 eBay 提出的,eBay 的完整方案 queue.acm.org/detail.cfm?…

本地消息表这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理。

image

对于本地消息队列来说,核心就是将大事务转变为小事务,还是用上面下订单扣库存的例子说说明

  1. 当我们去创建订单的时候,我们新增一个本地消息表,把创建订单和扣减库存写入到本地消息表,放在同一个事务(依靠数据库本地事务保证一致性)
  2. 配置一个定时任务去轮训这个本地事务表,扫描这个本地事务表,把没有发送出去的消息,发送给库存服务,当库存服务收到消息后,会进行减库存,并写入服务器的事务表,更新事务表的状态。
  3. 库存服务器通过定时任务或直接通知订单服务,订单服务在本地消息表更新状态。

这里须注意的是,对于一些扫描发送未成功的任务,会进行重新发送,所以必须保证接口的幂等性。

本地消息队列是BASE理论,是最终一致性模型,适用对一致性要求不高的情况。

MQ事务

RocketMq在4.3版本已经正式宣布支持分布式事务,在选择Rokcetmq做分布式事务请务必选择4.3以上的版本。

RocketMQ中实现了分布式事务,实际上是对本地消息表的一个封装,将本地消息表移动到了MQ内部。

image

事务消息作为一种异步确保型事务, 将两个事务分支通过 MQ 进行异步解耦,RocketMQ 事务消息的设计流程同样借鉴了两阶段提交理论,整体交互流程如下图所示:

image

MQ事务是对本地消息表的一层封装,将本地消息表移动到了MQ内部,所以也是基于BASE理论,是最终一致性模式,对强一致性要求不那么高的事务适用,同时MQ事务将整个流程异步化了,也非常适合在高并发情况下使用。

RocketMQ选择异步/同步刷盘,异步/同步复制,背后的CP和AP思考

虽然同步刷盘/异步刷盘,同步/异步复制,并没有对cAP直接的应用,但在配置的过程中也一样涉及到可用性和一致性的考虑

同步刷盘/异步刷盘

RocketMQ的消息是可以做到持久化的,数据会持久化到磁盘,RocketMQ为了提高性能,尽可能保证磁盘的顺序写入,消息在Producer写入RocketMq的时候,有两种写入磁盘方式:

  1. 异步刷盘: 消息快速写入到内存的pagecache,就立马返回写成功状态,当内存的消息累计到一定程度的时候,会触发统一的写磁盘操作。这种方式可以保证大吞吐量,但也存在着消息可能未存入磁盘丢失的风险。
  2. 同步刷盘: 消息快速写入内存的pagecahe,立刻通知刷盘线程进行刷盘,等待刷盘完成之后,唤醒等待的线程,返回消息写成功的状态。

image

同步复制/异步复制

一个broker组有Master和Slave,消息需要从Master复制到Slave上,所以有同步和异步两种复制方式。

  1. 同步复制: 是等Master和Slave均写成功后才反馈给客户端写成功状态。
  2. 异步复制: 是只要Master写成功即可反馈给客户端写成功状态。

image

异步复制的优点是可以提高响应速度,但牺牲了一致性 ,一般实现该类协议的算法需要增加额外的补偿机制。同步复制的优点是可以保证一致性(一般通过两阶段提交协议),但是开销较大,可用性不好(参见CAP定理),带来了更多的冲突和死锁等问题。值得一提的是Lazy+Primary/Copy的复制协议在实际生产环境中是非常实用的。

image

RocketMQ的设置要结合业务场景,合理设置刷盘方式和主从复制方式,尤其是SYNC_FLUSH方式,由于频繁的触发写磁盘动作,会明显降低性能。通常情况下,应该把Master和Slave设置成ASYNC_FLUSH的刷盘方式,主从之间配置成SYNC_MASTER的复制方式,这样即使有一台机器出故障,仍然可以保证数据不丢。

总结

在微服务的构建中,永远都逃离不了CAP理论,因为网络永远不稳定,硬件总会老化,软件会可能出现bug,所以分区容错性在微服务中是躲不过的命题,可以这么说,只要是分布式,只要是集群都面临着AP或者CP的选择,但你很贪心的时候,既要一致性又要可用性,那只能对一致性作出一点妥协,也就是引入了BASE理论,在业务允许的情况下实现最终一致性。

究竟是选AP还是选CP,真的在于对业务的了解,例如金钱,库存相关会优先考虑CP模型,例如社区发帖相关可以优先选择AP模型,这个说白了其实基于对业务的了解是一个选择和妥协的过程。

image