分布式事务
yuankaiqiang Lv5

1、事务

1.1什么是事务

Spring 事务管理分为编程式和声明式两种。

编程式事务指的是通过编码方式实现事务;

声明式事务基于 AOP,将具体的逻辑与事务处理解耦。生命式事务管理使业务代码逻辑不受污染,因此实际使用中声明式事务用的比较多。

声明式事务有两种方式,一种是在配置文件(XML)中做相关的事务规则声明,另一种是基于 @Transactional 注解的方式。本文着重介绍基于 @Transactional 注解的事务管理。

1.2 隔离机制

事务实现应该是具备原子性、一致性、隔离性和持久性,简称 ACID

  • 原子性(Atomicity),可以理解为一个事务内的所有操作要么都执行,要么都不执行。
  • 一致性(Consistency),可以理解为数据是满足完整性约束的,也就是不会存在中间状态的数据,比如你账上有400,我账上有100,你给我打200块,此时你账上的钱应该是200,我账上的钱应该是300,不会存在我账上钱加了,你账上钱没扣的中间状态
  • 隔离性(Isolation),指的是多个事务并发执行的时候不会互相干扰,即一个事务内部的数据对于其他事务来说是隔离的。
  • 持久性(Durability),指的是一个事务完成了之后数据就被永远保存下来,之后的其他操作或故障都不会对事务的结果产生影响。

1.3 如果不考虑隔离性,会发生什么事呢?

  1. 脏读

    事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据

  2. 不可重复读

    事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。

    不可重复读和脏读的区别是,脏读读取到的是一个未提交的数据不可重复读读取到的是前一个事务提交的数据

    而不可重复读在一些情况也并不影响数据的正确性,比如需要多次查询的数据也是要以最后一次查询到的数据为主。

  3. 幻读

    幻读是事务非独立执行时发生的一种现象。例如事务T1对一个表中所有的行的某个数据项做了从“1”修改为“2”的操作,这时事务T2又对这个表中插入了一行数据项,而这个数据项的数值还是为“1”并且提交给数据库。而操作事务T1的用户如果再查看刚刚修改的数据,会发现还有一行没有修改,其实这行是从事务T2中添加的,就好像产生幻觉一样,这就是发生了幻读。

    幻读和不可重复读都是读取了另一条已经提交的事务(这点就脏读不同),所不同的是不可重复读查询的都是同一个数据项,而幻读针对的是一批数据整体(比如数据的个数)。

    • 幻读有什么问题 ?
    1. 首先是语义上的. 前一次查询已经声明要把d=5的行加锁、不许别的事务进行读写操作, 而幻读却破坏了这个语义.
    2. 是数据一致性的问题. 锁的设计是为了保证数据的一致性. 这个一致性、不止是数据库内部数据状态此刻的一致性、还包含了数据和日志在逻辑上的一致性.
    • 如何解决幻读?
      产生幻读的原因是: 行锁只能锁住行、但是新插入记录这个动作、要更新的是记录之间的间隙、为了解决幻读、就引入了间隙锁(Gap Lock).
      跟行锁产生冲突的是、另外一个行锁. 跟间隙锁存在冲突关系的是: 往间隙中插入一个记录这个操作. 间隙锁之间不存在冲突关系.

    小结:不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表**

    img

    Q3 读到 id=1 这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在 前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。

    1. 前提)在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读(读取的是最新版本)”下才会出现。
    2. 新插入的行)上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行

1.4 四种隔离级别

01:Read uncommitted(读未提交):最低级别,任何情况都会发生。
02:Read Committed(读已提交):可避免脏读的发生。
03:Repeatable read(可重复读):可避免脏读、不可重复读的发生。(**Mysql默认隔离级别**)
04:Serializable(串行化):避免脏读、不可重复读,幻读的发生。(锁表的方式)

一般不会使用SerializableRead committed

​ 以上四种隔离级别最高的是 Serializable 级别,最低的是 Read uncommitted 级别,当然级别越高,执行效率就越低。

1.5 锁的分类

参考:https://zhuanlan.zhihu.com/p/52312376

INNODB的行锁默认算法。

  • 基于锁的属性分类:共享锁、排他锁。
  • 基于锁的粒度分类:表锁、行锁(记录锁、间隙锁、临键锁)。
  • 基于锁的状态分类:意向共享锁、意向排它锁。

1.6 for update(行级锁/排它锁)

for update是一种行级锁,又叫排它锁,一旦用户对某个行施加了行级加锁,则该用户可以查询也可以更新被加锁的数据行,其它用户只能查询但不能更新被加锁的数据行.如果其它用户想更新该表中的数据行,则也必须对该表施加行级锁.即使多个用户对一个表均使用了共享更新,但也不允许两个事务同时对一个表进行更新,真正对表进行更新时,是以独占方式锁表,一直到提交或复原该事务为止。行锁永远是独占方式锁
只有当出现如下之一的条件,才会释放共享更新锁:
1.执行提交(COMMIT)语句
2.退出数据库(LOG OFF)
3.程序停止运行

什么时候需要使用for update?

  1. 借助for update语句,我们可以在应用程序的层面手工实现数据加锁保护操作。就是那些需要业务层面数据独占时,可以考虑使用for update。
  2. 火车票订票、库存扣减,在屏幕上显示有票,而真正进行出票时,需要重新确定一下这个数据没有被其他客户端修改。所以,在这个确认过程中,可以使用for update。

加锁的目的是什么?

​ 加锁是为了**解决事务的隔离性问题,让事务之间相互不影响**,每个事务进行操作的时候都必须先对数据加上一把锁,防止其他事务同时操作数据。

锁是基于什么实现的?

​ 数据库里面的锁是基于索引实现的,在Innodb中我们的锁都是作用在索引上面的,当我们的SQL命中索引时,那么锁住的就是命中条件内的索引节点(行锁),如果没有命中索引的话,那我们锁的就是整个索引树(表锁)

1.7 @Transactional注解

参考:

https://blog.csdn.net/weixin_36586564/article/details/105687331

https://blog.csdn.net/qq_37795502/article/details/121143575

添加位置

  1. 接口实现类或接口实现方法上,而不是接口类中。
  2. 访问权限:public 的方法才起作用。@Transactional 注解应该只被应用到 public 方法上,这是由 Spring AOP 的本质决定的。
  3. 默认是实际抛出的异常类是要属于RuntimeException子类实例或者就是RuntimeException类又或者是Error才能返回true ,返回true就是能够回滚的!
  4. 如果你实际抛出的异常类是比你指定回滚异常类“要大”(也就是实际的是你指定的父类),实际抛出的异常类 > 指定回滚的异常类,会返回-1,表示没有匹配。
  5. 错误使用
  • 接口中A、B两个方法,A无@Transactional标签,B有,上层通过A间接调用B,此时事务不生效。
    • 在这里插入图片描述
    • spring 在扫描bean的时候会扫描方法上是否包含@Transactional注解,如果包含,spring会为这个bean动态地生成一个子类(即代理类,proxy),代理类是继承原来那个bean的。
      此时,当这个有注解的方法被调用的时候,实际上是由代理类来调用的,代理类在调用之前就会启动transaction。然而,如果这个有注解的方法是被同一个类中的其他方法调用的,那么该方法的调用并没有通过代理类,而是直接通过原来的那个bean,所以就不会启动transaction,我们看到的现象就是@Transactional注解无效。
  • 多线程下事务管理因为线程不属于 spring 托管,故线程不能够默认使用 spring 的事务,也不能获取spring 注入的 bean 。
  • 接口中异常(运行时异常)被捕获而没有被抛出。默认配置下,spring 只有在抛出的异常为运行时 unchecked 异常时才回滚该事务,也就是抛出的异常为RuntimeException 的子类(Errors也会导致事务回滚),而抛出 checked 异常则不会导致事务回滚 。可通过 @Transactional rollbackFor进行配置。

实现原理

@Transactional 实质是使用了 JDBC 的事务(ACID)来进行事务控制的
@Transactional 基于 Spring 的动态代理的机制

  1. 事务开始时,通过AOP机制,生成一个代理connection对象,并将其放入DataSource实例的某个与DataSourceTransactionManager相关的某处容器中。在接下来的整个事务中,客户代码都应该使用该connection连接数据库,执行所有数据库命令[不使用该connection连接数据库执行的数据库命令,在本事务回滚的时候得不到回滚](物理连接connection逻辑上新建一个会话session;DataSource与TransactionManager配置相同的数据源)
  2. 事务结束时,回滚在第1步骤中得到的代理connection对象上执行的数据库命令,然后关闭该代理connection对象(事务结束后,回滚操作不会对已执行完毕的SQL操作命令起作用)

失败原因情况

  1. @Transactional修饰的方法为非public方法,这个时候@Transactional会实现。
    失败的原理是:@Transactional是基于动态代理来实现的,非public的方法,他@Transactional的动态代理对象信息为空,所以不能回滚。

  2. 在类内部没有添加@Transactional的方法,调用了@Transactional方法时,当你调用时,他也不会回滚(**@Transactional是基于动态代理对象来实现的,而在类内部的方法的调用是通过this关键字来实现的,没有经过动态代理对象,所以事务回滚失效。**)。

  3. 就是在@Transactional方法内部捕获了异常,没有在catch代码块里面重新抛出异常,事务也不会回滚。(阿里开发手册事务场景中,抛出异常被catch后,如果需要回滚,一定要注意手动回滚事务

    手动回滚:TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Override
    @Transactional
    public void insertOne() {
    try {
    UserEntity userEntity = new UserEntity();
    userEntity.setUsername("Michael_C_2019");
    //插入到数据库
    userMapper.insertSelective(userEntity);
    //手动抛出异常
    throw new IndexOutOfBoundsException();
    } catch (IndexOutOfBoundsException e) {
    e.printStackTrace();
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
    }

2、分布式事务

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

​ 对于分布式事务而言几乎满足不了 ACID,其实对于单机事务而言大部分情况下也没有满足 ACID,不然怎么会有四种隔离级别呢?所以更别说分布在不同数据库或者不同应用上的分布式事务了。

解决方案

2PC和3PC原理^1

2.1 2PC(同步阻塞)

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

两阶段提交又称2PC,2PC是一个非常经典的强一致、中心化的原子提交协议

这里所说的中心化是指协议中有两类节点:一个是中心化协调者节点(coordinator)和N个参与者节点(partcipant)。

两个阶段:第一阶段:投票阶段 和第二阶段:提交/执行阶段

举例 订单服务A,需要调用 支付服务B 去支付,支付成功则处理购物订单为待发货状态,否则就需要将购物订单处理为失败状态。

那么看2PC阶段是如何处理的

第一阶段:投票阶段

image-20210705151151227

第一阶段主要分为3步

1)事务询问

协调者 向所有的 参与者 发送事务预处理请求,称之为Prepare,并开始等待各 参与者 的响应。

2)执行本地事务

各个 参与者 节点执行本地事务操作,但在执行完成后并不会真正提交数据库本地事务,而是先向 协调者 报告说:“我这边可以处理了/我这边不能处理”。.

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

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

第一阶段执行完后,会有两种可能。1、所有都返回Yes. 2、有一个或者多个返回No。

第二阶段:提交/执行阶段(成功流程)

成功条件:所有参与者都返回Yes。

image-20210705151329170

第二阶段主要分为两步

1)所有的参与者反馈给协调者的信息都是Yes,那么就会执行事务提交

协调者所有参与者 节点发出Commit请求.

2)事务提交

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

第二阶段:提交/执行阶段(异常流程)

异常条件:任何一个 参与者协调者 反馈了 No 响应,或者等待超时之后,协调者尚未收到所有参与者的反馈响应。

image-20210705151446575

异常流程第二阶段也分为两步

1)发送回滚请求

协调者 向所有参与者节点发出 RoollBack 请求.

2事务回滚

参与者 接收到RoollBack请求后,会回滚本地事务。

2PC缺点

1)性能问题

无论是在第一阶段的过程中,还是在第二阶段,所有的参与者资源和协调者资源都是被锁住的,只有当所有节点准备完毕,事务 协调者 才会通知进行全局提交,

参与者 进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大

2)单节点故障

由于协调者的重要性,一旦 协调者 发生故障。参与者 会一直阻塞下去。尤其在第二阶段,协调者 发生故障,那么所有的 参与者 还都处于

锁定事务资源的状态中,而无法继续完成事务操作。(虽然协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)

2PC出现单点问题的三种情况

(1)协调者正常,参与者宕机

由于 协调者 无法收集到所有 参与者 的反馈,会陷入阻塞情况。

解决方案:引入超时机制,如果协调者在超过指定的时间还没有收到参与者的反馈,事务就失败,向所有节点发送终止事务请求。

(2)协调者宕机,参与者正常

无论处于哪个阶段,由于协调者宕机,无法发送提交请求,所有处于执行了操作但是未提交状态的参与者都会陷入阻塞情况.

解决方案:引入协调者备份,同时协调者需记录操作日志.当检测到协调者宕机一段时间后,协调者备份取代协调者,并读取操作日志,向所有参与者询问状态。

(3)协调者和参与者都宕机

1)发生在第一阶段: 因为第一阶段,所有参与者都没有真正执行commit,所以只需重新在剩余的参与者中重新选出一个协调者,新的协调者在重新执行第一阶段和第二阶段就可以了。

2)发生在第二阶段 并且 挂了的参与者在挂掉之前没有收到协调者的指令。也就是上面的第4步挂了,这是可能协调者还没有发送第4步就挂了。这种情形下,新的协调者重新执行第一阶段和第二阶段操作。

3)发生在第二阶段 并且 有部分参与者已经执行完commit操作。就好比这里订单服务A和支付服务B都收到协调者 发送的commit信息,开始真正执行本地事务commit,但突发情况,Acommit成功,B确挂了。这个时候目前来讲数据是不一致的。虽然这个时候可以再通过手段让他和协调者通信,再想办法把数据搞成一致的,但是,这段时间内他的数据状态已经是不一致的了! 2PC 无法解决这个问题。

2.2 3PC

​ 三阶段提交协议(3PC)主要是为了解决两阶段提交协议的阻塞问题,2pc存在的问题是当协作者崩溃时,参与者不能做出最后的选择。因此参与者可能在协作者恢复之前保持阻塞。三阶段提交(Three-phase commit),是二阶段提交(2PC)的改进版本。

  • 引入超时机制。同时在协调者和参与者中都引入超时机制。

  • 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。

也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommitPreCommitDoCommit三个阶段。

CanCommit阶段

之前2PC的一阶段是本地事务执行结束后,最后不Commit,等其它服务都执行结束并返回Yes,由协调者发生commit才真正执行commit。而这里的CanCommit指的是 尝试获取数据库锁 如果可以,就返回Yes。

image-20210705152102104

这阶段主要分为2步

事务询问 协调者参与者 发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待 参与者 的响应。
响应反馈 参与者 接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No

PreCommit阶段

​ 在阶段一中,如果所有的参与者都返回Yes的话,那么就会进入PreCommit阶段进行事务预提交。这里的PreCommit阶段 跟上面的第一阶段是差不多的,只不过这里 协调者和参与者都引入了超时机制 (2PC中只有协调者可以超时,参与者没有超时机制)。

DoCommit阶段

这里跟2pc的阶段二是差不多的。

2.3 对比2PC与3PC

​ 相比较2PC而言,3PC对于协调者(Coordinator)和参与者(Partcipant)都设置了超时时间,而2PC只有协调者才拥有超时机制。这解决了一个什么问题呢?

​ 这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,

自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。

​ 另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。

以上就是3PC相对于2PC的一个提高(相对缓解了2PC中的前两个问题),但是3PC依然没有完全解决数据不一致的问题。

参考网址:

 评论