本人微信公众号"aeolian"~

架构探险笔记7-事务管理简介

什么是事务

事务(Transaction)通俗的理解为一件事,要么做完,要么不做,不能做一半留一半。也就是说,事务必须是一个不可分割的整体,就像我们在化学课上学到的原子,原子是构成物质的最小单位。于是人们就归纳出第一个事务的特性:原子性(Atomicity)

特别是在数据库领域,事务是一个非常重要的概念,除了原子性以外,它还有一个及其重要的特性,那就是一致性(Consistency)。也就是说,执行完数据库操作后,数据库不会被破坏。打个比方,如果从A账户转到B账号,不可能A账户扣了钱,而B账户没有加钱。

当我们编写了一条update语句,提交到数据库的那一刹那,有可能别人也提交了一条delete语句到数据库中。也许我们都是对同一条记录进行操作,可以想象,如果不稍加控制,就会有大麻烦。我们必须保证数据库操作之间是“隔离”的(线程之间有时也要做到隔离)。彼此之间没有任何干扰,这就是隔离性(Isolation)。要想真正做到操作之间完全没有任何干扰是很难的,于是数据库权威转件就开始动脑筋了,“我们要指定一个规范,让各个数据库厂商都支持我们的规范!”,这个规范就是事务隔离级别(Transaction Isolation Level)。能定义出这么牛的规范真的挺不容易的,其实说白了就四个级别:

  • READ_UNCOMMITED;  读未提交(脏读、不可重复读、虚读都是有可能发生的)
  • READ_COMMITED;  读已提交(避免脏读,但是不可重复读和虚读是有可能发生的)
  • REPEATABLE_READ;  可重复读(避免脏读、不可重复读,但是虚读是有可能发生的)
  • SERIALIZABLE  事务串行执行(啥问题都没有(避免脏读、不可重复读、虚读的发生),但是效率低)

从上往下,级别越来越高,并发性越来越差,安全性越来越高。

当我们执行一条insert语句后,数据库必须要保证有一条数据永久地存放在磁盘中,这也算事务的一条特性,它就是持久性(Durability)。

归纳一下,以上一共提到了事务的四条特性,把它们的英文单词首字母合起来就是ACID,这就是传说中的“事务ACID特性”。

真的是非常牛的特性。这四条特性是事务管理的基石,一定要透彻理解。此外还要明确,这4个家伙当中,谁才是“老大”?其实也就想清楚了:原子性是基础,隔离性是手段,持久性是目的,真正的“老大”就是一致性。前三个特性都是为了一致性服务。

总结:

事务的4大特性

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

 

事务的4大隔离级别

  • read_uncommited
  • read_commited
  • repeatable_read
  • Serialization

事务面临的问题

ACID这4个特征当中,其实最难理解倒不是一致性,而是隔离性。因为它是保证一致性的重要手段,它是工具,使用它不能有半点差池。怪不得数据库权威转接都来研究所谓的事务隔离级别。其实,定义这四个级别就是为了解决数据在高并发下产生的问题:

  • Dirty Read(脏读,一个事务,读到了另一个事务未提交数据.)
  • Unrepeatable Read(不可重复读,一个事务,读到了另一个事务的提交数据(update).导致查询结果不一致.)
  • Phantom Read(幻读,一个事务,读到了另一个事务的提交数据(insert).导致查询结果不一致)

脏读

(读取未提交的update)

首先看看“脏读”,数据怎么可能脏呢?其实脏数据也就是我们经常说的“垃圾数据”了。比如说,有两个事物,它们在并发执行(也就是竞争)

《架构探险笔记7-事务管理简介》

余额应该是1500才对,在T5时间点的时候,事物A此时查询余额为0元,这个数据就是脏数据(事物未提交,回滚造成脏读),它是事物B造成的,明显没有进行隔离,渗透过来,乱套了。

所以脏读这件事情是非常要不得的,一定要解决!让事物之间隔离起来才是硬道理。

不可重复读

(一事务update提交后,导致另一事务读取两次数据update前后不一样)

《架构探险笔记7-事务管理简介》

事物A其实除了查询两次以外,其他什么事情都没有做,结果钱就从1000变成0了。这就是重复读了。其实这样也是合理的,毕竟事务B提交了事务,数据库将结果进行了持久化,所以事务A再次读取时自然就发生了变化。

这种现象基本上是可以理解的,但是有些变态的场景下是不允许的。毕竟这种现象也是事务之间没有隔离所造成的,但我们对于这种问题似乎可以忽略。

幻读

(insert/delete导致两次读取总记录数不一致)

最后一条是幻读(虚读),听起来很奇幻。Phantom这个词不就是“幽灵、鬼魂”吗?其意义就是鬼在读,不是人在读,或者说搞不清为什么,它就变了。

《架构探险笔记7-事务管理简介》

银行工作人员每次统计总存款时看到不一样的结果。不过这确实也挺正常的,总存款增多了,肯定是这个时候有人在存钱。但是如果银行系统真的这样设计,那算是完了。这种情况下同样也是由于事务没有隔离造成的,但对于大多数应用系统而言,这似乎也是正常的。银行里的系统要求非常严密,统计的时候,甚至会将所有的其他操作给隔离开,这种隔离级别的就算非常高了(估计要到serializable级别了)。

归纳一下以上提到了事务并发所引起的与读取数据有关的问题,各用一句话来描述:

  • 脏读:事务A读取了事务B未提交的数据,并在这个基础上又做了其他操作。
  • 不可重复读:事务A读取了事务B已提交的更改数据。
  • 幻读:事务A读取了事务B已提交的新增数。

第一条(脏读)是坚决抵制的,后两条(不可重复读、幻读)在大多数情况下可不考虑。

这就是为什么必须要有事务隔离级别这个东西了,它就像一面墙一样,隔离不同的事务。不同的事务隔离级别能处理的事务并发问题如表:

《架构探险笔记7-事务管理简介》

根据用户的实际需求参考这张表,然后确定事务隔离级别,应该不再是一件难事了。

JDBC也提供了这四类事务隔离级别,但默认事务隔离级别对不同数据库产品而言却是不一样的。我们熟知的mysql数据库的默认事务隔离级别就是REPEATABLE_READ,Oracle、SQL Server、DB2等都有自己的默认值。READ_MMITED已经可以解决大多数问题了,其他的就具体情况具体分析了。

若对其他数据库的默认事务隔离级别不太清楚,可以用以下代码来获取:

DatabaseMetaData meta = DBUtil.getConnection().getMetaData();
int isolation = meta.getDefaultTransactionIsolation();

执行结果:

《架构探险笔记7-事务管理简介》

通过mysql获取/更改事务级别

--查看当前会话隔离级别
select @@tx_isolation;

--查看系统当前隔离级别
select @@global.tx_isolation;

--设置当前会话隔离级别
set session transaction isolatin level repeatable read;

--设置系统当前隔离级别
set global transaction isolation level repeatable read;

提示:在java.sql.Connection类中可查看所有的隔离级别。

我们知道JDBC只是连接java程序与数据库的桥梁而已,那么数据库又是怎样隔离事务的呢?其实它就是“锁”这个东西。当插入数据时,就锁定表,这叫“锁表”;当数据更新时,就锁定行,这叫“锁行”。

总结:

数据库并发操作产生的问题

  • 丢失更新(两个事务同时update,可用排他锁解决)
  • 脏读
  • 不可重复读
  • 幻读

数据库的锁机制

1、锁的两种分类方式

(1)从数据库系统的角度来看,锁分为以下三种类型:

mysql锁机制分为表级锁和行级锁,行级锁包括共享锁、排他锁和更新锁。

 排他锁,简称X锁(Exclusive Lock)
      独占锁锁定的资源只允许进行锁定操作的程序使用,其它任何对它的操作均不会被接受。执行数据更新命令,即INSERT、UPDATE 或DELETE 命令时,数据库会自动使用排他锁。但当对象上有其它锁存在时,无法对其加排他锁。排他锁一直到事务结束才能被释放。

 共享锁,简称S锁(Shared Lock)
      共享锁锁定的资源可以被其它用户读取,但其它用户不能修改它。在SELECT 命令执行时,数据库通常会对对象进行共享锁锁定。通常加共享锁的数据页被读取完毕后,共享锁就会立即被释放。

 更新锁(Update Lock)
      更新锁是为了防止死锁而设立的。当数据库准备更新数据时,它首先对数据对象作更新锁锁定,这样数据将不能被修改,但可以读取。等到数据库确定要进行更新数据操作时,它会自动将更新锁换为排他锁。但当对象上有其它锁存在时,无法对其作更新锁锁定。

      对于共享锁大家可能很好理解,就是多个事务只能读数据不能改数据,对于排他锁大家的理解可能就有些差别,很多人以为排他锁锁住一行数据后,其他事务就不能读取和修改该行数据,其实不是这样的。排他锁指的是一个事务在一行数据加上排他锁后,其他事务不能再在其上加其他的锁mysql InnoDB引擎默认的修改数据语句,update,delete,insert都会自动给涉及到的数据加上排他锁,select语句默认不会加任何锁类型,如果加排他锁可以使用select …for update语句,加共享锁可以使用select … lock in share mode语句。所以加过排他锁的数据行在其他事务种是不能修改数据的,也不能通过for update和lock in share mode锁的方式查询数据,但可以直接通过select …from…查询数据,因为普通查询没有任何锁机制

(2)从程序员的角度看,锁分为以下两种类型:

悲观锁(Pessimistic Lock)
      悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

乐观锁(Optimistic Lock)
      相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。
      而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

      常见实现方式:在数据表增加version字段,每次事务开始时将取出version字段值,而后在更新数据的同时version增加1(如: update xxx set data=#{data},version=version+1 where version=#{version} ),如没有数据被更新,那么说明数据由其它的事务进行了更新,此时就可以判断当前事务所操作的历史快照数据。

隔离级别实现机制

x锁 排他锁 被加锁的对象只能被持有锁的事务读取和修改,其他事务无法在该对象上加其他锁,也不能读取(不能加共享锁读取,正常不加锁读取是可以的)和修改该对象(修改对象会默认加X锁)
s锁 共享锁 被加锁的对象可以被持锁事务读取,但是不能被修改,其他事务也可以在上面再加s锁。

在运用X锁和S锁对数据对象加锁时,还需要约定一些规则 ,例如何时申请X锁或S锁、持锁时间、何时释放等。称这些规则为封锁协议(Locking Protocol)。对封锁方式规定不同的规则,就形成了各种不同的封锁协议。

  • 一级封锁协议 (对应 read uncommited)  

   一级封锁协议是:事务T在修改数据R之前必须先对其加X锁,直到事务结束才释放。事务结束包括正常结束(COMMIT)和非正常结束(ROLLBACK)。
   一级封锁协议可以防止丢失修改,并保证事务T是可恢复的。使用一级封锁协议可以解决丢失修改问题。
   在一级封锁协议中,如果仅仅是读数据不对其进行修改,是不需要加锁的,它不能保证可重复读和不读“脏”数据。

  • 二级封锁协议 (对应read commited)

   二级封锁协议是:一级封锁协议加上事务T在读取数据R之前必须先对其加S锁读完后方可释放S锁
   二级封锁协议除防止了丢失修改,还可以进一步防止读“脏”数据。但在二级封锁协议中,由于读完数据后即可释放S锁,所以它不能保证可重复读

  • 三级封锁协议 (对应reapetable read )

   三级封锁协议是:一级封锁协议加上事务T在读取数据R之前必须先对其加S锁直到事务结束才释放。  
   三级封锁协议除防止了丢失修改和不读“脏”数据外,还进一步防止了不可重复读。

锁和隔离级别的关系

       实际开发中,直接操作数据库中各种锁的几率相对比较少,更多的是利用数据库提供的四个隔离级别,未提交读、已提交读、可重复读、可序列化,那隔离级别和锁是什么关系?通俗来说,隔离级别是锁的一个整体打包解决方案,可以理解为隔离封装了锁。

Spring的事务传播行为

 除了JDBC给我们提供的事务隔离级别这种解决方案以外,还有哪些解决方案可以完善事务管理功能呢?

不妨看看Spring的解决方案,其实它是对JDBC的补充或扩展。它提供了一个非常重要的功能 — 事务传播行为(Transaction Propagation Behavior)

Spring一共提供了7种事务传播

  • PROPAGATION_REQUIRED;
  • PROPAGATION_REQUIRES_NEW;
  • PROPAGATION_NESTED;
  • PROPAGATION_NOT_SUPPORTS;
  • PROPAGATION_SUPPORTS;
  • PROPAGATION_NEVER;
  • PROPAGATION_MANDATORY

我们可以这样理解,首先要明确事务从哪里来传播到哪里去?答案是从方法A传播到方法B。Spring解决的只是方法之间的事务传播,比如:

  • 方法A有事务,方法B也有事务;
  • 方法A有事务,方法B没有事务;
  • 方法A没有事务,方法B有事务;
  • 方法A没有事务,方法B也没有事务;

这样就是4种了,还有3种特殊情况。下面做一个分析。

假设事务从方法A传播到方法B,用户需要面对方法B,问自己一个问题:方法A有事务吗?

  1. 如果没有,就新建一个事务;如果有,就加入当前事务。这就是PROPAGATION_REQUIRED,它也是Spring提供的默认事务传播行为,适合绝大多数情况。
  2. 如果没有,就新建一个事务;如果有,就将当前事务挂起。这就是PROPAGATION_REQUIRES_NEW,意思就是创建了一个新事务,它和原来的事务没有任何关系了。
  3. 如果没有,就新建一个事务;如果有,就在当前事务中嵌套其他事务。这就是PROPAGATION_NESTED,也就是“嵌套事务”,所嵌套的子事务与主事务之间是有关联的(当主事务提交或回滚,子事务也会提交或回滚)
  4. 如果没有,就以非事务方式执行;如果有,就是用当前事务。这就是PROPAGATION_SUPPORTS,这种方式非常随意,没有就没有,有就有,有点无所谓的态度,反正是支持的。
  5. 如果没有,就以非事务方式执行;如果有,就将当前事务挂起。这就是PROPAGATION_NOT_SUPPORTS,这种方式非常强硬,没有就没有,有也不支持,挂起来,不管它。
  6. 如果没有,就以非事务方式执行;如果有,就抛出异常。这就是PROPAGATION_NEVER,这种方式更强硬。没有就没有,有了反而报错,它对大家宣称,我不支持事务。
  7. 如果没有,就抛出异常;如果有,就是用当前事务。这就是PROPAGATION_MANDATORY,这种方式可以说是最强硬的,没有事务直接就报错,它对全世界说:我必须要有事务。

需要注意的是PROPAGATION_NESTED,不要被它的名字所欺骗–Nested(嵌套)。凡是在类似方法A调用方法B的时候,在方法B上使用了这种事务传播行为,都是错的。因为这是错误地以为PROPAGATION_NESTED就是为方法嵌套调用而准备的,其实默认的PROPAGATION_REQUIRED就可以做我们想要做的事情。

Spring给我们带来了事务传播行为,这确实是一个非常强大而又实用的功能。除此以外,它也提供了一些小的附加功能,比如:

  1. 事务超时(Transaction Timeout) — 为了解决事务时间太长,消耗太多资源的问题,所以故意给事务设置一个最大时长,如果超过了,就回滚事务。
  2. 只读事务(Readonly Transaction). — 为了忽略那些不需要事务的方法,比如读取数据,这样可以有效地提高一些性能。

推荐使用Spring的注解事务配置,而放弃XML式事务配置。因为注解实在是太优雅了。

在Spring配置文件中使用

<tx:annotation-driven />

在需要事务的方法上使用:

@Transactional
public void xxx(){
}

可在Transaction注解中设置事务隔离级别、事务传播行为、事务超时时间、是否只读事务。

 《架构探险笔记7-事务管理简介》

 

点赞

Leave a Reply

Your email address will not be published. Required fields are marked *