事务那些事儿

2020/8/15 14:03:41

本文主要是介绍事务那些事儿,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

前言

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

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

什么是事务

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

举个例子:

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

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

事务的体现:这两个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 可以解决了不可重复读和幻读的问题。

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



这篇关于事务那些事儿的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程