事务那些事儿

前言

有段时间没更新博客了,最近在学习一些有关事务的知识点,今天来总结一下,本文会涉及到如下几个知识点:mysql

  • MySQL事务
  • Spring事务
  • 分布式事务

什么是事务

事务是一系列操做组成的工做单元,该工做单元内的操做是不可分割的,即要么全部操做都作,要么全部操做都不作,这就是事务。

举个例子:算法

张三要给李四转帐100元,那么咱们会有这样的一段SQL:spring

begin transaction;
    update account set money = money-100 where name = '张三';
    update account set money = money+100 where name = '李四';
commit transaction;

事务的体现:这两个SQL要么所有成功,要么所有失败。sql

事务可否生效数据库引擎是否支持事务是关键。好比经常使用的 MySQL 数据库默认使用支持事务的innodb引擎。可是,若是把数据库引擎变为 myisam,那么程序也就再也不支持事务了!数据库

ACID

事务具备如下四个特性:编程

  • 原子性
  • 一致性
  • 隔离性
  • 持久性

原子性(Atomicity)

通常来讲,原子是指不能分解成小部分的东西。例如,在多线程编程中,若是一个线程执行一个原子操做,这意味着另外一个线程没法看到该操做的一半结果。系统只能处于操做以前或操做以后的状态,而不是介于二者之间的状态。服务器

一致性(Consistency)

事务一致性是指数据库中的数据在事务操做先后都必须知足业务规则约束。
好比A转帐给B,那么转帐先后,AB的帐户总金额应该是一致的。网络

隔离性(Isolation)

一个事务的执行不能被其它事务干扰。即一个事务内部的操做及使用的数据对其它并发事务是隔离的,并发执行的各个事务之间不能互相干扰。
(设置不一样的隔离级别,互相干扰的程度会不一样)多线程

持久性(Durability)

事务一旦提交,结果即是永久性的。即便发生宕机,仍然能够依靠事务日志完成数据的持久化。并发

日志包括回滚日志(undo)和重作日志(redo),当咱们经过事务修改数据时,首先会将数据库变化的信息记录到重作日志中,而后再对数据库中的数据进行修改。这样即便数据库系统发生奔溃,咱们还能够经过重作日志进行数据恢复。

MySQL事务隔离级别

MySQL有如下四个事务隔离级别:

  • 未提交读(READ UNCOMMITTED)
  • 已提交读(READ COMMITTED)
  • 可重复读(REPEATABLE READ)
  • 串行化(SERIALIZABLE)

各个隔离级别可能会存在如下的问题:

那么什么是脏读、不可重复读和幻读?

脏读:指一个事务能够看到另外一个事务未提交的数据

好比说事务A修改了一个值可是还未提交,这时事务B能够看到A修改的值,这就是脏读。

不可重复读:一个事务执行两次一样的查询语句,先后得出的数据却不一致

好比说事务A执行了select语句,事务B修改了某个值,事务A再次执行select语句时发现结果和上次不一致,所以叫作不可重复读。

幻读:在同一个事务中,同一个查询屡次返回的记录行数不一致(这里的结果特指查询到的记录行数,幻读能够看作不可重复读的一种特殊状况)

好比说事务A执行了select语句,事务B插入数据,事务A再次执行select语句时发现多了几条记录,好像出现了幻觉同样,所以叫作幻读。

Read Commit(读已提交)级别是如何解决脏读的?

先说结论:经过改变锁的释放时机来解决脏读问题

首先先了解一下为何会出现脏读?缘由就是在未提交读这个级别下,当事务A修改了数据以后就立马释放了锁,所以事务B能够读取到这个未提交的数据。

已提交读级别下写操做加的锁会到事务提交后释放,因此事务B不会读到事务A未提交的数据,经过改变锁的释放时机解决了脏读的问题。

Repeatable Read(可重复读)级别是如何解决不可重复读的?

结论:可重复读级别就是经过MVCC机制来解决不可重复读问题的

MVCC

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别老是读取最新的数据行,无需使用 MVCC。可串行化隔离级别须要对全部读取的行都加锁,单纯使用 MVCC 没法实现。

MVCC机制(多版本并发控制)就我我的理解来讲其实就是给每行数据都添加了几个隐藏字段,用来表示数据的版本号,即一个数据在mysql中会有多个不一样的版本

在讲 MVCC 的实现原理以前,我觉颇有必要先去了解一下 MVCC 的两种读形式。

有了MVCC以后咱们能够把SQL操做分为两类:

  • 快照读

读取当前事务可见的数据,默认的select操做就是快照读,读的是历史版本的数据。

  • 当前读

读取最新的数据,除了默认select操做外的select..for updateupdateinsertdelete等操做都是当前读,读取的都是最新的数据。

如今咱们有了MVCC,当事务A执行一个普通的select操做(快照读),MySQL会把此次读取的数据保存起来,在这期间无论事务B执行update或是insert操做,事务A再次执行select操做读取到的数据是不会变的,所以经过可重复读级别经过MVCC解决了不可重复读问题,顺便解决了部分的幻读问题,没错MVCC并无解决全部的幻读问题,只是解决了一部分。

那么何时会出现幻读呢?

当事务A执行的是当前读,也就是加锁的select操做时如select * from Employee for update,会去读取最新的数据,这样的话仍是能够看到事务B提交的数据,所以MySQL提供了Next-Key Lock算法来帮助咱们对数据加锁。

Next-Key Lock

InnoDB有三种行锁的算法:

  1. Record Lock:单个行记录上的锁。
  2. Gap Lock:间隙锁,锁定一个范围,但不包括记录自己。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的状况。

3. Next-Key Lock:1+2,锁定一个范围,而且锁定记录自己。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。

Next-Key Lock 是 MySQL 的 InnoDB 存储引擎的一种锁实现。

MVCC 不能解决幻读的问题,Next-Key Lock 就是为了解决这个问题而存在的。在可重复读(REPEATABLE READ)隔离级别下,使用 MVCC + Next-Key Lock 能够解决幻读问题。

当查询的索引含有惟一属性的时候,Next-Key Lock 会进行优化,将其降级为Record Lock,即仅锁住索引自己,不是范围。

它是 Record LockGap Lock 的结合,不只锁定一个记录上的索引,也锁定索引之间的间隙。

这样的话当事务A执行了select * from Employee for update以后,事务B插入数据会被阻塞,这样的话Repeatable Read(可重复读)级别使用 MVCC + Next-Key Lock 能够解决了不可重复读和幻读的问题。

注意:Repeatable Read(可重复读)是MySQL的默认隔离级别。

串行化(SERIALIZABLE)

这是最高的隔离级别,它经过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每一个读的数据行上加上共享锁。在这个级别,可能致使大量的超时现象和锁竞争。

Spring事务

@Transaction事务失效

在项目开发中咱们有时候会遇到Spring事务失效的场景,那么什么场景会致使事务失效呢?

- @Transactional 注解属性 propagation 设置错误

这种失效是因为配置错误,如果错误的配置如下三种 propagation,事务将不会发生回滚。

TransactionDefinition.PROPAGATION_SUPPORTS:若是当前存在事务,则加入该事务;若是当前没有事务,则以非事务的方式继续运行。
TransactionDefinition.PROPAGATION_NOT_SUPPORTED:以非事务方式运行,若是当前存在事务,则把当前事务挂起。
TransactionDefinition.PROPAGATION_NEVER:以非事务方式运行,若是当前存在事务,则抛出异常。

- @Transactional 应用在非 public 修饰的方法上

如下来自 Spring 官方文档:

When using proxies, you should apply the @Transactional annotation only to methods with public visibility. If you do annotate protected, private or package-visible methods with the @Transactional annotation, no error is raised, but the annotated method does not exhibit the configured transactional settings. Consider the use of AspectJ (see below) if you need to annotate non-public methods.

大概意思就是 @Transactional 只能用于 public 的方法上,不然事务不会失效,若是要用在非 public 方法上,能够开启 AspectJ 代理模式。

- @Transactional 注解属性 rollbackFor 设置错误

rollbackFor 能够指定可以触发事务回滚的异常类型。Spring默认抛出了未检查unchecked异常(继承自 RuntimeException 的异常)或者 Error才回滚事务;其余异常不会触发回滚事务。若是在事务中抛出其余类型的异常,但却指望 Spring 可以回滚事务,就须要指定 rollbackFor属性。

// 但愿自定义的异常能够进行回滚
@Transactional(propagation= Propagation.REQUIRED,rollbackFor= MyException.class

同一个类中方法调用,致使@Transactional失效

来看两个示例:

@Service
public class OrderServiceImpl implements OrderService {
    public void update(Order order) {
        updateOrder(order);
    }
    @Transactional
    public void updateOrder(Order order) {
        // update order;
    }
}

update方法上面没有加 @Transactional 注解,调用有 @Transactional 注解的 updateOrder 方法,updateOrder 方法上的事务管用吗?

@Service
public class OrderServiceImpl implements OrderService {
    @Transactional
    public void update(Order order) {
        updateOrder(order); 
   }
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateOrder(Order order) {
        // update order;
    }
}

此次在 update 方法上加了 @Transactional,updateOrder 加了 REQUIRES_NEW 新开启一个事务,那么新开的事务管用么?

这两个例子的答案是:无论用!

由于@Transactional注解底层实际上是Spring帮咱们生成了一个代理对象,当其它对象调用带有@Transactional的方法时,其实调的是代理对象,Spring会在代理对象中帮咱们加上一系列的事务操做。

在上面的例子中它们发生了自身调用,就调用该类本身的方法,而没有通过 Spring 的代理类,默认只有在外部调用事务才会生效,这也是老生常谈的经典问题了。

异常被吃了

这种状况是最多见的一种@Transactional注解失效场景

@Autowired
private B b;

@Service
public class OrderServiceImpl implements OrderService {
    @Transactional
    public void A(Order order) {
        try {
            b.insert();
         }catch (Exception e){
            //do something;
        }
    }
}

若是b.insert()方法内部抛了异常,而A方法此时try catch了B方法的异常,那这个事务还能正常回滚吗?

答案:不能!而是会抛出下面异常:

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

由于当ServiceB中抛出了一个异常之后,ServiceB标识当前事务须要rollback。可是ServiceA中因为你手动的捕获这个异常并进行处理,ServiceA认为当前事务应该正常commit。此时就出现了先后不一致,也就是由于这样,抛出了前面的UnexpectedRollbackException异常。

spring的事务是在调用业务方法以前开始的,业务方法执行完毕以后才执行commit or rollback,事务是否执行取决因而否抛出Runtime异常。若是抛出runtime exception 并在你的业务方法中没有catch到的话,事务会回滚。

在业务方法中通常不须要catch异常,若是非要catch必定要抛出throw new RuntimeException(),或者注解中指定抛异常类型@Transactional(rollbackFor=Exception.class),不然会致使事务失效,数据commit形成数据不一致,因此有些时候try catch反倒会多此一举。

分布式事务

首先看下什么是分布式事务:

分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不一样的分布式系统的不一样节点之上。简单的说,就是一次大的操做由不一样的小操做组成,这些小的操做分布在不一样的服务器上,且属于不一样的应用,分布式事务须要保证这些小操做要么所有成功,要么所有失败。本质上来讲,分布式事务就是为了保证不一样数据库的数据一致性。

那么为何须要分布式事务?直接用Spring提供的@Transaction注解不行吗?

这里极其重要的一点:单块系统是运行在同一个 JVM 进程中的,可是分布式系统中的各个系统运行在各自的 JVM 进程中。所以你直接加@Transactional 注解是不行的,由于它只能控制同一个 JVM 进程中的事务,可是对于这种跨多个 JVM 进程的事务无能无力。

分布式的几种解决方案

可靠消息最终一致性方案

preview

咱们来解释一下这个方案的大概流程:

  1. A 系统先发送一个 prepared 消息到 mq,若是这个 prepared 消息发送失败那么就直接取消操道别执行了,后续操做都再也不执行。
  2. 若是这个消息发送成功过了,那么接着执行 A 系统的本地事务,若是执行失败就告诉 mq 回滚消息,后续操做都再也不执行。
  3. 若是 A 系统本地事务执行成功,就告诉 mq 发送确认消息。
  4. 那若是 A 系统迟迟不发送确认消息呢? 此时 mq 会自动定时轮询全部 prepared 消息,而后调用 A 系统事先提供的接口,经过这个接口反查 A 系统的上次本地事务是否执行成功 若是成功,就发送确认消息给 mq;失败则告诉 mq 回滚消息(后续操做都再也不执行)。
  5. 此时 B 系统会接收到确认消息,而后执行本地的事务,若是本地事务执行成功则事务正常完成。
  6. 若是系统 B 的本地事务执行失败了咋办? 基于 mq 重试咯,mq 会自动不断重试直到成功,若是实在是不行,能够发送报警由人工来手工回滚和补偿。 这种方案的要点就是能够基于 mq 来进行不断重试,最终必定会执行成功的。 由于通常执行失败的缘由是网络抖动或者数据库瞬间负载过高,都是暂时性问题。 经过这种方案,99.9%的状况都是能够保证数据最终一致性的,剩下的 0.1%出问题的时候,就人工修复数据呗。

适用场景: 这个方案的使用仍是比较广,目前国内互联网公司大都是基于这种思路玩儿的。

最大努力通知方案

整个流程图以下所示:
preview

这个方案的大体流程:

  1. 系统 A 本地事务执行完以后,发送个消息到 MQ。
  2. 这里会有个专门消费 MQ 的最大努力通知服务,这个服务会消费 MQ,而后写入数据库中记录下来,或者是放入个内存队列。接着调用系统 B 的接口。
  3. 假如系统 B 执行成功就万事 ok 了,可是若是系统 B 执行失败了呢? 那么此时最大努力通知服务就定时尝试从新调用系统 B,反复 N 次,最后仍是不行就放弃。

这套方案和上面的可靠消息最终一致性方案的区别:

可靠消息最终一致性方案能够保证的是只要系统 A 的事务完成,经过不停(无限次)重试来保证系统 B 的事务总会完成。

可是最大努力方案就不一样,若是系统 B 本地事务执行失败了,那么它会重试 N 次后就再也不重试,系统 B 的本地事务可能就不会完成了。

至于你想控制它究竟有“多努力”,这个须要结合本身的业务来配置。

好比对于电商系统,在下完订单后发短信通知用户下单成功的业务场景中,下单正常完成,可是到了发短信的这个环节因为短信服务暂时有点问题,致使重试了 3 次仍是失败。

那么此时就再也不尝试发送短信,由于在这个场景中咱们认为 3 次就已经算是尽了“最大努力”了。

简单总结:就是在指定的重试次数内,若是能执行成功那么皆大欢喜,若是超过了最大重试次数就放弃,再也不进行重试。

适用场景: 通常用在不过重要的业务操做中,就是那种完成的话是锦上添花,但失败的话对我也没有什么坏影响的场景。

好比上边提到的电商中的部分通知短信,就比较适合使用这种最大努力通知方案来作分布式事务的保证。

TCC 强一致性方案

TCC 的全称是:

  • Try(尝试)
  • Confirm(确认/提交)
  • Cancel(回滚)。

这个实际上是用到了补偿的概念,分为了三个阶段:

  1. Try 阶段:这个阶段说的是对各个服务的资源作检测以及对资源进行锁定或者预留;
  2. Confirm 阶段:这个阶段说的是在各个服务中执行实际的操做;
  3. Cancel 阶段:若是任何一个服务的业务方法执行出错,那么这里就须要进行补偿,就是执行已经执行成功的业务逻辑的回滚操做。

仍是给你们举个例子:

preview

好比跨银行转帐的时候,要涉及到两个银行的分布式事务,若是用 TCC 方案来实现,思路是这样的:

  1. Try 阶段:先把两个银行帐户中的资金给它冻结住就不让操做了;
  2. Confirm 阶段:执行实际的转帐操做,A 银行帐户的资金扣减,B 银行帐户的资金增长;
  3. Cancel 阶段:若是任何一个银行的操做执行失败,那么就须要回滚进行补偿,就是好比 A 银行帐户若是已经扣减了,可是 B 银行帐户资金增长失败了,那么就得把 A 银行帐户资金给加回去。

适用场景:这种方案说实话几乎不多有人使用,可是也有使用的场景。

由于这个事务回滚其实是严重依赖于你本身写代码来回滚和补偿了,会形成补偿代码巨大,很是之恶心。

好比说咱们,通常来讲跟钱相关的,跟钱打交道的,支付、交易相关的场景,咱们会用 TCC,严格保证分布式事务要么所有成功,要么所有自动回滚,严格保证资金的正确性,在资金上不容许出现问题。

比较适合的场景:除非你是真的一致性要求过高,是你系统中核心之核心的场景,好比常见的就是资金类的场景,那你能够用 TCC 方案了。 你须要本身编写大量的业务逻辑,本身判断一个事务中的各个环节是否 ok,不 ok 就执行补偿/回滚代码。

并且最好是你的各个业务执行的时间都比较短。

可是说实话,通常尽可能别这么搞,本身手写回滚逻辑,或者是补偿逻辑,实在太恶心了,那个业务代码很难维护。

总结

今天简单对事务作了一个总结,有什么不对的地方请多多指教!

参考

https://zhuanlan.zhihu.com/p/85790242

相关文章
相关标签/搜索