DistributedSystem-CheatSheet
Distributed System CheatSheet | 分布式系统导论
A distributed system involves a set of distinct processes (e.g., computers) passing messages to one another and coordinating to accomplish a common objective (i.e., solving a computational problem). As I mentioned, there are hundreds of architectures for a distributed system. For example, a single computer can also be viewed as a distributed system: the central control unit, memory units, and input-output channels are separate processes collaborating to complete an objective.
分布式系统(确切地说应该是分布式计算机系统)从它诞生到现在已经过去了很长的时间。这种在多台计算机之间交换 / 共享数据的需求催生了面向消息通信的想法,即两台计算机使用包含了数据的消息来共享数据。文件共享、数据库共享等其他机制当时还没有出现。
分布式架构是数据库发展的大势所趋。分布式架构显著提升大容量数据存储和管理能力,既保障面对大量用户的高并发需求,又保障了面对业务变化的弹性增长能力。分布式数据库的使用成本,也远低于传统数据库。由于分布式架构主要使用 PC 服务器与内置盘,因此几乎全部新型分布式数据库均使用多副本技术来保障数据的可靠性与安全性。利用 Windows、Unix、Linux 等操作系统,我们可以在同一台计算机上运行多个任务。这使得分布式系统开发人员能够在一台或者几台通过消息传递连接的计算机内构建和运行整个分布式系统。这催生了面向服务的架构(SOA),其中每个分布式系统可以通过一组集成在一台计算机或多台计算机上运行的服务来构建。我们通过 WSDL(用于 SOAP 协议)或 WADL(用于 REST 协议)等语言适当地定义服务接口。
但是,一旦服务或系统的数量增加,这些服务之间的点到点连接就不再是可扩展和可维护的了。这催生了集中式“服务总线”概念的产生。服务总线通过类似集线器的架构将所有系统连接在一起。这个组件被称为 ESB(企业服务总线)。它作为一个“语言”翻译者,就像一个中间人在帮助一群使用不同“语言”但希望相互通信的人进行沟通。在企业应用中,“语言”代表着在通信时不同系统的消息传递协议和消息格式。
随着万维网的普及和模型的简化,基于 REST 的通信比基于 SOAP 的通信模型变得更加流行。这促进了基于应用程序编程接口(API)的 REST 模型通信的发展。由于 REST 模型的简洁特性,我们需要在标准 REST API 实现之上实现安全(身份验证和授权)、缓存、流控和监控等各种类型的功能。但我们并不想独立地在每个 API 上实现这些功能,而是需要一个公共组件将这些功能应用于这些 API 之上。这样的需求催生了 API 管理平台的发展。现在,它已经成为了任何分布式系统的核心功能之一。
构建跨越多个地理区域和多个数据中心的分布式系统,他们不再把一台计算机当作一台计算机来看,而在同一台计算机内创建多台虚拟计算机。这催生了关于虚拟机的想法,即同一台计算机可以充当多台计算机并且全部并行运行。尽管这是一个还不错的主意,但在宿主计算机的资源利用方面,这并不是最好的选择。运行多个操作系统需要更多的资源,但在同一个操作系统里运行多个程序并不需要这些资源。容器只使用一个宿主操作系统(Linux)的内核,就可以运行多个程序并分别依赖于相互独立的运行时。这个概念在 Linux 操作系统上已经有一段时间了。随着基于容器技术的应用程序部署的普及,它变得更加流行并且有了很多改进和提升。容器可以像虚拟机一样工作,却不需要多一个操作系统的开销。您可以将应用程序和所有相关的依赖项放入容器镜像中。它便可以被放在任何可以运行容器的宿主操作系统中运行。
基于容器的部署带来的轻量特性让跨多个容器的平台维护和编排变得非常复杂。随着微服务架构(MSA)的出现,单体式应用程序被分成更小块的微服务。这些微服务能够完成整个服务里的某一个特定功能并部署在容器中(在大多数情况下都可以)。这给分布式系统生态系统带来了一系列新的需求。要让系统最终保持一致,并且彼此之间没有太多复杂的通信。
现代服务端架构,都可以称为分布式系统
微服务,分布式存储,分布式计算
Fallacies of Distributed Computing
网络是稳定的。网络传输的延迟是零。网络的带宽是无穷大。网络是安全的。网络的拓扑不会改变。只有一个系统管理员。传输数据的成本为零。整个网络是同构的。
分布式系统中的一致性问题其实是一个结果,本质是由于数据冗余导致的,如果没有冗余,也就不会有一致性问题了。一致性问题是结果,共识是为达到这个结果所要经过的过程,或者说一种手段。高可用的本质是通过相同数据存储多个副本,并都可对外提供服务。
分布式系统的产生我认为主要的目的就是“快”和“海量”。这个“快”可以分为两个方面: 第一个是系统的处理速度快。 第二个是开发的速度快(历时短)。“海量”则是由于不存在无穷大的硬盘,所以我们需要把数据分别存储到不同的硬盘上,才能满足需求。
在考虑时间维度的情况下,不存在真正意义上的一致。况且我们在分布式系统中,也没有必要去达到真正的意义上的一致。因为越趋近于一致,系统相当于又归一成一个单体了,在某一个时刻,只能做一件事,完全丧失了分布式系统的两个目的之一“快”的优势。
系统中使用的大部分方案都是所谓的最终一致性,也就容忍一定条件下的不一致,优先保证局部一致,然后再通过一系列复杂的状态同步达到全局的一致。
分布式系统面临了几个问题:一致性问题,可终止性问题、合法性问题。
可终止性可以理解为系统必须在有限的时间内给出一致性结果,合法性是指提案必须是系统内的节点提出。当然其中面对的最重要也是最基础的问题,就是我们常说的一致性问题。
一致性是指在某个分布式系统中,任意节点的提案能够在约定的协议下被其他所有节点所认可。需要提醒你区分的一点是:这里的“认可”表示所有节点对外呈现的信息一致,而不是对信息的内容认可。一致性也分严格一致性、最终一致性。
非人为恶意的意外投票过程。非人为恶意篡改可归类为信鸽半路挂掉、信鸽迷路、信鸽送错目的地、信鸽送信途中下雨导致 信件内容模糊、接收信件的人不在家、天气变化信鸽延迟送达等等。这些对应到分布式系统面临的问题就是:消息丢包、网络拥堵、消息延迟、消息内容校验失败、节点宕机等。 人为恶意篡改投票过程。人为恶意篡改包括“精神分裂式投票”,中继篡改上一个村落的投票信息。对应到分布式系统面临的问题就是:消息被伪造、系统安全攻击等等。发生的人为恶意篡改的过程就可以称之为系统发生了拜占庭错误(Byzantine Fault),如果系统可以容忍拜占庭错误而不至于崩溃,也就是在发生系统被恶意篡改的情况下仍然可以达成一致,我们将这样系统称作为做拜占庭容错系统。
第一个是 FLP 不可能性,简单来说是:即使网络通信完全可靠,只要产生了拜占庭错误,就不存在一个确定性的共识算法能够为异步分布式系统提供一致性。换句话来说就是,不存在一个通用的共识算法 可以解决所有的拜占庭错误。
严格一致性是指在约定的时间内,通常是非常短、高精度的时间内,系统达到一致性的状态,这种系统很难实现,即使实现也很难有高的性能。
所以人们从工程的角度提出了最终一致性,最终一致性不要求严格的短时间内达到一致。为了其他两个指标,我们 相当于让一致性在时间上做了妥协。区块链满足了最终一致性,而且处理过程时间比较长。
可用性其实是传统技术 后端架构上非常重要的指标,从单点到主备模式、从主备模式到异地多活,再到现在的 Paxos 和 Raft 协议。
基础理论
CAP
CAP 定理是分布式系统设计中最基础,也是最为关键的理论,由加州大学伯克利分校 Eric Brewer 教授提出来的。它指出,分布式数据存储不可能同时满足以下三个条件。一致性(Consistency):每次读取要么获得最近写入的数据,要么获得一个错误。可用性(Availability):每次请求都能获得一个(非错误)响应,但不保证返回的是最新写入的数据。分区容忍(Partition tolerance):尽管任意数量的消息被节点间的网络丢失(或延迟),系统仍继续运行。也就是说,CAP 定理表明,在存在网络分区的情况下,一致性和可用性必须二选一。而在没有发生网络故障时,即分布式系统正常运行时,一致性和可用性是可以同时被满足的。这里需要注意的是,CAP 定理中的一致性与 ACID 数据库事务中的一致性截然不同。
对于大多数互联网应用来说(如门户网站),因为机器数量庞大,部署节点分散,网络故障是常态,可用性是必须要保证的,所以只有舍弃一致性来保证服务的 AP。而对于银行等,需要确保一致性的场景,通常会权衡 CA 和 CP 模型,CA 模型网络故障时完全不可用,CP 模型具备部分可用性。
CA (consistency + availability),这样的系统关注一致性和可用性,它需要非常严格的全体一致的协议,比如“两阶段提交”(2PC)。CA 系统不能容忍网络错误或节点错误,一旦出现这样的问题,整个系统就会拒绝写请求,因为它并不知道对面的那个结点是否挂掉了,还是只是网络问题。唯一安全的做法就是把自己变成只读的。 CP (consistency + partition tolerance),这样的系统关注一致性和分区容忍性。它关注的是系统里大多数人的一致性协议,比如:Paxos 算法 (Quorum 类的算法)。这样的系统只需要保证大多数结点数据一致,而少数的结点会在没有同步到最新版本的数据时变成不可用的状态。这样能够提供一部分的可用性。 AP (availability + partition tolerance),这样的系统关心可用性和分区容忍性。因此,这样的系统不能达成一致性,需要给出数据冲突,给出数据冲突就需要维护数据版本。Dynamo 就是这样的系统。
BASE
在分布式系统中,我们往往追求的是可用性,它的重要程序比一致性要高,那么如何实现高可用性呢?前人已经给我们提出来了另外一个理论,就是 BASE 理论,它是用来对 CAP 定理进行进一步扩充的。BASE 理论指的是:
Basically Available(基本可用) Soft state(软状态) Eventually consistent(最终一致性) BASE 理论是对 CAP 中的一致性和可用性进行一个权衡的结果,理论的核心思想就是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
幂等性与分布式 ID
本质是一个操作,无论执行多少次,执行结果总是一致的 幂等核心是全局唯一 ID,链路依据全局 ID 做幂等,依据业务复杂度可以选取多种实现方式 数据库自增长 ID 本地生成 uuid Redis 生产 id Twitter 开源算法 Snowflake HTTP 幂等性,除 POST 外,HEAD,GET,OPTIONS,DELETE,PUT 均满足幂等
分布式锁
在很多互联网产品应用中,有些场景需要加锁处理,比如:秒杀,全局递增 ID,楼层生成等等。大部分是解决方案基于 DB 实现的,Redis 为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对 Redis 的连接并不存在竞争关系。分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。简单的理解就是:分布式锁是一个在很多环境中非常有用的原语,它是不同的系统或是同一个系统的不同主机之间互斥操作共享资源的有效方法。
悲观锁,先获取锁,再进行操作,吞吐量底 乐观锁,使用版本号方式实现,吞吐量高,可能出现锁异常,适用于多读情况 CAS,修改共享数据源的场景可以代替分布式锁
排他性,任意条件只有一个 client 可以获取锁 锁有自动释放方式,比如超时释放 锁必须高可用,且持久化 锁必须非阻塞且可重入 避免死锁,client 最终一定可以获取锁,不存在异常情况锁无法释放的情况 集群容错性,集群部分机器故障,锁操作仍然可用
Redis
1、为避免特殊原因导致锁无法释放, 在加锁成功后, 锁会被赋予一个生存时间(通过 lock 方法的参数设置或者使用默认值), 超出生存时间锁将被自动释放.
2、锁的生存时间默认比较短(秒级, 具体见 lock 方法), 因此若需要长时间加锁, 可以通过 expire 方法延长锁的生存时间为适当的时间. 比如在循环内调用 expire
3、系统级的锁当进程无论因为任何原因出现 crash,操作系统会自己回收锁,所以不会出现资源丢失。
4、但分布式锁不同。若一次性设置很长的时间,一旦由于各种原因进程 crash 或其他异常导致 unlock 未被调用,则该锁在剩下的时间就变成了垃圾锁,导致其他进程或进程重启后无法进入加锁区域。
在分布式版本的算法里我们假设我们有 N 个 Redis master 节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。在我们的例子里面我们把 N 设成 5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行 5 个 master 节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
1、获取当前时间(单位是毫秒)。
2、轮流用相同的 key 和随机值在 N 个节点上请求锁,在这一步里,客户端在每个 master 上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是 10 秒钟,那每个节点锁请求的超时时间可能是 5-50 毫秒的范围,这个可以防止一个客户端在某个宕掉的 master 节点上阻塞过长时间,如果一个 master 节点不可用了,我们应该尽快尝试下一个 master 节点。
3、客户端计算第二步中获取锁所花的时间,只有当客户端在大多数 master 节点上成功获取了锁(在这里是 3 个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
4、如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
5、如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个 master 节点上释放锁,即便是那些他认为没有获取成功的锁。
CAS 是一种思想:Conditional Update,后验保证一致,而锁是前验机制。CAS 的基本框架
1)读取当前状态
2)基于读取的状态进行计算得到新状态。
3)Conditional Update:如果计算结果基于的状态没有变,则更新,否则回到第一步。
MVCC
分布式事务
分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。
分布式系统区别于传统的单体应用,单体应用的服务模块和数据都在一个服务中,使用 Spring 框架的事务管理器即可满足事务的属性。而分布式系统中,来自客户端的一次请求往往涉及多个服务,事务的一致性问题由此产生。
分布式事务往往和本地事务进行对比分析,以支付宝转账到余额宝为例,假设有:
支付宝账户表:A(id,userId,amount)
余额宝账户表:B(id,userId,amount)
用户的 userId=1;
从支付宝转账 1 万块钱到余额宝的动作分为两步:
1)支付宝表扣除 1 万:update A set amount=amount-10000 where userId=1;
2)余额宝表增加 1 万:update B set amount=amount+10000 where userId=1;
Begin transaction
update A set amount=amount-10000 where userId=1;
update B set amount=amount+10000 where userId=1;
End transaction
commit;
在Spring中使用一个@Transactional事务也可以搞定上述的事务功能:
@Transactional(rollbackFor=Exception.class)
public void update() {
updateATable(); //更新A表
updateBTable(); //更新B表
}
如果系统规模较小,数据表都在一个数据库实例上,上述本地事务方式可以很好地运行,但是如果系统规模较大,比如支付宝账户表和余额宝账户表显然不会在同一个数据库实例上,他们往往分布在不同的物理节点上,这时本地事务已经失去用武之地。
强一致性
分布式事务是指会涉及到操作多个数据库的事务。其实就是将对同一库事务的概念扩大到了对多个库的事务。目的是为了保证分布式系统中的数据一致性。分布式事务处理的关键是必须有一种方法可以知道事务在任何地方所做的所有动作,提交或回滚事务的决定必须产生统一的结果(全部提交或全部回滚)。X/Open 组织(即现在的 Open Group)定义了分布式事务处理模型。X/Open DTP 模型(1994)包括应用程序(AP)、事务管理器(TM)、资源管理器(RM)、通信资源管理器(CRM)四部分。一般,常见的事务管理器(TM)是交易中间件,常见的资源管理器(RM)是数据库,常见的通信资源管理器(CRM)是消息中间件。
X/Open 组织(即现在的 Open Group)定义了分布式事务处理模型。X/Open DTP 模型(1994)包括应用程序(AP)、事务管理器(TM)、资源管理器(RM)、通信资源管理器(CRM)四部分。
XA 就是 X/Open DTP 定义的交易中间件与数据库之间的接口规范(即接口函数),交易中间件用它来通知数据库事务的开始、结束以及提交、回滚等。XA 接口函数由数据库厂商提供。
多阶段提交
两阶段提交,是实现分布式事务的成熟方案。第一阶段是表决阶段,是所有参与者都将本事务能否成功的反馈发给协调者;第二阶段是执行阶段,协调者根据所有参与者的反馈,通知所有参与者,步调一致地在所有分支上提交,或者在所有分支上回滚。2PC 的方案可以达到数据的强一致性。 但是这个方案存在最致命的问题,就是同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。该锁的时间很长,在第一阶段即需要上锁,第二阶段才能解锁,依赖于所有参与者的最慢者。因此限制了吞吐量。
二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。第一阶段:准备阶段(投票阶段)和第二阶段:提交阶段(执行阶段)。
2PC 两阶段提交
同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送 commit 请求之后,发生了局部网络异常或者在发送 commit 请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了 commit 请求。而在这部分参与者接到 commit 请求之后就会执行 commit 操作。但是其他部分未接到 commit 请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
二阶段无法解决的问题:协调者在发出 commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
两阶段提交协议(Two-phase Commit,2PC)经常被用来实现分布式事务。一般分为协调器 C 和若干事务执行者 Si 两种角色,这里的事务执行者就是具体的数据库,协调器可以和事务执行器在一台机器上。
-
我们的应用程序(client)发起一个开始请求到 TC;
-
TC 先将
消息写到本地日志,之后向所有的 Si 发起 消息。以支付宝转账到余额宝为例,TC 给 A 的 prepare 消息是通知支付宝数据库相应账目扣款 1 万,TC 给 B 的 prepare 消息是通知余额宝数据库相应账目增加 1w。为什么在执行任务前需要先写本地日志,主要是为了故障后恢复用,本地日志起到现实生活中凭证 的效果,如果没有本地日志(凭证),容易死无对证; -
Si 收到
消息后,执行具体本机事务,但不会进行 commit,如果成功返回 ,不成功返回 。同理,返回前都应把要返回的消息写到日志里,当作凭证。 -
TC 收集所有执行器返回的消息,如果所有执行器都返回 yes,那么给所有执行器发生送 commit 消息,执行器收到 commit 后执行本地事务的 commit 操作;如果有任一个执行器返回 no,那么给所有执行器发送 abort 消息,执行器收到 abort 消息后执行事务 abort 操作。
注:TC 或 Si 把发送或接收到的消息先写到日志里,主要是为了故障后恢复用。如某一 Si 从故障中恢复后,先检查本机的日志,如果已收到
现如今实现基于两阶段提交的分布式事务也没那么困难了,如果使用 java,那么可以使用开源软件 atomikos(http://www.atomikos.com/)来快速实现。
不过但凡使用过的上述两阶段提交的同学都可以发现性能实在是太差,根本不适合高并发的系统。为什么?
1)两阶段提交涉及多次节点间的网络通信,通信时间太长!
2)事务时间相对于变长了,锁定的资源的时间也变长了,造成资源等待时间也增加好多!
3PC 三阶段提交
三阶段提交(Three-phase commit),也叫三阶段提交协议(Three-phase commit protocol),是二阶段提交(2PC)的改进版本。
如果因为协调者或网络问题,导致参与者迟迟不能收到来自协调者的 commit 或 rollback 请求,那么参与者将不会如两阶段提交中那样陷入阻塞,而是等待超时后继续 commit。相对于两阶段提交虽然降低了同步阻塞,但仍然无法避免数据的不一致性。在分布式数据库中,如果期望达到数据的强一致性,那么服务基本没有可用性可言,这也是为什么许多分布式数据库提供了跨库事务,但也只是个摆设的原因,在实际应用中我们更多追求的是数据的弱一致性或最终一致性,为了强一致性而丢弃可用性是不可取的。
柔性事务与事务消息
根据 BASE 理论,系统并不保证续进程或者线程的访问都会返回最新的更新过的值。系统在数据写入成功之后,不承诺立即可以读到最新写入的值,也不会具体的承诺多久之后可以读到。
弱一致性的特定形式。系统保证在没有后续更新的前提下,系统最终返回上一次更新操作的值。在没有故障发生的前提下,不一致窗口的时间主要受通信延迟,系统负载和复制副本的个数影响。DNS 是一个典型的最终一致性系统。在工程实践上,为了保障系统的可用性,互联网系统大多将强一致性需求转换成最终一致性的需求,并通过系统执行幂等性的保证,保证数据的最终一致性。但在电商等场景中,对于数据一致性的解决方法和常见的互联网系统(如 MySQL 主从同步)又有一定区别。
补偿机制 TCC
TCC(Try、Confirm、Cancel)是两阶段提交的一个变种。TCC 提供了一个框架,需要应用程序按照该框架编程,将业务逻辑的每个分支都分为 Try、Confirm、Cancel 三个操作集。TCC 让应用程序自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。TCC 可以达到数据的最终一致。
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:
Try 阶段主要是对业务系统做检测及资源预留 Confirm 阶段主要是对业务系统做确认提交,Try 阶段执行成功并开始执行 Confirm 阶段时,默认 Confirm 阶段是不会出错的。 Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。
TCC 与 2PC 协议比较:
位于业务服务层而非资源层 没有单独的准备(Prepare)阶段,Try 操作兼备资源操作与准备能力 Try 操作可以灵活选择业务资源的锁定粒度(以业务定粒度) 较高开发成本
Atomikos 公司对微服务的分布式事务所提出的RESTful TCC解决方案。
- Trying 阶段主要针对业务系统检测及作出预留资源请求,若预留资源成功,则返回确认资源的链接与过期时间。
- Confirm 阶段主要是对业务系统的预留资源作出确认,要求 TCC 服务的提供方要对确认预留资源的接口实现幂等性,若 Confirm 成功则返回 204,资源超时则证明已经被回收且返回 404。
- Cancel 阶段主要是在业务执行错误或者预留资源超时后执行的资源释放操作,Cancel 接口是一个可选操作,因为要求 TCC 服务的提供方实现自动回收的功能,所以即便是不认为进行 Cancel,系统也会自动回收资源。
本地消息表
消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过 MQ 发送到消息的消费方。如果消息发送失败,会进行重试发送。
消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
这种方案遵循 BASE 理论,采用的是最终一致性,即不会出现像 2PC 那样复杂的实现(当调用链很长的时候,2PC 的可用性是非常低的),也不会像 TCC 那样可能出现确认或者回滚不了的情况。
优点:一种非常经典的实现,避免了分布式事务,实现了最终一致性。
缺点:消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。
消息重复投递
RocketMQ 第一阶段发送 Prepared 消息时,会拿到消息的地址,第二阶段执行本地事物,第三阶段通过第一阶段拿到的地址去访问消息,并修改消息的状态。
如果确认消息发送失败了怎么办?RocketMQ 会定期扫描消息集群中的事务消息,如果发现了 Prepared 消息,它会向消息发送端(生产者)确认,Bob 的钱到底是减了还是没减呢?如果减了是回滚还是继续发送确认消息呢?RocketMQ 会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
如果 endTransaction 方法执行失败,数据没有发送到 broker,导致事务消息的 状态更新失败,broker 会有回查线程定时(默认 1 分钟)扫描每个存储事务状态的表格文件,如果是已经提交或者回滚的消息直接跳过,如果是 prepared 状态则会向 Producer 发起 CheckTransaction 请求,Producer 会调用 DefaultMQProducerImpl.checkTransactionState()方法来处理 broker 的定时回调请求,而 checkTransactionState 会调用我们的事务设置的决断方法来决定是回滚事务还是继续执行,最后调用 endTransactionOneway 让 broker 来更新消息的最终状态。
消费失败 解决超时问题的思路就是一直重试,直到消费端消费消息成功
消费超时 消费失败怎么办?阿里提供给我们的解决方法是:人工解决。大家可以考虑一下,按照事务的流程,因为某种原因 Smith 加款失败,那么需要回滚整个流程。如果消息系统要实现这个回滚流程的话,系统复杂度将大大提升,且很容易出现 Bug,估计出现 Bug 的概率会比消费失败的概率大很多。这也是 RocketMQ 目前暂时没有解决这个问题的原因,在设计实现消息系统时,我们需要衡量是否值得花这么大的代价来解决这样一个出现概率非常小的问题,这也是大家在解决疑难问题时需要多多思考的地方。
比如在北京很有名的姚记炒肝点了炒肝并付了钱后,他们并不会直接把你点的炒肝给你,往往是给你一张小票,然后让你拿着小票到出货区排队去取。为什么他们要将付钱和取货两个动作分开呢?原因很多,其中一个很重要的原因是为了使他们接待能力增强(并发量更高)。
还是回到我们的问题,只要这张小票在,你最终是能拿到炒肝的。同理转账服务也是如此,当支付宝账户扣除 1 万后,我们只要生成一个凭证(消息)即可,这个凭证(消息)上写着“让余额宝账户增加 1 万”,只要这个凭证(消息)能可靠保存,我们最终是可以拿着这个凭证(消息)让余额宝账户增加 1 万的,即我们能依靠这个凭证(消息)完成最终一致性。
还有一个很严重的问题就是消息重复投递,以我们支付宝转账到余额宝为例,如果相同的消息被重复投递两次,那么我们余额宝账户将会增加 2 万而不是 1 万了。为什么相同的消息会被重复投递?比如余额宝处理完消息 msg 后,发送了处理成功的消息给支付宝,正常情况下支付宝应该要删除消息 msg,但如果支付宝这时候悲剧的挂了,重启后一看消息 msg 还在,就会继续发送消息 msg。
解决方法很简单,在余额宝这边增加消息应用状态表(message_apply),通俗来说就是个账本,用于记录消息的消费情况,每次来一个消息,在真正执行之前,先去消息应用状态表中查询一遍,如果找到说明是重复消息,丢弃即可,如果没找到才执行,同时插入到消息应用状态表(同一事务)。
list for each msg in queue
Begin transaction
select count(\*) as cnt from message_apply where msg_id=msg.msg_id;
if cnt==0 then
update B set amount=amount+10000 where userId=1;
insert into message_apply(msg_id) values(msg.msg_id);
End transaction
commit;
业务与消息耦合的方式
支付宝在完成扣款的同时,同时记录消息数据,这个消息数据与业务数据保存在同一数据库实例里(消息记录表表名为message);
Begin transaction
update A set amount=amount-10000 where userId=1;
insert into message(userId, amount,status) values(1, 10000, 1);
End transaction
commit;
上述事务能保证只要支付宝账户里被扣了钱,消息一定能保存下来。当上述事务提交成功后,我们通过实时消息服务将此消息通知余额宝,余额宝处理成功后发送回复成功消息,支付宝收到回复后删除该条消息数据。
业务与消息解耦方式
上述保存消息的方式使得消息数据和业务数据紧耦合在一起,从架构上看不够优雅,而且容易诱发其他问题。为了解耦,可以采用以下方式。
1)支付宝在扣款事务提交之前,向实时消息服务请求发送消息,实时消息服务只记录消息数据,而不真正发送,只有消息发送成功后才会提交事务;
2)当支付宝扣款事务被提交成功后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才真正发送该消息;
3)当支付宝扣款事务提交失败回滚后,向实时消息服务取消发送。在得到取消发送指令后,该消息将不会被发送;
4)对于那些未确认的消息或者取消的消息,需要有一个消息状态确认系统定时去支付宝系统查询这个消息的状态并进行更新。为什么需要这一步骤,举个例子:假设在第 2 步支付宝扣款事务被成功提交后,系统挂了,此时消息状态并未被更新为“确认发送”,从而导致消息不能被发送。
优点:消息数据独立存储,降低业务系统与消息系统间的耦合;
缺点:一次消息发送需要两次请求;业务处理服务需要实现消息状态回查接口。
数据一致性
共识算法与分布式一致性算法
- 经典的分布式一致性算法 经典分布式一致性算法有 Raft 协议,Raft 协议是一种强 Leader 的一致性算法,它的吞吐量基本就是 Leader 的吞吐量,它无法抵御节点恶意篡改数据的攻击。
稍微复杂一点的就是 Paxos 协议,Paxos 能提供不同场合不同种类的一致性算法,所以 Paxos 有很多变种,经典 Paxos 是 Leaderless 的,有变种是强 Leader 型的,叫做 Fast Paxos,有关 Paxos 的文献非常丰富,这里就不赘述了。
以上两种都是不提供拜占庭容错的系统,下面介绍一种具有拜占庭容错的一致性算法。
PBFT 全称实用性拜占庭容错系统(Practical Byzantine Fault Tolerance, PBFT),PBFT 是一种状态机,要求所有节点共同维护一个状态,所有节点采取的行动一致,PBFT 非常适合联盟链等对性能具有较高要求的场合,超级账本项目中的 Fabric 框架默认采用的就是 PBFT 的修改版本。
一致性分类
- 最终一致性,先保证局部一致,然后在未来的某个时刻达到顺序一致性
- 顺序一致性,保证所有进程以相同顺序看到所有的共享访问,与时间无关
- 线性化,在顺序一致性的基础上,还要保证在一个全局的相对时间下顺序 一致
- 绝对 一致性,所有共享访问按绝对时间排序,仅存在于理论中