分布式事务讲解之CAP,2PC,3PC,TCC,分布式锁


学习此篇分布式事务前请先学习Spring事务讲解
点击了解Spring事务讲解

1 CAP

1.1 CAP原则

CAP原则又称CAP定理, 指的是在一个分布式系统中, Consistency(一致性) 、Availability(可用性) 、 Partition tolerance(分区容错性) , 三者不可兼得。

原则分类 详解
C
数据一致性(Consistency)
也叫做数据原子性系统在执行某项操作后仍然处于一致的状态。 在分布式系统中, 更新操作执行成功后所有的用户都应该读到最新的值,这样的系统被认为是具有强一致性的,也就是每个时刻都必须一样,不一样整个系统就不能对外提供服务。 等同于所有节点访问同一份最新的数据副本
A
服务可用性(Availablity)
每一个操作总是能够在一定的时间内返回结果, 这里需要注意的是一定时间内返回结果。 一定时间内指的是,在可以容忍的范围内返回结果, 结果可以是成功或者是失败
P
分区容错性(Partition-torlerance)
在网络分区的情况下, 被分隔的节点仍能正常对外提供服务(分布式集群, 数据被分布存储在不同的服务器上, 无论什么情况, 服务器都能正常被访问)

分区容错性重点讲解:一个分布式系统里面,节点组成的网络本来应该是连通的。然而可能因为一些故障,使得有些节点之间不连通了,整个网络就分成了几块区域。数据就散布在了这些不连通的区域中。这就叫分区。当一个数据项只在一个节点中保存,那么分区出现后,和这个节点不连通的部分就访问不到这个数据了。这是分区就是无法容忍的。提高分区容忍性的办法就是一个数据项复制到多个节点上,那么出现分区之后,这一数据项就可能分布到各个区里。容忍性就提高了。然而,要把数据复制到多个节点,就会带来一致性的问题,就是多个节点上面的数据可能是不一致的。要保证一致,每次写操作就都要等待全部节点写成功,而这等待又会带来可用性的问题。
总的来说就是,数据存在的节点越多,分区容忍性越高,但要复制更新的数据就越多,一致性就越难保证。为了保证一致性,更新所有节点数据所需要的时间就越长,可用性就会降低

1.1.1 数据一致性

数据一致性的种类:

  • 强一致性(线性一致性):即复制是同步的
    任何一次读都能读到某个数据的最近一次写的数据
    系统中的所有进程,看到的操作顺序,都和全局时钟下的顺序一致
    简言之,在任意时刻,所有节点中的数据是一样的
  • 弱一致性:即复制是异步的
    数据更新后,如果能容忍后续的访问只能访问到部分或者全部访问不到,则是弱一致性

最终一致性就属于弱一致性

最终一致性
不保证在任意时刻任意节点上的同一份数据都是相同的,但是随着时间的迁移,不同节点上的同一份数据总是在向趋同的方向变化。
最终两个字用得很微妙,因为从写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,也可能是几个小时
简单说,就是在一段时间后,节点间的数据会最终达到一致状态

1.1.2 图示讲解

让我们来考虑一个非常简单的分布式系统,它由两台服务器G1和G2组成;这两台服务器都存储了同一个变量v,v的初始值为v0;G1和G2互相之间能够通信,并且也能与外部的客户端通信;我们的分布式系统的架构图如下图所示:
在这里插入图片描述
一个简单的分布式系统

客户端可以向任何服务器发出读写请求。服务器当接收到请求之后,将根据请求执行一些计算,然后把请求结果返回给客户端。譬如,下图是一个写请求的例子:

客户端发起写请求
客户端发起写请求

接着,下图是一个读请求的例子
客户端发起读请求
客户端发起读请求

现在我们的分布式系统建立起来了,下面我们就来回顾一下分布式系统的可用性、一致性以及分区容错性的含义。

1.1.2.1 一致性

在一个一致性的系统中,客户端向任何服务器发起一个写请求,将一个值写入服务器并得到响应,那么之后向任何服务器发起读请求,都必须读取到这个值(或者更加新的值)。

下图是一个不一致的分布式系统的例子:
不一致的分布式系统
客户端向G1发起写请求,将v的值更新为v1且得到G1的确认响应;当向G2发起读v的请求时,读取到的却是旧的值v0,与期待的v1不一致。

下图一致的分布式系统的例子:
一致的分布式系统
在这个系统中,G1在将确认响应返回给客户端之前,会先把v的新值复制给G2,这样,当客户端从G2读取v的值时就能读取到最新的值v1

1.1.2.2 可用性

在一个可用的分布式系统中,客户端向其中一个服务器发起一个请求且该服务器未崩溃,那么这个服务器最终必须响应客户端的请求。

1.1.2.3 分区容错性

服务器G1和G2之间互相发送的任意消息都可能丢失。如果所有的消息都丢失了,那么我们的系统就变成了下图这样:
网络分区
为了满足分区容错性,我们的系统在任意的网络分区情况下都必须正常的工作

1.2 CAP如何舍弃

定律: 任何分布式系统只可同时满足二点,没法三者兼顾,对于 分布式数据系统,分区容忍性是基本要求,否则就失去了价值

三者择其二 分析
CA, 放弃 P 如果想避免分区容错性问题的发生, 一种做法是将所有的数据(与事务相关的)都放在一台机器上。 虽然无法 100%保证系统不会出错, 但不会碰到由分区带来的负面效果。 当然这个选择会严重的影响系统的扩展性
CP, 放弃 A 相对于放弃"分区容错性"来说, 其反面就是放弃可用性。一旦遇到分区容错故障, 那么受到影响的服务需要等待一定时间, 因此在等待时间内系统无法对外提供服务
AP, 放弃 C 这里所说的放弃一致性, 并不是完全放弃数据一致性,而是放弃数据的强一致性, 而保留数据的最终一致性。 以网络购物为例, 对只剩下一件库存的商品, 如果同时接受了两个订单, 那么较晚的订单将被告知商品告罄

1.3 eureka与zookeeper区别

对比项 Zookeeper Eureka
CAP CP AP
Dubbo 集成 已支持 -
Spring Cloud 集成 已支持 已支持
kv 服务 支持 - ZK 支持数据存储,eureka不支持
使用接口(多语言能力) 提供客户端 http 多语言 ZK的跨语言支持比较弱
watch 支持 支持 支持 什么是Watch 支持?就是客户单监听服务端的变化情况。zk 通过订阅监听来实现eureka 通过轮询的方式来实现
集群监控 - metrics metrics,运维者可以收集并报警这些度量信息达到监控目的

1.4 CAP对应的模型和应用

1.4.1 CA without P

理论上放弃P(分区容错性),则C(强一致性)和A(可用性)是可以保证的。实际上分区是不可避免的,严格上CA指的是允许分区后各子系统依然保持CA。

CA模型的常见应用:

  • 集群数据库
  • xFS文件系统

1.4.2 CP without A

放弃A(可用),相当于每个请求都需要在Server之间强一致,而P(分区)会导致同步时间无限延长,如此CP也是可以保证的。很多传统的数据库分布式事务都属于这种模式。

CP模型的常见应用:

  • 分布式数据库
  • 分布式锁

1.4.3 AP wihtout C

要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。现在众多的NoSQL都属于此类。

AP模型常见应用:

  • Web缓存
  • DNS

1.4.4 常见注册中心

举个大家更熟悉的例子,像我们熟悉的注册中心ZooKeeperEurekaNacos中:

  • ZooKeeper 保证的是 CP
  • Eureka 保证的则是 AP
  • Nacos 不仅支持 CP 也支持 AP

1.5 BASE理论

BASE(Basically Available、Soft state、Eventual consistency)是基于CAP理论逐步演化而来的,核心思想是即便不能达到强一致性(Strong consistency),也可以根据应用特点采用适当的方式来达到最终一致性(Eventual consistency)的效果。
权衡了可用性一致性而提出的理论,相当于满足PA的情况(并没有完全舍弃C,只是舍弃强一致性,保留了最终一致性
在这里插入图片描述

BASE的主要含义:

  • Basically Available(基本可用)
    什么是基本可用呢?假设系统出现了不可预知的故障,但还是能用,只是相比较正常的系统而言,可能会有响应时间上的损失,或者功能上的降级。
    响应时间上:可能因为网络故障导致响应时间延长一点点
    功能上:由于某个服务突然被大量访问,那新来的访问被降级到其他服务,如返回网络繁忙等等
  • Soft State(软状态)
    什么是硬状态呢?要求多个节点的数据副本都是一致的,这是一种硬状态
    软状态也称为弱状态,相比较硬状态而言,允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
    即:允许系统中的某些数据处于中间状态,且这些中间状态不影响整体的可用性,即允许各个节点之间的数据同步存在延迟
  • Eventually Consistent(最终一致性)
    上面说了软状态,但是不应该一直都是软状态。在一定时间后,应该到达一个最终的状态,保证所有副本保持数据一致性,从而达到数据的最终一致性。这个时间取决于网络延时、系统负载、数据复制方案设计等等因素。

2 分布式事务

现在实现分布式事务的设计方案应该有3种,分别就是二阶段提交、三阶段提交和TCC
他们都有2种重要的角色 事务协调者参与者,也就是各个服务

2.1 二阶段提交(2PC)

两阶段提交的思路可以概括为:
参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情况决定各参与者是否要提交操作还是回滚操作。

sequenceDiagram 协调者 ->>参与者集群: 1.1询问各个参与者是否可以正常执行事务 activate 参与者集群 参与者集群 ->>参与者集群: 1.2 执行事务,不提交 参与者集群 -->>协调者: 1.3 反馈准备提交或回滚 deactivate 参与者集群 协调者 ->>参与者集群: 2.1 通知所有参与者提交或回滚 activate 参与者集群 参与者集群 ->>参与者集群:2.2 事务提交或回滚 参与者集群 -->>协调者: 2.3 反馈结果 deactivate 参与者集群

2.1.1 准备阶段

准备阶段:协调者(事务管理器)要求每个涉及到事务的数据库参与者 预提交(precommit)此操作,并反映是否可以提交

根据上面的UML图来看
1.3反馈准备提交或回滚 存在多种情况:

  • 所有参与者都反馈可以提交
  • 有参与者反馈回滚(不管多少)

2.1.2. 提交阶段

提交阶段:协调者(事务管理器)要求每个数据库参与者提交数据或者回滚数据
根据上面的UML图来看

  • 2.1根据1.3的反馈情况:
    • 所有参与者都反馈可以提交 –> 通知全部提交
    • 有参与者反馈回滚 –> 通知全部回滚
    • 等待反馈超时 –> 通知全部回滚
  • 2.2根据2.1
    • 通知全部提交 –> 提交
    • 通知全部回滚 –> 回滚
    • 一直没收到请求 –> 阻塞住

2.1.3 两阶段优缺点

优点:尽量保证了数据的强一致,实现成本较低,在各大主流数据库都有自己实现,对于MySQL是从5.5开始支持。

缺点:

  • 单点问题
    事务管理器在整个流程中扮演的角色很关键,如果其宕机,比如在第一阶段已经完成,在第二阶段正准备提交的时候事务管理器宕机,资源管理器就会一直阻塞,导致数据库无法使用。
  • 同步阻塞
    在准备就绪之后,资源管理器中的资源一直处于阻塞,直到提交完成,释放资源
    如上图所示,参与者反馈1.3后是处于阻塞状态等待2.1,如果网络问题或协调者宕机了,接受不到2.1,那么会一直阻塞
  • 数据不一致
    两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性
    如上图所示,部分参与者接收不到2.1,阻塞中,而接受到的就进行提交或回滚了,造成数据不一致

2.2 三阶段提交(3PC)

sequenceDiagram 协调者 ->>+参与者集群: 1.1 询问各个参与者是否可以正常执行事务 参与者集群 -->>-协调者: 1.2 反馈是否可执行 协调者 ->>+参与者集群: 2.1 准备提交 参与者集群 ->>参与者集群: 2.2 执行事务,不提交 参与者集群 -->>-协调者: 2.3 反馈结果 协调者 ->>+参与者集群:3.1 通知所有参与者提交或回滚 参与者集群 ->>参与者集群:3.2 事务提交或回滚 参与者集群 -->>-协调者:3.3 反馈结果

2.2.1 询问阶段(CanCommit)

预判断:协调者向参与者发送 CanCommit 请求,参与者如果可以提交就返回Yes响应,否则返回No响应
如上图所示,1.2反馈情况

  • 所有参与者都反馈可以
  • 有参与者反馈不能成功执行(不管多少)

2.2.2 准备阶段(PreCommit)

准备提交:协调者根据参与者在询问阶段的响应判断是否执行事务还是中断事务,参与者执行完操作之后返回ACK响应,同时开始等待最终指令。
如上图所示

  • 2.1根据1.2的情况
    • 所有参与者都反馈可以 –> 通知全部准备提交
    • 有参与者反馈不能成功执行 –> 通知全部 abort 通知
    • 等待反馈超时 –> 通知全部 abort 通知
      协调者发起abort通知后就会进入结束状态了,不再进行后续
  • 2.2根据2.1
    • 通知全部准备提交 –> 执行事务,不提交
    • 通知全部 abort 通知 –> 会中断事务的操作
    • 等待超时 –> 会中断事务的操作

2.2.3 提交阶段(DoCommit)

提交阶段:协调者根据参与者在准备阶段的响应判断是否执行事务还是中断事务:

  • 如果所有参与者都返回正确的ACK响应,则提交事务
  • 如果参与者有一个或多个参与者返回错误的ACK响应或者超时,则中断事务
  • 如果参与者无法及时接收到来自协调者的提交或者中断事务请求时,在等待超时之后,会继续进行事务提交

如上图所示

  • 3.1根据2.3的情况
    • 所有参与者都反馈体可以提交 –> 通知全部提交
    • 有参与者反馈回滚或中断事务 –> 通知全部回滚
    • 等待超时 –> 通知全部回滚
  • 3.2根据3.1
    • 通知全部提交 –> 提交
    • 通知全部回滚 –> 回滚
    • 等待超时 –> 提交

为什么第三阶段等待超时就会自动提交呢?
因为经过了前面两阶段的判断,第三阶段可以提交的概率会大于回滚的概率

2.2.4 三阶段/二阶段差异

三阶段的参与者是有超时机制的,等待请求超时会进行事务中断,或事务提交
而二阶段不会超时,只会阻塞
可以看出,三阶段提交解决的只是两阶段提交中单体故障和同步阻塞的问题,因为加入了超时机制,这里的超时的机制作用于 准备提交阶段提交阶段。如果等待 准备提交请求 超时,参与者直接回到准备阶段之前。如果等到提交请求超时,那参与者就会提交事务了

2PC3PC 是分布式事务中两种常见的协议,3PC 可以看作是 2PC 协议的改进版本,相比于 2PC 它有两点改进:

  • 引入了超时机制,同时在协调者和参与者中都引入超时机制(2PC 只有协调者有超时机制);
  • 3PC 相比于 2PC 增加了 CanCommit 阶段,可以尽早的发现问题,从而避免了后续的阻塞和无效操作。

也就是说,3PC 相比于 2PC,因为引入了超时机制,所以发生阻塞的几率变小了;同时 3PC 把之前 2PC 的准备阶段一分为二,变成了两步,这样就多了一个缓冲阶段,保证了在最后提交阶段之前各参与节点的状态是一致的

注意:无论是2PC还是3PC都不能保证分布式系统中的数据100%一致,与 2PC 协议相比,3PC 协议仍然可能存在阻塞的问题。

2.3 补偿提交(TCC)

2.3.1 定义

TCC(Try Confirm Cancel) ,是两阶段提交的一个变种,针对每个操作,都需要有一个其对应的确认取消操作,当操作成功时调用确认操作,当操作失败时调用取消操作,类似于二阶段提交,只不过是这里的提交回滚是针对业务上的,所以基于TCC实现的分布式事务也可以看做是对业务的一种补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。

TCC(Try-Confirm-Cancel)包括三段流程:

  • try阶段:尝试去执行,完成所有业务的一致性检查,预留必须的业务资源。
  • Confirm阶段:确认执行业务,如果Try阶段执行成功,接着执行Confirm 阶段。该阶段对业务进行确认提交,不做任何检查,因为try阶段已经检查过了,默认Confirm阶段是不会出错的。
  • Cancel 阶段:取消待执行的业务,如果Try阶段执行失败,执行Cancel 阶段。进入该阶段会释放try阶段占用的所有业务资源,并回滚Confirm阶段执行的所有操作。

TCC 是业务层面的分布式事务,保证最终一致性,不会一直持有资源的锁。

  • 优点: 把数据库层的二阶段提交交给应用层来实现,规避了数据库的 2PC 性能低下问题
  • 缺点TCCTryConfirmCancel 操作功能需业务提供,开发成本高。TCC 对业务的侵入较大和业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作

2.3.2 操作

数据库表需要存多两个字段可更新数冻结数,将各个服务的事务执行分成3个步骤

  • T(try ,尝试更新阶段)
    • 原数据不变,只更新【可更新数】,同时保存变化差值在【冻结数】,方便回滚
    • 状态设置为【更新中】
  • C(confirm,确认阶段)
    • 更新原数据,【可更新数】不变(在try阶段更新好了),清除【冻结数】
    • 状态设置为【更新完】
      此时表示业务正常完成了
  • C(cancel,补偿还原阶段)
    如果try出错了,那各个服务就执行cancel还原数据,相当于回滚
    • 根据【可更新数】【冻结数】更新数据为事务前的样子
    • 状态设置为更新前的状态【未更新】

如果是 confirmcancel出错了,一般会去重复执行,因为过了try,可以认为confirm是一定可以执行成功的,除非重复执行次数达到阈值,就落地成日志,让人工处理
在这里插入图片描述

注意:这里事务回滚的方式不像我们认为的那样 — 数据库直接给我们处理好
而是通过cancel将数据补回去,所以TCC也叫补偿机制

2.4 分布式事务总结

二阶段提交三阶段提交TCC分别是三种实现分布式事务的方案
至于具体的实现框架:二阶段有mysql的XA事务,TCC有seata、tcc-transaction
我们看TCC会涉及到服务与服务之间的接口调用,因为网络问题,极有可能出现重复调用的情况,所以【confirm】【cancel】这些接口应该要实现幂等
当然服务间的通信除了通过【同步的rpc】,也可以通过【异步的MQ】来实现,所以引出了接下来的基于MQ最终一致性方案

3 分布式锁

3.1 Redis 分布式锁和Zookeeper 区别

3.1.1 二者使用方式

点击了解Zookeeper分布式锁
点击了解Redis分布式锁

3.1.2 二者区别

在功能上,Redis 的分布式锁和 Zookeeper 的分布式锁都能实现我们想要的功能,锁的互斥、重入等等。他们主要有以下几个区别:

  • 性能区别
    在性能方面,Redis 是基于内存存储的,而 Zookeeper 是基于磁盘存储的,所以,在性能上,Redis 要比 ZK 更好一些。
  • 自动释放
    Zookeeper 的锁的实现原理是基于客户端服务端的连接来保证的,一旦连接断了,锁就会被自动释放。而 Redis 的锁是需要自己主动加锁和解锁的,除非达到了超时时间,否则不会自动释放。
    所以,Zookeeper 的分布式锁可以更好的应对客户端崩溃的情况,一旦客户端崩溃,锁就会释放,而 Redis 实现的分布式锁,一旦客户端崩溃了,就没有人去进行释放了,只能等超时。
    锁能自动释放有啥好处?除了提升并发度以外,还有个好处就是可以减少死锁发生的概率。因为锁释放了,所以就不会出现死锁了。
  • 一致性&可用性要求(CAP)
    我们知道 Zookeeper 是一个 CP 的系统,也就是他是保证强一致性的,而 Redis 是一个 AP 的系统,它是保证可用性的。
    Zookeeper 会牺牲可用性来保证数据的一致性,即出现部分节点宕机后,集群中少于一半的节点后,或者集群正在进行 master 选举时,都会拒绝新的写请求,导致无法加锁。
    Redis 会牺牲一致性性来保证可用性,即 Redis 的集群中在做数据同步时,如果出现网络延迟,那么即使多个节点上面的数据不一样,客户端也可以正常的进行写入和读取。
    那么,在使用 Zookeeper 的分布式锁的时候,不会存在锁丢失的情况,也就是说不太会出现因为锁丢失而导致并发的情况。但是,可能会出现短暂的无法加锁的情况
    而在使用 Redis 的分布式锁的时候,除非集群都挂了,要不然不太会出现无法加锁的情况。但是可能会出现锁丢失的情况,或者说是重复加锁的情况,RedLock 的单点故障的问题。

3.1.3 选择使用

二者选择:

  • Redis 实现的分布式锁、性能更好,可用性更高。Zookeeper 实现的分布式锁可以自动释放,减少死锁出现的概率,并且他的一致性更有保障。
  • 如果分布式锁使用场景,对性能要求更高,可以牺牲一点一致性,那么就选择 Redis 的分布式锁。而如果场景对性能要求没那么高,但是对一致性要求非常高,那么则可以选择 Zookeeper

其实,如果对可用性的要求高的话,用 Redis 也行,因为有个 RedLock,他的机制和 zk 很像,都是通过半数以上提交这种方式来避免因为单点问题而导致锁重复的。但是,RedLock 其实也不建议大家用,并且 Zookeeper 的分布式锁其实也不建议大家用。就直接用 Redis 就好了。

为啥呢?因为一般来说,我们在用分布式锁的时候,对性能要求肯定很高的,如果不高的话,直接用数据库的悲观锁就好了。没必要用分布式锁。
而且,往往我们在用分布式锁的时候,同时会伴随着幂等性判断、以及数据库兜底的唯一性约束的校验。所以,即使出现了极端情况,因为 Redis 的一致性没保证好,导致重复加锁了,我们也能在后续的环节中识别并防止并发。
Redis 的不可用的问题其实可以通过哨兵、集群等运维手段来解决的,所以,发生的概率本来就极低。所以说,日常开发的时候,只要我们把幂等判断、唯一性约束做好,对账最好,用 Redis 是最简单,高效的办法。
而且,Redis 作为一个缓存框架,很多应用都会直接依赖,直接用SETNX 或者 Redisson 加锁不要太方便。而 Zookeeper,很多都是中间件在使用, 真正的业务应用依赖的很少的,多引入一个底层中间件,对系统来说也会提升复杂度,减少整体的稳定性的。

3.2 Springboot集成分布式锁

3.2.1 简介

Spring Integration在基于Spring的应用程序中实现轻量级消息传递,并支持通过声明适配器与外部系统集成。Spring Integration的主要目标是提供一个简单的模型来构建企业集成解决方案,同时保持关注点的分离,这对于生成可维护,可测试的代码至关重要。我们熟知的 Spring Cloud Stream的底层就是Spring Integration
它们使用相同的API抽象,这意味着,不论使用哪种存储,编码体验是一样的。试想一下你目前是基于zookeeper实现的分布式锁,哪天想换成redis的实现,我们只需要修改相关依赖和配置就可以了,无需修改代码。下面是你使用 Spring Integration 实现分布式锁时需要关注的方法:

方法名 描述
lock() 加锁,如果已经被其他线程锁住或者当前线程不能获取锁则阻塞
lockInterruptibly() 加锁,除非当前线程被打断。
tryLock() 尝试加锁,如果已经有其他锁锁住,获取当前线程不能加锁,则返回false,加锁失败;加锁成功则返回true
tryLock(long time, TimeUnit unit) 尝试在指定时间内加锁,如果已经有其他锁锁住,获取当前线程不能加锁,则返回false,加锁失败;加锁成功则返回true
unlock() 解锁

3.2.2 基于Redis实现springboot集成

3.2.2.1 pom.xml和配置

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-integration</artifactId>
</dependency>

<dependency>
 <groupId>org.springframework.integration</groupId>
 <artifactId>spring-integration-redis</artifactId>
</dependency>

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

在application.yml中添加redis的配置

spring:
 redis:
  host: 172.31.0.149
  port: 7111

3.2.2.2 配置类

建立配置类,注入RedisLockRegistry

@Configuration
public class RedisLockConfiguration {

    @Bean
    public RedisLockRegistry redisLockRegistry(RedisConnectionFactory redisConnectionFactory){
        return new RedisLockRegistry(redisConnectionFactory, "redis-lock");
    }
}

3.2.2.3 测试类

编写测试代码

@RestController
@RequestMapping("lock")
@Log4j2
public class DistributedLockController {
    @Autowired
    private RedisLockRegistry redisLockRegistry;

    @GetMapping("/redis")
    public void test1() {
        Lock lock = redisLockRegistry.obtain("redis");
        try{
            //尝试在指定时间内加锁,如果已经有其他锁锁住,获取当前线程不能加锁,则返回false,加锁失败;加锁成功则返回true
            if(lock.tryLock(3, TimeUnit.SECONDS)){
                log.info("lock is ready");
                TimeUnit.SECONDS.sleep(5);
            }
        } catch (InterruptedException e) {
            log.error("obtain lock error",e);
        } finally {
            lock.unlock();
        }
    }
}

测试:启动多个实例,分别访问/lock/redis 端点,一个正常秩序业务逻辑,另外一个实例访问出现如下错误
图片
说明第二个实例没有拿到锁,证明了分布式锁的存在。
注意,如果使用新版Springboot进行集成时需要使用Redis4版本,否则会出现下面的异常告警,主要是 unlock() 释放锁时使用了UNLINK命令,这个需要Redis4版本才能支持。

2020-05-14 11:30:24,781 WARN  RedisLockRegistry:339 - The UNLINK command has failed (not supported on the Redis server?); falling back to the regular DELETE command
org.springframework.data.redis.RedisSystemException: Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: ERR unknown command 'UNLINK'

3.2.3 基于Zookeeper实现springboot集成

3.2.3.1 pom.xml和配置

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-integration</artifactId>
</dependency>

 <dependency>
 <groupId>org.springframework.integration</groupId>
 <artifactId>spring-integration-zookeeper</artifactId>
</dependency>

application.yml中添加zookeeper的配置

zookeeper:  
    host: 172.31.0.43:2181

3.2.3.2 配置类

建立配置类,注入ZookeeperLockRegistry

@Configuration
public class ZookeeperLockConfiguration {
    @Value("${zookeeper.host}")
    private String zkUrl;


    @Bean
    public CuratorFrameworkFactoryBean curatorFrameworkFactoryBean(){
        return new CuratorFrameworkFactoryBean(zkUrl);
    }

    @Bean
    public ZookeeperLockRegistry zookeeperLockRegistry(CuratorFramework curatorFramework){
        return new ZookeeperLockRegistry(curatorFramework,"/zookeeper-lock");
    }
}

3.2.3.3 编写测试代码

@RestController
@RequestMapping("lock")
@Log4j2
public class DistributedLockController {

    @Autowired
    private ZookeeperLockRegistry zookeeperLockRegistry;

    @GetMapping("/zookeeper")
    public void test2() {
        Lock lock = zookeeperLockRegistry.obtain("zookeeper");
        try{
            //尝试在指定时间内加锁,如果已经有其他锁锁住,获取当前线程不能加锁,则返回false,加锁失败;加锁成功则返回true
            if(lock.tryLock(3, TimeUnit.SECONDS)){
                log.info("lock is ready");
                TimeUnit.SECONDS.sleep(5);
            }
        } catch (InterruptedException e) {
            log.error("obtain lock error",e);
        } finally {
            lock.unlock();
        }
    }
}

测试:启动多个实例,分别访问/lock/zookeeper 端点,一个正常秩序业务逻辑,另外一个实例访问出现如下错误
图片

说明第二个实例没有拿到锁,证明了分布式锁的存在

3.3 分布式锁框架 Lock4j

3.3.1 简介

Lock4j 是一个分布式锁组件,它提供了多种不同的支持以满足不同性能和环境的需求,基于Spring AOP的声明式和编程式分布式锁,支持RedisTemplate、Redisson、Zookeeper

特性:

  • 简单易用,功能强大,扩展性强。
  • 支持redission, redisTemplate, zookeeper,可混用,支持扩展。

3.3.2 Pom依赖和配置

<!-- Lock4j -->
<!-- 若使用redisTemplate作为分布式锁底层,则需要引入 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>lock4j-redis-template-spring-boot-starter</artifactId>
    <version>2.2.4</version>
</dependency>
<!-- 若使用redisson作为分布式锁底层,则需要引入 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>lock4j-redisson-spring-boot-starter</artifactId>
    <version>2.2.4</version>
</dependency>
spring:
  redis:
    database: 0
    # Redis服务器地址 写你的ip
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    password:
    # 连接池最大连接数(使用负值表示没有限制  类似于mysql的连接池
    jedis:
      pool:
        max-active: 200
        # 连接池最大阻塞等待时间(使用负值表示没有限制) 表示连接池的链接拿完了 现在去申请需要等待的时间
        max-wait: -1
        # 连接池中的最大空闲连接
        max-idle: 10
        # 连接池中的最小空闲连接
        min-idle: 0
    # 连接超时时间(毫秒) 去链接redis服务端
    timeout: 6000

3.3.3 注解属性介绍

package com.baomidou.lock.annotation;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Lock4j {
    String name() default "";

    Class<? extends LockExecutor> executor() default LockExecutor.class;

    String[] keys() default {""};

    long expire() default -1L;

    long acquireTimeout() default -1L;

    boolean autoRelease() default true;
}
@Lock4j属性 说明
name 需要锁住的key名称
executor 可以通过该参数设置自定义特定的执行器
keys 需要锁住的 keys 名称,可以是多个
expire 锁过期时间,主要是用来防止死锁
acquireTimeout 可以理解为排队等待时长,超过这个时长就退出排队,并排除获取锁超时异常
autoRelease 是否自动释放锁,默认是 true

3.3.4 简单使用

@RestController
@RequestMapping("/mock")
public class MockController {

    @GetMapping("/lockMethod")
    @Lock4j(keys = {"#key"}, acquireTimeout = 1000, expire = 10000)
    public Result lockMethod(@RequestParam String key) {
        ThreadUtil.sleep(5000);
        return Result.OK(key);
    }
}

如果抢占不到锁,Lock4j会抛出com.baomidou.lock.exception.LockFailureException: request failed,please retry it.异常,通过全局异常处理

3.3.5 高级使用

3.3.5.1 自定义执行器Exector

Lock4j 的执行器(Executor)负责实际的加锁和解锁操作。通过自定义执行器,可以根据具体的需求来实现加锁和解锁的逻辑。通过自定义执行器,可以灵活地选择适合你业务场景的分布式锁方案,并与 Lock4j 进行集成。

import com.baomidou.lock.executor.AbstractLockExecutor;
import org.springframework.stereotype.Component;
/**
 * 自定义分布式锁执行器
 */
@Component
public class CustomRedissonLockExecutor extends AbstractLockExecutor {  
    @Override
    public Object acquire(String lockKey, String lockValue, long expire, long acquireTimeout) {
        return null;
    }

    @Override
    public boolean releaseLock(String key, String value, Object lockInstance) {
        return false;
    }
}

在注解上直接指定特定的执行器:@Lock4j(executor = CustomRedissonLockExecutor.class)

3.3.5.2 自定义分布式锁key生成器

分布式锁通常需要一个唯一的键(key)来标识不同的锁。默认情况下,Lock4j 使用 DefaultLockKeyBuilder 来生成锁键,它使用了一些基本信息如锁名称、作用域和持有者来生成一个唯一且具有可读性的字符串作为锁键。
通过自定义分布式锁键生成器,可以根据实际需求来定制生成逻辑。例如,可以基于业务场景、资源类型等因素来生成更具有语义和可读性的锁键。自定义分布式锁键生成器可以提高代码可读性,并确保在不同场景下生成唯一且合适的分布式锁键

import com.baomidou.lock.DefaultLockKeyBuilder;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.stereotype.Component;
/**
 * 自定义分布式锁key生成器
 */
@Component
public class CustomKeyBuilder extends DefaultLockKeyBuilder {
    public CustomKeyBuilder(BeanFactory beanFactory) {
        super(beanFactory);
    }
}

3.3.5.3 自定义抢占锁失败执行策略

import com.baomidou.lock.LockFailureStrategy;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
 * 自定义抢占锁失败执行策略
 */
@Component
public class GrabLockFailureStrategy implements LockFailureStrategy {
    @Override
    public void onLockFailure(String key, Method method, Object[] arguments) {

    }
}

默认的锁获取失败策略为:com.baomidou.lock.DefaultLockFailureStrategy.

3.3.5.4 手动加锁释放锁

@Service
public class LockServiceImpl implements LockService {

    @Autowired
    private LockTemplate lockTemplate;

    @Override
    public void lock(String resourceKey) {

        LockInfo lock = lockTemplate.lock(resourceKey, 10000L, 2000L, CustomRedissonLockExecutor.class);
        if (lock == null) {
            // 获取不到锁
            throw new FrameworkException("业务处理中,请稍后再试...");
        }
        // 获取锁成功,处理业务
        try {
            doBusiness();
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            lockTemplate.releaseLock(lock);
        }
    }

    private void doBusiness() {
        // TODO 业务执行逻辑
    }
}
posted @ 2023-03-31 19:46  上善若泪  阅读(355)  评论(0编辑  收藏  举报