分布式总结

什么是分布式系统?

分布式系统是一个硬件或软件组件分布在不同的网路计算机上,彼此之间仅仅通过消息传递进行通信和协调的系统。

分布式服务顾名思义服务是 分散部署在不同的机器上的,一个服务可能负责几个功能,是一种面向SOA架构的,服务之间也是通过rpc来交互或者是webservice来交互的。 逻辑架构设计完后就该做物理架构设计,系统应用部署在超过一台服务器或虚拟机上,且各分开部署的部分彼此通过各种通讯协议交互信息,就可算作分布式部署,生产环境下的微服务肯定是分布式部署的,分布式部署的应用不一定是微服务架构的,比如集群部署,它是把相同应用复制到不同服务器上,但是逻辑功能上还是单体应用。

微服务是啥?

这里不引用书本上的复杂概论了,简单来说微服务就是很小的服务,小到一个服务只对应一个单一的功能,只做一件事。这个服务可以单独部署运行,服务之间可以通过RPC来相互交互,每个微服务都是由独立的小团队开发,测试,部署,上线,负责它的整个生命周期。

微服务架构又是啥?

在做架构设计的时候,先做逻辑架构,再做物理架构,当你拿到需求后,估算过最大用户量和并发量后,计算单个应用服务器能否满足需求,如果用户量只有几百人的小应用,单体应用就能搞定,即所有应用部署在一个应用服务器里,如果是很大用户量,且某些功能会被频繁访问,或者某些功能计算量很大,建议将应用拆解为多个子系统,各自负责各自功能,这就是微服务架构。

SOA和微服务

SOA,全称 Service-Oriented Architecture即面向服务的架构。说到SOA就离不开 ESB,全称Enterprise Service Bus。SOA和微服务一样都是面向服务的。

img

可以看到 SOA 架构通过企业服务总线进行交互,也就是说中心化,需要按照总线的标准进行开发改造,而微服务是去中心化的。

其实我们可以抓到关键字企业,SOA 我认为是企业级别的面向服务概念,而微服务是应用级别的概念

两种都是面向服务,只是 SOA 注重的是企业资源的重复利用,把企业的各个应用通过 ESB 进行整合。

而微服务注重的是应用级别的服务划分,使得应用内服务边界清晰,易扩展

为什么要用分布式系统?

分布式系统最大的好处就是能够让你横向的扩展系统。横向扩展是指通过增加更多的机器来提升整个系统的性能,而不是靠升级单台计算机的硬件。横向扩展则没有这个限制,它没有上限,每当性能下降的时候,你就需要增加一台机器,这样理论上讲可以达到无限大的工作负载支持。在容错和低延迟上也有很多优势。容错性是指你的分布式系统的某个节点出现错误以后,并不会导致整个系统的瘫痪。低延迟是通过在不同的物理位置部署不同的机器,通过就近获取的原则降低访问的延迟时间。

CAP定理是什么?

CAP理论告诉我们,一个分布式系统不可能同时满足**一致性(Consistency)、可用性(Avaliability)、分区容错性(Partition)**这三分基本需求,最多只能同时满足其中两个

选项描述
一致性分布式环境中,数据在多个副本之间能够保持一致的特性(严格一致性),在一致性的需求下,当一个系统在数据一致性的状态下执行更新操作后,应该保证系统的数据依然处在一直的转态
可用性系统提供的服务必须一直处于可用的状态,每次请求都能获取到非错的响应–但不保证获取的数据为最新数据
分区容错性分布式系统在遇到任何网络分区故障的时候,依然能够对外提供满足一致性和可用性的服务,除非整个网络环境都发生了故障

在这里插入图片描述

网络分区
在分布式环境下,有时由于网络通讯故障,而不是服务器上的应用故障,导致一些节点认为应用不可用,另外一些节点认为应用仍可用。导致,整个系统在提供服务时,造成了不一致性。

假设有一个系统如下:

在这里插入图片描述

整个系统由两个节点配合组成,之间通过网络通信,当节点A进行更新数据库操作的时候,需要同时更新节点B的数据库(这是一个原子操作)

上面这个系统怎么满足CAP呢?C(一致性):当节点A更新的时候,节点B也要更新, A(可用性):必须保证两个节点都是可用的, P(网络分区:由于网络故障导致各个机器不能通讯,又可能其他机器又在通信,这样就造成了网络划分成多个子网络)当节点A、B出现了网络分区,必须保证对外可用。

可见,根本完成不了同时满足CAP,因为只要出现了网络分区,C就无法满足,因为节点A根本连接不上节点B。如果强行满足C一致性,就必须停止服务运行,从而放弃可用性A

所以,最多满足两个条件:

组合分析结果
CA(一致性+可用性)放弃分区容错性,说白了,就是一个整体的应用,如果希望能够避免系统出现分区容错性问题,一种较为简单的做法是**将所有的数据(或者仅仅是那些与事务相关的数据)都放在一个分布式节点上。**这样做虽然无法100%保证系统不会出错,但至少不会碰到由于网络分区带来的负面影响。但同时需要注意的是,放弃P的同时也就意味着放弃了系统的可拓展性
CP(一致性+分区容错性)一旦系统遇到网络分区或其它故障或为了保证一致性时,放弃可用性,那么受到影响的服务需要等待一定的时间需要等网络修复好以后才能继续提供服务,因此在等待期间系统无法对外提供正常的服务,即不可用
AP(可用性+分区容错性)出现网络分区,为了保证可用性,必须让节点继续对外提供服务,这样必然失去一致性。这里所说的放弃一致性,并不是完全不需要数据一致性,指的是放弃系统的强一致性,保留最终一致性。这样的系统无法保证数据保持实时的一致性,但是能够承诺的是,数据最终会达到一个一致的状态这就引入了一个时间窗口的概念,具体多久能够达到数据一致性的状态取决于系统的设计,主要包括数据副本在不同节点之间的复制时间长短

能不能解决3选2的问题
想要解决3选2的问题,首先大家需要思考分区是100%出现的?如果不出现分区,那么就能够同时满足CAP。如果出现了分区,可以根据策略进行调整。比如C不必使用那么强的一致性,可以先将数据存在一起,稍后再更新,实现所谓的"最终一致性"

基于这个思路,引出了第二个理论Base理论

BASE理论

什么是BASE理论?

BASE:全称:Basically Available(基本可用),Soft state(软状态),和Eventually consistent(最终一致性)三个短语的缩写,来自eBay架构师提出。

Base理论是对CAP中一致性和可用性权衡的结果,其来源于对大型互联网分布式实践的总结,是基于CAP定理逐步演化而来的。

核心思想:既然无法做到强一致性(String consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency )

Basically Available(基本可用)

什么是基本可用呢?

基本可用是指分布式系统在出现不可预知故障的时候,允许损失部门可用性-但不等于系统不可用。以下就是两个“基本可用”的例子

响应时间上的损失:正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了1~2秒。
功能上的损失:双十一消费者的购物行为激增,为了保护系统的稳定性(或者一致性),部分消费者可能会被引导到一个降级页面:

在这里插入图片描述

( Soft state)软状态, 系统可以随着时间的推移而变化,甚至在没有输入的情况下也可以变化, 如保持最终的一致性的同步。

( Eventual consistency)最终的一致性, 在没有输入的情况下,数据迟早会传播到每一个节点上,从而变得一致。

分布式事务

分布式事务顾名思义就是要在分布式系统中实现事务,它其实是由多个本地事务组合而成。

分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。

一致性协议2PC

为了使系统尽量能够达到CAP,于是有了BASE协议,而BASE协议是在可用性和一致性之间做的取舍和妥协。

也就是说,我们在对分布式系统进行架构设计的过程中,往往需要我们在系统的可用性和数据一致性之间反复的权衡。于是,就涌现了许多经典的算法和协议,最著名的几种就是二阶段提交协议三阶段提交协议Paxos算法等。

什么是2PC

2PC(Two-phase commit protocol),中文叫二阶段提交。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者的角色来协调管理各参与者(也可称之为各本地资源)的提交和回滚,二阶段分别指的是准备(投票)和提交两个阶段。

在分布式系统中,会有多个机器节点,每一个机器节点虽然能够明确地知道自己在进行事务操作过程中的结果是成功或失败,但无法直接获取到其他分布式节点的操作结果,因此当一个事务操作需要跨越多个分布式节点的时候,为了保证事务处理的ACID特性,就需要引入一个“协调者”的组件来统一调度所有分布式节点的执行逻辑,这些被调度的节点则称为“参与者”,**协调者负责调度参与者的行为,并最终决定这些参与者是否要把事务真正进行提交。**基于这个思想,就衍生了二阶段提交和三阶段提交两种协议。

协议说明:
二阶段提交就是将事务的提交过程分成了两个阶段来进行处理,流程如下:

阶段一:提交事务请求

在这里插入图片描述

1.事务询问

协调者向所有的参与者发送事务内容询问是否可以执行事务提交操作,并开始等待其他参与者的响应

2.执行事务

各参与者节点执行事务操作,并将Undo和Redo信息记入事务日志中(Undo能保证事务的原子性,Redo用来保证事务的持久性,两者也是系统恢复的基础前提)

3.各参与者向协调者反馈事务询问的响应

如果参与者成功执行了事务操作,那么就反馈给协调者Yes响应,表示事务可以执行;如果参与者没有成功执行事务,就返回No给协调者,表示事务不可以执行。

由于上面的内容在形式上近似是协调者组织各参与者对一次事务操作的投票表态过程,因此二阶段提交协议的阶段一也被称为“投票阶段”,即各参与者投票表明是否要继续执行接下去的事务提交操作。

阶段二:执行事务提交

在阶段二中,就会根据阶段一的投票结果来决定最终是否可以进行事务提交操作,正常情况下,包含两种操作可能:提交事务中断事务

提交事务步骤如下:

假如协调者从所有的参与者获得的反馈都是yes响应,那么就会执行事务提交。

在这里插入图片描述

1.发送提交请求
协调者向所有参与者发出commit请求。

2.事务提交
参与者收到commit请求后,会正式执行事务提交操作,并在完成提交之后释放整个事务执行期间占用的事务资源。

3.反馈事务提交结果
参与者在完成事务提交之后,向协调者发送ACK信息。

4.完成事务
协调者接收到所有参与者反馈的ACK信息后,完成事务。

ACK:ACK字符是一些通信协议下用来做确认消息的字符,也有通信协议使用其他字符。

中断事务步骤如下:

假如任何一个参与者向协调者反馈了No响应,或者在等待超时之后,协调者尚无法接受到所有参与者的反馈响应,那么就会中断事务。

在这里插入图片描述

1.发送事务回滚请求
协调者向所有参与者发出Rollback请求。

2.事务回滚
参与者接受到Rollback请求后,会利用其在阶段一记录的Undo记录来执行事务回滚操作,并在完成回滚之后释放在整个事务执行期间占用的资源。

3.反馈事务回滚结果
参与者在完成事务回滚之后,向协调者发送ACK信息。

4.中断事务
协调者接收到所有参与者反馈的ACK信息后,完成事务中断。

从上面逻辑可以看出,二阶段提交就做了两件事情:投票、执行。

2PC优缺点

优点

原理简单,实现方便

缺点

同步阻塞,单点问题,数据不一致,过于保守

  • 同步阻塞:
    二阶段提交协议存在最明显也是最大的一个问题就是同步阻塞,在二阶段提交的执行过程中,所有参与该事务操作的逻辑都处于阻塞状态,也就是说,各个参与者在等待其他参与者响应的过程中,无法进行其他操作。这种同步阻塞极大的限制了分布式系统的性能。
  • 单点问题:
    协调者在整个二阶段提交过程中很重要,如果协调者在提交阶段出现问题,那么整个流程将无法运转,更重要的是:其他参与者将会处于一直锁定事务资源的状态中,而无法继续完成事务操作。
  • 数据不一致:
    假设当协调者向所有的参与者发送commit请求之后,发生了局部网络异常或者是协调者在尚未发送完所有commit请求之前自身发生了崩溃,导致最终只有部分参与者收到了commit请求。这将导致严重的数据不一致问题。
  • 过于保守:
    如果在二阶段提交的提交询问阶段中,参与者出现故障而导致协调者始终无法获取到所有参与者的响应信息的话,这时协调者只能依靠其自身的超时机制来判断是否需要中断事务,显然,这种策略过于保守。btw,二阶段提交协议没有设计较为完善的容错机制,任意一个阶段失败都会导致整个事务的失败。

那可能就有人问了,那第二阶段提交失败的话呢?

这里有两种情况。

第一种是第二阶段执行的是回滚事务操作,那么答案是不断重试,直到所有参与者都回滚了,不然那些在第一阶段准备成功的参与者会一直阻塞着。

第二种是第二阶段执行的是提交事务操作,那么答案也是不断重试,因为有可能一些参与者的事务已经提交成功了,这个时候只有一条路,就是头铁往前冲,不断的重试,直到提交成功,到最后真的不行只能人工介入处理。

协调者故障分析

协调者是一个单点,存在单点故障问题

假设协调者在发送准备命令之前挂了,还行等于事务还没开始。

假设协调者在发送准备命令之后挂了,这就不太行了,有些参与者等于都执行了处于事务资源锁定的状态。不仅事务执行不下去,还会因为锁定了一些公共资源而阻塞系统其它操作。

假设协调者在发送回滚事务命令之前挂了,那么事务也是执行不下去,且在第一阶段那些准备成功参与者都阻塞着。

假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令发出去了,很大的概率都会回滚成功,资源都会释放。但是如果出现网络分区问题,某些参与者将因为收不到命令而阻塞着。

假设协调者在发送提交事务命令之前挂了,这个不行,傻了!这下是所有资源都阻塞着。

假设协调者在发送提交事务命令之后挂了,这个还行,也是至少命令发出去了,很大概率都会提交成功,然后释放资源,但是如果出现网络分区问题某些参与者将因为收不到命令而阻塞着。

协调者故障,通过选举得到新协调者

因为协调者单点问题,因此我们可以通过选举等操作选出一个新协调者来顶替。

如果处于第一阶段,其实影响不大都回滚好了,在第一阶段事务肯定还没提交。

如果处于第二阶段,假设参与者都没挂,此时新协调者可以向所有参与者确认它们自身情况来推断下一步的操作。

假设有个别参与者挂了!这就有点僵硬了,比如协调者发送了回滚命令,此时第一个参与者收到了并执行,然后协调者和第一个参与者都挂了。

此时其他参与者都没收到请求,然后新协调者来了,它询问其他参与者都说OK,但它不知道挂了的那个参与者到底O不OK,所以它傻了。

问题其实就出在每个参与者自身的状态只有自己和协调者知道,因此新协调者无法通过在场的参与者的状态推断出挂了的参与者是什么情况。

虽然协议上没说,不过在实现的时候我们可以灵活的让协调者将自己发过的请求在哪个地方记一下,也就是日志记录,这样新协调者来的时候不就知道此时该不该发了?

但是就算协调者知道自己该发提交请求,那么在参与者也一起挂了的情况下没用,因为你不知道参与者在挂之前有没有提交事务。

如果参与者在挂之前事务提交成功,新协调者确定存活着的参与者都没问题,那肯定得向其他参与者发送提交事务命令才能保证数据一致。

如果参与者在挂之前事务还未提交成功,参与者恢复了之后数据是回滚的,此时协调者必须是向其他参与者发送回滚事务命令才能保持事务的一致。

所以说极端情况下还是无法避免数据不一致问题。

2PC 是一种尽量保证强一致性的分布式事务,因此它是同步阻塞的,而同步阻塞就导致长久的资源锁定问题,总体而言效率低,并且存在单点故障问题,在极端条件下存在数据不一致的风险。

一致性协议3PC

刚刚讲解了二阶段提交协议的设计和实现原理,并明确指出了其在实际运行过程中可能存在的诸如同步阻塞,单点问题,数据不一致,过于保守的容错机制等缺陷
而为了弥补二阶段提交的缺点,引入了三阶段提交协议。

什么是三阶段提交

将2PC的“提交事务请求”过程一分为二,共形成了由CanCommit、PreCommit和doCommit三个阶段组成的事务处理协议。

准备阶段、预提交阶段和提交阶段

在这里插入图片描述

阶段一:CanCommit
  1. 事务询问
    协调者向所有的参与者发送一个包含事务内容的CanCommit请求,询问是否可以执行事务提交操作,并等待各参与者响应
  2. 各参与者向协调者反馈事务询问的响应
    参与者在接收到来自协调者的包含事务内容的canCommit请求后,判断可以提交任务了返回Yes响应,否则No
阶段二:PreCommit

两种情况:
成功:执行事务预提交
失败:中断事务

  1. 发送预提交请求
    协调者向所有参与者发出PreCommit请求,进入准备阶段
  2. 事务预提交
    参与者收到请求后,执行事务操作,并将Undo、Redo信息记录到事务日志中(改日志信息可以用来回滚事务)
  3. 反馈执行结果
    协调者收到反馈,确定是提交(发送doCommit请求)还是终止(发送abort请求)操作。
阶段三:doCommit

提交事务(收到doCommit请求将预提交状态->提交状态)、中断事务(收到abort请求),完成事务以后发送ACK给协调者。

故障分析

那么引入了超时机制,参与者就不会傻等了,如果是等待提交命令超时,那么参与者就会提交事务了,因为都到了这一阶段了大概率是提交的,如果是等待预提交命令超时,那该干啥就干啥了,反正本来啥也没干

然而超时机制也会带来数据不一致的问题,比如在等待提交命令时候超时了,参与者默认执行的是提交事务操作,但是有可能执行的是回滚操作,这样一来数据就不一致了

从维基百科上看,3PC 的引入是为了解决提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚的问题。

新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了,所以此时执行的就是提交命令。

所以说 3PC 就是通过引入预提交阶段来使得参与者之间的状态得到统一

但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定。

所以说 3PC 通过预提交阶段可以减少故障恢复时候的复杂性,但是不能保证数据一致,除非挂了的那个参与者恢复。

2pc和3pc对比:

优点:
在2pc基础上有协调者新增了一个CanCommit阶段,会预先判断机器是否可以执行事务操作,而不是直接发送执行事务操作请求,这样做的优点是降低了参与者的阻塞范围(由于2pc是直接发送prepare,等待参与者事务处理完成并反馈结果),其次能够在单点故障后达成一致(由于第一阶段是判断服务器是否能够CanCommit,协调者会根据反馈结果判断有故障节点情况下,发送事务prepareCommit请求操作)

首先对于协调者和参与者都设置了超时机制(在2pc中,只有协调者拥有超时机制,即如果在一定时间内没有收到参与者的消息则默认失败)。其次在2pc的准备阶段和提交阶段之间,插入预提交阶段,这个阶段是一个缓冲,保证了在最后提交之前各参与节点的状态是一致的。

缺点:
如果参与者收到了PreCommit消息后,出现了网络分区,此时协调者和参与者无法通信,参与者等待超时后,会进行事务的提交(这种超时自动提交机制是3pc特性,就是为了解决同步阻塞情况),这必然出现分布式数据不一致问题

不管哪一个阶段有参与者返回失败都会宣布事务失败,这和 2PC 是一样的(当然到最后的提交阶段和 2PC 一样只要是提交请求就只能不断重试)。

所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。

2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务

TCC

2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务,就像我前面说的分布式事务不仅仅包括数据库的操作,还包括发送短信等,这时候 TCC 就派上用场了!

TCC 指的是Try - Confirm - Cancel

  • Try 指的是预留,即资源的预留和锁定,注意是预留
  • Confirm 指的是确认操作,这一步其实就是真正的执行了。
  • Cancel 指的是撤销操作,可以理解为把预留阶段的动作撤销了。

其实从思想上看和 2PC 差不多,都是先试探性的执行,如果都可以那就真正的执行,如果不行就回滚。

比如说一个事务要执行A、B、C三个操作,那么先对三个操作执行预留动作。如果都预留成功了那么就执行确认操作,如果有一个预留失败那就都执行撤销动作。

我们来看下流程,TCC模型还有个事务管理者的角色,用来记录TCC全局事务状态并提交或者回滚事务。

在这里插入图片描述

在这里插入图片描述

  • Try 阶段是做业务检查(一致性)及资源预留(隔离),此阶段仅是一个初步操作,它和后续的Confirm 一起才能 真正构成一个完整的业务逻辑。
  • Confirm 阶段是做确认提交,Try阶段所有分支事务执行成功后开始执行 Confirm。通常情况下,采用TCC则 认为 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引 入重试机制或人工处理。
  • Cancel 阶段是在业务执行错误需要回滚的状态下执行分支事务的业务取消,预留资源释放。通常情况下,采 用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。

可以看到流程还是很简单的,难点在于业务上的定义,对于每一个操作你都需要定义三个动作分别对应Try - Confirm - Cancel

因此 TCC 对业务的侵入较大和业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作。

还有一点要注意,撤销和确认操作的执行可能需要重试,因此还需要保证操作的幂等

相对于 2PC、3PC ,TCC 适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候你会发现这三个方法还真不好写。不过也因为是在业务上实现的,所以TCC可以跨数据库、跨不同的业务系统来实现事务

可靠消息最终一致性

什么是可靠消息最终一致性事务

可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能 够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致

此方案是利用消息中间件完成,如下图:

事务发起方(消息生产方)将消息发给消息中间件,事务参与方从消息中间件接收消息,事务发起方和消息中间件 之间,事务参与方(消息消费方)和消息中间件之间都是通过网络通信,由于网络通信的不确定性会导致分布式事 务问题。
在这里插入图片描述

因此可靠消息最终一致性方案要解决以下几个问题:

1.本地事务与消息发送的原子性问题
本地事务与消息发送的原子性问题即:事务发起方在本地事务执行成功后消息必须发出去,否则就丢弃消息。即实 现本地事务和消息发送的原子性,要么都成功,要么都失败。本地事务与消息发送的原子性问题是实现可靠消息最 终一致性方案的关键问题。

先来尝试下这种操作,先发送消息,再操作数据库:

begin transaction; 

    //1.发送MQ 
    //2.数据库操作 
commit transation;


这种情况下无法保证数据库操作与发送消息的一致性,因为可能发送消息成功,数据库操作失败。

你立马想到第二种方案,先进行数据库操作,再发送消息:

begin transaction;
    //1.数据库操作 
    //2.发送MQ 
commit transation;

这种情况下貌似没有问题,如果发送MQ消息失败,就会抛出异常,导致数据库事务回滚。但如果是超时异常,数 据库回滚,但MQ其实已经正常发送了,同样会导致不一致。

2 事务参与方接收消息的可靠性

事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息。

3 消息重复消费的问题

由于网络2的存在,若某一个消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致了消息的重 复消费。

要解决消息重复消费的问题就要实现事务参与方的方法幂等性

“幂等性:就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。”

解决方案

本地消息表方案

本地消息表这个方案最初是eBay提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后 通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。

下面以注册送积分为例来说明:

下例共有两个微服务交互,用户服务和积分服务,用户服务负责添加用户,积分服务负责增加积分。
在这里插入图片描述

交互流程如下:

1、用户注册 用户服务在本地事务新增用户和增加 ”积分消息日志“。(用户表和消息表通过本地事务保证一致)

下边是伪代码

begin transaction; 
    //1.新增用户 
    //2.存储积分消息日志 
commit transation;


这种情况下,本地数据库操作与存储积分消息日志处于同一个事务中,本地数据库操作与记录消息日志操作具备原子性。

2、定时任务扫描日志

如何保证将消息发送给消息队列呢?

经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息 中间件,在消息中间件反馈发送成功后删除该消息日志,否则等待定时任务下一周期重试。

3、消费消息

如何保证消费者一定能消费到消息呢?

这里可以使用MQ的ack(即消息确认)机制,消费者监听MQ,如果消费者接收到消息并且业务处理完成后向MQ 发送ack(即消息确认),此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则MQ会不断重 试向消费者来发送消息。

积分服务接收到”增加积分“消息,开始增加积分,积分增加成功后向消息中间件回应ack,否则消息中间件将重复 投递此消息。

由于消息会重复投递,积分服务的”增加积分“功能需要实现幂等性

RocketMQ事务消息方案

RocketMQ 事务消息设计则主要是为了解决 Producer 端的消息发送与本地事务执行的原子性问题,RocketMQ 的 设计中 broker 与 producer 端的双向通信能力,使得 broker 天生可以作为一个事务协调者存在;而 RocketMQ 本身提供的存储机制为事务消息提供了持久化能力;RocketMQ 的高可用机制以及可靠消息设计则为事务消息在系 统发生异常时依然能够保证达成事务的最终一致性。

在RocketMQ 4.3后实现了完整的事务消息,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ 内部,解决 Producer 端的消息发送与本地事务执行的原子性问题。
在这里插入图片描述

执行流程如下:
为方便理解我们还以注册送积分的例子来描述 整个流程。

Producer 即MQ发送方,本例中是用户服务,负责新增用户。MQ订阅方即消息消费方,本例中是积分服务,负责 新增积分。

1、Producer 发送事务消息

Producer (MQ发送方)发送事务消息至MQ Server,MQ Server将消息状态标记为Prepared(预备状态),注 意此时这条消息消费者(MQ订阅方)是无法消费到的。
本例中,Producer 发送 ”增加积分消息“ 到MQ Server。

2、MQ Server回应消息发送成功
MQ Server接收到Producer 发送给的消息则回应发送成功表示MQ已接收到消息。

3、Producer 执行本地事务
Producer 端执行业务代码逻辑,通过本地数据库事务控制
本例中,Producer 执行添加用户操作。

4、消息投递
若Producer 本地事务执行成功则自动向MQServer发送commit消息,MQ Server接收到commit消息后将”增加积 分消息“ 状态标记为可消费,此时MQ订阅方(积分服务)即正常消费消息;
若Producer 本地事务执行失败则自动向MQServer发送rollback消息,MQ Server接收到rollback消息后将删除“增加积分消息“”。

MQ订阅方(积分服务)消费消息,消费成功则向MQ回应ack,否则将重复接收消息。这里ack默认自动回应,即 程序执行正常则自动回应ack。

5、事务回查
如果执行Producer端本地事务过程中,执行端挂掉,或者超时,MQ Server将会不停的询问同组的其他 Producer 来获取事务执行状态,这个过程叫事务回查。MQ Server会根据事务回查结果来决定是否投递消息。

以上主干流程已由RocketMQ实现,对用户侧来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此 只需关注本地事务的执行状态即可。

//RoacketMQ提供RocketMQLocalTransactionListener接口:
public interface RocketMQLocalTransactionListener {
 /** 
 ‐ 发送prepare消息成功此方法被回调,该方法用于执行本地事务 
 ‐ @param msg 回传的消息,利用transactionId即可获取到该消息的唯一Id 
 ‐ @param arg 调用send方法时传递的参数,当send时候若有额外的参数可以传递到send方法中,这里能获取到 
 ‐ @return 返回事务状态,COMMIT:提交 ROLLBACK:回滚 UNKNOW:回调 
 */
 RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg); 
 /** 
 ‐ @param msg 通过获取transactionId来判断这条消息的本地事务执行状态 
 ‐ @return 返回事务状态,COMMIT:提交 ROLLBACK:回滚 UNKNOW:回调 
 */
 RocketMQLocalTransactionState checkLocalTransaction(Message msg); 
}



发送事务消息:

以下是RocketMQ提供用于发送事务消息的API:

    TransactionMQProducer producer = new TransactionMQProducer("ProducerGroup"); 
    producer.setNamesrvAddr("127.0.0.1:9876"); 
    producer.start();
   //设置TransactionListener实现 
   producer.setTransactionListener(transactionListener); 
   //发送事务消息 
    SendResult sendResult = producer.sendMessageInTransaction(msg, null);

小结

可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性,RocketMQ作为 消息中间件,主要解决了两个功能:

  • 本地事务与消息发送的原子性问题。
  • 事务参与方接收消息的可靠性。 可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。
  • 引入消息机制后,同步的事务操作变为基于消 息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。

最大努力通知

什么是最大努力通知

最大努力通知也是一种解决分布式事务的方案,下边是一个是充值的例子:

在这里插入图片描述

交互流程:

1、账户系统调用充值系统接口
2、充值系统完成支付处理向账户系统发起充值结果通知 若通知失败,则充值系统按策略进行重复通知
3、账户系统接收到充值结果通知修改充值状态。
4、账户系统未接收到通知会主动调用充值系统的接口查询充值结果

通过上边的例子我们总结最大努力通知方案的目标,
目标:发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。

具体包括:

1、有一定的消息重复通知机制。 因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
2、消息校对机制。 如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息 信息来满足需求。

最大努力通知与可靠消息一致性有什么不同?

1、解决方案思想不同

可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知 方来保证。

最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接 收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方

2、两者的业务应用场景不同

可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。

最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。

3、技术解决方向不同
可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。

最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消 息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)。

解决方案

通过对最大努力通知的理解,采用MQ的ack机制就可以实现最大努力通知。

在这里插入图片描述

本方案是利用MQ的ack机制由MQ向接收通知方发送通知,流程如下:

1、发起通知方将通知发给MQ。使用普通消息机制将通知发给MQ。 注意:如果消息没有发出去可由接收通知方主动请求发起通知方查询业务执行结果。(后边会讲)
2、接收通知方监听 MQ
3、接收通知方接收消息,业务处理完成回应ack
4、接收通知方若没有回应ack则MQ会重复通知。
5、接收通知方可通过消息校对接口来校对消息的一致性。

方案2:

本方案也是利用MQ的ack机制,与方案1不同的是应用程序向接收通知方发送通知,如下图:
在这里插入图片描述

交互流程如下:

1、发起通知方将通知发给MQ。 使用可靠消息一致方案中的事务消息保证本地事务与消息的原子性,最终将通知先发给MQ。
2、通知程序监听 MQ,接收MQ的消息。 方案1中接收通知方直接监听MQ,方案2中由通知程序监听MQ。通知程序若没有回应ack则MQ会重复通知。
3、通知程序通过互联网接口协议(如http、webservice)调用接收通知方案接口,完成通知。 通知程序调用接收通知方案接口成功就表示通知成功,即消费MQ消息成功,MQ将不再向通知程序投递通知消 息。
4、接收通知方可通过消息校对接口来校对消息的一致性。

方案1和方案2的不同点:

1、方案1中接收通知方与MQ接口,即接收通知方案监听 MQ,此方案主要应用与内部应用之间的通知。

2、方案2中由通知程序与MQ接口,通知程序监听MQ,收到MQ的消息后由通知程序通过互联网接口协议调用接收 通知方。此方案主要应用于外部应用之间的通知,例如支付宝、微信的支付结果通知。

分布式事务对比分析

在了解各种分布式事务的解决方案后,我们了解到各种方案的优缺点:

2PC 最大的诟病是一个阻塞协议。RM在执行分支事务后需要等待TM的决定,此时服务会阻塞并锁定资源。由于其 阻塞机制和最差时间复杂度高, 因此,这种设计不能适应随着事务涉及的服务数量增加而扩展的需要,很难用于并 发较高以及子事务生命周期较长 (long-running transactions) 的分布式服务中。

如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面的处 理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使 得降低锁冲突、提高吞吐量成为可能。而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现 try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实 现不同的回滚策略。典型的使用场景:满,登录送优惠券等。

可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消 息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。典型的使用场景:注 册送积分,登录送优惠券等。

最大努力通知是分布式事务中要求最低的一种,适用于一些最终一致性时间敏感度低的业务;允许发起通知方处理业 务失败,在接收通知方收到通知后积极进行失败处理,无论发起通知方如何处理结果都会不影响到接收通知方的后 续处理;发起通知方需提供查询执行情况接口,用于接收通知方校对结果。典型的使用场景:银行通知、支付结果 通知等。

2PCTCC可靠消息最大努力通知
一致性强一致性最终一致最终一致最终一致
吞吐量
实现复杂度

分布式锁

什么是分布式锁

分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。

分布式锁实现主要以Zookeeper(以下简称zk)、Redis、MySQL这三种为主。

线程通过争抢redis和zk以及MySQL上的资源来加锁

为什么要使用分布式锁

需要对某一个共享变量进行多线程同步访问的时候,为了保证数据的最终一致性,需要使用分布式锁

分布式锁应该具备哪些条件:

1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
2、高可用的获取锁与释放锁;
3、高性能的获取锁与释放锁;
4、具备可重入特性;
5、具备锁失效机制,防止死锁;
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

分布式锁的种类

主要有两大种分布式锁

通过自旋进行轮询访问是否能够获得锁的 主要体现 redis和MySQL

通过事件注册 也就是订阅/发布机制进行获得锁的 主要体现 zk

分布式锁的三种实现方式

基于数据库实现分布式锁;
基于缓存(Redis等)实现分布式锁;
基于Zookeeper实现分布式锁;

基于数据库的分布式锁实现方式

基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

(1)创建一个表:

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
  `desc` varchar(255) NOT NULL COMMENT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

img

(2)想要执行某个方法,就使用这个方法名向表中插入数据:

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

(3)成功插入则获取锁,执行完成后删除对应的行数据释放锁:

delete from method_lock where method_name ='methodName';

问题需要解决及优化:

1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;

2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;

3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;

4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。

5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。

redis的分布式锁

日常开发中,秒杀下单、抢红包等等业务场景,都需要用到分布式锁。而Redis非常适合作为分布式锁使用。

图片

  • 「互斥性」: 任意时刻,只有一个客户端能持有锁。
  • 「锁超时释放」:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
  • 「可重入性」:一个线程如果获取了锁之后,可以再次对其请求加锁。
  • 「高性能和高可用」:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
  • 「安全性」:锁只能被持有的客户端删除,不能被其他客户端删除

Redis分布式锁方案一:SETNX + EXPIRE

提到Redis的分布式锁,很多小伙伴马上就会想到setnx+ expire命令。即先用setnx来抢锁,如果抢到之后,再用expire给锁设置一个过期时间,防止锁忘记了释放。

SETNX 是SET IF NOT EXISTS的简写.日常命令格式是SETNX key value,如果 key不存在,则SETNX成功返回1,如果这个key已经存在了,则返回0。

假设某电商网站的某商品做秒杀活动,key可以设置为key_resource_id,value设置任意值,伪代码如下:

if(jedis.setnx(key_resource_id,lock_value) == 1{ //加锁
    expire(key_resource_id,100; //设置过期时间
    try {
        do something  //业务请求
    }catch(){
  }
  finally {
       jedis.del(key_resource_id); //释放锁
    }
}

但是这个方案中,setnxexpire两个命令分开了,「不是原子操作」。如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,「别的线程永远获取不到锁啦」

Redis分布式锁方案二:SETNX + value值是(系统时间+过期时间)

为了解决方案一,「发生异常锁得不到释放的场景」,有小伙伴认为,可以把过期时间放到setnx的value值里面。如果加锁失败,再拿出value值校验一下即可。加锁代码如下:

long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);

// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
        return true;
} 
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);

// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {

     // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
    String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
    
    if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
         // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
         return true;
    }
}
        
//其他情况,均返回加锁失败
return false;
}

这个方案的优点是,巧妙移除expire单独设置过期时间的操作,把**「过期时间放到setnx的value值」**里面来。解决了方案一发生异常,锁得不到释放的问题。但是这个方案还有别的缺点:

  • 过期时间是客户端自己生成的(System.currentTimeMillis()是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。
  • 如果锁过期的时候,并发多个客户端同时请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖
  • 该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。

Redis分布式锁方案三:使用Lua脚本(包含SETNX + EXPIRE两条指令)

实际上,我们还可以使用Lua脚本来保证原子性(包含setnx和expire两条指令),lua脚本如下:

if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
   redis.call('expire',KEYS[1],ARGV[2])
else
   return 0
end;

 String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
            " redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";   
Object result = jedis.eval(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values));
//判断是否成功
return result.equals(1L);

Redis分布式锁方案方案四:SET的扩展命令(SET EX PX NX)

除了使用,使用Lua脚本,保证SETNX + EXPIRE两条指令的原子性,我们还可以巧用Redis的SET指令扩展参数!(SET key value[EX seconds][PX milliseconds][NX|XX]),它也是原子性的!

SET key value [EX seconds] [PX milliseconds] [NX|XX]

  • NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
  • EX seconds :设定key的过期时间,时间单位是秒。
  • PX milliseconds: 设定key的过期时间,单位为毫秒
  • XX: 仅当key存在时设置值

伪代码demo如下:

if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1{ //加锁
    try {
        do something  //业务处理
    }catch(){
  }
  finally {
       jedis.del(key_resource_id); //释放锁
    }
}

方案五:SET EX PX NX + 校验唯一随机值,再删除

既然锁可能被别的线程误删,那我们给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下,不就OK了嘛。伪代码如下:

if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1{ //加锁
    try {
        do something  //业务处理
    }catch(){
  }
  finally {
       //判断是不是当前线程加的锁,是才释放
       if (uni_request_id.equals(jedis.get(key_resource_id))) {
        jedis.del(lockKey); //释放锁
        }
    }
}

在这里,**「判断是不是当前线程加的锁」「释放锁」**不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。

图片

为了更严谨,一般也是用lua脚本代替。lua脚本如下:

if redis.call('get',KEYS[1]) == ARGV[1] then 
   return redis.call('del',KEYS[1]) 
else
   return 0
end;

Redis分布式锁方案六:Redisson框架

方案五还是可能存在**「锁过期释放,业务没执行完」**的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

当前开源框架Redisson解决了这个问题。我们一起来看下Redisson底层原理图吧:

图片

只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了**「锁过期释放,业务没执行完」**问题。

Redis分布式锁方案七:多机实现的分布式锁Redlock+Redisson

前面六种方案都只是基于单机版的讨论,还不是很完美。其实Redis一般都是集群部署的:

图片

如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:

搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。

我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。

图片

RedLock的实现步骤:如下

  • 1.获取当前时间,以毫秒为单位。
  • 2.按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
  • 3.客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
  • 如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。
  • 如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。

简化下步骤就是:

  • 按顺序向5个master节点请求加锁
  • 根据设置的超时时间来判断,是不是要跳过该master节点。
  • 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
  • 如果获取锁失败,解锁!

普通实现

说道Redis分布式锁大部分人都会想到:setnx+lua,或者知道set key value px milliseconds nx。后一种方式的核心实现命令如下:

- 获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000

- 释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这种实现方式有3大要点(也是面试概率非常高的地方):

  1. set命令要用set key value px milliseconds nx
  2. value要具有唯一性;
  3. 释放锁时要验证value值,不能误解锁;

事实上这类琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  1. 在Redis的master节点上拿到了锁;
  2. 但是这个加锁的key还没有同步到slave节点;
  3. master故障,发生故障转移,slave节点升级为master节点;
  4. 导致锁丢失。

正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。笔者认为,Redlock也是Redis所有分布式锁实现方式中唯一能让面试官高潮的方式。

基于ZooKeeper的分布式锁实现方式

这里我以三个不同的客户端client1、client2、client3来演示ZK实现分布式锁的过程。

1、首先,在ZK创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序的节点(临时节点1),client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点(临时节点1)是不是顺序最靠前的一个。如果是则成功获得锁。执行同步代码块。

2、这个时候,如果再有一个客户端client2(可以理解成不同的进程)前来获取锁,则ParentLock下再创建一个临时顺序节点(临时节点2)。client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点(临时节点2)是不是顺序最靠前的一个,发现不是最小,于是,client2向前排序仅比它靠前的节点注册Watcher,用来监听–临时节点1是否存在。这意味着client2抢锁失败。

3、这个时候,又有一个客户端client3前来获取锁,则ParentLock下再创建一个临时顺序节点(临时节点3)。client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点(临时节点3)是不是顺序最靠前的一个,发现不是最小,于是,client3向前排序仅比它靠前的节点注册Watcher,用来监听–临时节点2是否存在。这意味着client3抢锁失败。

4、客户端client1执行完同步代码块,断开与zookeeper连接,对应的临时节点1也会被删除,解锁成功此时client2监听到临时节点1不存在,于是拿到锁。执行同步代码块。

5、客户端client2执行完同步代码块,断开与zookeeper连接,对应的临时节点2也会被删除,解锁成功此时client3监听到临时节点2不存在,于是拿到锁。执行同步代码块。

6、客户端client3执行完同步代码块,断开与zookeeper连接,对应的临时节点3也会被删除,解锁成功。

图解ZK实现分布式锁过程

1.client1拿锁,创建临时节点1并排序,临时节点1在第一位,成功拿锁==执行同步代码块

img

2.client2拿锁,创建临时节点2并排序,临时节点2不在第一位,创建监听器监听前面一个临时节点1,拿锁失败==监听中

img

3.client3拿锁,创建临时节点3并排序,临时节点3不在第一位,创建监听器监听前面一个临时节点2,拿锁失败==监听中。

此时client2也在监听着临时节点1

img

4.client1同步代码执行完毕断开与zookeeper连接,删除临时节点1,相当于释放锁。

client2一直监听着临时节点1,发现其不存在,拿锁成功==执行同步代码块

client3继续监听

img

5.client2同步代码执行完毕断开与zookeeper连接,删除临时节点2,相当于释放锁。

client3一直监听着临时节点2,发现其不存在,拿锁成功==执行同步代码块

img

6.client3同步代码执行完毕断开与zookeeper连接,删除临时节点3,相当于释放锁。

img

ZK和redis分布式锁的区别

  • redis分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能

  • zk分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小

  • zk在处理数据的性能没有redis高,redis支持更高的并发

    因为zk每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。

    ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上。

  • zk是cp原则 而 redis是ap

img

5.client2同步代码执行完毕断开与zookeeper连接,删除临时节点2,相当于释放锁。

client3一直监听着临时节点2,发现其不存在,拿锁成功==执行同步代码块

img

6.client3同步代码执行完毕断开与zookeeper连接,删除临时节点3,相当于释放锁。

img

ZK和redis分布式锁的区别

  • redis分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能

  • zk分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小

  • zk在处理数据的性能没有redis高,redis支持更高的并发

    因为zk每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。

    ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上。

  • zk是cp原则 而 redis是ap

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐