锁分类、分布式锁、分布式事务解决方案

一、各种锁

参考 通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其Java实现!
悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁

悲观锁

顾名思义,就是很悲观,每次拿数据时都认为别人会修改,所以每次拿数据时都会上锁,这样别人想拿这个数据就会阻塞(block )直到它拿到锁。传统关系型数据库就用到这种锁机制,比如行锁(for update),表锁等,读锁,写锁等,都是在做操作之前先上锁。

乐观锁

顾名思义,就是很乐观,每次去拿数据都认为别人不会改,所以不上锁,但在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作),适用于多读的应用类型,可以提高吞吐量,java.util.concurrent.atomic 包里面的原子类都是利用 乐观锁

乐观锁的基础——CAS

CAS:Compare-and-Swap,即比较并替换

1、比较:读取到了一个值 A,在将其更新为 B 之前,检查原值是否仍为 A(未被其他线程改动)。
2、设置:如果是,将 A 该为 B 结束。如不是则什么都不做(避免出现 A 先变成 B,再变回 A,误认为没变化,可以用版本号来解决)

**注意:**上面的两步操作是 原子性 的,简单地理解为瞬间完成,在 CPU 看来就是一步操作。

Java中真正的 CAS 操作调用的 native 方法
整个过程中并没有【加锁】和【解锁】操作,因此乐观锁策略也被称为无锁编程。乐观锁其实不是“锁”,它仅仅是一个循环重试CAS的算法而已!

自旋锁

有一种锁叫自旋锁。所谓自旋,说白了就是一个 while(true) 无限循环。

乐观锁有类似的无限循环操作,但它不是自旋锁。尽管自旋与 while(true) 的操作是一样的,但还是应该将这两个术语分开。“自旋”这两个字,特指自旋锁的自旋。

然而在 JDK 中并没有自旋锁(SpinLock)这个类,那什么才是自旋锁呢?读完下个小节就知道了。

synchronized锁升级:偏向锁 → 轻量级锁 → 重量级锁

jdk 1.6后 synchronized 关键字像是汽车自动档,一脚油门下去,synchronized 会从【无锁】升级为【偏向锁】,再升级为【轻量级锁】,最后升级为【重量级锁】,这里的轻量级锁就是一种自旋锁。

初次执行到 synchronized 代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。在JDK1.6之前,synchronized直接加重量级锁,很明显现在得到了很好的优化。

一个锁只能按照 偏向锁、轻量级锁、重量级锁的顺序逐渐升级(也有叫锁膨胀的),不允许降级。

可重入锁(递归锁)

可重入锁的意思是【可以重新进入的锁】,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java 里只要以 Reentrant 开头命名的锁、 Lock 实现类、synchronized 都是可重入的。大部分业务场景用可重入锁就可以了

公平锁、非公平锁

公平锁: 先申请的先得到,非常公平。
非公平锁: 后申请的线程可能先获取到锁,是随机或者按照其他优先级排序的。

对 ReentrantLock 类而言,通过构造函数传参可以指定该锁是否是公平锁,默认非公平锁。,非公平锁的吞吐量比公平锁大,优先使用非公平锁。
对于 synchronized 而言,它也是一种非公平锁,但是并没有任何办法使其变成公平锁。

可中断锁

如果线程 A 持有锁,线程 B 等待获取该锁。如果线程 B 不想继续等待了,我们可以让线程B中断自己或者在别的线程里中断它,这种就是可中断锁。

synchronized 是不可中断锁,Lock的实现类是可中断锁

public interface Lock {

    void lock(); // 拿不到锁就一直等,拿到马上返回。
    void lockInterruptibly() throws InterruptedException; // 拿不到锁就一直等,如果等待时收到中断请求,则需要处理InterruptedException。

    boolean tryLock(); // 无论拿不拿得到锁,都马上返回。拿到返回true,拿不到返回false。

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 同上,可以自定义等待的时间。

    void unlock();

    Condition newCondition();
}

读写锁、共享锁、互斥锁

读写锁其实是一对锁,一个读锁(共享锁)和一个写锁(互斥锁、排他锁)。

Java 里的 ReadWriteLock 接口,它只规定了两个方法,一个返回读锁,一个返回写锁。ReadWriteLock接口实现类是 ReentrantReadWriteLock。看名字就知道,不仅提供了读写锁,而且可重入。

读写锁其实做的事情是一样的,但是策略稍有不同。很多情况下,线程知道自己读取数据后,是否是为了更新它。那么何不在加锁的时候直接明确这一点呢?如果我读取值是为了更新它(SQL的for update就是这个意思),那么加锁的时候就直接加写锁,我持有写锁的时候别的线程无论读还是写都需要等待;如果我读取数据仅为了前端展示,那么加锁时就明确地加一个读锁,其他线程如果也要加读锁,不需要等待,可以直接获取(读锁计数器+1)。

Java 里使用的各种锁,几乎全都是悲观锁
synchronized 从偏向锁、轻量级锁到重量级锁,全是悲观锁。
JDK提供的 Lock实现类全是悲观锁。其实只要有“锁对象”出现,那么就一定是悲观锁。因为乐观锁不是锁,而是一个在循环里尝试CAS的算法。

网上有些资料认为偏向锁、轻量级锁是乐观锁,理由是它们底层用到了CAS。但应该站在应用层,看它们是如何锁住应用数据的,而不是站在底层看抢占锁的过程。如果一个线程尝试获取锁时,发现已经被占用,它是否继续读取数据,等后续要更新时再决定要不要重试?对于偏向锁、轻量级锁来说,显然答案是否定的。无论是挂起还是忙等,对应用数据的读取操作都被“挡住”了。从这个角度看,它们确实是悲观锁。

二、AQS 理解

参考 https://blog.csdn.net/qq_42046105/article/details/102384342
AbstractQueuedSynchronizer,抽象队列同步器

总结: AQS就是一个并发包的基础组件,包含了 state 变量、加锁线程、等待队列等核心组件用来实现各种锁,各种同步组件的。

java 并发包下很多 API (ReentrantLock、ReentrantReadWriteLock)底层都是基于 AQS 来实现的加锁和释放锁等功能

ReentrantLock 依靠 AQS 来实现锁的原理

ReentrantLock 可重入锁,这种东西只是一个外层的API,内核中的锁机制都是依赖 AQS 组件的。
ReentrantLock 内部包含了一个 AQS 对象,该对象就是实现加锁和释放锁的关键性的核心组件。
AQS 对象内部有一个核心的变量 int 类型的 state,代表了加锁的状态,初始为 0
AQS内部还有一个关键变量,用来记录当前加锁的是哪个线程,初始状态为 null
AQS 内部还有个等待队列
在这里插入图片描述

ReentrantLock 加锁过程如下

线程1跑过来调用 ReentrantLock 的 lock() 方法尝试进行加锁,这个加锁的过程,直接就是用 CAS 操作将 state 值从 0 变为1。如之前没人加过锁,state值是 0,线程1就可加锁成功,同时设置当前加锁线程是自己。ReentrantLock 加锁对象重入了,那么state 数值可以累加
在这里插入图片描述

AQS 就是并发包里一个核心组件,里面有 state 变量、加锁线程变量等核心的东西,维护了加锁状态。

  • 如果线程1加锁了之后,线程2跑过来加锁,发现 state 的值不是0,再看是不是自己之前加的锁,当然不是了,“加锁线程”这个变量明确记录了是线程1占用了这个锁,所以线程2此时就是加锁失败。

  • 加锁失败后,线程2会将自己放入【AQS中的一个等待队列】,等待线程1释放锁之后,自己就可以重新尝试加锁了

  • 接着,线程1在执行完自己的业务逻辑代码之后,就会释放锁!他释放锁的过程非常的简单,就是将AQS内的 state 变量的值递减 1,如果 state 值为 0,则彻底释放锁,会将“加锁线程”变量也设置为 null

  • 接下来,会从等待队列的队头唤醒线程2重新尝试加锁。还是用 CAS 操作将state从0变为1,此时就会成功将 state 设置为1,代表加锁成功,此外还要把“加锁线程”设置为线程2自己,同时线程2自己就从等待队列中出队了。

全流程如下
在这里插入图片描述

三、单体应用事务

1.1 普通事务 XML 配置模式

<!-- 事务管理器 -->
<bean id="transactionManager"
	class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
	<!-- 数据源 -->
	<property name="dataSource" ref="dataSource" />
</bean>
<!-- 通知 -->
<tx:advice id="txAdvice" transaction-manager="transactionManager">
	<tx:attributes>
		<!-- 传播行为 -->
		<tx:method name="save*" propagation="REQUIRED" rollback-for="Exception"/>
		<tx:method name="insert*" propagation="REQUIRED" rollback-for="Exception"/>
		<tx:method name="add*" propagation="REQUIRED" rollback-for="Exception"/>
		<tx:method name="create*" propagation="REQUIRED" rollback-for="Exception"/>
		<tx:method name="delete*" propagation="REQUIRED" rollback-for="Exception"/>
		<tx:method name="update*" propagation="REQUIRED" rollback-for="Exception"/>
		<tx:method name="find*" propagation="SUPPORTS" read-only="true" />
		<tx:method name="select*" propagation="SUPPORTS" read-only="true" />
		<tx:method name="get*" propagation="SUPPORTS" read-only="true" />
		<tx:method name="*" propagation="REQUIRED"/>
	</tx:attributes>
</tx:advice>
<!-- 切面 -->
<aop:config>
	<aop:advisor advice-ref="txAdvice" pointcut="execution(* com.aigosky.order.service.*.*(..))" />
</aop:config>

1.2 开启事务注解方式

<!-- 配置事务管理器 --> 
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> 
	<property name="dataSource" ref="dataSource" /> 
<!--开启注解,使用注释事务 --> 
<tx:annotation-driven  transaction-manager="transactionManager" /> 

1.3 springboot 方式启用事务注解

使用 spring-boot-starter-jdbc 或 spring-boot-starter-data-jpa 依赖时,会默认分别注入 
DataSourceTransactionManager 或 JpaTransactionManager ,并进行一系列的事务初始化操作,
可以直接用 @Transactional 进行事务操作,已不用在启动类上加 @EnableTransactionManagement 注解

四、分布式锁

1、redis 实现

原理主要是利用 setnx
setnx 是『SET if Not eXists』(如果不存在,则 SET)。 SETNX key value,只在键 key 不存在的情况下,将键 key 的值设置为 value 并返回1;若键 key 已经存在, 则不做任何动作并返回 0

在这里插入图片描述

Redisson 实现 Redis 分布式锁的底层原理
在这里插入图片描述

2、zookeeper 实现

参考 zookeeper 实现图解及简单实现
公平锁(独占锁): 先到先得,村民去水井打水,打水之前先取号,凭号取水,号在前面的先取水,井边安排一个看井人,维护取水的秩序
在这里插入图片描述
可重入锁: (一把独占锁,可以被多次锁定),取水时以家庭为单位,家庭的某人拿到号,其他的家庭成员过来打水,这时候不用再取号
在这里插入图片描述
ZooKeeper 分布式锁的原理
ZooKeeper 的临时顺序节点,天生就有一副实现分布式锁的胚子

zookeeper 实现分布式锁的原理就是多个节点同时在一个指定的节点下面创建临时会话顺序节点,谁创建的节点序号最小,谁就获得了锁,并且其他节点就会监听序号比自己小的节点,一旦序号比自己小的节点被删除了,其他节点就会得到相应的事件,然后查看自己是否为序号最小的节点,如果是,则获取锁

图10-5 Zookeeper临时顺序节点的天然的发号器作用

例如,有一个用于发号的节点“/test/lock”为父亲节点,可在该父节点下面创建相同前缀的临时顺序子节点,假定相同的前缀为“/test/lock/seq-”。第一个创建的子节点基本上应该为/test/lock/seq-0000000000,下一个节点则为/test/lock/seq-0000000001

完整步骤如下
在这里插入图片描述

可重入性: zookeeper 的锁是可重入的,即同一个线程可以多次获取锁,只有第一次真正的去创建临时会话顺序节点,后面的获取锁都是对重入次数加1。相应的,在释放锁的时候,前面都是对锁的重入次数减1,只有最后一次才是真正的去删除节点

客户端故障检测: 客户端和服务器有心跳检查,说明自己还是存活的。当因为网络异常或者其他原因,集群中占用锁的客户端失联时,锁能够被服务端释放。排在后面的节点,能收到删除事件,从而获得锁

数据一致性: zookeeper 集群一般由一个 leader 节点和其他的 follower 节点组成,数据的读写都是在leader节点上进行。当一个写请求过来时,leader 节点会发起一个proposal,待大多数follower节点都返回 ack 之后,再发起 commit,待大多数follower节点都对这个 proposal 进行commit了,leader才会对客户端返回请求成功;如果 leader 挂掉了,leader选举算法采用 zab 协议保证数据最新的 follower 节点当选为新的 leader。

CAP: 任何分布式架构都不能同时满足C(一致性)、A(可用性)、P(分区耐受性),因此,zookeeper集群在保证一致性的同时,在A和P之间做了取舍,最终选择了P,因此可用性差一点。

3、基于数据库

1、for update 数据库排它锁

排它锁 : 如果事务 T 对数据 A 加上排他锁后,其他事务不能再对 A 加任何类型的锁【不能读写】。获得排他锁的事务可以读写数据。

2、建一个关于方法的锁表(利用 insert )
在这里插入图片描述

当我们想要锁住某个方法时,执行以下 SQL
在这里插入图片描述
method_name 做了唯一性约束,多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,可认为操作成功的线程获得了该方法的锁,再去执行其他业务操作

当方法执行完毕之后,想要释放锁的话,需要执行以下Sql 删除
在这里插入图片描述
a、锁强依赖数据库,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
b、该锁无失效时间,一旦解锁操作失败,会导致记录一直在库中,其他线程无法再获得到锁。
c、该锁只能是非阻塞的,insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
d、该锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

总结

两种锁,分别适用的场景为:

(1)基于ZooKeeper 的分布式锁,适用于高可靠(高可用)而并发量不是太大的场景;
性能不高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过 Leader 服务器来执行,且 Leader 服务器还需要将数据同步到所有的 Follower 机器上,频繁的网络通信,性能短板突出。

(2)基于Redis的分布式锁,适用于并发量很大、性能要求很高的、而可靠性问题可以通过其他方案去弥补的场景。

(3)排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆

总之,这里没有谁好谁坏的问题,而是谁更合适的问题。

四、分布式事务

参考 D:\chapter12 分布式事务解决方案-seata+消息队列\讲义\第12章.分布式事务解决方案.pdf
参考 http://www.dreamwu.com/post-1741.html

CAP理论

Consistency,Availability 和 Partition Tolerance,即CAP。
其中,P (分区容错性)原则是必不可少的。CA 不可兼得,取舍 CP 或 AP

Consistency: 一致性,这个和数据库ACID的一致性类似,但这里关注的所有数据节点上的数据一致性和正确性,而数据库的ACID关注的是在在一个事务内,对数据的一些约束。系统在执行过某项操作后仍然处于一致的状态。在分布式系统中,更新操作执行成功后所有的用户都应该读取到最新值。

Availability: 可用性,每一个操作总是能够在一定时间内返回结果。需要注意“一定时间”和“返回结果”。“一定时间”是指,系统结果必须在给定时间内返回。“返回结果”是指系统返回操作成功或失败的结果。

Partition Tolerance: 分区容忍性,是否可以对数据进行分区。这是考虑到性能和可伸缩性。

分布式事务指事务的操作位于不同的节点上,需要保证事务的 AICD 特性。
一致性模型数据的一致性模型可以分成以下 3 类:

强一致性:数据更新成功后,任意时刻所有副本中的数据都是一致的,一般采用同步的方式实现。
弱一致性:数据更新成功后,系统不承诺立即可以读到最新写入的值,也不承诺具体多久之后可以读到。
最终一致性:弱一致性的一种形式,数据更新成功后,系统不承诺立即可以返回最新写入的值,但是保证最终会返回上一次更新操作的值。

例如在下单场景下,库存和订单如果不在同一个节点上,就涉及分布式事务。

两阶段提交与三阶段提交参考:https://segmentfault.com/a/1190000012534071

2.1、两阶段提交(2PC)(强一致性)

准备阶段和提交阶段

两阶段提交(Two-phase Commit,2PC),通过引入协调者(Coordinator)来协调参与者的行为,并最终决定这些参与者是否要真正执行事务。
用 TCC 分布式事务,须引入 TCC 分布式事务框架,比如国内开源的 ByteTCC、Himly、TCC-transaction。
1 准备阶段:协调者询问参与者事务是否执行成功,参与者发回事务执行结果。写本地的 redo 和 undo 日志,但不提交,到达一种“万事俱备,只欠东风”的状态。
2 提交阶段:如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。
注意:在准备阶段,参与者执行了事务,但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。

存在的问题:
1 同步阻塞 所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。
2 单点问题 协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。
3 数据不一致 在阶段二,如协调者只发送了部分 Commit 消息,此时网络异常,只有部分参与者接收到 Commit 消息,只有部分参与者提交了事务,使系统数据不一致。
4 太过保守 任意一个节点失败就会导致整个事务失败,没有完善的容错机制。

在这里插入图片描述
在这里插入图片描述

2.2、三阶段提交(3PC)(强一致性)

与两阶段提交不同的是,三阶段提交有两个改动点。
1、引入超时机制。在协调者和参与者中引入超时机制。
2、增加一个阶段,有 CanCommit、PreCommit、DoCommit 三个阶段。

引入can-Commit阶段,主要是为了在预执行之前,保证所有参与者都具备可执行条件,从而减少资源浪费。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
两阶段提交协议中所存在的长时间阻塞状态发生的几率还是非常低的,所以虽然三阶段提交协议相对于两阶段提交协议对于数据强一致性更有保障,但是因为效率问题,两阶段提交协议在实际系统中反而更加受宠

2.3、补偿事务(TCC)(最终一致性)

在这里插入图片描述

如何使用 TCC(Try-Confirm-Cancel)

  • 首先需要选择某种TCC分布式事务框架,各个服务里就会有这个TCC分布式事务框架在运行。
  • 然后你原本的一个接口,要改造为3个逻辑,Try-Confirm-Cancel。
1、先是服务调用链路依次执行 Try 逻辑
2、如果都正常的话,TCC 分布式事务框架推进执行 Confirm 逻辑,完成整个事务
3、如果某个服务的 Try 逻辑有问题,框架感知到之后就会推进执行各个服务的Cancel逻辑,撤销之前执行的各种操作
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:
1.Try 阶段主要是对业务系统做检测及资源预留
2.Confirm 阶段主要是对业务系统做确认提交,Try 阶段执行成功并开始执行 Confirm 阶段时,默认 Confirm 阶段是不会出错的。即:只要Try成功,Confirm一定成功。
3.Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

举个例子,假入 Bob 要向 Smith 转账,思路大概是: 我们有一个本地方法,里面依次调用
首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。
在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。

实现关键要素:
1.服务调用链必须被记录下来。
2.每个服务提供者都需要提供一组业务逻辑相反的操作,互为补偿,同时回滚操作要保证幂等。
3.必须按失败原因执行不同的回滚策略。

优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些
缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。

TCC 和 2PC
tcc 和 2pc 非常的相似,都是两阶段的性质。但是,2pc从事务资源的角度利用强一致性来解决问题,显得有些效率低下。而 tcc 从业务角度来解决问题,把强一致性改成了最终一致性,大大提高了效率。但是 tcc 明显对代码造成了入侵,你原本只需要一个接口,就不得不拆分成三个接口来处理,对开发者的要求也会更高。

另外,2pc 在二阶段如果出现网络故障的情况,即使利用持久化的事务日志补偿处理也会从强一致性变成最终一致性。而tcc从一开始就决定不维护强一致性,而是遵循最终一致性。这样看来,tcc 虽然增加了开发的复杂度问题,但是在使用上会更加地高效,稳定,即使极端情况下确实需要人工干预,最终一致性也能够保持。

2.4、本地消息表(异步确保)(分布式消息中间件 - activemq的分布式事务完整案例)

本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性。
在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中。
之后将本地消息表中的消息转发到 Kafka 等消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发。在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。

优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。

1)消息生产方,需要额外建一个【消息表】,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。 
2)消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作
3)生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的

2.5、MQ 事务消息

有一些第三方的 MQ 是支持事务消息的,比如 RocketMQ ,他们支持事务消息的方式也是类似于采用的二阶段提交,但其他主流的 MQ 如 RabbitMQ 和 Kafka 都【不支持事务消息】。
以阿里的 RocketMQ 中间件为例,其思路大致为:
	第一阶段 Prepared 消息,会拿到消息的地址。
	第二阶段执行本地事务,
	第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。
也就是说在业务方法内要想消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check 接口,RocketMQ 会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

优点: 实现了最终一致性,不需要依赖本地数据库事务。
缺点: 实现难度大,主流 MQ 不支持,RocketMQ 事务消息部分代码也未开源。

在这里插入图片描述

这张图说明了事务消息的大致方案,分为两个逻辑:正常事务消息的发送及提交、事务消息的补偿流程

事务消息发送及提交:

1、发送消息(half消息)
2、服务端响应消息写入结果
3、根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)
4、根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)

补偿流程:

1、对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
2、Producer收到回查消息,检查回查消息对应的本地事务的状态
3、根据本地事务状态,重新Commit或者Rollback
4、补偿阶段用于解决消息Commit或者Rollback发生超时或者失败的情况

消费者 B 消费失败咋办

如果 B 最终执行失败,几乎可以断定就是代码有问题所以才引起的异常,因为消费端 RocketMQ 有重试机制,如果不是代码问题一般重试几次就能成功。如果是代码的原因引起多次重试失败后,也没有关系,将该异常记录下来,由人工处理,人工兜底处理后,就可以让事务达到最终的一致性。

如果再 B 消费失败后去自动回滚整个流程的话,系统复杂度将大大提升,且很容易出现 Bug,出现Bug 的概率会比消费失败的概率大很多。这也是 RocketMQ 目前暂时没有解决这个问题的原因,在设计实现消息系统时,我们要衡量是否值得花这么大的代价来解决这样一个出现概率非常小的问题。

2.6、LCN

在这里插入图片描述

TX-LCN 定位于一款事务协调性框架,框架其本身并不操作事务,而是基于对事务的协调从而达到事务一致性的效果,LCN不生产事务,它只是事务的搬运工

  • 首先我们的 lcn 协调者(TM)会和 lcn 客户端(TC)通过引入的netty一直保持着长连接(持续监听)。

  • 当请求的发起方(调用方)进入接口业务之前,会通过AOP技术进到@LcnTransaction注解中去LCN协调者那边生成注册一个全局的事务组Id(groupId)。

  • 当发起方(调用方)通过rpc调用参与方(被调用方)的时候,lcn重写了Feign客户端,会从ThreadLocal中拿到该事务组Id(groupId),并将该事务组Id设置到请求头中。

  • 参与方(被调用方)在请求头中获取到了这个groupId的时候,lcn会标识该服务为参与方并加入到该事务组,并会被lcn代理数据源,当该服务业务逻辑执行完成后,进行数据源的假关闭,并不会真正的提交或回滚当前服务的事务。

  • 当发起方执行完全部业务逻辑的时候,如果无异常会告知lcn协调者,lcn协调者再分别告诉该请求链上的所有参与方可以提交了,再进行真正的提交。若发起方调用完参与方后报错了,也会告知lcn协调者,lcn协调者再告知所有的参与方进行真正的回滚操作,这样就解决了分布式事务的问题。

总结
分布式事务本身是一个技术难题,是没有一种完美的方案应对所有场景的,具体还是要根据业务场景去抉择。

五、Seata 布式事务框架

阿里开源的分布式事务框架
假如微服务架构中有 3 个模块为3个独立服务,有各自的数据库,如何保证调用时各服务的事务一致性

Seata实现2PC与传统2PC的差别:

架构层次方面,传统2PC方案的RM实际上是在数据库层,RM本质上就是数据库自身,通过XA协议实现,而Seata的RM是以jar包的形式作为中间件层部署在应用程序这一侧的。

两阶段提交方面,传统 2PC 无论第二阶段的决议是commit还是rollback,事务性资源的锁都要保持到Phase2完成才释放。而Seata的做法是在Phase1就将本地事务提交,这样可以省去Phase2持锁的时间,整体提高效率。

AT(Auto Transaction)模式

Seata 的 AT 模式,就是自动化事务,使用非常简单,对业务代码没有侵入性,业务系统要建一张undo_log 表来记录业务提交前后数据记录,方便回滚数据,再为方法添加 @GlobalTransactional 注解即可

1.实现过程
  • TC(事务协调者,Transaction Coordinator):是 Server 端,要单独部署,维护全局和分支事务的状态,指示全局提交或者回滚;
  • TM(Transaction Manager):是 Client 端,由业务系统集成,定义全局事务;
  • RM(Resource Manager):是 Client 端,由业务系统集成,管理分支事务处理的资源。
    在这里插入图片描述

Business 是业务入口,在程序中会通过注解来说明他是一个全局事务,角色为 TM(事务管理者)。他请求 TC(事务协调器,独立运行的服务),说明自己要开启一个全局事务,TC 会生成一个全局事务 ID(XID),并返回给 Business。

Business 得到 XID 后,开始调用微服务,如调用 Storage 服务

Storage 微服务(角色是 RM 资源管理者,资源指本地数据库)收到 XID,知道自己的事务属于这个全局事务。Storage 执行自己的业务逻辑,操作本地数据库。把自己的事务注册到 TC,作为这个 XID 下面的一个分支事务,并且把自己的事务执行结果也告诉 TC。

Order 微服务和 Account 微服务的执行逻辑与 Storage 一致。

在各个微服务都执行完成后,TC 可以知道 XID 下各个分支事务的执行结果,TM(Business) 也就知道了。Business 如果发现各个微服务的本地事务都执行成功了,就请求 TC 对这个 XID 提交,否则回滚。

TC 收到请求后,向 XID 下的所有分支事务发起相应请求。各个微服务收到 TC 的请求后,执行相应指令,并把执行结果上报 TC。
在这里插入图片描述

2.重要机制

(1)全局事务的回滚是如何实现的呢?

Seata 有一个重要的机制:回滚日志。

每个分支事务对应的数据库中都需要有一个回滚日志表 UNDO_LOG,在真正修改数据库记录之前,都会先记录修改前的记录值,以便之后回滚。

在收到回滚请求后,就会根据 UNDO_LOG 生成回滚操作的 SQL 语句来执行。
如果收到的是提交请求,就把 UNDO_LOG 中的相应记录删除掉。

(2)RM 是怎么自动和 TC 交互的?

是通过监控拦截JDBC实现的,例如监控到开启本地事务了,就会自动向 TC 注册、生成回滚日志、向 TC 汇报执行结果。

(3)二阶段回滚失败怎么办?

例如 TC 命令各个 RM 回滚的时候,有一个微服务挂掉了,那么所有正常的微服务也都不会执行回滚,当这个微服务重新正常运行后,TC 会重新执行全局回滚。

MT(Manual Transaction)模式

手动模式,这个模式适合其他的场景,因为底层存储可能没有事务支持,需要自己实现 prepare、commit和rollback的逻辑

混合模式

因为 AT 和 MT 模式的分支从根本上行为模式是一致的,所以可以完全兼容,即,一个全局事务中,可
以同时存在 AT 和 MT 的分支。这样就可以达到全面覆盖业务场景的目的:AT 模式可以支持的,使用
AT 模式;AT 模式暂时支持不了的,用 MT 模式来替代。另外,自然的,MT 模式管理的非事务性资源
也可以和支持事务的关系型数据库资源一起,纳入同一个分布式事务的管理中。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值