【51CTO.com原创稿件】对于开发或设计分布式系统的架构师工程师来说,CAP 是必须要掌握的理论。
图片来自 Pexels
But:这个文章的重点并不是讨论 CAP 理论和细节,重点是说说 CAP 在微服务中的开发怎么起到一个指引作用,会通过几个微服务开发的例子说明,尽量的去贴近开发。
CAP 定理又被称为布鲁尔定理,是加州大学计算机科学家埃里克·布鲁尔提出来的猜想,后来被证明成为分布式计算领域公认的定理。
不过布鲁尔在出来 CAP 的时候并没有对 CAP 三者(Consistency,Availability,Partition tolerance)进行详细的定义,所以在网上也出现了不少对 CAP 不同解读的声音。
CAP 定理
CAP 定理在发展中存在过两个版本,我们以第二个版本为准:
在一个分布式系统中(指互相连接并共享数据的节点集合)中,当涉及到读写操作时,只能保证一致性(Consistence)、可用性(Availability)、分区容错性(Partition Tolerance)三者中的两个,另外一个必须被牺牲。
这个版本的 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 在服务中实际的应用例子
理解貌似讲多了,项目的 CAP 可以参考下李运华的《从零开始学架构》的书里面的 21,22 章,比较详细的描绘了 CAP 的理论细节和 CAP 的版本演化过程。
这里着重讲解的是神一样的 CAP 在我们的微服务中怎么去指导和应用起来,大概会举几个平时常见的例子。
服务注册中心,是选择 CA 还是选择 CP?
服务注册中心解决的问题
在讨论 CAP 之前先明确下服务注册中心主要是解决什么问题:
- 服务注册:实例将自身服务信息注册到注册中心,这部分信息包括服务的主机 IP 和服务的 Port,以及暴露服务自身状态和访问协议信息等。
- 服务发现:实例请求注册中心所依赖的服务信息,服务实例通过注册中心,获取到注册到其中的服务实例的信息,通过这些信息去请求它们提供的服务。
目前作为注册中心的一些组件大致有:
- Dubbo 的 Zookeeper
- Spring Cloud 的 Eureka,Consul
- RocketMQ 的 nameServer
- HDFS 的 nameNode
目前微服务主流是 Dubbo 和 Spring Cloud,使用最多是 Zookeeper 和 Eureka,我们就来看看应该根据 CAP 理论怎么去选择注册中心。(Spring Cloud 也可以用 ZK,不过不是主流不讨论)
Zookeeper 选择 CP
Zookeeper 保证 CP,即任何时刻对 Zookeeper 的访问请求能得到一致性的数据结果,同时系统对网络分割具备容错性,但是它不能保证每次服务的可用性。
从实际情况来分析,在使用 Zookeeper 获取服务列表时,如果 ZK 正在选举或者 ZK 集群中半数以上的机器不可用,那么将无法获取数据。所以说,ZK 不能保证服务可用性。
Eureka 选择 AP
Eureka 保证 AP,Eureka 在设计时优先保证可用性,每一个节点都是平等的。
一部分节点挂掉不会影响到正常节点的工作,不会出现类似 ZK 的选举 Leader 的过程,客户端发现向某个节点注册或连接失败,会自动切换到其他的节点。
只要有一台 Eureka 存在,就可以保证整个服务处在可用状态,只不过有可能这个服务上的信息并不是最新的信息。
ZK 和 Eureka 的数据一致性问题
先要明确一点,Eureka 的创建初心就是为一个注册中心,但是 ZK 更多是作为分布式协调服务的存在。
只不过因为它的特性被 Dubbo 赋予了注册中心,它的职责更多是保证数据(配置数据,状态数据)在管辖下的所有服务之间保持一致。
所以这个就不难理解为何 ZK 被设计成 CP 而不是 AP,ZK 最核心的算法 ZAB,就是为了解决分布式系统下数据在多个服务之间一致同步的问题。
更深层的原因,ZK 是按照 CP 原则构建,也就是说它必须保持每一个节点的数据都保持一致。
如果 ZK 下节点断开或者集群中出现网络分割(例如交换机的子网间不能互访),那么 ZK 会将它们从自己的管理范围中剔除,外界不能访问这些节点,即使这些节点是健康的可以提供正常的服务,所以导致这些节点请求都会丢失。
而 Eureka 则完全没有这方面的顾虑,它的节点都是相对独立,不需要考虑数据一致性的问题,这个应该是 Eureka 的诞生就是为了注册中心而设计。
相对 ZK 来说剔除了 Leader 节点选取和事务日志机制,这样更有利于维护和保证 Eureka 在运行的健壮性。
再来看看,数据不一致性在注册服务中会给 Eureka 带来什么问题,无非就是某一个节点被注册的服务多,某个节点注册的服务少,在某一个瞬间可能导致某些 IP 节点被调用数多,某些 IP 节点调用数少的问题。
也有可能存在一些本应该被删除而没被删除的脏数据。
服务注册应该选择 AP 还是 CP
对于服务注册来说,针对同一个服务,即使注册中心的不同节点保存的服务注册信息不相同,也并不会造成灾难性的后果。
对于服务消费者来说,能消费才是最重要的,就算拿到的数据不是最新的数据,消费者本身也可以进行尝试失败重试。总比为了追求数据的一致性而获取不到实例信息整个服务不可用要好。
所以,对于服务注册来说,可用性比数据一致性更加的重要,选择 AP。
分布式锁,是选择 CA 还是选择 CP?
这里实现分布式锁的方式选取了三种:
- 基于数据库实现分布式锁
- 基于 Redis 实现分布式锁
- 基于 Zookeeper 实现分布式锁
基于数据库实现分布式锁
构建表结构:
利用表的 UNIQUE KEY idx_lock(method_lock)作为唯一主键,当进行上锁时进行 Insert 动作,数据库成功录入则以为上锁成功,当数据库报出 Duplicate entry 则表示无法获取该锁。
不过这种方式对于单主却无法自动切换主从的 MySQL 来说,基本就无法实现 P 分区容错性(MySQL 自动主从切换在目前并没有十分完美的解决方案)。
可以说这种方式强依赖于数据库的可用性,数据库写操作是一个单点,一旦数据库挂掉,就导致锁的不可用。这种方式基本不在 CAP 的一个讨论范围。
基于 Redis 实现分布式锁
Redis 单线程串行处理天然就是解决串行化问题,用来解决分布式锁是再适合不过。
实现方式:
setnx key value Expire_time 获取到锁 返回 1 , 获取失败 返回 0
为了解决数据库锁的无主从切换的问题,可以选择 Redis 集群,或者是 Sentinel 哨兵模式,实现主从故障转移,当 Master 节点出现故障,哨兵会从 Slave 中选取节点,重新变成新的 Master 节点。
哨兵模式故障转移是由 Sentinel 集群进行监控判断,当 Maser 出现异常即复制中止,重新推选新 Slave 成为 Master,Sentinel 在重新进行选举并不在意主从数据是否复制完毕具备一致性。
所以 Redis 的复制模式是属于 AP 的模式。保证可用性,在主从复制中“主”有数据,但是可能“从”还没有数据。
这个时候,一旦主挂掉或者网络抖动等各种原因,可能会切换到“从”节点,这个时候可能会导致两个业务线程同时获取得两把锁。
这个过程如下:
- 业务线程 -1 向主节点请求锁
- 业务线程 -1 获取锁
- 业务线程 -1 获取到锁并开始执行业务
- 这个时候 Redis 刚生成的锁在主从之间还未进行同步
- Redis 这时候主节点挂掉了
- Redis 的从节点升级为主节点
- 业务线程 -2 想新的主节点请求锁
- 业务线程 -2 获取到新的主节点返回的锁
- 业务线程 -2 获取到锁开始执行业务
- 这个时候业务线程 -1 和业务线程 -2 同时在执行任务
上述的问题其实并不是 Redis 的缺陷,只是 Redis 采用了 AP 模型,它本身无法确保我们对一致性的要求。
Redis 官方推荐 Redlock 算法来保证,问题是 Redlock 至少需要三个 Redis 主从实例来实现,维护成本比较高。
相当于 Redlock 使用三个 Redis 集群实现了自己的另一套一致性算法,比较繁琐,在业界也使用得比较少。
能不能使用 Redis 作为分布式锁?这个本身就不是 Redis 的问题,还是取决于业务场景。
我们先要自己确认我们的场景是适合 AP 还是 CP , 如果在社交发帖等场景下,我们并没有非常强的事务一致性问题,Redis 提供给我们高性能的 AP 模型是非常适合的。
但如果是交易类型,对数据一致性非常敏感的场景,我们可能要寻找一种更加适合的 CP 模型。
基于 Zookeeper 实现分布式锁
刚刚也分析过,Redis 其实无法确保数据的一致性,先来看 Zookeeper 是否适合作为我们需要的分布式锁。
首先 ZK 的模式是 CP 模型,也就是说,当 ZK 锁提供给我们进行访问的时候,在 ZK 集群中能确保这把锁在 ZK 的每一个节点都存在。
这个实际上是 ZK 的 Leader 通过二阶段提交写请求来保证的,这个也是 ZK 的集群规模大了的一个瓶颈点。
①ZK 锁实现的原理
说 ZK 的锁问题之前先看看 Zookeeper 中几个特性,这几个特性构建了 ZK 的一把分布式锁。
ZK 的特性如下:
- 有序节点:当在一个父目录下如 /lock 下创建 有序节点,节点会按照严格的先后顺序创建出自节点 lock000001,lock000002,lock0000003,以此类推,有序节点能严格保证各个自节点按照排序命名生成。
- 临时节点:客户端建立了一个临时节点,在客户端的会话结束或会话超时,Zookepper 会自动删除该节点 ID。
- 事件监听:在读取数据时,我们可以对节点设置监听,当节点的数据发生变化(1 节点创建 2 节点删除 3 节点数据变成 4 自节点变成)时,Zookeeper 会通知客户端。
结合这几个特点,来看下 ZK 是怎么组合分布式锁:
- 业务线程 -1,业务线程 -2 分别向 ZK 的 /lock 目录下,申请创建有序的临时节点。
- 业务线程 -1 抢到 /lock0001 的文件,也就是在整个目录下最小序的节点,也就是线程 -1 获取到了锁。
- 业务线程 -2 只能抢到 /lock0002 的文件,并不是最小序的节点,线程 2 未能获取锁。
- 业务线程 -1 与 lock0001 建立了连接,并维持了心跳,维持的心跳也就是这把锁的租期。
- 当业务线程 -1 完成了业务,将释放掉与 ZK 的连接,也就是释放了这把锁。
②ZK 分布式锁的代码实现
ZK 官方提供的客户端并不支持分布式锁的直接实现,我们需要自己写代码去利用 ZK 的这几个特性去进行实现。
究竟该用 CP 还是 AP 的分布式锁
首先得了解清楚我们使用分布式锁的场景,为何使用分布式锁,用它来帮我们解决什么问题,先聊场景后聊分布式锁的技术选型。
无论是 Redis,ZK,例如 Redis 的 AP 模型会限制很多使用场景,但它却拥有了几者中最高的性能。
Zookeeper 的分布式锁要比 Redis 可靠很多,但他繁琐的实现机制导致了它的性能不如 Redis,而且 ZK 会随着集群的扩大而性能更加下降。
简单来说,先了解业务场景,后进行技术选型。
分布式事务,是怎么从 ACID 解脱,投身 CAP/BASE
如果说到事务,ACID 是传统数据库常用的设计理念,追求强一致性模型,关系数据库的 ACID 模型拥有高一致性+可用性,所以很难进行分区。
在微服务中 ACID 已经是无法支持,我们还是回到 CAP 去寻求解决方案,不过根据上面的讨论,CAP 定理中,要么只能 CP,要么只能 AP。
如果我们追求数据的一致性而忽略可用性这个在微服务中肯定是行不通的,如果我们追求可用性而忽略一致性,那么在一些重要的数据(例如支付,金额)肯定出现漏洞百出,这个也是无法接受。所以我们既要一致性,也要可用性。
都要是无法实现的,但我们能不能在一致性上作出一些妥协,不追求强一致性,转而追求最终一致性,所以引入 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 原理。
在 XA 协议中分为两阶段:
- 事务管理器要求每个涉及到事务的数据库预提交(Precommit)此操作,并反映是否可以提交。
- 事务协调器要求每个数据库提交数据,或者回滚数据。
说一下,为何在互联网的系统中没被改造过的两阶段提交基本很少被业界应用,最大的缺点就是同步阻塞问题。
在资源准备就绪之后,资源管理器中的资源就一直处于阻塞,直到提交完成之后,才进行资源释放。
这个在互联网高并发大数据的今天,两阶段的提交是不能满足现在互联网的发展。
还有就是两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能。
例如:在第二阶段中,假设协调者发出了事务 Commit 的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了 Commit 操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。
②补偿事务(TCC)
TCC 是服务化的两阶段编程模型,每个业务服务都必须实现 Try,Confirm,Cancel 三个方法,这三个方式可以对应到 SQL 事务中 Lock,Commit,Rollback。
相比两阶段提交,TCC 解决了几个问题:同步阻塞,引入了超时机制,超时后进行补偿,并不会像两阶段提交锁定了整个资源,将资源转换为业务逻辑形式,粒度变小。
因为有了补偿机制,可以由业务活动管理器进行控制,保证数据一致性。
Try 阶段:Try 只是一个初步的操作,进行初步的确认,它的主要职责是完成所有业务的检查,预留业务资源。
Confirm 阶段:Confirm 是在 Try 阶段检查执行完毕后,继续执行的确认操作,必须满足幂等性操作,如果 Confirm 中执行失败,会有事务协调器触发不断的执行,直到满足为止。
Cancel 是取消执行:在 Try 没通过并释放掉 Try 阶段预留的资源,也必须满足幂等性,跟 Confirm 一样有可能被不断执行。
一个下订单,生成订单扣库存的例子:
接下来看看,我们的下单扣减库存的流程怎么加入 TCC:
在 Try 的时候,会让库存服务预留 N 个库存给这个订单使用,让订单服务产生一个“未确认”订单,同时产生这两个预留的资源。
在 Confirm 的时候,会使用在 Try 预留的资源,在 TCC 事务机制中认为,如果在 Try 阶段能正常预留的资源,那么在 Confirm 一定能完整的提交。
在 Try 的时候,有任务一方为执行失败,则会执行 Cancel 的接口操作,将在 Try 阶段预留的资源进行释放。
这个并不是重点要论 TCC 事务是怎么实现,重点还是讨论分布式事务在 CAP+BASE 理论的应用。
实现可以参考:
https://github.com/changmingxie/tcc-transaction
③本地消息表
本地消息表这个方案最初是 eBay 提出的,eBay 的完整方案:
https://queue.acm.org/detail.cfm?id=1394128
本地消息表这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理。
对于本地消息队列来说,核心就是将大事务转变为小事务,还是用上面下订单扣库存的例子说明:
- 当我们去创建订单的时候,我们新增一个本地消息表,把创建订单和扣减库存写入到本地消息表,放在同一个事务(依靠数据库本地事务保证一致性)。
- 配置一个定时任务去轮询这个本地事务表,扫描这个本地事务表,把没有发送出去的消息,发送给库存服务,当库存服务收到消息后,会进行减库存,并写入服务器的事务表,更新事务表的状态。
- 库存服务器通过定时任务或直接通知订单服务,订单服务在本地消息表更新状态。
这里须注意的是,对于一些扫描发送未成功的任务,会进行重新发送,所以必须保证接口的幂等性。
本地消息队列是 BASE 理论,是最终一致性模型,适用对一致性要求不高的情况。
④MQ 事务
RocketMQ 在 4.3 版本已经正式宣布支持分布式事务,在选择 RokcetMQ 做分布式事务请务必选择 4.3 以上的版本。
RocketMQ 中实现了分布式事务,实际上是对本地消息表的一个封装,将本地消息表移动到了 MQ 内部。
事务消息作为一种异步确保型事务, 将两个事务分支通过 MQ 进行异步解耦,RocketMQ 事务消息的设计流程同样借鉴了两阶段提交理论。
整体交互流程如下图所示:
MQ 事务是对本地消息表的一层封装,将本地消息表移动到了 MQ 内部,所以也是基于 BASE 理论,是最终一致性模式,对强一致性要求不那么高的事务适用,同时 MQ 事务将整个流程异步化了,也非常适合在高并发情况下使用。
RocketMQ 选择同步/异步刷盘,同步/异步复制,背后的 CP 和 AP 思考
虽然同步刷盘/异步刷盘,同步/异步复制,并没有对 CAP 直接的应用,但在配置的过程中也一样涉及到可用性和一致性的考虑。
同步刷盘/异步刷盘
RocketMQ 的消息是可以做到持久化的,数据会持久化到磁盘,RocketMQ 为了提高性能,尽可能保证磁盘的顺序写入。
消息在 Producer 写入 RocketMQ 的时候,有两种写入磁盘方式:
- 异步刷盘:消息快速写入到内存的 Pagecache,就立马返回写成功状态,当内存的消息累计到一定程度的时候,会触发统一的写磁盘操作。这种方式可以保证大吞吐量,但也存在着消息可能未存入磁盘丢失的风险。
- 同步刷盘:消息快速写入内存的 Pagecahe,立刻通知刷盘线程进行刷盘,等待刷盘完成之后,唤醒等待的线程,返回消息写成功的状态。
同步复制/异步复制
一个 Broker 组有 Master 和 Slave,消息需要从 Master 复制到 Slave 上,所以有同步和异步两种复制方式:
- 同步复制:是等 Master 和 Slave 均写成功后才反馈给客户端写成功状态。
- 异步复制:是只要 Master 写成功即可反馈给客户端写成功状态。
异步复制的优点是可以提高响应速度,但牺牲了一致性 ,一般实现该类协议的算法需要增加额外的补偿机制。
同步复制的优点是可以保证一致性(一般通过两阶段提交协议),但是开销较大,可用性不好(参见 CAP 定理),带来了更多的冲突和死锁等问题。
值得一提的是 Lazy+Primary/Copy 的复制协议在实际生产环境中是非常实用的。
RocketMQ 的设置要结合业务场景,合理设置刷盘方式和主从复制方式,尤其是 SYNC_FLUSH 方式,由于频繁的触发写磁盘动作,会明显降低性能。
通常情况下,应该把 Master 和 Slave 设置成 ASYNC_FLUSH 的刷盘方式,主从之间配置成 SYNC_MASTER 的复制方式,这样即使有一台机器出故障,仍然可以保证数据不丢。
总结
在微服务的构建中,永远都逃离不了 CAP 理论,因为网络永远不稳定,硬件总会老化,软件可能出现 Bug,所以分区容错性在微服务中是躲不过的命题。
可以这么说,只要是分布式,只要是集群都面临着 AP 或者 CP 的选择,但你很贪心的时候,既要一致性又要可用性,那只能对一致性作出一点妥协,也就是引入了 BASE 理论,在业务允许的情况下实现最终一致性。
究竟是选 CA 还是选 CP,真的在于对业务的了解,例如金钱,库存相关会优先考虑 CP 模型,例如社区发帖相关可以优先选择 AP 模型,这个说白了基于对业务的了解是一个选择和妥协的过程。
作者:陈于喆
简介:十余年的开发和架构经验,国内较早一批微服务开发实施者。曾任职国内互联网公司网易和唯品会高级研发工程师,后在创业公司担任技术总监/架构师,目前在洋葱集团任职技术研发副总监。
【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】