首页 > 代码库 > 并发下的事务处理

并发下的事务处理

事务保障,是软件行业必须要做的事情。很多金融公司,就是由于事务处理不得当而倒闭。

我们都知道,事务有四大特性:ACID。即:原子性、一致性、隔离性、持久性。


四大特性

原子性

事务是数据库的逻辑单位,事务总包括的诸操作那么全部执行,要么都不执行;

一致性

职务执行的结果,必须使数据库从一个一直状态、变到另一个一直状态。一致性与原子性紧密关联。

隔离性

一个事务的执行,不能被其他事务干扰

持久性

一个事务一旦提交,它对数据库中的数据改变就应该是永久性的。

这就是事务的四大特性。


隔离级别

下面,我们来具体来说一说隔离性

我们都知道,事务控制的太严格,程序在并发访问的情况下,会降低程序的性能。所以,人们总是想让事务为性能做出让步,那么就分出了四中隔离级别:

为提交读、提交读、重复读、序列化。

但是,由于隔离界别限制的程度不同,那么就会产生脏读、不可重复读、幻读的情况。

1. 脏读:脏读就是指当一个事务正在访问数据,并且对数据进行了修改,而这种修改还没有提交到数据库中,这时,另外一个事务也访问这个数据,然后使用了这个数据。

2. 不可重复读:是指在一个事务内,多次读同一数据。在这个事务还没有结束时,另外一个事务也访问该同一数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的,因此称为是不可重复读。例如,一个编辑人员两次读取同一文档,但在两次读取之间,作者重写了该文档。当编辑人员第二次读取文档时,文档已更改。原始读取不可重复。如果只有在作者全部完成编写后编辑人员才可以读取文档,则可以 避免该问题。

       3.幻读:是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象发生了幻觉一样。例如,一个编辑人员更改作者提交的文档,但当生产部门将其更改内容合并到该文档的主复本时,发现作者已将未编辑的新材料添加到该文档中。如果在编辑人员和生产部门完成对原始文档的处理之前,任何人都不能将新材料添加到文档中,则可以避免该问题。


       下面看数据库事务的隔离级别,由低到高依次为未提交读、提交读、重复读、序列化。

这四个级别可以逐个解决脏读、不可重复读、幻读这几类问题。

 

√:可能出现    ×:不会出现

 

脏读

不可重复读

幻读

Read uncommitted

Read committed

×

Repeatable read

×

×

Serializable

×

×

×

 

注意:我们讨论隔离级别的场景,主要是在多个事务并发的情况下,因此,接下来的讲解都围绕事务并发。

       未提交读

公司发工资了,领导把5000元打到singo的账号上,但是该事务并未提交,而singo正好去查看账户,发现工资已经到账,是5000元整,非常高兴。可是不幸的是,领导发现发给singo的工资金额不对,是2000元,于是迅速回滚了事务,修改金额后,将事务提交,最后singo实际的工资只有2000元,singo空欢喜一场。

 

出现上述情况,即我们所说的脏读,两个并发的事务,“事务A:领导给singo发工资”、“事务B:singo查询工资账户”,事务B读取了事务A尚未提交的数据。

当隔离级别设置为Readuncommitted时,就可能出现脏读,如何避免脏读,请看下一个隔离级别。


       读提交

singo拿着工资卡去消费,系统读取到卡里确实有2000元,而此时她的老婆也正好在网上转账,把singo工资卡的2000元转到另一账户,并在singo之前提交了事务,当singo扣款时,系统检查到singo的工资卡已经没有钱,扣款失败,singo十分纳闷,明明卡里有钱,为何......

出现上述情况,即我们所说的不可重复读,两个并发的事务,“事务A:singo消费”、“事务B:singo的老婆网上转账”,事务A事先读取了数据,事务B紧接了更新了数据,并提交了事务,而事务A再次读取该数据时,数据已经发生了改变。

当隔离级别设置为Readcommitted时,避免了脏读,但是可能会造成不可重复读。

大多数数据库的默认级别就是Readcommitted,比如Sql Server , Oracle。如何解决不可重复读这一问题,请看下一个隔离级别。


       重复读

当隔离级别设置为Repeatableread时,可以避免不可重复读。当singo拿着工资卡去消费时,一旦系统开始读取工资卡信息(即事务开始),singo的老婆就不可能对该记录进行修改,也就是singo的老婆不能在此时转账。

虽然Repeatableread避免了不可重复读,但还有可能出现幻读。

singo的老婆工作在银行部门,她时常通过银行内部系统查看singo的信用卡消费记录。有一天,她正在查询到singo当月信用卡的总消费金额(select sum(amount) from transaction where month =本月)为80元,而singo此时正好在外面胡吃海塞后在收银台买单,消费1000元,即新增了一条1000元的消费记录(insert transaction... ),并提交了事务,随后singo的老婆将singo当月信用卡消费的明细打印到A4纸上,却发现消费总额为1080元,singo的老婆很诧异,以为出现了幻觉,幻读就这样产生了。

注:Mysql的默认隔离级别就是Repeatableread。


       序列化

Serializable是最高的事务隔离级别,同时代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻像读。


PS:大多数数据库都是使用提交读,作为默认的隔离级别,如Oracle、SqlServer。因为在数据量访问的情况下,这种方式性能较好,同时防止了脏读的情况发生,尽管有不可重复读的情况,但是在可承受的范围内;也有一些数据采用重复读,作为默认的隔离级别。如果采用默认配置,那么使用MySql的性能会稍低一些。MySql隔离级别的默认配置实现,原理是数据访问时加了读写锁,并发读取时,分别加锁,但是只有第一个加锁的事务,才能修改事务,其他事务不能修改,它避免了可重复读的情况。序列化同样是加锁,但是它加的是独占锁,无论哪个线程读取到数据,立马会将其霸占,直至其操作完成。这种方式一致性高,但是并发性不好,很少使用。


事务的传播特性

在开发中,我们一个action中,可能调用多个Service,那么这种情况,是如何保证事务的呢?事务的传播特性。下面我们来看看Spring事务的传播特性:

            1.     PROPAGATION_REQUIRED:支持当前事务,如果当前没有事务,就新建一个事务。这是最常见的选择。
            2.     PROPAGATION_SUPPORTS:支持当前事务,如果当前没有事务,就以非事务方式执行。
            3.     PROPAGATION_MANDATORY:支持当前事务,如果当前没有事务,就抛出异常。
            4.     PROPAGATION_REQUIRES_NEW:新建事务,如果当前存在事务,把当前事务挂起。
            5.     PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
            6.     PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
            7.     PROPAGATION_NESTED:支持当前事务,新增Savepoint点,与当前事务同步提交或回滚。


    具体解释一下:
            1.     PROPAGATION_REQUIRED:加入当前正要执行的事务不在另外一个事务里,那么就起一个新的事务。比如说,ServiceB.methodB的事务级别定义为PROPAGATION_REQUIRED,那么由于执行ServiceA.methodA的时候,ServiceA.methodA已经起了事务,这时调用ServiceB.methodB,ServiceB.methodB看到自己已经运行在ServiceA.methodA的事务内部,就不再起新的事务。而假如ServiceA.methodA运行的时候发现自己没有在事务中,他就会为自己分配一个事务。这样,在ServiceA.methodA或者在ServiceB.methodB内的任何地方出现异常,事务都会被回滚。即使ServiceB.methodB的事务已经被提交,但是ServiceA.methodA在接下来fail要回滚,ServiceB.methodB也要回滚。
           2.     PROPAGATION_SUPPORTS:如果当前在事务中,即以事务的形式运行,如果当前不再一个事务中,那么就以非事务的形式运行。
           3.     PROPAGATION_MANDATORY:必须在一个事务中运行。也就是说,他只能被一个父事务调用。否则,他就要抛出异常。
           4.     PROPAGATION_REQUIRES_NEW:这个就比较绕口了。 比如我们设计ServiceA.methodA的事务级别为PROPAGATION_REQUIRED,ServiceB.methodB的事务级别为PROPAGATION_REQUIRES_NEW,那么当执行到ServiceB.methodB的时候,ServiceA.methodA所在的事务就会挂起,ServiceB.methodB会起一个新的事务,等待ServiceB.methodB的事务完成以后,他才继续执行。他与PROPAGATION_REQUIRED的事务区别在于事务的回滚程度了。因为ServiceB.methodB是新起一个事务,那么就是存在两个不同的事务。如果ServiceB.methodB已经提交,那么ServiceA.methodA失败回滚,ServiceB.methodB是不会回滚的。如果ServiceB.methodB失败回滚,如果他抛出的异常被ServiceA.methodA捕获,ServiceA.methodA事务仍然可能提交。
          5.     PROPAGATION_NOT_SUPPORTED:当前不支持事务。比如ServiceA.methodA的事务级别是PROPAGATION_REQUIRED ,而ServiceB.methodB的事务级别是PROPAGATION_NOT_SUPPORTED ,那么当执行到ServiceB.methodB时,ServiceA.methodA的事务挂起,而他以非事务的状态运行完,再继续ServiceA.methodA的事务。
          6.     PROPAGATION_NEVER:不能在事务中运行。假设ServiceA.methodA的事务级别是PROPAGATION_REQUIRED, 而ServiceB.methodB的事务级别是PROPAGATION_NEVER ,那么ServiceB.methodB就要抛出异常了。
          7.     PROPAGATION_NEST:理解Nested的关键是savepoint。
    他与PROPAGATION_REQUIRES_NEW的区别是,PROPAGATION_REQUIRES_NEW另起一个事务,将会与他的父事务相互独立, 而Nested的事务和他的父事务是相依的,他的提交是要等和他的父事务一块提交的。也就是说,如果父事务最后回滚,他也要回滚的。而Nested事务的好处是他有一个savepoint。也就是说ServiceB.methodB失败回滚,那么ServiceA.methodA也会回滚到savepoint点上,ServiceA.methodA可以选择另外一个分支,比如 ServiceC.methodC,继续执行,来尝试完成自己的事务。 但是这个事务并没有在EJB标准中定义。


PS:我们最常用的传播特性就是PROPAGATION_REQUIRED。支持当前事务,如果当前没有事务,就新建一个事务。


分布式事务

1.XA

XA 是由X/Open组织提出的分布式事务的规范。XA规范主要定义了(全局)事务管理器(Transaction Manager)和(局部)资源管理器(Resource Manager)之间的接口。XA接口是双向的系统接口,在事务管理器(Transaction Manager)以及一个或多个资源管理器(Resource Manager)之间形成通信桥梁。XA之所以需要引入事务管理器是因为,在分布式系统中,从理论上讲,两台机器理论上无法达到一致的状态,需要引入一个单点进行协调。事务管理器控制着全局事务,管理事务生命周期,并协调资源。资源管理器负责控制和管理实际资源(如数据库或 JMS队列)。

      2.JTA

作 为java平台上事务规范JTA(Java Transaction API)也定义了对XA事务的支持,实际上,JTA是基于XA架构上建模的,在JTA 中,事务管理器抽象为javax.transaction.TransactionManager接口,并通过底层事务服务(即JTS)实现。像很多其他 的java规范一样,JTA仅仅定义了接口,具体的实现则是由供应商(如J2EE厂商)负责提供,目前JTA的实现主要由以下几种:

1.J2EE容器所提供的JTA实现(JBoss)
2.独立的JTA实现:如JOTM,Atomikos.这些实现可以应用在那些不使用J2EE应用服务器的环境里用以提供分布事事务保证。如Tomcat,Jetty以及普通的java应用。


       3.两阶段提交

所有关于分布式事务的介绍中都必然会讲到两阶段提交,因为它是实现XA分布式事务的关键(确切地说:两阶段提交主要保证了分布式事务的原子性:即所有结点要么全做要么全不做)。所谓的两个阶段是指:第一阶段:准备阶段和第二阶段:提交阶段。



         1.准备阶段: 事务协调者(事务管理器)给每个参与者(资源管理器)发送Prepare消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本 地的redo和undo日志,但不提交,到达一种“万事俱备,只欠东风”的状态。(关于每一个参与者在准备阶段具体做了什么目前我还没有参考到确切的资 料,但是有一点非常确定:参与者在准备阶段完成了几乎所有正式提交的动作,有的材料上说是进行了“试探性的提交”,只保留了最后一步耗时非常短暂的正式提 交操作给第二阶段执行。)

        2.提交阶段:如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)

        总结二次提交:
        将提交分成两阶段进行的目的很明确,就是尽可能晚地提交事务,让事务在提交前尽可能地完成所有能完成的工作,这样,最后的提交阶段将是一个耗时极短的微小操 作,这种操作在一个分布式系统中失败的概率是非常小的,也就是所谓的“网络通讯危险期”非常的短暂,这是两阶段提交确保分布式事务原子性的关键所在。(唯 一理论上两阶段提交出现问题的情况是当协调者发出提交指令后当机并出现磁盘故障等永久性错误,导致事务不可追踪和恢复)


        从两阶段提交的工 作方式来看,很显然,在提交事务的过程中需要在多个节点之间进行协调,而各节点对锁资源的释放必须等到事务最终提交时,这样,比起一阶段提交,两阶段提交 在执行同样的事务时会消耗更多时间。事务执行时间的延长意味着锁资源发生冲突的概率增加,当事务的并发量达到一定数量的时候,就会出现大量事务积压甚至出 现死锁,系统性能就会严重下滑。这就是使用XA事务

        4.一阶段提交(Best Efforts 1PC模式)

不像两阶段提交那样复杂,一阶段提交非常直白,就是从应用程序向数据库发出提交请求到数据库完成提交或回滚之后将结果返回给应用程序的过程。一阶段提交不需 要“协调者”角色,各结点之间不存在协调操作,因此其事务执行时间比两阶段提交要短,但是提交的“危险期”是每一个事务的实际提交时间,相比于两阶段提 交,一阶段提交出现在“不一致”的概率就变大了。但是我们必须注意到:只有当基础设施出现问题的时候(如网络中断,当机等),一阶段提交才可能会出现“不 一致”的情况,相比它的性能优势,很多团队都会选择这一方案。关于在spring环境下如何实现一阶段提交,有一篇非常优秀的文章值得参考:http://www.javaworld.com/javaworld/jw-01-2009/jw-01-spring-transactions.html?page=5

        5.事务补偿机制

像best efforts 1PC这种模式,前提是应用程序能获取所有的数据源,然后使用同一个事务管理器(这里指是的spring的事务管理器)管理事务。这种模式最典型的应用场 景非数据库sharding莫属。但是对于那些基于web service/rpc/jms等构建的高度自治(autonomy)的分布式系统接口,best efforts 1PC模式是无能为力的,此类场景下,还有最后一种方法可以帮助我们实现“最终一致性”,那就是事务补偿机制。关于事务补偿机制是一个大话题,本文只简单 提及,以后会作专门的研究和介绍。

        6.在基于两阶段提交的标准分布式事务和Best Efforts 1PC两者之间如何选择

一 般而言,需要交互的子系统数量较少,并且整个系统在未来不会或很少引入新的子系统且负载长期保持稳定,即无伸缩要求的话,考虑到开发复杂度和工作量,可以 选择使用分布式事务。对于时间需求不是很紧,对性能要求很高的系统,应考虑使用Best Efforts 1PC或事务补偿机制。对于那些需要进行sharding改造的系统,基本上不应再考虑分布式事务,因为sharding打开了数据库水平伸缩的窗口,使 用分布式事务看起来好像是为新打开的窗口又加上了一把枷锁。

总结一下

事务的控制,是程序编写中必须进行的一步。我们编写程序时,往往注意不到自己在使用事务。这是因为事务一般分为两种方式:编程式事务和声明式事务。编程式事务虽然很灵活,但是需要手动写JDBC模板式的代码来控制事务,所以我们不经常使用;我们经常使用声明式事务,以AOP的方式切入程序中,完全是基于配置的,在代码中没有体现,所以我们会看不到事务代码。

并发下的事务处理