Marvelous

Shihuan.Wang


Never Stand Still


Distributed System Basics

Catalog

Disclaimer

This archive is the public resource collected and collated from the Internet for studying purpose.
We respect copyright owners. Please contact us in case of any infringement.

分布式一致性:CAP理论

集中式应用进行服务化拆分后,必然会出现一个问题:如何保证各个节点(Node)之间的数据一致性?

比如以下场景: 用户首先发起一次更新操作,映射到节点A;然后,用户又做了一次查询操作,操作映射到了节点B,此时A和B的数据如果不一致,对用户来说就会造成困扰。

分布式系统为了提高可用性,必然会引入冗余机制(副本),而冗余便带来了上面描述的一致性问题。为了解决这类问题,加州大学伯克利分校的Eric Brewer) 教授提出了 CAP 猜想。2年后, Seth Gilbert 和 Nancy Lynch 从理论上证明了猜想的可能性。从此,CAP 理论正式在学术上成为了分布式计算领域的公认定理。

一、CAP三指标

CAP 理论是一个很好的思考框架,它对分布式系统的特性做了高度抽象,抽象成了一致性、可用性和分区容错性,并对特性间的冲突做了总结。一旦掌握它,我们自然而然就能根据业务场景的特点进行权衡,设计出适合的系统模型。

我们先来看看CAP理论中三个指标的含义。

1.1 一致性(Consistence)

一致性,指的是客户端的每次读操作,不管访问哪个节点,要么读到的都是同一份最新数据,要么读取失败。

注意,一致性是站在客户端的视角出发的,并不是说在某个时间点分布式系统的所有节点的数据是一致的。事实上,在一个事务执行过程中,系统就是处于一种不一致状态,但是客户端是无法读取事务未提交的数据的,此时客户端会直接读取失败。

CAP理论中的一致性是强一致性,举个例子来理解下:

初始时,节点1和节点2的数据是一致的,然后客户端向节点1写入了新数据“X = 2”:

节点1在本地更新数据后,通过节点间的通讯,同步数据到节点2,确认节点2写入成功后,然后返回成功给客户端:

这样两个节点的数据就是一致的了,之后,不管客户端访问哪个节点,读取到的都是同一份最新数据。如果节点2在同步数据的过程中,有另外的客户端来访问任意节点,都会拒绝,这就是强一致性。

1.2 可用性(Availability)

可用性,指的是客户端的请求,不管访问哪个节点,都能得到响应数据,但不保证是同一份最新数据。你也可以把可用性看作是分布式系统对访问本系统的客户端的另外一种承诺:我尽力给你返回数据,不会不响应你,但是我不保证每个节点给你的数据都是最新的。

这个指标强调的是服务可用,但不保证数据的一致。

1.3 分区容错性(Network partitioning)

分区容错性,指的是当节点间出现消息丢失、高延迟或者已经发生网络分区时,系统仍然可以继续提供服务。也就是说,分布式系统在告诉访问本系统的客户端:不管我的内部出现什么样的数据同步问题,我会一直运行,提供服务。

分区容错性,强调的是集群对分区故障的容错能力。 因为分布式系统与单机系统不同,它涉及到多节点间的通讯和交互,节点间的分区故障是必然发生的,所以在分布式系统中分区容错性是必须要考虑的。

既然分区容错是必须要考虑的,那么这时候系统该如何运行呢?是选择一致性(C)呢,还是选择可用性(P)呢?这就引出了著名的“CAP不可能三角”。

二、CA/CP/AP选择

所谓“CAP不可能三角”,其实就是CAP理论中提到的: 对于一个分布式系统而言,一致性(Consistency)、可用性(Availability)、分区容错性(Partition Tolerance)3 个指标不可兼得,只能在 3 个指标中选择 2 个。

上面说过了,因为只要有网络交互就一定会有延迟和数据丢失,而这种状况我们必须接受,同时还必须保证系统不能挂掉,所以节点间的分区故障是必然发生的。也就是说,分区容错性(P)是前提,是必须要保证的。

所以理论上CAP一般只能取CP或AP,CA只存在于集中式应用中。

2.2 AP架构

当选择了可用性(A)的时候,系统将始终处理客户端的查询,返回特定信息,如果发生了网络分区,一些节点将无法返回最新的特定信息,它们将返回自己当前的相对新的信息。

如下图,T1时刻,客户端往节点A写入Message 2,此时发生了网络分区,节点A的数据无法同步到节点B;T2时刻,客户端访问节点B时,节点B将自己当前拥有的数据Message 1返回给客户端,而实际上当前最新数据已经是Message2了,这就不满足一致性(C),此时CAP三者只能满足AP。

注意:这里节点B返回的Message 1虽然不是一个”正确“的结果,但是一个”合理“的结果,因为节点B只是返回的不是最新结果,并不是一个错乱的值。

三、总结

  • CA 模型:在分布式系统中不存在,因为舍弃 P,意味着舍弃分布式系统,就比如单机版关系型数据库 MySQL,如果 MySQL 要考虑主备或集群部署时,它必须考虑 P。

  • CP 模型:采用 CP 模型的分布式系统,一旦因为消息丢失、延迟过高发生了网络分区,就影响用户的体验和业务的可用性。因为为了防止数据不一致,集群将拒绝新数据的写入,典型的应用是 ZooKeeper,Etcd 和 HBase。

  • AP 模型:采用 AP 模型的分布式系统,实现了服务的高可用。用户访问系统的时候,都能得到响应数据,不会出现响应错误,但当出现分区故障时,相同的读操作,访问不同的节点,得到响应数据可能不一样。典型应用就比如 Cassandra 和 DynamoDB。

最后,关于CAP 理论有个误解:就是认为无论在什么情况下,分布式系统都只能在 C 和 A 中选择 1 个。 事实上,在不存在网络分区的情况下(也就是分布式系统正常运行时),C 和 A 能够同时保证。只有当发生分区故障的时候,也就是说需要 P 时,才会在 C 和 A 之间做出选择。

所以,我们在进行系统设计时,需要根据实际的业务场景, 在一致性和可用性之间做出权衡。

分布式一致性:BASE理论

我提到分布式系统理论上只能取CP或AP,如果要实现强一致性必然会影响可用性。但是,大多数系统实际上不需要那么强的一致性,而是更关注可用性。

比如一个3节点的集群,假设每个节点的可用性为 99.9%,那么整个集群的可用性为 99.7%,也就是说,每个月约宕机 129.6 分钟,这对于很多系统是非常严重的问题,所以生产环境,大多数系统都会采用可用性优先的 AP 模型。

在对大规模分布式系统的实践过程中,eBay 的架构师 Dan Pritchett 提出了BASE理论,其核心思想就是:

即使无法做到强一致性(Strong Consistency,CAP 的一致性就是强一致性),但分布式系统可以采用适合的方式达到最终一致性(Eventual Consitency)。

Base 理论是 CAP 理论中的 AP 的延伸,是对互联网大规模分布式系统的实践总结,强调可用性。BASE 是 基本可用(Basically Available) 、软状态(Soft-state) 和 最终一致(Eventually Consistent) 三个短语的缩写:

一、基本可用

在BASE理论中,基本可用是说,当分布式系统在出现不可预知的故障时,允许损失部分功能的可用性,保障核心功能的可用性。

具体来说,你可以把基本可用理解成,当系统节点出现大规模故障的时候,比如专线的光纤被挖断、突发流量导致系统过载,这个时候可以通过服务降级,牺牲部分功能的可用性,保障系统的核心功能可用。

1.1 实现方式

实现分布式系统基本可用的手段有很多,常见的有:

  • 流量削峰
  • 请求排队
  • 服务降级
  • 服务熔断

所以,基本可用在本质上是一种妥协,也就是在出现节点故障或系统过载的时候,通过牺牲非核心功能的可用性,保障核心功能的稳定运行。

二、最终一致

最终一致性是指,分布式系统即使无法做到强一致性,但应当根据自身业务特点,采用适当的方式在一定时限后使各个节点的数据最终能够达到一致的状态。这个时限取决于网络延时,系统负载,数据复制方案设计等等因素。

几乎所有的互联网系统采用的都是最终一致性,只有在实在无法使用最终一致性,才使用强一致性或事务,比如,对于决定系统运行的敏感元数据,需要考虑采用强一致性,对于涉账类的支付系统或清算系统的数据,需要考虑采用事务。

我们可以将CAP理论中的“强一致性”理解为最终一致性的特例,也就是说,你可以把强一致性看作是不存在延迟的一致性。在实践中,你也可以这样思考: 如果业务功能无法容忍一致性的延迟(比如分布式锁对应的数据),就实现强一致性;如果能容忍短暂的一致性延迟(比如 QQ 状态数据),则可以考虑最终一致性。

2.1 实现方式

那么如何实现最终一致性呢?你首先要知道它以什么为准,因为这是实现最终一致性的关键。一般来说,在实际工程实践中有这样几种方式:

  • 读时修复:在读取数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Read Repair 实现,具体来说,就是向 Cassandra 系统查询数据的时候,如果检测到不同节点的副本数据不一致,系统就自动修复数据;

  • 写时修复:在写入数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Hinted Handoff 实现,具体来说,就是Cassandra 集群的节点之间远程写数据的时候,如果写失败就将数据缓存下来,然后定时重传,修复数据的不一致性。

  • 异步修复:这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。

注意,因为写时修复不需要做数据一致性对比,性能消耗比较低,对系统运行影响也不大,所以许多开源框架都是用这种方式实现最终一致性的。而读时修复和异步修复因为需要做数据的一致性对比,性能消耗比较多,所以需要尽量优化一致性对比的算法,降低性能消耗,避免对系统运行造成影响。

在实现最终一致性的时候,一般要实现自定义写一致性级别(All、Quorum、One、Any), 比如Elasticsearch在进行索引数据同步时,就支持各种写一致性级别。

三、软状态

软状态,描述的是实现服务可用性的时候系统数据的一种过渡状态,也就是说不同节点间,数据副本存在短暂的不一致。

比如,分布式存储中一般一份数据至少会有N个副本,允许系统在不同节点的数据副本之间进行数据同步的过程中存在延时。mysql replication的异步复制也是一种体现。

这里,我们只需要知道软状态是一种过渡状态就可以了,BASE理论的重点是基本可用以及最终一致性。

四、总结

BASE 理论是对 CAP 中一致性和可用性权衡的结果,它来源于对大规模互联网分布式系统实践的总结,是基于 CAP 定理逐步演化而来的。它的核心思想是:如果不是必须的话,不推荐实现事务或强一致性,鼓励可用性和性能优先,根据业务的特点,来实现非常弹性的基本可用,以及数据的最终一致性。

分布式事务:2PC

一、何谓分布式事务

1.1 单体应用

首先,来看下传统的单体应用(Monolithic App)。下图是一个单体应用的 3 个 模块,在同一个数据源上更新数据来完成一项业务,整个过程的数据一致性可以由数据库的本地事务来保证,如下图:

关于传统的数据库事务,其背后的核心就是ACID理论,本文不再赘述,读者可以参阅专门的书籍,后续我的MySQL系列也会专门讲解。

1.2 分布式应用

随着业务需求和架构的变化,单体应用进行了服务化拆分:原来的 3 个 模块被拆分为 3 个独立的服务,每个服务使用独立的数据源(Pattern: Database per service)。整个业务过程将由 3 个服务的调用来完成,如下图:

此时,每个服务自身的数据一致性仍有本地事务来保证,但是整个业务层面的全局数据一致性要如何保障呢?比如订单服务和账户服务,都有各自的数据库,必须保证操作的一致性,不能出现下单成功但是没记账的情况。这就是分布式系统所面临的典型分布式事务需求:

分布式系统需要一个解决方案来保障对所有节点操作的数据一致性,这些操作组成一个分布式事务,要么全部执行,要么全部不执行。

二、二阶段协议详解

二阶段提交(2PC, two-phase commit protocol),顾名思义,就是通过二阶段的协商来完成一个提交操作。

2PC 最早是用来实现数据库的分布式事务的,不过现在最常用的协议是 XA 协议。这个协议是 X/Open 国际联盟基于二阶段提交协议提出的,也叫作 X/Open Distributed Transaction Processing(DTP)模型,比如 MySQL 就是通过 MySQL XA 实现了分布式事务。

2PC分为两个阶段:投票阶段和提交阶段,我们来详细看下。

2.1 事务过程

二阶段提交协议,包含两类节点:

  • 一个中心化协调者节点(coordinator),一般也叫做事务协调者

  • 多个参与者节点(participant、cohort),一般也叫做事务参与者

二阶段提交协议的每一次事务提交过程如下:

投票阶段(commit-request phase / voting phase)

1、事务协调者请所有事务参与者进行投票:是否可以提交事务,然后等待所有参与者的投票结果;

2、参与者如果投票表示可以提交事务,那么就必须预留本地资源(执行本地事务),然后响应YES,后续也不再允许放弃事务;如果不能,就返回NO响应;

3、如果协调者接受某个参与者的响应超时,它会认为该参与者投票为NO,即预留资源失败。

提交阶段(commit phase)

在该阶段,事务协调者将基于投票阶段的投票结果进行决策:提交或取消各参与者的本地事务

1、仅当所有参与者都返回 YES 响应时,协调者才向所有参与者发出提交请求,此时所有参与者必须保证提交事务成功;

2、如果投票阶段中任意一个参与者返回 No 响应,则协调者向所有参与者发出回滚请求,所有参与者进行回滚操作。

两阶段提交协议成功场景示意图:

2PC假设所有节点都采用预写式日志(Write-Ahead Logging)来写数据,且日志写入后不会丢失。WAL 的核心思想就是先写日志,再写数据。

2.2 优缺点

优点:

  • 强一致性,因为一阶段预留了资源,所有只要节点或者网络最终恢复正常,协议就能保证二阶段执行成功;

  • 业界标准支持,二阶段协议在业界有标准规范——XA 规范,许多数据库和框架都有针对XA规范的分布式事务实现。

缺点:

  • 在提交请求阶段,需要预留资源,在资源预留期间,其他人不能操作(比如,XA 在第一阶段会将相关资源锁定) ,会造成分布式系统吞吐量大幅下降;

  • 容错能力较差,比如在节点宕机或者超时的情况下,无法确定流程的状态,只能不断重试,同时这也会导致事务在访问共享资源时发生冲突和死锁的概率增高,随着数据库节点的增多,这种趋势会越来越严重,从而成为系统在数据库层面上水平伸缩的”枷锁”;

2PC分布式事务方案,比较适合单体应用跨多库的场景,一般用spring + JTA就可以实现。但是因为严重依赖于数据库层面来搞定复杂的事务,效率很低,所以绝对不适合高并发的场景。

注意:一般来说,如果某个服务内部出现跨多库的直接操作,其实是不合规的。 按照分布式服务治理的规范,一个分布式系统,拆成几十个服务,每个服务只能操作自己对应的一个数据库,如果需要操作别的服务对应的库,不允许直连库,必须通过调用别的服务的接口来实现。

三、总结

二阶段提交协议,虽然是目前分布式事务的事实规范,但实际应用并不多。不过2PC是一种非常经典的思想,Paxos、Raft 等强一致性算法,都采用了二阶段提交操作。

所以,读者应当理解该协议背后的二阶段提交的思想,当后续需要时,能灵活地根据二阶段提交思想,设计新的事务或一致性协议。

分布式事务:3PC

一、简介

在二阶段协议中,事务参与者在投票阶段,如果同意提交事务,则会锁定资源,此时任何其他访问该资源的请求将处于阻塞状态。

正因为这个原因,三阶段协议(Three-phase commit protocol, 3PC)对二阶段协议进行了改进:

  • 一方面引入超时机制,解决资源阻塞问题;

  • 另一方面新增一个询问阶段(CanCommit),提前确认下各个参与者的状态是否正常。

二、协议详解

我们先来看下三阶段提交协议的成功场景:

2.1 询问阶段(CanCommit)

询问阶段,事务协调者向事务参与者发送 CanCommit 请求,参与者如果可以提交就返回 Yes 响应,否则返回 No 响应。这样的话,询问阶段就可以确保尽早的发现无法执行操作的参与者节点,提升效率。该阶段参与者也不会取锁定资源。

1、事务协调者发送事务询问指令(canCommit),询问事务参与者是否可以提交事务;

2、参与者如果可以提交就返回 Yes 响应,否则返回 No 响应,不需要做真正的操作。

对于事务协调者,如果询问阶段有任一参与者返回NO或超时,则协调者向所有参与者发送abort指令。 对于返回NO的参与者,如果在指定时间内无法收到协调者的abort指令,则自动中止事务。

2.2 准备阶段(PreCommit)

事务协调者根据事务参与者在询问阶段的响应,判断是执行事务还是中断事务:

1、如果询问阶段所有参与者都返回YES,则协调者向参与者们发送预执行指令(preCommit),参与者接受到preCommit指令后,写redo和undo日志,执行事务操作,占用资源,但是不会提交事务;

2、参与者响应事务操作结果,并等待最终指令:提交(doCommit)或中止(abort)。

2.3 提交阶段(DoCommit)

1、如果每个参与者在准备阶段都返回ACK确认(即事务执行成功),则协调者向参与者发起提交指令(doCommit),参与者收到指令后提交事务,并释放锁定的资源,最后响应ACK;

2、如果任意一个参与者在准备阶段返回NO(即执行事务操作失败),或者协调者在指定时间没收到全部的ACK响应,就会发起中止(abort)指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源。

当参与者响应ACK后,即使在指定时间内没收到doCommit指令,也会进行事务的最终提交; 一旦进入提交阶段,即使因为网络原因导致参与者无法收到协调者的doCommit或Abort请求,超时时间一过,参与者也会自动完成事务的提交。

三、优缺点

优点:

1、增加了一个询问阶段,询问阶段可以确保尽早的发现无法执行操作的参与者节点,提升效率;

2、在准备阶段成功以后,协调者和参与者执行的任务中都增加了超时,一旦超时,参与者都会继续提交事务,默认为成功,降低了阻塞范围。

缺点:

1、如果准备阶段执行事务后,某些参与者反馈执行事务失败,但是由于出现网络分区,导致这些参与者无法收到协调者的中止请求,那么由于超时机制,这些参与者仍会提交事务,导致出现不一致;

2、性能瓶颈,不适合高并发场景。

所以无论是 2PC 还是 3PC,当出现网络分区且不能及时恢复时, 都不能保证分布式系统中的数据 100% 一致。

四、总结

三阶段提交协议,虽然针对二阶段提交协议的“协调者故障,参与者长期锁定资源”的痛点,通过引入了询问阶段和超时机制,来减少资源被长时间锁定的情况,但这也会导致集群各节点在正常运行的情况下,使用更多的消息进行协商,增加系统负载和响应延迟。也正是因为这些问题,三阶段提交协议很少被使用。

分布式事务:TCC

一、简介

2007年,Pat Helland发表了一篇名为《Life beyond Distributed Transactions: an Apostate’s Opinion》的论文,提出了TCC(Try-Confirm-Cancel) 的概念。

两阶段提交(2PC)和三阶段提交(3PC)并不适用于并发量大的业务场景。TCC事务机制相比于2PC、3PC,不会锁定整个资源,而是通过引入补偿机制,将资源转换为业务逻辑形式,锁的粒度变小。

TCC的核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作,分为三个阶段:

  • Try:这个阶段对各个服务的资源做检测以及对资源进行锁定或者预留;

  • Confirm :执行真正的业务操作,不作任何业务检查,只使用Try阶段预留的业务资源,Confirm操作要求具备幂等设计,Confirm失败后需要进行重试;

  • Cancel:如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,即执行回滚操作,释放Try阶段预留的业务资源 ,Cancel操作要求具备幂等设计,Cancel失败后需要进行重试。

举个例子,电商系统中有两个服务:订单服务A、库存服务B: 对外提供服务时,必须接受一些不确定性,即对服务A/B的一次调用仅是一个临时性操作,服务消费方保留了后续的取消权。

如果消费方认为全局事务应该rollback,它会要求取消之前的临时性操作;如果消费方认为全局事务应该commit时,它会进行的一个确认操作。

二、TCC的执行

TCC将一次事务操作分为三个阶段:Try、Confirm、Cancel,我们通过一个订单/库存的示例来理解。假设我们的分布式系统一共包含4个服务:订单服务、库存服务、积分服务、仓储服务,每个服务有自己的数据库,如下图:

2.1 Try

Try阶段一般用于锁定某个资源,设置一个预备状态或冻结部分数据。对于示例中的每一个服务,Try阶段所做的工作如下:

  • 订单服务:先置一个中间状态“UPDATING”,而不是直接设置“支付成功”状态;

  • 库存服务:先用一个冻结库存字段保存冻结库存数,而不是直接扣掉库存;

  • 积分服务:预增加会员积分;

  • 仓储服务:创建销售出库单,但状态是UNKONWN。

2.2 Confirm

根据Try阶段的执行情况,Confirm分为两种情况:

1、理想情况下,所有Try全部执行成功,则执行各个服务的Confirm逻辑;

2、部分服务Try执行失败,则执行第三阶段——Cancel。

Confirm阶段一般需要各个服务自己实现Confirm逻辑:

  • 订单服务:confirm逻辑可以是将订单的中间状态变更为PAYED-支付成功;

  • 库存服务:将冻结库存数清零,同时扣减掉真正的库存;

  • 积分服务:将预增加积分清零,同时增加真实会员积分;

  • 仓储服务:修改销售出库单的状态为已创建-CREATED。

Confirm阶段的各个服务本身可能出现问题,这时候一般就需要TCC框架了(比如ByteTCC,tcc-transaction,himly),TCC事务框架一般会记录一些分布式事务的活动日志,保存事务运行的各个阶段和状态,从而保证整个分布式事务的最终一致性。

2.3 Cancel

如果Try阶段执行异常,就会执行Cancel阶段。比如:对于订单服务,可以实现的一种Cancel逻辑就是:将订单的状态设置为“CANCELED”;对于库存服务,Cancel逻辑就是:将冻结库存扣减掉,加回到可销售库存里去。

许多公司为了简化TCC的使用,通常会将一个服务的某个核心接口拆成两个,比如库存服务的扣减库存接口,拆成两个子接口:①扣减接口 ②回滚扣减库存接口,由TCC框架来保证当某个接口执行失败后去执行对应的rollback接口。

三、总结

从正常的流程上讲,TCC仍然是一个两阶段提交协议。但是,在执行出现问题的时候,有一定的自我修复能力,如果任何一个事务参与者出现了问题,协调者可以通过执行逆操作来取消之前的操作,达到最终的一致状态(比如冲正交易、查询交易)。

从TCC的执行流程也可以看出,服务提供方需要提供额外的补偿逻辑,那么原来一个服务接口,引入TCC后可能要改造成3种逻辑:

  • Try:先是服务调用链路依次执行Try逻辑;

  • Confirm:如果都正常的话,TCC分布式事务框架推进执行Confirm逻辑,完成整个事务;

  • Cancel:如果某个服务的Try逻辑有问题,TCC分布式事务框架感知到之后就会推进执行各个服务的Cancel逻辑,撤销之前执行的各种操作。

注意:在设计TCC事务时,接口的Cancel和Confirm操作都必须满足幂等设计。

3.1 框架选型

TCC框架的可供选择余地比较少,目前相对比较成熟的是阿里开源的分布式事务框架seata(Seata并不完全是一个TCC事务框架),这个框架是经历过阿里生产环境的大量考验,同时也支持dubbo、spring cloud。

3.2 优点

跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些,当然性能也可以得到提升。

3.3 缺点

TCC模型对业务的侵入性太强,事务回滚实际上就是自己写业务代码来进行回滚和补偿,改造的难度大。一般来说支付、交易等核心业务场景,可能会用TCC来严格保证分布式事务的一致性,要么全部成功,要么全部自动回滚。这些业务场景都是整个公司的核心业务有,比如银行核心主机的账务系统,不容半点差池。

但是,在一般的业务场景下,尽量别没事就用TCC作为分布式事务的解决方案,因为自己手写回滚/补偿逻辑,会造成业务代码臃肿且很难维护。

分布式事务:可靠消息最终一致性方案

一、简介

本章,我们将要介绍一种生产上最常用的分布式事务解决方案——可靠消息最终一致性方案。所谓可靠消息最终一致性方案,其实就是在分布式系统当中,把一个业务操作转换成一个消息,然后利用消息来实现事务的最终一致性。

比如从A账户向B账户转账的操作,当服务A从A账户扣除完金额后,通过消息中间件向服务B发一个消息,服务B收到这条消息后,进行B账户的金额增加操作。

可靠消息最终一致性方案一般有两种实现方式,原理其实是一样的:

  • 基于本地消息表

  • 基于支持分布式事务的消息中间件,如RocketMQ等

二、本地消息表

基于本地消息表的分布式事务,是最简便的实现方式,其核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于eBay。

我们来看下面这张图,基于本地消息服务的分布式事务分为三大部分:

  • 可靠消息服务:存储消息,因为通常通过数据库存储,所以也叫本地消息表

  • 生产者(上游服务):生产者是接口的调用方,生产消息

  • 消费者(下游服务):消费者是接口的服务方,消费消息

2.1 可靠消息服务

可靠消息服务就是一个单独的服务,有自己的数据库,其主要作用就是存储消息(包含接口调用信息,全局唯一的消息编号),消息通常包含以下状态:

  • 待确认:上游服务发送待确认消息

  • 已发送:上游服务发送确认消息
  • 已取消(终态):上游服务发送取消消息
  • 已完成(终态):下游服务确认接口执行完成

2.2 生产者

服务调用方(消息生产者)需要调用下游接口时,不直接通过RPC之类的方式调用,而是先生成一条消息,其主要步骤如下:

1、生产者调用接口前,先发送一条待确认消息(一般称为half-msg,包含接口调用信息)给可靠消息服务,可靠消息服务会将这条记录存储到自己的数据库(或本地磁盘),状态为【待确认】;

2、生产者执行本地事务,本地事务执行成功并提交后,向可靠消息服务发送一条确认消息;如果本地执行失败,则向消息服务发送一条取消消息;

3、可靠消息服务如果收到消息后,修改本地数据库中的那条消息记录的状态改为【已发送】或【已取消】。如果是确认消息,则将消息投递到MQ消息队列;(修改消息状态和投递MQ必须在一个事务里,保证要么都成功要么都失败)

为了防止出现:生产者的本地事务执行成功,但是发送确认/取消消息超时的情况。可靠消息服务里一般会提供一个后台定时任务,不停的检查消息表中那些【待确认】的消息,然后回调生产者(上游服务)的一个接口,由生产者确认到底是取消这条消息,还是确认并发送这条消息。

通过上面这套机制,可以保证生产者对消息的100%可靠投递。

2.3 消费者

服务提供方(消息消费者),从MQ消费消息,然后执行本地事务。执行成功后,反过来通知可靠消息服务,说自己处理成功了,然后可靠消息服务就会把本地消息表中的消息状态置为最终状态【已完成】 。

这里要注意两种情况:

1、消费者消费消息失败,或者消费成功但执行本地事务失败。 针对这种情况,可靠消息服务可以提供一个后台定时任务,不停的检查消息表中那些【已发送】但始终没有变成【已完成】的消息,然后再次投递到MQ,让下游服务来再次处理。也可以引入zookeeper,由消费者通知zookeeper,生产者监听到zookeeper上节点变化后,进行消息的重新投递。

2、如果消息重复投递,消息者的接口逻辑需要实现幂等性,保证多次处理一个消息不会插入重复数据或造成业务数据混乱。 针对这种情况,消费者可以准备一张消息表,用于判重。消费者消费消息后,需要去本地消息表查看这条消息有没处理成功,如果处理成功直接返回成功。

2.4 总结

这个方案的优点是简单,但最大的问题在于可靠消息服务是严重依赖于数据库的,即通过数据库的消息表来管理事务,不太适合并发量很高的场景。

三、分布式消息中间件

许多开源的消息中间件都支持分布式事务,比如RocketMQ、Kafka。其思想几乎是和本地消息表/服务实一样的,只不过是将可靠消息服务和MQ功能封装在一起,屏蔽了底层细节,从而更方便用户的使用。这种方案有时也叫做可靠消息最终一致性方案。

以RocketMQ为例,消息的发送分成2个阶段:Prepare阶段和确认阶段。

3.1 prepare阶段

1、生产者发送一个不完整的事务消息——HalfMsg到消息中间件,消息中间件会为这个HalfMsg生成一个全局唯一标识,生产者可以持有标识,以便下一阶段找到这个HalfMsg;

2、生产者执行本地事务。

注意:消费者无法立刻消费HalfMsg,生产者可以对HalfMsg进行Commit或者Rollback来终结事务。只有当Commit了HalfMsg后,消费者才能消费到这条消息。

3.2 确认阶段

1、如果生产者执行本地事务成功,就向消息中间件发送一个Commit消息(包含之前HalfMsg的唯一标识),中间件修改HalfMsg的状态为【已提交】,然后通知消费者执行事务;

2、如果生产者执行本地事务失败,就向消息中间件发送一个Rollback消息(包含之前HalfMsg的唯一标识),中间件修改HalfMsg的状态为【已取消】。

消息中间件会定期去向生产者询问,是否可以Commit或者Rollback那些由于错误没有被终结的HalfMsg,以此来结束它们的生命周期,以达成事务最终的一致。之所以需要这个询问机制,是因为生产者可能提交完本地事务,还没来得及对HalfMsg进行Commit或者Rollback,就挂掉了,这样就会处于一种不一致状态。

3.3 ACK机制

消费者消费完消息后,可能因为自身异常,导致业务执行失败,此时就必须要能够重复消费消息。RocketMQ提供了ACK机制,即RocketMQ只有收到服务消费者的ack message后才认为消费成功。

所以,服务消费者可以在自身业务员逻辑执行成功后,向RocketMQ发送ack message,保证消费逻辑执行成功。

四、示例

我们最后以一个电子商务支付系统的核心交易链路为示例,来更好的理解下可靠消息最终一致性方案。

4.1 交易链路

假设我们的系统的核心交易链路如下图。用户支付订单时,首先调用订单服务的对外接口服务,然后开始核心交易链路的调用,依次经过订单业务服务、库存服务、积分服务,全部成功后再通过MQ异步调用仓储服务:

上图中,订单业务服务、库存服务、积分服务都是同步调用的,由于是核心链路,我们可以通过上一章中讲解的TCC分布式事务来保证分布式事务的一致性。而调用仓储服务可以异步执行,所以我们依赖RocketMQ来实现分布式事务。

4.2 事务执行

接着,我们来看下引入RocketMQ来实现分布式事务后,整个系统的业务执行流程发生了哪些变化,整个流程如下图:

1、当用户针对订单发起支付时,首先订单接口服务先发送一个half-msg消息给RocketMQ,收到RocketMQ的成功响应(注意,此时仓储服务还不能消费消息,因为half-msg还没有确认)。

2、然后,订单接口服务调用核心交易链路,如果其中任一服务执行失败,则先执行内部的TCC事务回滚;

3、如果订单接口服务收到链路失败的响应,则向MQ投递一个rollback消息,取消之前的half-msg;

4、如果订单接口服务收到链路成功的响应,则向MQ投递一个commit消息,确认之前的half-msg,那仓库服务就可以消费消息;

5、仓储服务消费消息成功并执行完自身的逻辑后,会向RocketMQ投递一个ack message,以确保消费成功。

注意,如果因为网络原因,导致RocketMQ始终没有收到订单接口服务对half-msg的commit或rollback消息,RocketMQ就会回调订单接口服务的某个接口,以查询该half-msg究竟是进行commit还是rollback。

五、总结

可靠消息最终一致性方案,一般适用于异步的服务调用,比如支付成功后,调用积分服务进行积分累加、调用库存服务进行发货等等。总结一下,可靠消息最终一致性方案其实最基本的思想就两点:

1、通过引入消息中间件,保证生产者对消息的100%可靠投递;

2、通过引入Zookeeper,保证消费者能够对未成功消费的消息进行重新消费(消费者要保证自身接口的幂等性)。

5.1 优缺点

可靠消息最终一致性方案是目前业务主流的分布式事务落地方案,其优缺点主要如下:

优点:消息数据独立存储,降低业务系统与消息系统间的耦合。

缺点:一次消息发送需要两次请求,业务服务需要提供消息状态查询的回调接口。

一般来讲,99%的分布式接口调用不需要做分布式事务,通过监控(邮件、短信告警)、记录日志,就可以事后快速定位问题,然后就是排查、出解决方案、修复数据。

因为用分布式事务一定是有成本的,而且这个成本会比较高,特别是对于一些中小型公司。同时,引入分布式事务后,代码复杂度、开发周期会大幅上升,系统性能和吞吐量会大幅下跌,这就导致系统更加更加脆弱,更容易出bug。当然,如果有资源能够持续投入,分布式事务做好了的话,好处就是可以100%保证数据一致性不会出错。

全局唯一ID

一、简介

在数据分片中,不管是普通hash、一致性hash还是range based,都要基于某个key进行hash运算,然后根据计算值进行分片。

Key一般采用基于记录的特征值,这个特征值在不同的框架中有不同的叫法,比如MongoDB中的sharding key ,Oracle中的Partition Key。一般来讲,特征值的选取应当具有区分度。比如,在分布式调度系统中,通常会为每个子任务生成一个全局唯一的流水号,由任务控制者通过对流水号进行hash运算,将其分配给Job Executor(负责子任务的执行)。

对于流水号的生成,读者肯定不会陌生,比如数据库的自增主键、uuid等都是生成流水号的方式。但是数据库自增主键只能保证单个DB实例内的单表唯一,uuid则利用了mac地址,缺少规律、可读性及安全性。我们希望的流水号应该满足以下特性:

  • 分布式系统内全局唯一
  • 趋势递增

二、数据库ID表

基于数据库ID表,生成全局流水号,是一种比较常见的方式,以Mysql为例,可以建立一张ID表,表一共两列:id为自增主键,type则表示流水的类型,可以自定义取值,但要保证唯一:

CREATE TABLE `ID_TABLE` (
     `id` BIGINT(64) UNSIGNED NOT NULL AUTO_INCREMENT,
     `type` VARCHAR(16) NOT NULL DEFAULT '',
     PRIMARY KEY (`id`),
     UNIQUE KEY `idx_type` (`type`)
) ENGINE=MYISAM

每次客户端需要生成唯一流水号时,可以通过以下语句获取:

REPLACE INTO ID_TABLE('type') VALUES('ORDER_KEY');
SELECT LAST_INSERT_ID();

这里解释下REPLACE INTO:假如表中有一条记录与用PRIMARY KEY或UNIQUE KEY索引的新记录具有相同的值,则先删除旧记录,再插入新记录。

这种方式对于同一个type值,比如“ORDER_KEY”,生成的流水号都是唯一的。在分布式系统中,可以通过数据库中间件请求到不同的分库,每个分库设置不同的初始值和自增步长,以避免出现重复流水号,如下图:

上述,DB1生成的ID是1,4,7,10,13….,DB2生成的ID是2,5,8,11,14…..

缺点:

显然,基于数据库表生成流水号的方式效率太低,很难满足高并发的业务场景,而且与数据库紧耦合。所以,通常只适合并发量低,但是数据量大的场景。

三、Snow Flake算法

3.1 原理

Snow Flake是Twitter开源的分布式ID生成算法,其结果是一个long型的ID,Snow Flake的核心思想是:

将一个long类型整数按位划分,41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号,最后还有一个符号位,永远是0。

算法实现基本就是二进制操作,单机每秒内理论上最多可以生成1024*(2^12),即409.6万个ID(1024x4096=4194304)

  • 1bit:未使用,因为二进制里第一个bit为如果是1,那么都是负数,但是我们生成的id都是正数,所以第一个bit统一都是0;

  • 41bit:时间截,存储的是时间截的差值(当前时间截 - 开始时间截) ,开始时间截一般是我们的id生成器开始使用的时间,由我们程序来指定的。41位的时间截,可以使用69年,年T = (1L « 41) / (1000x60x60x24x365) = 69;

  • 10bit:记录工作机器ID,代表的是这个服务最多可以部署在2^10(1024)台机器上,包括5位datacenterId和5位workerId。注意,10-bit可以完全表示1024台机器,如果对IDC划分有需求,可以将10-bit分5-bit给IDC,分5-bit给工作机器,这样就可以表示32个IDC,每个IDC下可以有32台机器,可以根据自身需求定义;

  • 12bit:序列号,记录同一个毫秒内产生的不同id,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号。

理论上Snow Flake方案的QPS约为409.6w/s,这种分配方式可以保证在任何一个IDC的任何一台机器在任意毫秒内生成的ID都是不同的。

3.2 优缺点

优点:

  • 整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),效率较高;

  • 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高;

缺点:

  • 由于41位的时间戳代表的是时间差值,所以SnowFlake强依赖于机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。

关于时钟回拨,美团开源了一个Leaf框架,其中的Leaf-segment方案可以比较好的解决时钟回拨问题,但是该方案又引入了外部依赖——Zookeeper,感兴趣的读者可以自行参考。

3.3 代码示例

public class SnowFlake {

    private long workerId;
    private long datacenterId;
    private long sequence;

    public IdWorker(long workerId, long datacenterId, long sequence) {
        // sanity check for workerId
        // 这儿不就检查了一下,要求就是你传递进来的机房id和机器id不能超过32,不能小于0
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
        }
        System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
                timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);

        this.workerId = workerId;
        this.datacenterId = datacenterId;
        this.sequence = sequence;
    }

    private long twepoch = 1288834974657L;

    private long workerIdBits = 5L;
    private long datacenterIdBits = 5L;
    private long maxWorkerId = -1L ^ (-1L << workerIdBits); // 这个是二进制运算,就是5 bit最多只能有31个数字,也就是说机器id最多只能是32以内
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); // 这个是一个意思,就是5 bit最多只能有31个数字,机房id最多只能是32以内
    private long sequenceBits = 12L;

    private long workerIdShift = sequenceBits;
    private long datacenterIdShift = sequenceBits + workerIdBits;
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    private long sequenceMask = -1L ^ (-1L << sequenceBits);

    private long lastTimestamp = -1L;

    public long getWorkerId() {
        return workerId;
    }

    public long getDatacenterId() {
        return datacenterId;
    }

    public long getTimestamp() {
        return System.currentTimeMillis();
    }

    public synchronized long nextId() {
        // 这儿就是获取当前时间戳,单位是毫秒
        long timestamp = timeGen();

        if (timestamp < lastTimestamp) {
            System.err.printf("clock is moving backwards.  Rejecting requests until %d.", lastTimestamp);
            throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds",
                    lastTimestamp - timestamp));
        }

        // 在同一个毫秒内,又发送了一个请求生成一个id,0 -> 1
        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & sequenceMask; // 这个意思是说一个毫秒内最多只能有4096个数字,无论你传递多少进来,这个位运算保证始终就是在4096这个范围内,避免你自己传递个sequence超过了4096这个范围
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0;
        }

        // 这儿记录一下最近一次生成id的时间戳,单位是毫秒
        lastTimestamp = timestamp;

        // 这儿就是将时间戳左移,放到41 bit那儿;将机房id左移放到5 bit那儿;将机器id左移放到5 bit那儿;将序号放最后10 bit;
        // 最后拼接起来成一个64 bit的二进制数字,转换成10进制就是个long型
        return ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }

    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    private long timeGen() {
        return System.currentTimeMillis();
    }

    //---------------测试---------------
    public static void main(String[] args) {
        IdWorker worker = new IdWorker(1, 1, 1);
        for (int i = 0; i < 30; i++) {
            System.out.println(worker.nextId());
        }
    }
}

限流

限流,是从用户访问压力的角度来考虑如何应对故障,即只允许系统能够承受的访问量进来,超出系统访问能力的请求将被丢弃。限流一般都是系统内实现的,常见的限流方式可以分为两类:基于请求限流、基于资源限流、排队限流。

基于请求限流

基于请求限流,指从外部访问的请求角度考虑限流,常见的方式有:限制总量、限制时间量。

  • 限制总量:限制某个指标的累积上限,如限制当前系统服务的用户总量(如在线总用户数上限为100万,超过100万后新的用户无法进入)、限制某个抢购活动商品数量只有100个,限制参与抢购的用户上限为1万个,1万以后的用户直接拒绝;

  • 限制时间量:限制一段时间内某个指标的上限,例如,1分钟内只允许10000个用户访问,每秒请求峰值最高为10万。

无论是限制总量还是限制时间量,共同的特点都是实现简单,但在实践中面临的主要问题是比较难以找到合适的阈值,例如系统设定了1分钟10000个用户,但实际上6000个用户的时候系统就扛不住了;也可能达到1分钟10000用户后,其实系统压力还不大,但此时已经开始丢弃用户访问了。

为了找到合理的阈值,通常情况下可以采用性能压测,但性能压测也存在覆盖场景有限的问题,可能出现某个性能压测没有覆盖的功能导致系统压力很大;另外一种方式是逐步优化,即:先设定一个阈值然后上线观察运行情况,发现不合理就调整阈值。

根据阈值来限制访问量的方式更多的适应于业务功能比较简单的系统,例如负载均衡系统、网关系统、抢购系统等。

目前也有一些比较成熟的基于请求限流的算法,比如令牌桶算法和漏桶算法。

令牌桶算法

令牌桶算法的基本思想如下:

1、首先,我们有一个固定容量的桶,桶里存放着令牌(token),可以看成是资源;

2、桶一开始是空的,token以 一个固定的速率 r 往桶里填充,直到达到桶的容量,多余的令牌将会被丢弃;

3、每当一个请求过来时,就会尝试从桶里移除一个令牌,如果没有令牌的话,就阻塞等待。

令牌桶算法的特点是:当出现峰值流量时,消耗token的速率就会加快,但是token本身产生的速率是一定的,所以该算法可以应对一定的突发流量。

漏桶算法

漏桶算法的基本思想如下:

  • 水(请求)先进入到漏桶里,漏桶以固定的速度出水(接口有响应速率);

  • 当水流速过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求。

漏桶的漏出速率是固定的参数,所以漏桶算法对于存在突发特性的流量来说缺乏效率。

基于资源限流

基于资源限流,指找到系统内部影响性能的关键资源,对其使用上限进行限制。常见的内部资源有:连接数、文件句柄、线程数、请求队列等。

例如,采用Netty来实现服务器,每个进来的请求都先放入一个队列,业务线程再从队列读取请求进行处理,队列长度最大值为10000,队列满了就拒绝后面的请求;也可以根据CPU的负载或者占用率进行限流,当CPU的占用率超过80%的时候就开始拒绝新的请求。

相比基于请求限流,基于资源限流更能有效地反映当前系统的压力,但实践中设计也面临两个主要的难点:如何确定关键资源,如何确定关键资源的阈值。通常情况下,这也是一个逐步调优的过程,即:设计的时候先根据推断选择某个关键资源和阈值,然后测试验证,再上线观察,如果发现不合理,再进行优化。

排队限流

排队限流就是让用户等待一段时间,排队限流不会直接拒绝用户,一般将用户请求放入一个队列中。

排队模块

负责接收用户的请求,将请求以FIFO的方式保存下来。例如,在商户秒杀业务中,可以将每一个参加秒杀活动的商品保存一个队列,队列大小可以根据参与秒杀的商品数量自行定义。

调度模块

负责排队模块到服务模块的动态调度:不断检查服务模块,一旦处理能力有空闲,就从排队队列出队一个用户请求调入服务模块。调度模块不只是传递请求而已,还担负着调节系统处理能力的重任,对服务模块进行负载均衡。

服务模块

负责调用真正的业务处理服务,并返回处理结果。

负载均衡

引言

负载均衡主要从计算能力的角度来考虑性能。

单服务器无论如何优化,无论采用多好的硬件,总会有一个性能天花板,当单服务器的性能无法满足业务需求时,就需要设计高性能集群来提升系统整体的处理性能。高性能集群的本质很简单——通过增加更多的服务器来提升系统整体的计算能力。

高性能集群设计的复杂度主要体现在任务分配这部分,需要设计合理的任务分配策略,将计算任务分配到多台服务器上执行。这个任务分配器,就是“负载均衡器”。常见的负载均衡系统包括3种:DNS负载均衡、硬件负载均衡和软件负载均衡。

DNS负载均衡

DNS是最简单也是最常见的负载均衡方式,一般用来实现地理级别的均衡。例如,北方的用户访问北京的机房,南方的用户访问上海的机房。DNS负载均衡的本质是DNS解析同一个域名可以返回不同的IP地址。例如,同样是www.baidu.com,北方用户解析后获取的地址是61.135.165.224(这是北京机房的IP),南方用户解析后获取的地址是14.215.177.38(这是上海机房的IP)。

下面是DNS负载均衡的简单示意图:

DNS负载均衡实现简单、成本低,但也存在粒度太粗、负载均衡算法少等缺点。

优点:

  • 简单、成本低:负载均衡工作交给DNS服务器处理,无须自己开发或者维护负载均衡设备;

  • 就近访问,提升访问速度:DNS解析时可以根据请求来源IP,解析成距离用户最近的服务器地址,可以加快访问速度,改善性能。

缺点:

  • 更新不及时:DNS缓存的时间比较长,修改DNS配置后,由于缓存的原因,还是有很多用户会继续访问修改前的IP,这样的访问会失败,达不到负载均衡的目的,并且也影响用户正常使用业务;

  • 扩展性差:DNS负载均衡的控制权在域名商那里,无法根据业务特点针对其做更多的定制化功能和扩展特性;

  • 分配策略比较简单:DNS负载均衡支持的算法少;不能区分服务器的差异(不能根据系统与服务的状态来判断负载),也无法感知后端服务器的状态。

硬件负载均衡

硬件负载均衡是通过单独的硬件设备来实现负载均衡功能,这类设备和路由器、交换机类似,可以理解为一个用于负载均衡的基础网络设备。目前业界典型的硬件负载均衡设备有两款:F5和A10。

这类设备性能强劲、功能强大,但价格都不便宜,一般只有大型公司才会考虑使用此类设备。普通业务量级的公司一是负担不起,二是业务量没那么大,用这些设备也是浪费。

优点:

  • 功能强大:全面支持各层级的负载均衡,支持全面的负载均衡算法,支持全局负载均衡;

  • 性能强大:硬件负载均衡可以支持100万以上的并发,而软件负载均衡能支持到10万级并发就很不错了;

  • 稳定性高:商用硬件负载均衡,经过了良好的严格测试,经过大规模使用,稳定性高;

  • 支持安全防护:硬件均衡设备除具备负载均衡功能外,还具备防火墙、防DDoS攻击等安全功能。

缺点:

  • 定制化较为困难

软件负载均衡

软件负载均衡是指通过负载均衡软件来实现负载均衡功能,常见的有Nginx和LVS,其中Nginx是软件的7层负载均衡,LVS是Linux内核的4层负载均衡。下面是Nginx的负载均衡架构示意图:

4层和7层的区别就在于协议和灵活性,Nginx支持HTTP、E-mail协议;而LVS是4层负载均衡,和协议无关,几乎所有应用都可以做,例如,聊天、数据库等。

优点:

  • 简单:无论是部署还是维护都比较简单;

  • 便宜:只要买个Linux服务器,装上软件即可;

  • 灵活:4层和7层负载均衡可以根据业务进行选择,也可以根据业务进行比较方便的扩展,例如,可以通过Nginx的插件来实现业务的定制化功能。

缺点:

  • 性能一般:Ngxin的性能是万级,一个Nginx大约能支撑5万并发,LVS的性能是十万级;

  • 一般不具备防火墙和防DDoS攻击等安全功能。

负载均衡架构

前面我们介绍了3种常见的负载均衡机制:DNS负载均衡、硬件负载均衡、软件负载均衡,每种方式都有一些优缺点,但并不意味着在实际应用中只能基于它们的优缺点进行非此即彼的选择,反而是基于它们的优缺点进行组合使用。具体来说,组合的基本原则为:

  • DNS负载均衡用于实现地理级别的负载均衡;

  • 硬件负载均衡用于实现集群级别的负载均衡;

  • 软件负载均衡用于实现机器级别的负载均衡。

以一个假想的实例来说明一下这种组合方式,如下图所示:

上述整个系统的负载均衡分为三层:

  • 地理级别负载均衡:www.xxx.com部署在北京、广州、上海三个机房,当用户访问时,DNS会根据用户的地理位置来决定返回哪个机房的IP,图中返回了广州机房的IP地址,这样用户就访问到广州机房了。

  • 集群级别负载均衡:广州机房的负载均衡用的是F5设备,F5收到用户请求后,进行集群级别的负载均衡,将用户请求发给3个本地集群中的一个,我们假设F5将用户请求发给了“广州集群2”。

  • 机器级别的负载均衡:广州集群2的负载均衡用的是Nginx,Nginx收到用户请求后,将用户请求发送给集群里面的某台服务器,服务器处理用户的业务请求并返回业务响应。

需要注意的是,上图只是一个示例,一般在大型业务场景下才会这样用,如果业务量没这么大,则没有必要严格照搬这套架构。例如,一个普通的公司管理系统,完全可以不需要DNS负载均衡,也不需要F5设备,只需要用Nginx作为一个简单的负载均衡就足够了。

负载均衡算法

  • 负载均衡算法数量较多,而且可以根据一些业务特性进行定制开发,抛开细节上的差异,根据算法期望达到的目的,大体上可以分为下面几类:

  • 任务平分类:负载均衡系统将收到的任务平均分配给服务器进行处理,这里的“平均”可以是绝对数量的平均,也可以是比例或者权重上的平均;

  • 负载均衡类:负载均衡系统根据服务器的负载来进行分配,这里的负载并不一定是通常意义上我们说的“CPU负载”,而是系统当前的压力,可以用CPU负载来衡量,也可以用连接数、I/O使用率、网卡吞吐量等来衡量系统的压力;

  • 性能最优类:负载均衡系统根据服务器的响应时间来进行任务分配,优先将新任务分配给响应最快的服务器;

  • Hash类:负载均衡系统根据任务中的某些关键信息进行Hash运算,将相同Hash值的请求分配到同一台服务器上。常见的有源地址Hash、目标地址Hash、session id hash、用户ID Hash等。

接下来介绍一下常见的负载均衡算法以及它们的优缺点。

普通轮询

负载均衡系统收到请求后,按照顺序轮流分配到服务器上。轮询是最简单的一个策略,无须关注服务器本身的状态。但如果服务器直接宕机了,或者服务器和负载均衡系统断连了,这时负载均衡系统是能够感知的,也需要做出相应的处理。

“简单”是轮询算法的优点,也是它的缺点。

加权轮询

负载均衡系统根据服务器权重进行任务分配,权重一般是根据硬件配置进行静态配置的,采用动态的方式计算会更加契合业务,但复杂度也会更高。

加权轮询是普通轮询的一种特殊形式,其主要目的就是为了解决不同服务器处理能力有差异的问题。例如,集群中有新的机器是32核的,老的机器是16核的,那么理论上我们可以假设新机器的处理能力是老机器的2倍,负载均衡系统就可以按照2:1的比例分配更多的任务给新机器,从而充分利用新机器的性能。

加权轮询解决了普通轮询算法中无法根据服务器的配置差异进行任务分配的问题,但同样存在无法根据服务器的状态差异进行任务分配的问题。

负载最低优先

负载均衡系统将任务分配给当前负载最低的服务器,这里的负载根据不同的任务类型和业务场景,可以用不同的指标来衡量。例如:

  • LVS这种4层网络负载均衡设备,可以以“连接数”来判断服务器的状态,服务器连接数越大,表明服务器压力越大;

  • Nginx这种7层网络负载系统,可以以“HTTP请求数”来判断服务器状态(Nginx内置的负载均衡算法不支持这种方式,需要进行扩展);

  • 如果我们自己开发负载均衡算法,可以根据业务特点来选择指标衡量系统压力。如果是CPU密集型,可以以“CPU负载”来衡量系统压力;如果是I/O密集型,可以以“I/O负载”来衡量系统压力。

负载最低优先的算法解决了轮询算法中无法感知服务器状态的问题,由此带来的代价是复杂度要增加很多:

  • 负载均衡系统需要统计每个服务器当前建立的连接,其应用场景仅限于负载均衡接收的任何连接请求都会转发给服务器进行处理,否则如果负载均衡系统和服务器之间是固定的连接池方式,就不适合采取这种算法。例如,LVS可以采取这种算法进行负载均衡,而一个通过连接池的方式连接MySQL集群的负载均衡系统就不适合采取这种算法进行负载均衡;

  • CPU负载最低优先的算法要求负载均衡系统以某种方式收集每个服务器的CPU负载,而且要确定是以1分钟的负载为标准,还是以15分钟的负载为标准,不存在1分钟肯定比15分钟要好或者差。不同业务最优的时间间隔是不一样的,时间间隔太短容易造成频繁波动,时间间隔太长又可能造成峰值来临时响应缓慢。

负载最低优先算法基本上能够比较完美地解决轮询算法的缺点,因为采用这种算法后,负载均衡系统需要感知服务器当前的运行状态。当然,其代价是复杂度大幅上升。负载最低优先算法如果本身没有设计好,或者不适合业务的运行特点,算法本身就可能成为性能的瓶颈,或者引发很多莫名其妙的问题。所以负载最低优先算法虽然效果看起来很美好,但实际上真正应用的场景反而没有轮询(包括加权轮询)那么多。

性能最优优先

负载最低优先类算法是站在服务器的角度来进行分配的,而性能最优优先类算法则是站在客户端的角度来进行分配的,优先将任务分配给处理速度最快的服务器,通过这种方式达到最快响应客户端的目的。

性能最优优先类算法本质上也是感知了服务器的状态,只是通过响应时间这个外部标准来衡量服务器状态而已。因此性能最优优先类算法存在的问题和负载最低优先类算法类似,复杂度都很高,主要体现在:

  • 负载均衡系统需要收集和分析每个服务器每个任务的响应时间,在大量任务处理的场景下,这种收集和统计本身也会消耗较多的性能;

  • 为了减少统计上的消耗,可以采取抽样统计:即不统计所有任务的响应时间,而是抽样统计部分任务的响应时间来估算整体任务的响应时间。抽样统计虽然能够减少性能消耗,但使得复杂度进一步上升,采样率太低会导致结果不准确,采样率太高会导致性能消耗较大,找到合适的采样率也是一件复杂的事情。

Hash类

负载均衡系统根据任务中的某些关键信息进行Hash运算,将相同Hash值的请求分配到同一台服务器上,这样做的目的主要是为了满足特定的业务需求。例如:

源地址Hash:

将来源于同一个源IP地址的任务分配给同一个服务器进行处理,适合于存在事务、会话的业务。例如,当我们通过浏览器登录网上银行时,会生成一个会话信息,这个会话是临时的,关闭浏览器后就失效。网上银行后台无须持久化会话信息,只需要在某台服务器上临时保存这个会话就可以了,但需要保证用户在会话存在期间,每次都能访问到同一个服务器,这种业务场景就可以用源地址Hash来实现。

ID Hash:

将某个ID标识的业务分配到同一个服务器中进行处理,这里的ID一般是临时性数据的ID(如session id)。例如,上述的网上银行登录的例子,对sessionId 进行hash同样可以实现同一个会话期间,用户每次都是访问到同一台服务器的目的。