分布式
分布式和集群的区别是什么?
- 分布式:一个业务分拆多个子业务,部署在不同的服务器上,不同的业务模块部署在不同的服务器上或者同一个业务模块分拆多个子业务,部署在不同的服务器上,解决高并发的问题
- 集群:同一个业务,部署在多个服务器上,提高系统可用性。
CAP定理
CAP定义,又称为布鲁尔定理,它指对于一个分布式计算系统来说,不可能同时满足以下三点:
- 一致性(Consistence):系统在执行过某项操作后仍然处于一致的状态。在分布式系统中,更新操作执行成功后所有的用户都应该读到最新的值,这样的系统被认为是具有强一致性的。 等同于所有节点访问同一份最新的数据副本
- 可用性(Availability):每一个操作总是能够在一定的时间内返回结果,这里需要注意的是”一定时间内”和”返回结果”。一定时间指的是,在可以容忍的范围内返回结果,结果可以是成功或者失败。对数据更新具备高可用性。每次请求都能获取到非错的响应——但是不保证获取的数据为最新数据
- 分区容错性(Partition tolerance):分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。这里的网络分区是指由于某种原因,网络被分成若干个孤立的区域,而区域之间互不相通。还有一些人将分区容错性理解为系统对节点动态加入和离开的能力,因为节点的加入和离开可以认为是集群内部的网络分区。
CAP仅适用于原子读写的NOSQL场景中,并不适合数据库系统。现在的分布式系统具有更多特性比如扩展性、可用性等等,在进行系统设计和开发时,可以不仅仅局限在CAP问题上。
当发生网络分区的时候,如果要继续服务,那么强一致性和可用性只能2选1。也就是说当网络分区之后P是前提,决定了P之后才有C和A的选择。也就是说分区容错性(Partition tolerance)是必须要实现的。
BASE理论
BASE 是 Basically Available(基本可用)、Soft-state(软状态)和Eventually Consistent(最终一致性)三个短语的缩写。BASE 理论是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的,它大大降低了对系统的要求。
核心思想
即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。
针对数据库领域,BASE思想的主要实现是对业务数据进行拆分,让不同的数据分布在不同的机器上,以提升系统的可用性,当前主要有以下两种做法:
- 按功能划分数据库
- 分片(如开源的Mycat、Amoeba等)。
BASE 理论三要素
1. 基本可用
基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这不等价于系统不可用。例如:
- 响应时间上的损失:正常情况下,一个在线搜索引擎需要在0.5秒内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加1-2秒
- 系统功能上的损失:正常情况下,在一个电子商务网站上进行购物的时候,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
2. 软状态
软状态是指允许系统中的数据存在中间状态,并认为该中间状体的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
3. 最终一致性
最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一直,而不需要实时保证系统数据的强一致性。
分布式系统设计的两大思路
分布式系统设计的两大思路:中心化和去中心化
中心化设计
- 两个角色:中心化的设计思想很简单,分布式集群中的节点机器按照角色分工,大体上分为两种角色:“领导”和“干活的”
- 角色职责:“领导”通常负责分发任务并监督“干活的”,发现谁空闲或者相对太闲,就想方设法给其安排新任务,确保没有一个“干活的”能够偷懒,如果“领导”发现某个“干活的”崩溃了,会直接将其踢除,然后把它的任务分给其他人。
- 中心化设计的问题:
- 中心化的设计存在的最大问题就是“领导”的安危问题,如果“领导”出了问题,则群龙无首,整个集群就崩溃了,但是难以同时安排两个“领导”以避免单点问题。
- 中心化设计还存在的另一个潜在的问题:即“领导”的能力问题:可以领导10个人高效工作并不意味着可以领到100个人高效工作,如果系统设计和实现不好,问题就会卡在“领导”身上。
- 领导安危问题解决方法:大多数中心化系统都采用了主备两个“领导”的设计方案,可以是热备或者冷备,也可以是自动切换或者手动切换,而且越来越多的新系统都开始具备自动选取切换“领导”的能力,以提升系统的可用性。
去中心化设计
- 众生地位平等:在去中心化的设计里,通常没有“领导”和“干活的”这两种角色的区分,大家的角色都是一样的,地位是平等的,全球互联网就是一个典型的去中心化的分布式系统,联网的任意节点设备宕机,都只会影响很小范围的功能。
- “去中心化”不是不要中心,而是由节点来自由选择中心:集群的成员会自发的举行“会议”选举新的“领导”主持工作。最典型的案例就是ZooKeeper及Go语言实现的Etcd
- 去中心化设计的问题:去中心化设计里最难解决的一个问题是脑裂问题,这种情况的发生概率很低,但影响很大。脑裂指一个集群由于网络的故障,被分为至少两个彼此无法通信的单独集群,此时如果两个集群都各自工作,则可能会产生严重的数据冲突和错误。一般的设计思路是,当集群判断发生了脑裂问题时,规模较小的集群就“自杀”或者拒绝服务。
分布式事务
分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
分布式事务和分布式锁的区别
分布式锁和分布式事务区别:
- 锁问题的关键在于进程操作的互斥关系,例如多个进程同时修改账户的余额,如果没有互斥关系则会导致该账户的余额不正确。
- 而事务问题的关键则在于事务涉及的一系列操作需要满足 ACID 特性,例如要满足原子性操作则需要这些操作要么都执行,要么都不执行。
分布式事务产生的原因
数据库分库分表
当数据库单表一年产生的数据超过1000W时,就需要考虑分库分表,就是说将原来的数据库变成多个数据库。这时候,如果一个操作既访问01库,又访问02库,而且还要保证数据的一致性,那么就需要用到分布式事务。
应用SOA化
所谓的SOA化,就是业务的服务化。比如原来单机支撑了整个电商网站,现在对整个网站进行拆解,分离出了订单中心、用户中心、库存中心。对于订单中心,有专门的数据库存储订单信息,用户中心也有专门的数据库存储用户信息,库存中心也会有专门的数据库存储库存信息。这时候如果要同时对订单和库存进行操作,那么就会涉及到订单数据库和库存数据库,为了保证数据一致性,就需要用到分布式事务。
常见的分布式事务解决方案
基于数据库资源层面实现方案,由于存在多个事务,需要存在一个角色管理各个事务的状态。我们将这个角色称为协调者,事务参与者称为参与者。参与者与协调者一般会基于某种特定协议,目前比较有名的为 XA 接口协议。基于协调者与参与者的思想设定,分别提出了 2PC 与 3PC 实现 XA 分布式事务。
基于XA协议的两阶段提交(2PC)
XA 是一个分布式事务协议,XA中大致分为两部分:事务管理器(协调者)和本地资源管理器。其中本地资源管理器往往由数据库实现,比如 Oracle、DB2 这些商业数据库都实现了 XA 接口,而事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。主要过程如下:
第一阶段
应用程序调用了事务管理器的提交方法,此后第一阶段分为两个步骤:
- 事务管理器通知参与该事务的各个资源管理器,通知他们准备事务
- 资源管理器接收到消息后开始准备阶段,写好
Undo
和Redo
事务日志并执行事务,但不提交,然后将是否就绪的消息返回给事务管理器(此时已经将事务的大部分事情做完,以后的内容耗时极小)。
第二阶段
第二阶段也分为两个步骤:
- 事务管理器在接收各个消息后,开始分析,如果有任意其一失败,则发送回滚命令,否则发送提交命令。
- 各个资源管理器接收到命令后,执行(耗时很少),并将提交信息返回给事务管理器。
事务管理器接收消息后,事务结束,应用程序继续执行。
以下是成功和回滚两种场景示例:
为什么要分两步执行:一是因为分两步,就有了事务管理器统一管理的机会;二是尽可能晚提交事务,让事务在提交前尽可能地完成所有能完成的工作,这样,最后的提交阶段将是耗时极短,耗时极短意味着操作失败的可能性也就降低。同时,二阶段提交协议为了保证事务的一致性,不管是事务管理器还是各个资源管理器,每执行一步操作,都会记录日志,为出现故障后的恢复准备依据。
XA 也有致命的缺点,那就是性能不理想,特别是在交易下单链路,往往并发量很高,XA无法满足高并发场景。XA 目前在商业数据库支持的比较理想,在 mysql 数据库中支持的不太理想,mysql 的 XA 实现,没有记录 prepare 阶段日志,主备切换会导致主库与备库数据不一致。许多 nosql 也没有支持 XA,这让 XA 的应用场景变得非常狭隘。具有如下问题:
- 同步阻塞:从上面的描述可以看出,对于任何一次指令必须收到明确的响应,才会继续做下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放。
- 单点故障:如果事务管理器宕机,资源管理器没有了事务管理器指挥,会一直阻塞,尽管可以通过选举新的事务管理器替代原有协调者,但是如果之前事务管理器在发送一个提交指令后宕机,而提交指令仅仅被一个资源管理器接收,并且参与接收后资源管理器也宕机,新上任的事务管理器无法处理这种情况。
- 脑裂:事务管理器发送提交指令,有的资源管理器接收后执行了事务,有的参与者没有接收到事务,就没有执行事务,多个参与者之间是不一致的。
基于XA协议的三阶段提交(3PC)
三阶段提交,在两阶段提交的基础下,改进两阶段。三阶段步骤如下。
CanCommit
,协调者询问参与者是否可以进行事务提交。PreCommit
,若所有参与者可以进行事务提交,协调者下达PreCommit
命令,参与者锁定资源,并等待最终命令。- 所有参与者返回确认信息,协调者向各个事务下发事务执行通知,锁定资源,并将执行情况返回。
- 部分参与者返回否认信息或协调者等待超时。这种情况,协调者认为事务无法正常执行,下发中断指令,各个参与者退出预备状态
Do Commit
,若第二阶段全部回应ack
,则下达Do Commit
,进行事务最终提交,否则下达中断事务命令,所有参与者进行事务回滚。- 所有参与者正常执行执行事务,协调者下发最终提交指令,释放锁定资源。
- 部分参与者执行事务失败,协调者等待超时,协调者下发回滚指令,释放锁定资源。
三阶段提交对比两阶段,引入超时机制减少事务阻塞,解决单点故障。在第三阶段,一旦参与者无法接受到协调者信号时,等待超时之后,参与者默认执行 commit,释放资源。
这里之所以这么设计,其实是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了
PreCommit
请求,那么协调者产生PreCommit
请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit
响应都是 Yes。(一旦参与者收到了PreCommit
,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit
或者abort
响应,但是他有理由相信:成功提交的几率很大。
三阶段仍然不能解决数据一致性问题。若协调者发出回滚命令,但是由于网络问题,参与者在等待时间内都无法接收到,这时参与者默认提交事务,而其他事务进行了回滚,造成事务不一致。
补偿事务(TCC)
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:
Try
阶段主要是对业务系统做检测及资源预留,完成所有业务检查(一致性),预留必须业务资源(准隔离性)Confirm
阶段主要是对业务系统做确认提交,Try
阶段执行成功并开始执行Confirm
阶段时,默认Confirm
阶段是不会出错的。即:只要Try
成功,Confirm
一定成功。Cancel
阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。
比如执行如下事务:
A:-100(补偿为 A:+100)
B:+100
那么如果 B:+100
失败后就需要执行 A:+100
。
优点:跟 2PC 比起来,实现以及流程相对简单了一些,但是数据的一致性比 2PC 要差一些。
缺点:TCC 属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用 TCC 不太好定义及处理。
引入 TCC 的例子
下面模拟商城一次支付过程。用户下单使用组合支付,即余额加红包支付。一次正常流程为:
创建订单
下单
- 调用余额系统,扣减余额
- 调用红包系统,扣减红包余额
- 修改订单状态为已支付
- 完后支付。
实际过程如下图:
但是这么一个支付过程调用多个子服务,并不能保证所有服务都能成功,比如在调用红包系统扣减红包系统失败。这个时候就碰到尴尬的场景,由于红包服务失败,导致方法异常退出,这个时候订单状态为初始状态,但是用户余额已经扣减。这对用户体验非常不友好。所以这次支付过程,必须存在机制将这次过程当成一次整体的行为,必须保证这其中服务调用,要么都成功,要么都失败,成为一个整体的事务。
这时可以引入 TCC 事务,将整个下单过程作为一个整体。引入后,由于余额系统扣减是失败,这个时候回滚订单系统与红包系统。整个过程如下图:
由于余额系统的失败,需要撤销这次过程中所有更改,所以向订单系统发送撤销通知,向红包系统发出撤销通知。
因此系统引入 TCC 事务后,需要改造我们的调用过程。
系统如何引入 TCC 事务
根据 TCC 事务三步,这个时候必须将各个服务改造成 Try
、Confirm
、Cancle
三步
TCC TRY:
根据上面的业务,订单系统增加 try
方法将订单状态修改成 PAYING。余额系统增加一个 try
方法,先检查用于余额是否充足,然后先将余额扣减,然后将扣减的余额增加到冻结金额。红包系统同余额系统。从改造过程可以看出,TCC try
方法需检查各业务资源,且这过程需要引入中间状态。根据下图来看整个过程:
TCC Confirm:
TCC 第一步 TRY 如果所有子服务调用都成功,这个时候就需要确认各服务。各个服务增加 confirm
方法。如余额系统 confirm
方法用来将冻结金额置为0,红包系统如上。订单系统将订单状态修改为 SUCCESS。confirm
方法需要注意实现幂等。如订单系统更新前,一定要先判断该笔订单状态处于 PAYING,才能更新订单。整个过程如下图:
讲到这里,必须用到 TCC 事务框架推动各服务。TCC 事务管理器感知到 TRY
方法结束后,自动调用各服务提供的 confirm
方法,将各服务状态修改为终态。
TCC Cancle:
如若 TCC Try
过程中,冻结红包方法失败,这时就需要将之前修改都撤销,修改成其初始状态。cancle
方法也需要实现幂等如 confirm
方法,如下图:
看到这,可以看出 TCC Try 成功,confirm 必定要成功,try 失败,cancle 必定要成功。因为 confirm 是系统更新为终态的关键。但是实际上,生产系统 confirm 或 cancle 肯定会有几率失败,这个时候就需要 TCC 框架记录调用 confirm 结果。如果 confirm 调用失败,TCC 框架需要记录下来,然后间隔一定时间再次去调用。
TCC 与阶段提交对比
使用 2PC 或 3PC 实现的分布式框架,业务应用层无需改动,接入较简单。但是相对应能较低,数据资源锁定较长。不太适合互联网等高并发业务场景。
而使用基于 TCC 实现分布式框架,相对 2PC 性能较高,可以保证数据最终一致性。但是对于应用层来说,一个方法必须改造成三个方法,且业务中需引入一些中间状态,相对而言应用改造程度较大。
本地消息表(异步确保)
本地消息表这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于ebay。
基本思路是:
在消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。
在消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会执行重试。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。
这种方案遵循BASE理论,采用的是最终一致性,是这几种方案里面比较适合实际业务场景的,即不会出现像2PC那样复杂的实现(当调用链很长的时候,2PC的可用性是非常低的),也不会像TCC那样可能出现确认或者回滚不了的情况。
优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。在 .NET中 有现成的解决方案。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。
MQ事务消息
所谓的消息事务就是基于消息中间件的两阶段提交,本质上是对消息中间件的一种特殊利用,它是将本地事务和发消息放在一个分布式事务里,保证要么本地操作成功并且对外发消息成功,要么两者都失败,开源的RocketMQ就支持这一特性,具体原理如下:
- A 系统向消息中间件发送一条预备消息
- 消息中间件保存预备消息并返回成功
- A 执行本地事务
- A 发送提交消息给消息中间件
通过以上4步就完成了一个消息事务,对于以上4个步骤,每个步骤都可能发生错误:
- 步骤一出错,则整个事务失败,不会执行A的本地操作
- 步骤二出错,则整个事务失败,不会执行A的本地操作
- 步骤三出错,这时候需要回滚预备消息,怎么回滚?答案是A系统实现一个消息中间件的回调接口,消息中间件会去不断执行回调接口,检查A事务执行是否执行成功,如果失败则回滚预备消息
- 步骤四出错,这时候A的本地事务是成功的,那么消息中间件要回滚A吗?答案是不需要,其实通过回调接口,消息中间件能够检查到A执行成功了,这时候其实不需要A发提交消息了,消息中间件可以自己对消息进行提交,从而完成整个消息事务。
基于消息中间件的两阶段提交往往用在高并发场景下,将一个分布式事务拆成一个消息事务(A系统的本地操作+发消息)+B系统的本地操作,其中B系统的操作由消息驱动,只要消息事务成功,那么A操作就一定成功,消息也一定发出来了,这时候B会收到消息去执行本地操作,如果本地操作失败,消息就会重投,直到B操作成功,这样就变相地实现了A与B的分布式事务。原理如下:
虽然上面的方案能够完成A和B的操作,但是A和B并不是严格一致的,而是最终一致的,在这里牺牲了一致性,换来了性能的大幅度提升。当然,这种做法也是有风险的,如果B一直执行不成功,那么一致性会被破坏,具体要不要这样做,还是得看业务能够承担多少风险。
Sagas事务模型
Sagas事务模型又叫做长时间运行的事务(Long-running-transaction),它描述的是另外一种在没有两阶段提交的情况下解决分布式系统中复杂的业务事务问题。
该模型其核心思想就是拆分分布式系统中的长事务为多个短事务,或者叫多个本地事务,然后由Sagas工作流引擎负责协调,如果整个流程正常结束,那么就算是业务成功完成,如果在这过程中实现失败,那么Sagas工作流引擎就会以相反的顺序调用补偿操作,重新进行业务回滚。
比如我们一次关于购买旅游套餐业务操作涉及到三个操作,他们分别是预定车辆,预定宾馆,预定机票,他们分别属于三个不同的远程接口。可能从程序的角度来说他们不属于一个事务,但是从业务角度来说是属于同一个事务的。
他们的执行顺序如上图所示,所以当发生失败时,会依次进行取消的补偿操作。
因为长事务被拆分了很多个业务流,所以 Sagas 事务模型最重要的一个部件就是工作流或者也可以叫流程管理器(Process Manager),工作流引擎和Process Manager虽然不是同一个东西,但是在这里,他们的职责是相同的。
Sagas事务模型理论是一个相对比较新的理论,目前市面上还没有什么解决方案,只是一个理论上的模型。
分布式一致性算法
在分布式系统中,为了消除单点提高系统可用性,通常会使用副本来进行容错,但这会带来另一个问题,即如何保证多个副本之间的一致性?
分布式一致性 (distributed consensus) 是分布式系统中最基本的问题,用来保证一个分布式系统的可靠性以及容错能力。简单来说,分布式一致性是指多个服务器的保持状态一致。
在分布式系统中,可能出现各种意外(断电、网络拥塞、CPU/内存耗尽等等),使得服务器宕机或无法访问,最终导致无法和其他服务器保持状态一致。为了应对这种情况,就需要有一种一致性协议来进行容错,使得分布式系统中即使有部分服务器宕机或无法访问,整体依然可以对外提供服务。
所谓的强一致性(线性一致性)并不是指集群中所有节点在任一时刻的状态必须完全一致,而是指一个目标,即让一个分布式系统看起来只有一个数据副本,并且读写操作都是原子的,这样应用层就可以忽略系统底层多个数据副本间的同步问题。也就是说,我们可以将一个强一致性分布式系统当成一个整体,一旦某个客户端成功的执行了写操作,那么所有客户端都一定能读出刚刚写入的值。即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。
共识算法(Consensus Algorithm)就是用来做这个事情的,它保证即使在小部分(≤ (N-1)/2)节点故障的情况下,系统仍然能正常对外提供服务。
共识算法通常基于状态复制机(Replicated State Machine)模型,也就是所有节点从同一个 state 出发,经过同样的操作 log,最终达到一致的 state。
复制状态机(Replicated State Machines) 是指一组服务器上的状态机产生相同状态的副本,并且在一些机器宕掉的情况下也可以继续运行。一致性算法管理着来自客户端指令的复制日志。状态机从日志中处理相同顺序的相同指令,所以产生的结果也是相同的。
复制状态机通常都是基于复制日志实现的,每一个服务器存储一个包含一系列指令的日志,并且按照日志的顺序进行执行。每一个日志都按照相同的顺序包含相同的指令,所以每一个服务器都执行相同的指令序列。因为每个状态机都是确定的,每一次执行操作都产生相同的状态和同样的序列。
保证复制日志相同就是一致性算法的工作了。在一台服务器上,一致性模块接收客户端发送来的指令然后增加到自己的日志中去。它和其他服务器上的一致性模块进行通信来保证每一个服务器上的日志最终都以相同的顺序包含相同的请求,尽管有些服务器会宕机。一旦指令被正确的复制,每一个服务器的状态机按照日志顺序处理他们,然后输出结果被返回给客户端。因此,服务器集群看起来形成一个高可靠的状态机。
实际系统中使用的一致性算法通常含有以下特性:
安全性保证(绝对不会返回一个错误的结果):在非拜占庭错误(消息篡改)情况下,包括网络延迟、分区、丢包、冗余和乱序等错误都可以保证正确。
可用性:集群中只要有大多数的机器可运行并且能够相互通信、和客户端通信,就可以保证可用。因此,一个典型的包含 5 个节点的集群可以容忍两个节点的失败。服务器被停止就认为是失败。他们当有稳定的存储的时候可以从状态中恢复回来并重新加入集群。
不依赖时序来保证一致性:物理时钟错误或者极端的消息延迟只有在最坏情况下才会导致可用性问题。
通常情况下,一条指令可以尽可能快的在集群中大多数节点响应一轮远程过程调用时完成。小部分比较慢的节点不会影响系统整体的性能。
共识算法是构建强一致性分布式系统的基石,Paxos 是共识算法的代表,而 Raft 则是其作者在博士期间研究 Paxos 时提出的一个变种,主要优点是容易理解、易于实现,甚至关键的部分都在论文中给出了伪代码实现。
Paxos算法
Paxos 算法是基于消息传递且具有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一。
在常见的分布式系统中,总会发生诸如机器宕机或网络异常(包括消息的延迟、丢失、重复、乱序,还有网络分区)等情况。Paxos 算法需要解决的问题就是如何在一个可能发生上述异常的分布式系统中,快速且正确地在集群内部对某个数据的值达成一致,并且保证不论发生以上任何异常,都不会破坏整个系统的一致性。
注:这里某个数据的值并不只是狭义上的某个数,它可以是一条日志,也可以是一条命令(command)等。根据应用场景不同,某个数据的值有不同的含义。
在Paxos算法中,有三种角色:
- Proposer(提案人)
- Acceptor(接收者)
- Learners(学习者)
在具体的实现中,一个进程可能同时充当多种角色,比如一个进程可以既是 Proposer 又是 Acceptor 又是 Learner。
还有一个概念叫提案(Proposal)。最终要达成一致的 value 就在提案里。
假设有一组可以提出(propose)value
(value 在提案 Proposal 里)的进程集合。一个一致性算法需要保证提出的这么多 value
中,只有一个 value
被选定(chosen)。如果没有 value
被提出,就不应该有 value
被选定。如果一个 value
被选定,那么所有进程都应该能学习(learn)到这个被选定的 value
。对于一致性算法,安全性(safaty)要求如下:
- 只有被提出的
value
才能被选定。 - 只有一个
value
被选定 - 如果某个进程认为某个
value
被选定了,那么这个value
必须是真的被选定的那个。
一致性算法的目标是保证最终有一个提出的 value
被选定。当一个 value
被选定后,进程最终也能学习到这个 value
。
Paxos的目标:保证最终有一个
value
会被选定,当value
被选定后,进程最终也能获取到被选定的value
。
假设不同角色之间可以通过发送消息来进行通信,那么:
- 每个角色以任意的速度执行,可能因出错而停止,也可能会重启。一个
value
被选定后,所有的角色可能失败然后重启,除非那些失败后重启的角色能记录某些信息,否则等他们重启后无法确定被选定的值。 - 消息在传递过程中可能出现任意时长的延迟,可能会重复,也可能丢失。但是消息不会被损坏,即消息内容不会被篡改(拜占庭将军问题)。
Paxos算法分为两个阶段:
- 阶段一:
- Proposer 选择一个提案编号 N,然后向半数以上的 Acceptor 发送编号为 N 的 Prepare 请求。
Proposer 生成提案之前,应该先去学习已经被选定或者可能被选定的 value,然后以该 value 作为自己提出的提案的 value。如果没有 value 被选定,Proposer 才可以自己决定 value 的值。这样才能达成一致。这个学习的阶段就是通过一个 Prepare 请求实现。
- 如果 Acceptor 收到一个编号为 N 的 Prepare 请求,且 N 大于该 Acceptor 已经响应过的所有 Prepare 请求的编号,那么它就会将它已经接受过的编号最大的提案(如果有的话)作为响应反馈给 Proposer,同时该 Acceptor 承诺不再接受任何编号小于N的提案。
- 阶段二:
- 如果 Proposer 收到半数以上 Acceptor 对其发出的编号为 N 的 Prepare 请求的响应,那么它就会发送一个针对
[N,V]
提案的 Accept 请求给半数以上的 Acceptor。如果响应中不包含任何提案,那么 V 就由 Proposer 自己决定V 就是收到的响应中编号最大的提案的 value。
- 如果 Accepter 收到一个针对编号为 N 的提案的 Accept 请求,只要该 Acceptor 没有对编号大于 N 的 Prepare 请求作出响应,它就接收该提案。
Learner 学习(获取)被选定的value有以下三种方案:
如何保证 Paxos 算法的活性?
如果有两个 Proposer 依次提出编号递增的提案,最终会陷入死循环,没有 value 被选定(无法保证活性)
结局方法是选取一个主 Proposer,只有主 Proposer 才能提出提案。
RAFT
Raft 是一种为了管理日志复制的分布式一致性算法。Raft 出现之前,Paxos 一直是分布式一致性算法的标准。Paxos 难以理解,更难以实现。Raft 的设计目标是简化 Paxos,使得算法既容易理解,也容易实现。
Raft 算法同样是一种分布式算法,是对 paxos 的一种简化和改进。相比于 Paxos 难以理解、实现和排错,RAFT 是一个通俗易懂,更容易落的分布式协议。
Paxos 和 Raft 都是分布式一致性算法,这个过程如同投票选举领袖(Leader),参选者(Candidate)需要说服大多数投票者(Follower)投票给他,一旦选举出领袖,就由领袖发号施令。Paxos 和 Raft 的区别在于选举的具体过程不同。
Raft 可以解决分布式 CAP 理论中的 CP,即一致性(C:Consistency) 和分区容忍性(P:Partition Tolerance),并不能解决可用性(A:Availability) 的问题。
Raft 的基本概念
Raft 使用 Quorum 机制来实现共识和容错,对 Raft 集群的操作称为提案,每当发起一个提案,必须得到大多数(> N/2)节点的同意才能提交。
这里的“提案”可以先狭义地理解为对集群的读写操作,“提交”理解为操作成功。
那么当向 Raft 集群发起一系列读写操作时,集群内部究竟发生了什么呢?
首先,Raft 集群必须存在一个主节点(leader),客户端向集群发起的所有操作都必须经由主节点处理。所以 Raft 核心算法中的第一部分就是选主(Leader election)——没有主节点集群就无法工作,先票选出一个主节点,再考虑其它事情。
其次,主节点需要承载什么工作呢?它会负责接收客户端发过来的操作请求,将操作包装为日志同步给其它节点,在保证大部分节点都同步了本次操作后,就可以安全地给客户端回应响应了。这一部分工作在 Raft 核心算法中叫日志复制(Log replication)。
然后,因为主节点的责任是如此之大,所以节点们在选主的时候一定要谨慎,只有符合条件的节点才可以当选主节点。此外主节点在处理操作日志的时候也一定要谨慎,为了保证集群对外展现的一致性,不可以覆盖或删除前任主节点已经处理成功的操作日志。所谓的“谨慎处理”,其实就是在选主和提交日志的时候进行一些限制,这一部分在 Raft 核心算法中叫安全性(Safety)。
Raft 核心算法其实就是由这三个子问题组成的:选主(Leader election)、日志复制(Log replication)、安全性(Safety)。这三部分共同实现了 Raft 核心的共识和容错机制。
除了核心算法外,Raft 也提供了几个工程实践中必须面对问题的解决方案。
第一个是关于日志无限增长的问题。Raft 将操作包装成为了日志,集群每个节点都维护了一个不断增长的日志序列,状态机只有通过重放日志序列来得到。但由于这个日志序列可能会随着时间流逝不断增长,因此必须有一些办法来避免无休止的磁盘占用和过久的日志重放。这一部分叫日志压缩(Log compaction)。
第二个是关于集群成员变更的问题。一个 Raft 集群不太可能永远是固定几个节点,总有扩缩容的需求,或是节点宕机需要替换的时候。直接更换集群成员可能会导致严重的脑裂问题。Raft 给出了一种安全变更集群成员的方式。这一部分叫集群成员变更(Cluster membership change)。
此外,还会额外讨论线性一致性的定义、为什么 Raft 不能与线性一致划等号、如何基于 Raft 实现线性一致,以及在如何保证线性一致的前提下进行读性能优化。
选主
原生的 Paxos 算法使用了一种点对点(peer-to-peer)的方式,所有节点地位是平等的。在理想情况下,算法的目的是制定一个决策,这对于简化的模型比较有意义。但在工业界很少会有系统会使用这种方式,当有一系列的决策需要被制定的时候,先选出一个 leader 节点然后让它去协调所有的决策,这样算法会更加简单快速。
此外,和其它一致性算法相比,Raft 赋予了 leader 节点更强的领导力,称之为 Strong Leader。比如说日志条目只能从 leader 节点发送给其它节点而不能反着来,这种方式简化了日志复制的逻辑,使 Raft 变得更加简单易懂。
节点角色
Raft 集群中每个节点都处于以下三种角色之一:
- Leader: 所有请求的处理者,接收客户端发起的操作请求,写入本地日志后同步至集群其它节点。
- Follower: 请求的被动更新者,从 leader 接收更新请求,写入本地文件。如果客户端的操作请求发送给了 follower,会首先由 follower 重定向给 leader。
- Candidate: 如果 follower 在一定时间内没有收到 leader 的心跳,则判断 leader 可能已经故障,此时启动 leader election 过程,本节点切换为 candidate 直到选主结束。
任期
每开始一次新的选举,称为一个任期(term),每个 term 都有一个严格递增的整数与之关联。
每当 candidate 触发 leader election 时都会增加 term,如果一个 candidate 赢得选举,他将在本 term 中担任 leader 的角色。但并不是每个 term 都一定对应一个 leader,有时候某个 term 内会由于选举超时导致选不出 leader,这时 candicate 会递增 term 号并开始新一轮选举。
不同服务器节点观察到的任期转换状态可能不一样:有的服务器节点可能观察到多次的任期转换,而有的服务器节点可能观察不到任何一次任期转换。
任期在 Raft 算法中充当逻辑时钟的作用,使得服务器节点可以查明一些过期的信息(比如过期的 Leader)。每个服务器节点都会存储一个当前任期号,这一编号在整个时期内单调的增长。当服务器之间通信的时候会交换当前任期号。
- 如果一个服务器的当前任期号比其他人小,那么他会更新自己的编号到较大的编号值。
- 如果一个 Candidate 或者 Leader 发现自己的任期号过期了,那么他会立即恢复成跟随者状态。
- 如果一个节点接收到一个包含过期的任期号的请求,那么他会直接拒绝这个请求。
节点通信
Raft 算法中服务器节点之间的通信使用 远程过程调用(RPC)。基本的一致性算法只需要两种 RPC:
- RequestVote RPC - 请求投票 RPC,由 Candidate 在选举期间发起。
- AppendEntries RPC - 附加条目 RPC,由 Leader 发起,用来复制日志和提供一种心跳机制。
节点状态转换
集群每个节点的状态都只能是 leader、follower 或 candidate,那么节点什么时候会处于哪种状态呢?下图展示了一个节点可能发生的状态转换:
下面详细讨论下每个转换所发生的场景。
Follower 状态转换过程
Raft 的选主基于一种心跳机制,集群中每个节点刚启动时都是 follower 身份(Step: starts up),leader 会周期性的向所有节点发送心跳包来维持自己的权威,那么首个 leader 是如何被选举出来的呢?方法是如果一个 follower 在一段时间内没有收到任何心跳,也就是选举超时,那么它就会主观认为系统中没有可用的 leader,并发起新的选举(Step: times out, starts election)。
若 follower 想发起一次选举,follower 需要先增加自己的当前 term,并将身份切换为 candidate。然后它会向集群其它节点发送“请给自己投票”的消息(RequestVote RPC)。
这里有一个问题,即这个“选举超时时间”该如何制定?如果所有节点在同一时刻启动,经过同样的超时时间后同时发起选举,整个集群会变得低效不堪,极端情况下甚至会一直选不出一个主节点(后面分析)。Raft 巧妙的使用了一个随机化的定时器,让每个节点的“超时时间”在一定范围内随机生成(一般为 150ms ~ 300ms),这样就大大的降低了多个节点同时发起选举的可能性。
Candidate 状态转换过程
Follower 切换为 candidate 并向集群其他节点发送“请给自己投票”的消息后,接下来会有三种可能的结果,也即上面节点状态图中 candidate 状态向外伸出的三条线。
选举成功(Step: receives votes from majority of servers)
当candicate从整个集群的大多数(N/2+1)节点获得了针对同一 term 的选票时,它就赢得了这次选举,立刻将自己的身份转变为 leader 并开始向其它节点发送心跳来维持自己的权威。
每个节点针对每个 term 只能投出一张票,并且按照先到先得的原则。这个规则确保只有一个 candidate 会成为 leader。
选举失败(Step: discovers current leader or new term)
Candidate 在等待投票回复的时候,可能会突然收到其它自称是 leader 的节点发送的心跳包(AppendEntries RPC),如果这个心跳包里携带的 term 不小于 candidate 当前的 term,那么 candidate 会承认这个 leader,并将身份切回 follower。
这说明其它节点已经成功赢得了选举,只需立刻跟随即可。但如果心跳包中的 term 比自己小,candidate 会拒绝这次请求并保持选举状态。
选举超时(Step: times out, new election)
第三种可能的结果是 candidate 既没有赢也没有输。如果有多个 follower 同时成为 candidate,选票是可能被瓜分的,如果没有任何一个 candidate 能得到大多数节点的支持,那么每一个 candidate 都会超时。
此时 candidate 需要增加自己的 term,然后发起新一轮选举。
如果这里不做一些特殊处理,选票可能会一直被瓜分,导致选不出 leader 来。这里的“特殊处理”指的就是前文所述的随机化选举超时时间。
Raft 算法使用随机选举超时时间的方法来确保很少会发生选票瓜分的情况,就算发生也能很快的解决。为了阻止选票起初就被瓜分,竞选超时时间是一个随机的时间,在一个固定的区间(例如 150-300 毫秒)随机选择,这样可以把选举都分散开。
以至于在大多数情况下,只有一个服务器会超时,然后它赢得选举,成为 Leader,并在其他服务器超时之前发送心跳包。
同样的机制也被用在选票瓜分的情况下:每一个 Candidate 在开始一次选举的时候会重置一个随机的选举超时时间,然后在超时时间内等待投票的结果;这样减少了在新的选举中另外的选票瓜分的可能性。
Leader 状态转换过程
节点状态图中的最后一条线是:discovers server with higher term。
想象一个场景:当 leader 节点发生了宕机或网络断连,此时其它 follower 会收不到 leader 心跳,首个触发超时的节点会变为 candidate 并开始拉票(由于随机化各个 follower 超时时间不同),由于该 candidate 的 term 大于原 leader 的 term,因此所有 follower 都会投票给它,这名 candidate 会变为新的 leader。一段时间后原 leader 恢复了,收到了来自新 leader 的心跳包,发现心跳中的 term 大于自己的 term,此时该节点会立刻切换为 follower 并跟随的新 leader。
以上就是 Raft 的选主逻辑,但还有一些细节(譬如是否给该 candidate 投票还有一些其它条件)依赖算法的其它部分基础,会在后续“安全性”一章描述。
当票选出 leader 后,leader 也该承担起相应的责任了,这个责任是什么?就是下面介绍的“日志复制”。
日志复制
前面说过,共识算法通常基于状态复制机(Replicated State Machine)模型,所有节点从同一个 state 出发,经过一系列同样操作 log 的步骤,最终也必将达到一致的 state。也就是说,只要保证集群中所有节点的 log 一致,那么经过一系列应用(apply)后最终得到的状态机也就是一致的。
Raft 负责保证集群中所有节点 log 的一致性。
此外还提到过:Raft 赋予了 leader 节点更强的领导力(Strong Leader)。那么 Raft 保证 log 一致的方式就很容易理解了,即所有 log 都必须交给 leader 节点处理,并由 leader 节点复制给其它节点。
这个过程,就叫做日志复制(Log replication)。
日志格式
日志由含日志索引(log index)的日志条目(log entry)组成。每个日志条目包含它被创建时的 Term 号(下图中方框中的数字),和一个复制状态机需要执行的指令。如果一个日志条目被复制到半数以上的服务器上,就被认为可以提交(Commit)了。
日志模型如下图所示:(x ← 3 代表 x 赋值为 3)
每条日志除了存储状态机的操作指令外,还会拥有一个唯一的整数索引值(log index)来表明它在日志集合中的位置。
此外,每条日志还会存储一个 term 号(日志条目方块最上方的数字,相同颜色 term 号相同),该 term 表示 leader 收到这条指令时的当前任期,term 相同的 log 是由同一个 leader 在其任期内发送的。
当一条日志被 leader 节点认为可以安全的 apply 到状态机时,称这条日志是 committed(上图中的 committed entries)。
那么什么样的日志可以被 commit 呢?答案是:当 leader 得知这条日志被集群过半的节点复制成功时。因此在上图中可以看到 (term3, index7) 这条日志以及之前的日志都是 committed,尽管有两个节点拥有的日志并不完整。
Raft 保证所有 committed 日志都已经被持久化,且“最终”一定会被状态机apply。
注:这里的“最终”用词很微妙,它表明了一个特点:Raft 保证的只是集群内日志的一致性,而真正期望的集群对外的状态机一致性还需要做一些额外工作,这一点在后面线性一致性与读性能优化说。
日志复制流程
S1 当选 leader,此时还没有任何日志。此时模拟客户端向 S1 发起一个请求。
S1 收到客户端请求后新增了一条日志 (term2, index1),然后并行地向其它节点发起 AppendEntries RPC。
S2、S4 率先收到了请求,各自附加了该日志,并向 S1 回应响应。
所有节点都附加了该日志,但由于 leader 尚未收到任何响应,因此暂时还不清楚该日志到底是否被成功复制。
当 S1 收到2个节点的响应时,该日志条目的边框就已经变为实线,表示该日志已经安全的复制,因为在5节点集群中,2个 follower 节点加上 leader 节点自身,副本数已经确保过半,此时 S1 将响应客户端的请求。
leader 后续会持续发送心跳包给 followers,心跳包中会携带当前已经安全复制(我们称之为 committed)的日志索引,此处为 (term2, index1)。
所有 follower 都通过心跳包得知 (term2, index1) 的 log 已经成功复制 (committed),因此所有节点中该日志条目的边框均变为实线。
日志一致性保障
前边使用了 (term2, index1) 这种方式来表示一条日志条目,这里为什么要带上 term,而不仅仅是使用 index?原因是 term 可以用来检查不同节点间日志是否存在不一致的情况。
Raft 日志同步保证如下两点:
如果不同日志中的两个日志条目有着相同的日志索引和 Term,则它们所存储的命令是相同的。
这个特性基于这条原则:Raft 要求 leader 在一个 term 内针对同一个 index 只能创建一条日志,并且永远不会修改它。
如果不同日志中的两个日志条目有着相同的日志索引和 Term,则它们之前的所有条目都是完全一样的。
这个特性由 AppendEntries RPC 的一个简单的一致性检查所保证。在发送 AppendEntries RPC 时,Leader 会把新日志条目之前的日志条目的日志索引和 Term 号一起发送。如果 Follower 在它的日志中找不到包含相同日志索引和 Term 号的日志条目,它就会拒绝接收新的日志条目。
所以,只要 follower 持续正常地接收来自 leader 的日志,那么就可以通过归纳法验证上述结论。
可能出现的日志不一致场景
一般情况下,Leader 和 Followers 的日志保持一致,因此日志条目一致性检查通常不会失败。然而,Leader 崩溃可能会导致日志不一致:旧的 Leader 可能没有完全复制完日志中的所有条目。
Leader 和 Follower 可能存在多种日志不一致的可能:
上图展示了一个 term8 的 leader 刚上任时,集群中日志可能存在的混乱情况。例如 follower 可能缺少一些日志(a ~ b),可能多了一些未提交的日志(c ~ d),也可能既缺少日志又多了一些未提交日志(e ~ f)。
Follower 不可能比 leader 多出一些已提交(committed)日志,这一点是通过选举上的限制来达成的。
先来尝试复现上述 a ~ f 场景,最后再讲 Raft 如何解决这种不一致问题。
场景a~b: Follower 日志落后于 leader
这种场景其实很简单,即 follower 宕机了一段时间,follower-a 从收到 (term6, index9) 后开始宕机,follower-b 从收到 (term4, index4) 后开始宕机。
场景c. Follower 日志比 leader 多 term6
当 term6 的 leader 正在将 (term6, index11) 向 follower 同步时,该 leader 发生了宕机,且此时只有 follower-c 收到了这条日志的 AppendEntries RPC。然后经过一系列的选举,term7 可能是选举超时,也可能是 leader 刚上任就宕机了,最终 term8 的 leader 上任了,于是就看到了场景 c。
场景d. Follower 日志比 leader 多 term7
当 term6 的 leader 将 (term6, index10) 成功 commit 后,发生了宕机。此时 term7 的 leader 走马上任,连续同步了两条日志给 follower,然而还没来得及 commit 就宕机了,随后集群选出了 term8 的 leader。
场景e. Follower 日志比 leader 少 term5 ~ 6,多 term4
当 term4 的 leader 将 (term4, index7) 同步给 follower,且将 (term4, index5) 及之前的日志成功 commit 后,发生了宕机,紧接着 follower-e 也发生了宕机。这样在 term5~7 内发生的日志同步全都被 follower-e 错过了。当 follower-e 恢复后,term8 的 leader 也刚好上任了。
场景f. Follower 日志比 leader 少 term4 ~ 6,多 term2 ~ 3
当 term2 的 leader 同步了一些日志(index4 ~ 6)给 follower 后,尚未来得及 commit 时发生了宕机,但它很快恢复过来了,又被选为了 term3 的 leader,它继续同步了一些日志(index7~11)给 follower,但同样未来得及 commit 就又发生了宕机,紧接着 follower-f 也发生了宕机,当 follower-f 醒来时,集群已经前进到 term8 了。
如何处理日志不一致的场景
通过上述场景可以看到,真实世界的集群情况很复杂,那么 Raft 是如何应对这么多不一致场景的呢?其实方式很简单暴力,想想 Strong Leader 这个词。
Raft 强制要求 follower 必须复制 leader 的日志集合来解决不一致问题。
也就是说,follower 节点上任何与 leader 不一致的日志,都会被 leader 节点上的日志所覆盖。这并不会产生什么问题,因为某些选举上的限制,如果 follower 上的日志与 leader 不一致,那么该日志在 follower 上一定是未提交的。未提交的日志并不会应用到状态机,也不会被外部的客户端感知到。
要使得 follower 的日志集合跟自己保持完全一致,leader 必须先找到二者间最后一次达成一致的地方。因为一旦这条日志达成一致,在这之前的日志一定也都一致。这个确认操作是在 AppendEntries RPC 的一致性检查步骤完成的。
Leader 针对每个 follower 都维护一个 next index,表示下一条需要发送给该follower 的日志索引。当一个 leader 刚刚上任时,它初始化所有 next index 值为自己最后一条日志的 index+1。但凡某个 follower 的日志跟 leader 不一致,那么下次 AppendEntries RPC 的一致性检查就会失败。在被 follower 拒绝这次 Append Entries RPC 后,leader 会减少 next index 的值并进行重试。
最终一定会存在一个 next index 使得 leader 和 follower 在这之前的日志都保持一致。极端情况下 next index 为1,表示 follower 没有任何日志与 leader 一致,leader 必须从第一条日志开始同步。
针对每个 follower,一旦确定了 next index 的值,leader 便开始从该 index 同步日志,follower 会删除掉现存的不一致的日志,保留 leader 最新同步过来的。
整个集群的日志会在这个简单的机制下自动趋于一致。此外要注意,leader 从来不会覆盖或者删除自己的日志,而是强制 follower 与它保持一致。
这就要求集群票选出的 leader 一定要具备“日志的正确性”,这也就关联到了前文提到的:选举上的限制。
安全性及正确性
前面的章节描述了 Raft 算法是如何选主和复制日志的,然而到目前为止描述的这套机制还不能保证每个节点的状态机会严格按照相同的顺序 apply 日志。想象以下场景:
- Leader 将一些日志复制到了大多数节点上,进行 commit 后发生了宕机。
- 某个 follower 并没有被复制到这些日志,但它参与选举并当选了下一任 leader。
- 新的 leader 又同步并 commit 了一些日志,这些日志覆盖掉了其它节点上的上一任 committed 日志。
- 各个节点的状态机可能 apply 了不同的日志序列,出现了不一致的情况。
因此需要对“选主+日志复制”这套机制加上一些额外的限制,来保证状态机的安全性,也就是 Raft 算法的正确性。
Raft 增加了一些限制来完善 Raft 算法,以保证安全性:保证了任意 Leader 对于给定的 Term,都拥有了之前 Term 的所有被提交的日志条目。
对选举的限制
拥有最新的已提交的日志条目的 Follower 才有资格成为 Leader。
Raft 使用投票的方式来阻止一个 Candidate 赢得选举,除非这个 Candidate 包含了所有已经提交的日志条目。Candidate 为了赢得选举必须联系集群中的大部分节点,这意味着每一个已经提交的日志条目在这些服务器节点中肯定存在于至少一个节点上。如果 Candidate 的日志至少和大多数的服务器节点一样新,那么他一定持有了所有已经提交的日志条目。
RequestVote RPC 实现了这样的限制:RequestVote RPC 中包含了 Candidate 的日志信息, Follower 会拒绝掉那些日志没有自己新的投票请求。
再来分析下前文所述的 committed 日志被覆盖的场景,根本问题其实发生在第2步。Candidate 必须有足够的资格才能当选集群 leader,否则它就会给集群带来不可预料的错误。
Candidate 想要赢得选举成为 leader,必须得到集群大多数节点的投票,那么它的日志就一定至少不落后于大多数节点。又因为一条日志只有复制到了大多数节点才能被 commit,因此能赢得选举的 candidate 一定拥有所有 committed 日志。
因此才会断定地说:Follower 不可能比 leader 多出一些 committed 日志。
如何判断哪个日志条目比较新?
Raft 通过比较两份日志中最后一条日志条目的日志索引和 Term 来判断哪个日志比较新。
- 先判断 Term,哪个数值大即代表哪个日志比较新。
- 如果 Term 相同,再比较 日志索引,哪个数值大即代表哪个日志比较新。
对提交的限制
Leader 只允许 commit 包含当前 term 的日志。
除了对选举增加一点限制外,还需对 commit 行为增加一点限制,来完成 Raft 算法核心部分的最后一块拼图。
回忆下什么是 commit:
当 leader 得知某条日志被集群过半的节点复制成功时,就可以进行 commit,committed 日志一定最终会被状态机 apply。
所谓 commit 其实就是对日志简单进行一个标记,表明其可以被 apply 到状态机,并针对相应的客户端请求进行响应。
然而 leader 并不能在任何时候都随意 commit 旧任期留下的日志,即使它已经被复制到了大多数节点。
Raft 论文给出了一个经典场景:
上图从左到右按时间顺序模拟了问题场景。
阶段a:S1 是 leader,收到请求后将 (term2, index2) 只复制给了 S2,尚未复制给 S3 ~ S5。
阶段b:S1 宕机,S5 当选 term3 的 leader(S3、S4、S5 三票),收到请求后保存了 (term3, index2),尚未复制给任何节点。
阶段c:S5 宕机,S1 恢复,S1 重新当选 term4 的 leader,继续将 (term2, index2) 复制给了 S3,已经满足大多数节点,我们将其 commit。
阶段d:S1 又宕机,S5 恢复,S5 重新当选 leader(S2、S3、S4 三票),将 (term3, inde2) 复制给了所有节点并 commit。注意,此时发生了致命错误,已经 committed 的 (term2, index2) 被 (term3, index2) 覆盖了。
为了避免这种错误,需要添加一个额外的限制:Leader 只允许 commit 包含当前 term 的日志。一旦当前 Term 的日志条目以这种方式被提交,那么由于日志匹配特性,之前的日志条目也都会被间接的提交。
针对上述场景,问题发生在阶段c,即使作为 term4 leader 的 S1 将 (term2, index2) 复制给了大多数节点,它也不能直接将其 commit,而是必须等待 term4 的日志到来并成功复制后,一并进行 commit。
阶段e:在添加了这个限制后,要么 (term2, index2) 始终没有被 commit,这样 S5 在阶段d将其覆盖就是安全的;要么 (term2, index2) 同 (term4, index3) 一起被 commit,这样 S5 根本就无法当选 leader,因为大多数节点的日志都比它新,也就不存在前边的问题了。
以上便是对算法增加的两个小限制,它们对确保状态机的安全性起到了至关重要的作用。
集群成员变更与日志压缩
尽管已经通过的内容了解了 Raft 算法的核心部分,但相较于算法理论来说,在工程实践中仍有一些现实问题需要去面对。Raft 非常贴心的在论文中给出了两个常见问题的解决方案,它们分别是:
- 集群成员变更:如何安全地改变集群的节点成员。
- 日志压缩:如何解决日志集合无限制增长带来的问题。
集群成员变更
前文的理论描述中都假设了集群成员是不变的,然而在实践中有时会需要替换宕机机器或者改变复制级别(即增减节点)。一种最简单暴力达成目的的方式就是:停止集群、改变成员、启动集群。这种方式在执行时会导致集群整体不可用,此外还存在手工操作带来的风险。
为了避免这样的问题,Raft 论文中给出了一种无需停机的、自动化的改变集群成员的方式,其实本质上还是利用了 Raft 的核心算法,将集群成员配置作为一个特殊日志从 leader 节点同步到其它节点去。
直接切换集群成员配置
先说结论:所有将集群从旧配置直接完全切换到新配置的方案都是不安全的。
因此不能想当然的将新配置直接作为日志同步给集群并 apply。因为不可能让集群中的全部节点在“同一时刻”原子地切换其集群成员配置,所以在切换期间不同的节点看到的集群视图可能存在不同,最终可能导致集群存在多个 leader。
为了理解上述结论,来看一个实际出现问题的场景,下图对其进行了展现。
阶段a:集群存在 S1 ~ S3 三个节点,将该成员配置表示为 C-old,绿色表示该节点当前视图(成员配置)为 C-old,其中红边的 S3 为 leader。
阶段b:集群新增了 S4、S5 两个节点,该变更从 leader 写入,将 S1 ~ S5 的五节点新成员配置表示为 C-new,蓝色表示该节点当前视图为 C-new。
阶段c:假设 S3 短暂宕机触发了 S1 与 S5 的超时选主。
阶段d:S1 向 S2、S3 拉票,S5 向其它全部四个节点拉票。由于 S2 的日志并没有比 S1 更新,因此 S2 可能会将选票投给 S1,S1 两票当选(因为 S1 认为集群只有三个节点)。而 S5 肯定会得到 S3、S4 的选票,因为 S1 感知不到 S4,没有向它发送 RequestVote RPC,并且 S1 的日志落后于 S3,S3 也一定不会投给 S1,结果 S5 三票当选。最终集群出现了多个主节点的致命错误,也就是所谓的脑裂。
上图来自论文,用不同的形式展现了和图5-1相同的问题。颜色代表的含义与图5-1是一致的,在 problem: two disjoint majorities 所指的时间点,集群可能会出现两个 leader。
但是,多主问题并不是在任何新老节点同时选举时都一定可能出现的,有一些文章在举多主的例子时可能存在错误,下面是一个案例:
该假想场景类似前面说的阶段d,模拟过程如下:
- S1 为集群原 leader,集群新增 S4、S5,该配置被推给了 S3,S2 尚未收到。
- 此时 S1 发生短暂宕机,S2、S3 分别触发选主。
- 最终 S2 获得了 S1 和自己的选票,S3 获得了 S4、S5 和自己的选票,集群出现两个 leader。
上面的过程看起来好像和前面没有什么大的不同,只是参与选主的节点存在区别,然而事实是上面的情况是不可能出现的。
注意:Raft 论文中传递集群变更信息也是通过日志追加实现的,所以也受到选主的限制。
很多人对选主限制中比较的日志是否必须是 committed 产生疑惑:
每个 candidate 必须在 RequestVote RPC 中携带自己本地日志的最新 (term, index),如果 follower 发现这个 candidate 的日志还没有自己的新,则拒绝投票给该 candidate。
这里明确下,论文里确实间接表明了,选主时比较的日志是不要求 committed 的,只需比较本地的最新日志就行!
回到上面的场景,不可能出现的原因在于,S1 作为原 leader 已经第一个保存了新配置的日志,而 S2 尚未被同步这条日志,根据选主限制,S1 不可能将选票投给 S2,因此 S2 不可能成为 leader。
两阶段切换集群成员配置
Raft 使用一种两阶段方法平滑切换集群成员配置来避免遇到前一节描述的问题,具体流程如下:
阶段一
- 客户端将 C-new 发送给 leader,leader 将 C-old 与 C-new 取并集并立即 apply,表示为 C-old,new。
- Leader 将 C-old,new 包装为日志同步给其它节点。
- Follower 收到 C-old,new 后立即 apply,当 C-old,new 的大多数节点(即 C-old 的大多数节点和 C-new 的大多数节点)都切换后,leader 将该日志 commit。
阶段二
- Leader 接着将 C-new 包装为日志同步给其它节点。
- Follower 收到 C-new 后立即 apply,如果此时发现自己不在 C-new 列表,则主动退出集群。
- Leader 确认 C-new 的大多数节点都切换成功后,给客户端发送执行成功的响应。
为什么两阶段切换集群成员配置可以保证不会出现多个 leader?
来按流程逐阶段分析。
阶段1. C-old,new 尚未 commit
该阶段所有节点的配置要么是 C-old,要么是 C-old,new,但无论是二者哪种,只要原 leader 发生宕机,新 leader 都必须得到大多数 C-old 集合内节点的投票。
以上面说到的场景为例,S5 在阶段d根本没有机会成为 leader,因为 C-old 中只有 S3 给它投票了,不满足大多数。
阶段2. C-old,new 已经 commit,C-new 尚未下发
该阶段 C-old,new 已经 commit,可以确保已经被 C-old,new 的大多数节点(再次强调:C-old 的大多数节点和 C-new 的大多数节点)复制。
因此当 leader 宕机时,新选出的 leader 一定是已经拥有 C-old,new 的节点,不可能出现两个 leader。
阶段3. C-new 已经下发但尚未 commit
该阶段集群中可能有三种节点 C-old、C-old,new、C-new,但由于已经经历了阶段2,因此 C-old 节点不可能再成为 leader。而无论是 C-old,new 还是 C-new 节点发起选举,都需要经过大多数 C-new 节点的同意,因此也不可能出现两个 leader。
阶段4. C-new 已经 commit
该阶段 C-new 已经被 commit,因此只有 C-new 节点可以得到大多数选票成为 leader。此时集群已经安全地完成了这轮变更,可以继续开启下一轮变更了。
以上便是对该两阶段方法可行性的分步验证,Raft 论文将该方法称之为共同一致(Joint Consensus)。
日志压缩
Raft 核心算法维护了日志的一致性,通过 apply 日志也就得到了一致的状态机,客户端的操作命令会被包装成日志交给 Raft 处理。
然而在实际系统中,客户端操作是连绵不断的,但日志却不能无限增长,首先它会占用很高的存储空间,其次每次系统重启时都需要完整回放一遍所有日志才能得到最新的状态机。
因此 Raft 提供了一种机制去清除日志里积累的陈旧信息,叫做日志压缩。
快照(Snapshot)是一种常用的、简单的日志压缩方式,ZooKeeper、Chubby 等系统都在用。简单来说,就是将某一时刻系统的状态 dump 下来并落地存储,这样该时刻之前的所有日志就都可以丢弃了。
这里对“压缩”一词不要产生错误理解,并没有办法将状态机快照“解压缩”回日志序列。
上图展示了一个节点用快照替换了 (term1, index1) ~ (term3, index5) 的日志。
快照一般包含以下内容:
- 日志的元数据:最后一条被该快照 apply 的日志 term 及 index
- 状态机:前边全部日志 apply 后最终得到的状态机
当 leader 需要给某个 follower 同步一些旧日志,但这些日志已经被 leader 做了快照并删除掉了时,leader 就需要把该快照发送给 follower。
同样,当集群中有新节点加入,或者某个节点宕机太久落后了太多日志时,leader 也可以直接发送快照,大量节约日志传输和回放时间。
同步快照使用一个新的 RPC 方法,叫做 InstallSnapshot RPC。
注意,在 Raft 中只能为 committed 日志做 snapshot,因为只有 committed 日志才是确保最终会应用到状态机的。
另外,生成快照的频率要适中,频率过高会消耗大量 I/O 带宽;频率过低,一旦需要执行恢复操作,会丢失大量数据,影响可用性。
推荐当日志达到某个固定的大小时生成快照。生成一次快照可能耗时过长,影响正常日志同步。可以通过使用 copy-on-write 技术避免快照过程影响正常日志同步。
线性一致性与读性能优化
什么是线性一致性?
在分布式系统中,为了消除单点提高系统可用性,通常会使用副本来进行容错,但这会带来另一个问题,即如何保证多个副本之间的一致性。
什么是一致性?所谓一致性有很多种模型,不同的模型都是用来评判一个并发系统正确与否的不同程度的标准。而现在要讨论的是强一致性(Strong Consistency)模型,也就是线性一致性(Linearizability),经常听到的 CAP 理论中的 C 指的就是它。
前面已经简要描述过何为线性一致性:
所谓的强一致性(线性一致性)并不是指集群中所有节点在任一时刻的状态必须完全一致,而是指一个目标,即让一个分布式系统看起来只有一个数据副本,并且读写操作都是原子的,这样应用层就可以忽略系统底层多个数据副本间的同步问题。也就是说,可以将一个强一致性分布式系统当成一个整体,一旦某个客户端成功的执行了写操作,那么所有客户端都一定能读出刚刚写入的值。即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。
“像单机一样提供服务”从感官上描述了一个线性一致性系统应该具备的特性,那么该如何判断一个系统是否具备线性一致性呢?通俗来说就是不能读到旧(stale)数据,但具体分为两种情况:
- 对于调用时间存在重叠(并发)的请求,生效顺序可以任意确定。
- 对于调用时间存在先后关系(偏序)的请求,后一个请求不能违背前一个请求确定的结果。
只要根据上述两条规则即可判断一个系统是否具备线性一致性。
线性一致性并非限定在分布式环境下,在单机单核系统中可以简单理解为“寄存器”的特性。
Raft 线性一致性读
在了解了什么是线性一致性之后,我们将其与 Raft 结合来探讨。
首先需要明确一个问题,使用了 Raft 的系统都是线性一致的吗?不是的,Raft 只是提供了一个基础,要实现整个系统的线性一致还需要做一些额外的工作。
假设期望基于 Raft 实现一个线性一致的分布式 kv 系统,从最朴素的方案开始,指出每种方案存在的问题,最终使整个系统满足线性一致性。
写主读从缺陷分析
写操作并不是我们关注的重点,如果稍微看了一些理论部分就应该知道,所有写操作都要作为提案从 leader 节点发起,当然所有的写命令都应该简单交给 leader 处理。真正关键的点在于读操作的处理方式,这涉及到整个系统关于一致性方面的取舍。
在该方案中假设读操作直接简单地向 follower 发起,那么由于 Raft 的 Quorum 机制(大部分节点成功即可),针对某个提案在某一时间段内,集群可能会有以下两种状态:
- 某次写操作的日志尚未被复制到一少部分 follower,但 leader 已经将其 commit。
- 某次写操作的日志已经被同步到所有 follower,但 leader 将其 commit 后,心跳包尚未通知到一部分 follower。
以上每个场景客户端都可能读到过时的数据,整个系统显然是不满足线性一致的。
写主读主缺陷分析
在该方案中我们限定,所有的读操作也必须经由 leader 节点处理,读写都经过 leader 难道还不能满足线性一致?是的!!并且该方案存在不止一个问题!!
问题一:状态机落后于 committed log 导致脏读
回想一下前文讲过的,在解释什么是 commit 时提到了写操作什么时候可以响应客户端:
所谓 commit 其实就是对日志简单进行一个标记,表明其可以被 apply 到状态机,并针对相应的客户端请求进行响应。
也就是说一个提案只要被 leader commit 就可以响应客户端了,Raft 并没有限定提案结果在返回给客户端前必须先应用到状态机。所以从客户端视角当我们的某个写操作执行成功后,下一次读操作可能还是会读到旧值。
这个问题的解决方式很简单,在 leader 收到读命令时只需记录下当前的 commit index,当 apply index 追上该 commit index 时,即可将状态机中的内容响应给客户端。
问题二:网络分区导致脏读
假设集群发生网络分区,旧 leader 位于少数派分区中,而且此刻旧 leader 刚好还未发现自己已经失去了领导权,当多数派分区选出了新的 leader 并开始进行后续写操作时,连接到旧 leader 的客户端可能就会读到旧值了。
因此,仅仅是直接读 leader 状态机的话,系统仍然不满足线性一致性。
Raft Log Read
为了确保 leader 处理读操作时仍拥有领导权,可以将读请求同样作为一个提案走一遍 Raft 流程,当这次读请求对应的日志可以被应用到状态机时,leader 就可以读状态机并返回给用户了。
这种读方案称为 Raft Log Read,也可以直观叫做 Read as Proposal。
为什么这种方案满足线性一致?
因为该方案根据 commit index 对所有读写请求都一起做了线性化,这样每个读请求都能感知到状态机在执行完前一写请求后的最新状态,将读写日志一条一条的应用到状态机,整个系统当然满足线性一致。但该方案的缺点也非常明显,那就是性能差,读操作的开销与写操作几乎完全一致。而且由于所有操作都线性化了,我们无法并发读状态机。
Raft 读性能优化
接下来将介绍几种优化方案,它们在不违背系统线性一致性的前提下,大幅提升了读性能。
Read Index
与 Raft Log Read 相比,Read Index 省掉了同步 log 的开销,能够大幅提升读的吞吐,一定程度上降低读的时延。其大致流程为:
- Leader 在收到客户端读请求时,记录下当前的 commit index,称之为 read index。
- Leader 向 followers 发起一次心跳包,这一步是为了确保领导权,避免网络分区时少数派 leader 仍处理请求。
- 等待状态机至少应用到 read index(即 apply index 大于等于 read index)。
- 执行读请求,将状态机中的结果返回给客户端。
这里第三步的 apply index 大于等于 read index 是一个关键点。因为在该读请求发起时,我们将当时的 commit index 记录了下来,只要使客户端读到的内容在该 commit index 之后,那么结果一定都满足线性一致)。
Lease Read
与 Read Index 相比,Lease Read 进一步省去了网络交互开销,因此更能显著降低读的时延。
基本思路是 leader 设置一个比选举超时(Election Timeout)更短的时间作为租期,在租期内我们可以相信其它节点一定没有发起选举,集群也就一定不会存在脑裂,所以在这个时间段内直接读主即可,而非该时间段内可以继续走 Read Index 流程,Read Index 的心跳包也可以为租期带来更新。
Lease Read 可以认为是 Read Index 的时间戳版本,额外依赖时间戳会为算法带来一些不确定性,如果时钟发生漂移会引发一系列问题,因此需要谨慎的进行配置。
Follower Read
在前边两种优化方案中,无论怎么折腾,核心思想其实只有两点:
- 保证在读取时的最新 commit index 已经被 apply。
- 保证在读取时 leader 仍拥有领导权。
其实无论是 Read Index 还是 Lease Read,最终目的都是为了解决第二个问题。换句话说,读请求最终一定都是由 leader 来承载的。
那么读 follower 真的就不能满足线性一致吗?其实不然,这里给出一个可行的读 follower 方案:Follower 在收到客户端的读请求时,向 leader 询问当前最新的 commit index,反正所有日志条目最终一定会被同步到自己身上,follower 只需等待该日志被自己 commit 并 apply 到状态机后,返回给客户端本地状态机的结果即可。这个方案叫做 Follower Read。
注意:Follower Read 并不意味着我们在读过程中完全不依赖 leader 了,在保证线性一致性的前提下完全不依赖 leader 理论上是不可能做到的。
以上就是 Raft 算法的核心内容及工程实践最需要考虑的内容。
Redis 中哨兵就是用了 RAFT 算法
实现分布式锁的方式
在单机场景下,可以使用语言的内置锁来实现进程同步。但是在分布式场景下,需要同步的进程可能位于不同的节点上,那么就需要使用分布式锁。
阻塞锁通常使用互斥量来实现:
互斥量为 0 表示有其它进程在使用锁,此时处于锁定状态;
互斥量为 1 表示未锁定状态。
1 和 0 可以用一个整型值表示,也可以用某个数据是否存在表示。
数据库唯一索引
获得锁时向表中插入一条记录,释放锁时删除这条记录。唯一索引可以保证该记录只被插入一次,那么就可以用这个记录是否存在来判断是否处于锁定状态。
存在以下几个问题:
- 锁没有失效时间,解锁失败的话其它进程无法再获得该锁;
- 只能是非阻塞锁,插入失败直接就报错了,无法重试;
- 不可重入,已经获得锁的进程也必须重新获取锁。
Redis 的 SETNX 指令
使用 SETNX(set if not exist)指令插入一个键值对,如果 Key 已经存在,那么会返回 False,否则插入成功并返回 True。
SETNX 指令和数据库的唯一索引类似,保证了只存在一个 Key 的键值对,那么可以用一个 Key 的键值对是否存在来判断是否存于锁定状态。
EXPIRE 指令可以为一个键值对设置一个过期时间,从而避免了数据库唯一索引实现方式中释放锁失败的问题。
Redis 的 RedLock 算法
使用了多个 Redis 实例来实现分布式锁,这是为了保证在发生单点故障时仍然可用。
- 尝试从 N 个互相独立 Redis 实例获取锁;
- 计算获取锁消耗的时间,只有时间小于锁的过期时间,并且从大多数(N / 2 + 1)实例上获取了锁,才认为获取锁成功;
- 如果获取锁失败,就到每个实例上释放锁。
使用Zookeeper
负载均衡的方式和实现
集群中的应用服务器(节点)通常被设计成无状态,用户可以请求任何一个节点。
负载均衡器会根据集群中每个节点的负载情况,将用户请求转发到合适的节点上。
负载均衡器可以用来实现高可用以及伸缩性:
- 高可用:当某个节点故障时,负载均衡器会将用户请求转发到另外的节点上,从而保证所有服务持续可用;
- 伸缩性:根据系统整体负载情况,可以很容易地添加或移除节点。
负载均衡器运行过程包含两个部分:
- 根据负载均衡算法得到转发的节点;
- 进行转发。
负载均衡算法
1. 轮询
轮询算法把每个请求轮流发送到每个服务器上。
该算法比较适合每个服务器的性能差不多的场景,如果有性能存在差异的情况下,那么性能较差的服务器可能无法承担过大的负载。
2. 加权轮询
加权轮询是在轮询的基础上,根据服务器的性能差异,为服务器赋予一定的权值,性能高的服务器分配更高的权值。
3. 最少连接
由于每个请求的连接时间不一样,使用轮询或者加权轮询算法的话,可能会让一台服务器当前连接数过大,而另一台服务器的连接过小,造成负载不均衡。
最少连接算法就是将请求发送给当前最少连接数的服务器上。
4. 加权最少连接
在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数。
5. 随机算法
把请求随机发送到服务器上。
和轮询算法类似,该算法比较适合服务器性能差不多的场景。
6. 源地址哈希法
源地址哈希通过对客户端 IP 计算哈希值之后,再对服务器数量取模得到目标服务器的序号。
可以保证同一 IP 的客户端的请求会转发到同一台服务器上,用来实现会话粘滞(Sticky Session)
转发的实现
1. HTTP 重定向
HTTP 重定向负载均衡服务器使用某种负载均衡算法计算得到服务器的 IP 地址之后,将该地址写入 HTTP 重定向报文中,状态码为 302。客户端收到重定向报文之后,需要重新向服务器发起请求。
缺点:
- 需要两次请求,因此访问延迟比较高;
- HTTP 负载均衡器处理能力有限,会限制集群的规模。
该负载均衡转发的缺点比较明显,实际场景中很少使用它。
2. DNS 域名解析
在 DNS 解析域名的同时使用负载均衡算法计算服务器 IP 地址。
优点:
- DNS 能够根据地理位置进行域名解析,返回离用户最近的服务器 IP 地址。
缺点:
- 由于 DNS 具有多级结构,每一级的域名记录都可能被缓存,当下线一台服务器需要修改 DNS 记录时,需要过很长一段时间才能生效。
大型网站基本使用了 DNS 做为第一级负载均衡手段,然后在内部使用其它方式做第二级负载均衡。也就是说,域名解析的结果为内部的负载均衡服务器 IP 地址。
3. 反向代理服务器
反向代理服务器位于源服务器前面,用户的请求需要先经过反向代理服务器才能到达源服务器。反向代理可以用来进行缓存、日志记录等,同时也可以用来做为负载均衡服务器。
在这种负载均衡转发方式下,客户端不直接请求源服务器,因此源服务器不需要外部 IP 地址,而反向代理需要配置内部和外部两套 IP 地址。
优点:
- 与其它功能集成在一起,部署简单。
缺点:
- 所有请求和响应都需要经过反向代理服务器,它可能会成为性能瓶颈。
4. 网络层
在操作系统内核进程获取网络数据包,根据负载均衡算法计算源服务器的 IP 地址,并修改请求数据包的目的 IP 地址,最后进行转发。
源服务器返回的响应也需要经过负载均衡服务器,通常是让负载均衡服务器同时作为集群的网关服务器来实现。
优点:
- 在内核进程中进行处理,性能比较高。
缺点:
- 和反向代理一样,所有的请求和响应都经过负载均衡服务器,会成为性能瓶颈。
5. 链路层
在链路层根据负载均衡算法计算源服务器的 MAC 地址,并修改请求数据包的目的 MAC 地址,并进行转发。
通过配置源服务器的虚拟 IP 地址和负载均衡服务器的 IP 地址一致,从而不需要修改 IP 地址就可以进行转发。也正因为 IP 地址一样,所以源服务器的响应不需要转发回负载均衡服务器,可以直接转发给客户端,避免了负载均衡服务器的成为瓶颈。
这是一种三角传输模式,被称为直接路由。对于提供下载和视频服务的网站来说,直接路由避免了大量的网络传输数据经过负载均衡服务器。
这是目前大型网站使用最广负载均衡转发方式,在 Linux 平台可以使用的负载均衡服务器为 LVS(Linux Virtual Server)。
负载均衡session管理
一个用户的 Session 信息如果存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器,由于服务器没有用户的 Session 信息,那么该用户就需要重新进行登录等操作。
Sticky Session
需要配置负载均衡器,使得一个用户的所有请求都路由到同一个服务器,这样就可以把用户的 Session 存放在该服务器中。
缺点:
- 当服务器宕机时,将丢失该服务器上的所有 Session。
Session Replication
在服务器之间进行 Session 同步操作,每个服务器都有所有用户的 Session 信息,因此用户可以向任何一个服务器进行请求。
缺点:
- 占用过多内存;
- 同步过程占用网络带宽以及服务器处理器时间。
Session Server
使用一个单独的服务器存储 Session 数据,可以使用传统的 MySQL,也使用 Redis 或者 Memcached 这种内存型数据库。
优点:
- 为了使得大型网站具有伸缩性,集群中的应用服务器通常需要保持无状态,那么应用服务器不能存储用户的会话信息。Session Server 将用户的会话信息单独进行存储,从而保证了应用服务器的无状态。
缺点:
- 需要去实现存取 Session 的代码。
微服务架构
微服务的概念
就目前而言,对于微服务业界并没有一个统一的、标准的定义(While there is no precise definition of this architectural style ) 。
但通在其常而言,微服务架构是一种架构模式或者说是一种架构风格,它提倡将单一应用程序划分成一组小的服务,每个服务运行独立的自己的进程中,服务之间互相协调、互相配合,为用户提供最终价值。服务之间采用轻量级的通信机制互相沟通(通常是基于 HTTP 的 RESTful API ) 。每个服务都围绕着具体业务进行构建,并且能够被独立地部署到生产环境、类生产环境等。
另外,应尽量避免统一的、集中式的服务管理机制,对具体的一个服务而言,应根据业务上下文,选择合适的语言、工具对其进行构建,可以有一个非常轻量级的集中式管理来协调这些服务。可以使用不同的语言来编写服务,也可以使用不同的数据存储。
微服务具有以下特点:
- 单一职责:微服务架构中的每个服务,都是具有业务逻辑的,符合高内聚、低耦合原则以及单一职责原则的单元,不同的服务通过“管道”的方式灵活组合,从而构建出庞大的系统。
- 进程独立:每一组服务都是独立运行的,可能我这个服务运行在tomcat容器,而另一个服务运行在jetty上。可以通过进程方式,不断的横向扩展整个服务。
- 轻量级通信:过去的协议都是很重的,就像ESB,就像SOAP,轻通信,这意味着相比过去更智能更轻量的服务相互调用,就所谓 smart endpoints and dumb pipes,这些 endpoint 都是解耦的,完成一个业务通信调用串起这些 micro service 就像是 linux 系统中通过管道串起一系列命令业务(通常使用 RESE 作为轻量级通信机制)。
- 基于业务的能力:过去的业务,我们通常会考虑各种各样的依赖关系,考虑系统耦合带来的问题。微服务,可以让开发者更专注于业务的逻辑开发。
- 独立部署:不止业务要独立,部署也要独立。不过这也意味着,传统的开发流程会出现一定程度的改变,开发的适合也要有一定的运维指责
- 无集中式管理:传统的企业级 SOA 服务往往很大,不易于管理,耦合性高,团队开发成本比较大。微服务,可以让团队各思其政的选择技术实现,不同的 service 可以根据各自的需要选择不同的技术栈来实现其业务逻辑。
和单体应用对比
单体应用的优缺点:
优点:
- 易于开发: 开发方式简单,IDE 支持好,方便运行和调试。
- 易于测试: 所有功能运行在一个进程中,一旦进程启动,便可以进行系统测试。
- 易于部署: 只需要将打好的一个软件包发布到服务器即可。
- 易于水平伸缩: 只需要创建一个服务器节点,配置好运行时环境,再将软件包发布到新服务器节点即可运行程序(当然也需要采取分发策略保证请求能有效地分发到新节点)。
缺点:
- 维护成本大: 当应用程序的功能越来越多、团队越来越大时,沟通成本、管理成本显著增加。当出现 bug 时,可能引起 bug 的原因组合越来越多,导致分析、定位和修复的成本增加;并且在对全局功能缺乏深度理解的情况下,容易在修复 bug 时引入新的 bug。
- 持续交付周期长: 构建和部署时间会随着功能的增多而增加,任何细微的修改都会触发部署流水线。
- 新人培养周期长: 新成员了解背景、熟悉业务和配置环境的时间越来越长。
- 技术选型成本高: 单块架构倾向于采用统一的技术平台或方案来解决所有问题,如果后续想引入新的技术或框架,成本和风险都很大。
- 可扩展性差: 随着功能的增加,垂直扩展的成本将会越来越大;而对于水平扩展而言,因为所有代码都运行在同一个进程,没办法做到针对应用程序的部分功能做独立的扩展。
微服务的优缺点
优点
- 每个服务足够内聚,足够小,代码容易理解这样能聚焦一个指定的业务功能或业务需求
- 开发简单、开发效率提高,一个服务可能就是专一的只干一件事。
- 微服务能够被小团队单独开发,这个小团队是 2 到 5 人的开发人员组成。
- 微服务是松藕合的,是有功能意义的服务,无论是在开发阶段或部署阶段都是独立的。
- 微服务能使用不同的语言开发。
- 易于和第三方集成,微服务允许容易且灵活的方式集成自动部署,通过持续集成工具,如Jenkins,Hudson,bamboo。
- 微服务易于被一个开发人员理解,修改和维护,这样小团队能够更关注自己的工作成果。无需通过合作才能体现价值。微服务允许你利用融合最新技术。
- 微服务只是业务逻辑的代码,不会和 HTML,CSS 或其他界面组件混合。
- 每个微服务都有自己的存储能力,可以有自己的数据库。也可以有统一数据库。
总的来说,微服务的优势,就是在于,面对大的系统,可以有效的减少复杂程度,使服务架构的逻辑更清晰明了。
但是这样也会带来很多问题,就譬如分布式环境下的数据一致性,测试的复杂性,运维的复杂性。
缺点
缺点:
- 微服务提高了系统的复杂度;
- 开发人员要处理分布式系统的复杂性:性能、可靠性、异步、数据一致性等
- 服务之间的分布式通信问题;
- 服务的注册与发现问题;
- 服务之间的分布式事务问题;
- 数据隔离带来的报表处理问题;
- 服务之间的分布式一致性问题;
- 服务管理的复杂性,服务的编排;
- 不同服务实例的管理。
微服务架构体系
微服务架构体系主要包括服务注册与发现、服务网关、服务配置中心、服务通信、服务监控以及服务的熔断、隔离、限流、降级。
服务注册与发现——动态扩容
首先,部署一个服务发现服务,它提供所有已注册服务的地址信息的服务。DNS 也算是一种服务发现服务。然后各个应用服务在启动时自动将自己注册到服务发现服务上。并且应用服务启动后会实时(定期)从服务发现服务同步各个应用服务的地址列表到本地。服务发现服务也会定期检查应用服务的健康状态,去掉不健康的实例地址。这样新增实例时只需要部署新实例,实例下线时直接关停服务即可,服务发现会自动检查服务实例的增减。
服务发现还会跟客户端负载均衡配合使用。由于应用服务已经同步服务地址列表在本地了,所以访问微服务时,可以自己决定负载策略。甚至可以在服务注册时加入一些元数据(服务版本等信息),客户端负载则根据这些元数据进行流量控制,实现A/B测试、蓝绿发布等功能。服务发现有很多组件可以选择,比如说Zookeeper 、Eureka、Consul、Etcd等。
有三种实现方式:
第一种:
开发人员开发了程序以后,会找运维配一个域名,服务的话通过 DNS 就能找到我们对应的服务。
缺点是,由于服务没有负载均衡功能,对负载均衡服务,可能会有相当大的性能问题。
第二种,是目前普遍的做法。每一个服务都通过服务端内置的功能注册到注册中心,服务消费者不断轮询注册中心发现对应的服务,使用内置负载均衡调用服务。
缺点是,对多语言环境不是很好,你需要单独给消费者的客户端开发服务发现和负载均衡功能。当然了,这个方法通常都是用在 Spring Cloud 上的。
第三种,是将客户端和负载均衡放在同一个主机,而不是同一个进程内。
这种方法相对第一种第二种方法来说,改善了他们的缺点,但是会极大增加运维成本。
服务网关——权限控制,服务治理
拆分成微服务后,出现大量的服务,大量的接口,使得整个调用关系乱糟糟的。经常在开发过程中,写着写着,忽然想不起某个数据应该调用哪个服务。或者写歪了,调用了不该调用的服务,本来一个只读的功能结果修改了数据……
为了应对这些情况,微服务的调用需要一个把关的东西,也就是网关。在调用者和被调用者中间加一层网关,每次调用时进行权限校验。另外,网关也可以作为一个提供服务接口文档的平台。
使用网关有一个问题就是要决定在多大粒度上使用:最粗粒度的方案是整个微服务一个网关,微服务外部通过网关访问微服务,微服务内部则直接调用;最细粒度则是所有调用,不管是微服务内部调用或者来自外部的调用,都必须通过网关。折中的方案是按照业务领域将微服务分成几个区,区内直接调用,区间通过网关调用。
网关的作用:
- 反向路由:很多时候,公司不想让外部人员看到公司的内部,就需要网关来进行反向路由。即将外部请求转换成内部具体服务条用
- 安全认证:网络中会有很多恶意访问,譬如爬虫,譬如黑客攻击,网关维护安全功能。
- 限流熔断:当请求很多服务不堪重负,会让我们的服务自动关闭,导致不能用服务。限流熔断可以有效的避免这类问题
- 日志监控:所有的外面的请求都会经过网关,这样我们就可以使用网关来记录日志信息
- 灰度发布,蓝绿部署:是指能够平滑过渡的一种发布方式。在其上可以进行 A/B testing,即让一部分用户继续用产品特性 A,一部分用户开始用产品特性 B,如果用户对 B 没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到 B 上面来。
开源网关 Zuul 架构:
zuul 网关核心其实是一个 servlet,所有请求都会经过 zuul servlet 传到 zuulFilter Runner
,然后分发到三种过滤器。
先说说架构图左半部分,分别是使用 Groovy 实现的前置路由过滤器,路由过滤器,后置路由过滤器。
一般请求都会先经过前置路由过滤器处理,一般的自定义 java 封装逻辑也会在这里实现。
路由过滤器,实现的是找到对应的微服务进行调用。
调用完了,响应回来,会经过后置路由过滤器,通过后置路由过滤器我们可以封装日志审计的处理。
可以说zuul网关最大的特色就是它三层过滤器。
架构图右半部分,是 zuul 网关设计的自定义过滤器加载机制。网关内部会有生产者消费者模型,自动的将过滤器脚本发布到zuul网关读取加载运行。
配置中心
以前,开发人员把配置文件放在开发文件里面,这样会有很多隐患。譬如,配置规范不同,无法追溯配置人员。一旦需要大规模改动配置,改动时间会很长,无法追溯配置人员,从而影响整个产品,后果是我们承担不起的。因此就有了配置中心。
以 Spring Cloud Config 来说,它是用来为分布式系统中的基础设施和微服务应用提供集中化的外部配置支持,它分为服务端与客户端两个部分。其中服务端也称为分布式配置中心,它是一个独立的微服务应用,用来连接配置仓库并为客户端提供获取配置信息、加密/解密信息等访问接口;而客户端则是微服务架构中的各个微服务应用或基础设施,它们通过指定的配置中心来管理应用资源与业务相关的配置内容,并在启动的时候从配置中心获取和加载配置信息。
服务通讯
服务间远程调用方式一般有两种:RPC 和 REST
RPC | REST | |
---|---|---|
耦合性 | 强耦合 | 松散耦合 |
消息协议 | TCP | HTTP |
通讯协议 | 二进制 | 文本XML,Json |
性能 | 高 | 低于RPC |
接口契约IDL | thrift,protobuf,IdL | Swagger |
客户端 | 强类型客户端,一般自动生成 | 一般HTTP可访问,生成强类型客户端,多语言支持好 |
案例 | Dubbo,Dubbox,motan,tars,grpc,thrift | spring boot,tax-rs,dropwizard |
开发者友好 | 客户端比较方面,二进制消息不能读 | 可读消息 |
对外开放 | 一般需要转成REST/文本协议 | 可直接对外开发 |
服务监控预警——发现故障的征兆
在高并发分布式的场景下,故障经常是突然间就雪崩式爆发。所以必须建立完善的监控体系,尽可能发现故障的征兆。微服务架构中组件繁多,各个组件所需要监控的指标不同。比如 Redis 缓存一般监控占用内存值、网络流量,数据库监控连接数、磁盘空间,业务服务监控并发数、响应延迟、错误率等。因此如果做一个大而全的监控系统来监控各个组件是不大现实的,而且扩展性会很差。一般的做法是让各个组件提供报告自己当前状态的接口( metrics 接口),这个接口输出的数据格式应该是一致的。然后部署一个指标采集器组件,定时从这些接口获取并保持组件状态,同时提供查询服务。最后还需要一个UI,从指标采集器查询各项指标,绘制监控界面或者根据阈值发出告警。
微服务可分5个监控点:日志监控,Metrics监控,健康检查,调用链检查,告警系统。
监控架构
每一个服务都有一个agent,agent收集到关键信息,会传到一些 MQ 中,为了解耦。同时将日志传入 ELK,将指标传入 InfluxDB 时间序列库。而像 nagios,可以定期向 agent 发起信息检查微服务。
链路追踪
在微服务架构下,一个用户的请求往往涉及多个内部服务调用。为了方便定位问题,需要能够记录每个用户请求时,微服务内部产生了多少服务调用,及其调用关系。这个叫做链路跟踪。
要实现链路跟踪,每次服务调用会在 HTTP 的 HEADERS 中记录至少记录四项数据:
- traceId:traceId 标识一个用户请求的调用链路。具有相同 traceId 的调用属于同一条链路。
- spanId:标识一次服务调用的ID,即链路跟踪的节点 ID。
- parentId:父节点的 spanId。
- requestTime & responseTime:请求时间和响应时间。
另外,还需要调用日志收集与存储的组件,以及展示链路调用的UI组件。
熔断、隔离、限流和降级
面对巨大的突发流量下,大型公司一般会采用一系列的熔断(系统自动将服务关闭防止让出现的问题最大化)、隔离(将服务和服务隔离,防止一个服务挂了其他服务不能访问)、限流(单位时间内之允许一定数量用户访问)、降级(当整个微服务架构整体的负载超出了预设的上限阈值或即将到来的流量预计将会超过预设的阈值时,为了保证重要或基本的服务能正常运行,我们可以将一些不重要或不紧急的服务或任务进行服务的延迟使用 或暂停使用)措施。
熔断
熔断当一个服务因为各种原因停止响应时,调用方通常会等待一段时间,然后超时或者收到错误返回。如果调用链路比较长,可能会导致请求堆积,整条链路占用大量资源一直在等待下游响应。所以当多次访问一个服务失败时,应熔断,标记该服务已停止工作,直接返回错误。直至该服务恢复正常后再重新建立连接。
服务降级
当下游服务停止工作后,如果该服务并非核心业务,则上游服务应该降级,以保证核心业务不中断。比如网上超市下单界面有一个推荐商品凑单的功能,当推荐模块挂了后,下单功能不能一起挂掉,只需要暂时关闭推荐功能即可。
限流
一个服务挂掉后,上游服务或者用户一般会习惯性地重试访问。这导致一旦服务恢复正常,很可能因为瞬间网络流量过大又立刻挂掉,在棺材里重复着仰卧起坐。因此服务需要能够自我保护——限流。限流策略有很多,最简单的比如当单位时间内请求数过多时,丢弃多余的请求。另外,也可以考虑分区限流。仅拒绝来自产生大量请求的服务的请求。例如商品服务和订单服务都需要访问促销服务,商品服务由于代码问题发起了大量请求,促销服务则只限制来自商品服务的请求,来自订单服务的请求则正常响应。
分布式服务接口的幂等性如何设计(比如不能重复扣款)?
所谓幂等性,就是说一个接口,多次发起同一个请求,你这个接口得保证结果是准确的,比如不能多扣款、不能多插入一条数据、不能将统计值多加了 1。这就是幂等性。
设计方案
查询操作:查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select 是天然的幂等操作;
删除操作:删除操作也是幂等的,删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个) ;
唯一索引,防止新增脏数据。比如:支付宝的资金账户,支付宝也有用户账户,每个用户只能有一个资金账户,怎么防止给用户创建资金账户多个,那么给资金账户表中的用户ID加唯一索引,所以一个用户新增成功一个资金账户记录。要点:唯一索引或唯一组合索引来防止新增数据存在脏数据(当表存在唯一索引,并发时新增报错时,再查询一次就可以了,数据应该已经存在了,返回结果即可);
token机制,防止页面重复提交:
- 业务要求:页面的数据只能被点击提交一次;
- 发生原因:由于重复点击或者网络重发,或者 nginx 重发等情况会导致数据被重复提交;
- 解决办法:集群环境采用 token 加 redis (redis单线程的,处理需要排队);单JVM环境:采用 token 加 redis 或 token 加 jvm 内存。
- 处理流程:
- 数据提交前要向服务的申请 token,token 放到 redis 或 jvm 内存,token 有效时间;
- 提交后后台校验 token,同时删除 token,生成新的 token 返回。token 特点:要申请,一次有效性,可以限流。
注意:redis 要用删除操作来判断 token,删除成功代表 token 校验通过,如果用 select + delete 来校验 token,存在并发问题,不建议使用;
悲观锁——获取数据的时候加锁获取:select * from table_xxx where id='xxx' for update
; 注意:id 字段一定是主键或者唯一索引,不然是锁表,悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用;
乐观锁——乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。乐观锁的实现方式多种多样可以通过 version 或者其他状态条件:
- 通过版本号实现
update table_xxx set name=#name#,version=version+1 where version=#version#
- 通过条件限制
update table_xxx set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0
。要求:quality-#subQuality# >= 0,这个情景适合不用版本号,只更新是做数据安全校验,适合库存模型,扣份额和回滚份额,性能更高;
分布式锁:还是拿插入数据的例子,如果是分布是系统,构建全局唯一索引比较困难,例如唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统(redis或zookeeper),在业务系统插入数据或者更新数据,获取分布式锁,然后做操作,之后释放锁,这样其实是把多线程并发的锁的思路,引入多个系统,也就是分布式系统中得解决思路。要点:某个长流程处理过程要求不能并发执行,可以在流程执行之前根据某个标志(用户 ID + 后缀等)获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完成后,释放分布式锁(分布式锁要第三方系统提供);
select + insert: 并发不高的后台系统,或者一些任务 JOB,为了支持幂等,支持重复执行,简单的处理方法是,先查询下一些关键数据,判断是否已经执行过,在进行业务处理,就可以了。注意:核心高并发流程不要用这种方法;
对外提供接口的 api 如何保证幂等。如银联提供的付款接口:需要接入商户提交付款请求时附带:source 来源,seq 序列号
source + seq 在数据库里面做唯一索引,防止多次付款(并发时,只能处理一个请求) 。重点:对外提供接口为了支持幂等调用,接口有两个字段必须传,一个是来源 source,一个是来源方序列号 seq,这个两个字段在提供方系统里面做联合唯一索引,这样当第三方调用时,先在本方系统里面查询一下,是否已经处理过,返回相应处理结果;没有处理过,进行相应处理,返回结果。注意,为了幂等友好,一定要先查询一下,是否处理过该笔业务,不查询直接插入业务系统,会报错,但实际已经处理了。
微服务如何进行拆分
服务粒度
- 三个火枪手原则,即一个微服务三个人负责开发
- 从系统规模来讲,3个人负责开发一个系统,系统的复杂度刚好达到每个人都能全面理解整个系统,又能够进行分工的粒度;2个人,系统的复杂度不够,开发人员可能觉得无法体现自己的技术实力;4个及以上,系统复杂度又无法让开发人员对系统的细节都了解很深
- 从团队管理来说,3个人可以形成一个稳定的备份,即使一个人休假或者调配到其他系统,剩余2个人还可以支撑;2个人压力太大;一个人就是单点啦
拆分方法
基于“三个火枪手”的理论,可以计算出拆分后合适的服务数量
基于业务逻辑拆分
将系统中的业务模块按照职责范围识别出来,每个单独的业务模块拆分为一个独立的服务。
难点问题在于,对“职责范围”的理解差异很大。例如,一个电商系统,第一种方式是将服务划分为“商品”“交易”“用户”3个服务,第二种方式是划分为“商品”“订单”“支付”“发货”“卖家”“买家”6个服务,哪种方式更合理?
困惑在于从业务的角度来拆分,规模粗和细都没有问题,因为拆分基础都是业务逻辑,要判断拆分粒度,不能从业务逻辑角度,根据“三个火枪手”原则,计算一下大概的服务范围
例如,有10个人,按以上原则,大约需要划分4个服务,那么“登录、注册、用户信息管理”都可以划到“用户服务”职责范围内;如果团队规模是100人支撑服务,服务数量可以达到40个,那么“用户登录”就是一个服务了;如果团队规模达到1000人支撑业务,那“用户连接管理”可能就是一个独立的服务了
基于可扩展拆分
将系统中的业务模块按照稳定性排序,将已经成熟和改动不大的服务拆分为稳定服务,将经常变化和迭代的服务拆分为变动服务
稳定的服务粒度可以粗一些,即使逻辑上没有强关联的服务,也可以放在同一个子系统中,例如将“日志服务”和“升级服务”放在同一个子系统中;不稳定的服务粒度可以细一些,但不要太细,始终记住要控制服务的总数量
这样的拆分主要是为了提升项目快速迭代的效率,避免在开发的时候,不小心影响了已有的成熟功能导致线上问题。
基于可靠性拆分
- 将系统中的业务模块按照优先级排序,将可靠性要求高的核心服务和要求低的非核心服务拆分开来,然后重点保证核心服务的高可用。
好处:避免非核心服务故障影响核心服务
例如,日志上报一般都属于非核心服务,但是在某些场景下可能有大量的日志上报,如果系统没有拆分,那么日志上报可能导致核心服务故障;拆分后即使日志上报有问题,也不会影响核心服务
- 核心服务高可用方案可以更加简单
核心服务的功能逻辑更加简单,存储的数据可能更少,用到的组件也会更少,设计高可用方案部分情况下要比不拆分简单很多
- 能够降低高可用成本
将核心服务拆分出来后,核心服务占用的机器、带宽等资源比不拆分要少很多。因此,只针对核心服务做高可用方案,机器、带宽等成本比不拆分要节省较多
基于性能拆分
将性能要求高或者性能压力大的模块拆分出来,避免性能压力大的服务影响其他服务
常见的拆分方式和具体的性能瓶颈有关,可以拆分Web服务、数据库、缓存等
例如,电商的抢购,性能压力最大的是入口的排队功能,可以将排队功能独立为一个服务
以上拆分,可以根据实际情况自由排列组合
微服务和 SOA 区别
应用SOA化
所谓的SOA化,就是业务的服务化。SOA(Service Oriented Architecture),即面向服务的架构。比如原来单机支撑了整个电商网站,现在对整个网站进行拆解,分离出了订单中心、用户中心、库存中心。对于订单中心,有专门的数据库存储订单信息,用户中心也有专门的数据库存储用户信息,库存中心也会有专门的数据库存储库存信息。这时候如果要同时对订单和库存进行操作,那么就会涉及到订单数据库和库存数据库,为了保证数据一致性,就需要用到分布式事务。
区别
微服务是 SOA 发展出来的产物,它是一种比较细化的 SOA 实现方式。
较早实践微服务的公司 Netflix 就曾经称他们构建的架构是“细粒度的SOA”
SOA的出现其实是为了解决历史问题:企业在信息化的过程中会有各种各样互相隔离的系统,需要有一种机制将他们整合起来。同样的,也造成了 SOA 初期的服务是很大的概念,通常指定的一个可以独立运作的系统(这样看,好像服务间天然的松耦合)。这种做法相当于是把子系统服务化。
而微服务轻装上阵,服务的尺寸通常不会太大,关于服务的尺寸,在实际情况中往往是一个服务应该能够代表实际业务场景中的一块不可分割或不易分割的业务实体。将服务的尺寸控制在一个较小的体量可以带来很多的好处:
- 更易于实现低耦合、高内聚
- 更易于维护
- 更易于扩展
- 更易于关注实际业务场景
单体应用怎么改造成分布式应用
单体由于流量越来越大出现服务器性能问题。
改进1:应用服务器和数据库服务器分离
对架构增加了一台服务器,应用和数据库分别部署到不同的服务器上,对于开发和测试没有任何影响,只需要应用服务器新增一个远程调用数据库服务器的连接,有效的缓解了应用服务器负载的压力。
出现以下问题:
- 随着请求流量得进一步增大出现应用服务器性能问题。
改进2:应用服务器集群
流量请求得到缓解。
应用服务器集群后出现以下问题:
- 需要使用 session+cookie 维护用户
- 如何做请求转发(cdn,前端做负载均衡器)
改进3:负载均衡器
- 负载均衡器优化了访问请求在服务器组之间的分配,消除了服务器之间的负载不平衡,从而提高了系统的反应速度与总体性能;
- 负载均衡器可以对服务器的运行状况进行监控,及时发现运行异常的服务器,并将访问请求转移到其它可以正常工作的服务器上,从而提高服务器组的可靠性采用了负均衡器器以后,可以根据业务量的发展情况灵活增加服务器,系统的扩展能力得到提高,同时简化了管理。
负载均衡器之后出现以下问题:
随着流量的新增,数据库服务器有性能压力,数据库遇到瓶颈。
改进4:数据库服务器集群
数据库服务器集群后出现以下问题:
- 数据库读写分离
- 数据库数据同步
- 数据库路由
改进5:缓存服务器
- 用户量是没有上限的
- 缓存、 限流、 降级
改进6:数据库水平/垂直拆分
目前将数据库进行垂直拆分,还未进行数据库水平拆分(比如将订单表分库分表就属于水平拆分)
改进7: 应用服务器垂直拆分
根据不同域名请求访问不同服务器,如果涉及到用户需要查询商品或订单,直接在用户服务器里写DAO层查询商品或订单数据库表。
产生问题:应用服务器交互调用问题。
改进8:微服务拆分
参考内容
主要参考以来两篇博客以及相关博客推荐,因找的博客比较多,没注意记录,最后好多忘了在哪2333,如果有侵权,请及时联系我,非常抱歉。
https://github.com/Snailclimb/JavaGuide
https://github.com/CyC2018/CS-Notes