从事务隔离级别谈到Hibernate乐观锁,悲观锁

时间:2024-08-17 21:37:44

数据库的事务,是指作为单个逻辑工作单元执行的一系列操作。 事务处理可以确保除非事务性单元内的所有操作都成功完成,否则不会永久更新面向数据的资源。通过将一组相关操作组合为一个要么全部成功要么全部失败的单元,可以简化错误恢复并使应用程序更加可靠。一个逻辑工作单元要成为事务,必须满足所谓的ACID(原子性、一致性、隔离性和持久性)属性。

操作流程

  设想网上购物的一次交易,其付款过程至少包括以下几步数据库操作:
  · 更新客户所购商品的库存信息
  · 保存客户付款信息--可能包括与银行系统的交互
  · 生成订单并且保存到数据库中
  · 更新用户相关信息,例如购物数量等等
  正常的情况下,这些操作将顺利进行,最终交易成功,与交易相关的所有数据库信息也成功地更新。但是,如果在这一系列过程中任何一个环节出了差错,例如在更新商品库存信息时发生异常、该顾客银行帐户存款不足等,都将导致交易失败。一旦交易失败,数据库中所有信息都必须保持交易前的状态不变,比如最后一步更新用户信息时失败而导致交易失败,那么必须保证这笔失败的交易不影响数据库的状态--库存信息没有被更新、用户也没有付款,订单也没有生成。否则,数据库的信息将会一片混乱而不可预测。
  数据库事务正是用来保证这种情况下交易的平稳性和可预测性的技术。

数据库事务的ACID属性

原子性(atomic)
  事务必须是原子工作单元;对于其数据修改,要么全都执行,要么全都不执行。通常,与某个事务关联的操作具有共同的目标,并且是相互依赖的。如果系统只执行这些操作的一个子集,则可能会破坏事务的总体目标。原子性消除了系统处理操作子集的可能性。
一致性(consistent)
  事务在完成时,必须使所有的数据都保持一致状态。在相关数据库中,所有规则都必须应用于事务的修改,以保持所有数据的完整性。事务结束时,所有的内部数据结构(如 B 树索引或双向链表)都必须是正确的。某些维护一致性的责任由应用程序开发人员承担,他们必须确保应用程序已强制所有已知的完整性约束。例如,当开发用于转帐的应用程序时,应避免在转帐过程中任意移动小数点。
隔离性(insulation)
  由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看数据时数据所处的状态,要么是另一并发事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看中间状态的数据。这称为可串行性,因为它能够重新装载起始数据,并且重播一系列事务,以使数据结束时的状态与原始事务执行的状态相同。当事务可序列化时将获得最高的隔离级别。在此级别上,从一组可并行执行的事务获得的结果与通过连续运行每个事务所获得的结果相同。由于高度隔离会限制可并行执行的事务数,所以一些应用程序降低隔离级别以换取更大的吞吐量。防止数据丢失
持久性(durability)
  事务完成之后,它对于系统的影响是永久性的。该修改即使出现致命的系统故障也将一直保持。

一个数据库可能拥有多个访问客户端,这些客户端都可以并发方式访问数据库。数据库中的相同数据可能同时被多个事务访问,如果没有采取必要的隔离措施,就会导致各种并发问题,破坏数据的完整性。这些问题可以归结为5类,包括3类数据读问题(脏读、幻象读和不可重复读)以及2类数据更新问题(第一类丢失更新和第二类丢失更新)。下面,我们分别通过实例讲解引发问题的场景。

并发的5类情况


脏读(dirty read) 
    在讲解脏读前,我们先讲一个笑话:一个有结巴的人在饮料店柜台前转悠,老板很热情地迎上来:“喝一瓶?”,结巴连忙说:“我…喝…喝…”,老板麻利地打开易拉罐递给结巴,结巴终于憋出了他的那句话:“我…喝…喝…喝不起啊!”。在这个笑话中,饮料店老板就对结巴进行了脏读。 
A事务读取B事务尚未提交的更改数据,并在这个数据的基础上操作。如果恰巧B事务回滚,那么A事务读到的数据根本是不被承认的。来看取款事务和转账事务并发时引发的脏读场景:

时间
转账事务A
取款事务B
T1
开始事务
T2
开始事务
T3
查询账户余额为1000元    
T4
取出500元把余额改为500元
T5
查询账户余额为500元(脏读)
T6
撤销事务余额恢复为1000元
T7
汇入100元把余额改为600元
T8
提交事务

  在这个场景中,B希望取款500元而后又撤销了动作,而A往相同的账户中转账100元,就因为A事务读取了B事务尚未提交的数据,因而造成账户白白丢失了500元。

不可重复读(unrepeatable read) 
   不可重复读是指A事务读取了B事务已经提交的更改数据。假设A在取款事务的过程中,B往该账户转账100元,A两次读取账户的余额发生不一致:

时间
取款事务A
转账事务B
T1
开始事务
T2
开始事务
T3
查询账户余额为1000元     
T4
查询账户余额为1000元
T5
取出100元把余额改为900元
T6
提交事务                  
T7
查询账户余额为900元(和T4读取的不一致)

  在同一事务中,T4时间点和T7时间点读取账户存款余额不一样。
幻象读(phantom read) 
    A事务读取B事务提交的新增数据,这时A事务将出现幻象读的问题。幻象读一般发生在计算统计数据的事务中,举一个例子,假设银行系统在同一个事务中,两次统计存款账户的总金额,在两次统计过程中,刚好新增了一个存款账户,并存入100元,这时,两次统计的总金额将不一致:

时间
统计金额事务A
转账事务B
T1
开始事务
T2
开始事务
T3
统计总存款数为10000元
T4
新增一个存款账户,存款为100元
T5
提交事务     
T6
再次统计总存款数为10100元(幻象读)

  如果新增数据刚好满足事务的查询条件,这个新数据就进入了事务的视野,因而产生了两个统计不一致的情况。 
  幻象读和不可重复读是两个容易混淆的概念,前者是指读到了其它已经提交事务的新增数据,而后者是指读到了已经提交事务的更改数据(更改或删除),为了避免这两种情况,采取的对策是不同的,防止读取到更改数据,只需要对操作的数据添加行级锁,阻止操作中的数据发生变化,而防止读取到新增数据,则往往需要添加表级锁——将整个表锁定,防止新增数据。

第一类丢失更新 (Lost update)
    A事务撤销时,把已经提交的B事务的更新数据覆盖了。这种错误可能造成很严重的问题,通过下面的账户取款转账就可以看出来:

时间
取款事务A
转账事务B
T1
开始事务
T2
开始事务
T3
查询账户余额为1000元    
T4
查询账户余额为1000元
T5
汇入100元把余额改为1100元
T6
提交事务
T7
取出100元把余额改为900元
T8
撤销事务
T9
余额恢复为1000元(丢失更新)

  A事务在撤销时,“不小心”将B事务已经转入账户的金额给抹去了。

第二类丢失更新 (Second lost updates problem)
  A事务覆盖B事务已经提交的数据,造成B事务所做操作丢失:

时间
转账事务A
取款事务B
T1
开始事务
T2
开始事务
T3
查询账户余额为1000元    
T4
查询账户余额为1000元
T5
取出100元把余额改为900元
T6
提交事务           
T7
汇入100元
T8
提交事务
T9
把余额改为1100元(丢失更新)

上面的例子里由于支票转账事务覆盖了取款事务对存款余额所做的更新,导致银行最后损失了100元,相反如果转账事务先提交,那么用户账户将损失100元。

事务隔离级别 


    尽管数据库为用户提供了锁的DML操作方式,但直接使用锁管理是非常麻烦的,因此数据库为用户提供了自动锁机制。只要用户指定会话的事务隔离级别,数据库就会分析事务中的SQL语句,然后自动为事务操作的数据资源添加上适合的锁。此外数据库还会维护这些锁,当一个资源上的锁数目太多时,自动进行锁升级以提高系统的运行性能,而这一过程对用户来说完全是透明的。 
    ANSI/ISO SQL 92标准定义了4个等级的事务隔离级别,在相同数据环境下,使用相同的输入,执行相同的工作,根据不同的隔离级别,可以导致不同的结果。不同事务隔离级别能够解决的数据并发问题的能力是不同的。

1、未提交读(Read Uncommitted)

    直译就是"读未提交",意思就是即使一个更新语句没有提交,但是别
的事务可以读到这个改变.这是很不安全的。允许任务读取数据库中未提交的数据更改,也称为脏读。
2、提交读(Read Committed)
   直译就是"读提交",可防止脏读,意思就是语句提交以后即执行了COMMIT以后
别的事务就能读到这个改变. 只能读取到已经提交的数据。Oracle等多数数据库默认都是该级别
3、可重复读(Repeatable Read):
   直译就是"可以重复读",这是说在同一个事务里面先后执行同一个查询语句的时候,得到的结果是一样的.在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻象读
4、串行读(Serializable)
   直译就是"序列化",意思是说这个事务执行的时候不允许别的事务并发执行. 完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞

表 1 事务隔离级别对并发问题的解决情况

隔离级别
脏读
不可
重复读
幻象读
第一类丢失更新
第二类丢失更新
READ UNCOMMITED
允许
允许
允许
不允许
允许
READ COMMITTED
不允许
允许
允许
不允许
允许
REPEATABLE READ
不允许
不允许
允许
不允许
不允许
SERIALIZABLE
不允许
不允许
不允许
不允许

不允许
 

这里推荐一篇文章http://www.cnblogs.com/zhujingyuan/archive/2009/11/12/1602193.html

此文中把隔离级别讲得很清楚了。对于SqlServer和mysql默认的事务隔离级别是Read Commited。已经可以满足大部分的应用场景了。

要实现上述的隔离级别就必须要使用锁,从程序员的角度来看锁分为两种:悲观锁和乐观锁

下面看Hibernate中的悲观锁和乐观锁。

悲观锁的使用场景是基于以下假设:在修改一条数据的时候,另外一个线程也在修改同一条数据的可能性很大。

乐观锁的使用场景则相反,在修改一条数据的时候另外一个线程修改同一条数据的可能性非常小。

悲观锁依赖数据库本身的锁机制,当然也只有依赖数据库本身的锁机制,才能真正将数据锁住。否则即使当前系统无法修改数据,其他系统还是可以修改数据的!你加了锁,别人没加锁!

很多文章都提到这样一个例子来说明悲观锁:

  1. select * from some_table where id = 1 for update

在执行这条SQL后,并在当前事务结束之前,id为1的所有记录的所有字段都将被锁定。我觉得这个例子正好暴露了悲观锁不能解决Phantom Read的问题,如果此事务操作过程中,有某一条数据的id由2变为1,那么将出现Phantom Read。

Hibernate中如下使用悲观锁:

  1. public class PessimisticLocking
  2. {  
  3.     public static void main(String[] args) throws Exception  
  4.     {  
  5.         Session session = HibernateSessionFactory.getSession();  
  6.         Transaction tx = session.beginTransaction();  
  7.         tx.begin();  
  8.         String hql = "from message as m where id = 1";  
  9.         Query query = session.createQuery(hql);  
  10.         query.setLockMode("m",LockMode.UPGRADE);  
  11.         List<Message> message = query.list();  
  12.         ...  
  13.         System.out.println("Now,the data whose id = 1 could not be modify,after press enter you can modify");  
  14.         System.in.read();//read a char from the console , let the application in a staying state!  
  15.         tx.commit();  
  16.         session.close();  
  17.     }  
  18. }  

在运行这段代码的时候,如果不按回车那么tx.commit就不会执行,这个事务就没提交,这个事务没提交数据库中所有id=1的记录都不能被任何工具修改!使用setLockMode方法获得悲观锁后,Hibernate会生成如下SQL:

  1. select message0_.id as id0_,message0_name as name0_ from message mymessage0_ where mymessage0_.id=1 for update

可以看出来,Hibernate是通过在SQL语句中加入for update来获得悲观锁的。

到这里,我想了下为什么不直接使用数据库的事务隔离级别来解决问题呢,而非要在代码中加入悲观锁?

想了下,理由很简单:如果直接设置数据库的事务隔离级别,就不能动态调整事务隔离级别了,在一个项目中不同的地方可能使用不同的隔离级别,如果都使用串行化的隔离级别自然能解决所有并发问题,但是这会带来性能上的下降!终归还是性能问题!而在适当的时候给予相配的隔离级别不但解决了问题,性能也要快很多!

总结起来就是:可以动态更改事务隔离级别。

再来看看乐观锁(Optimistic Locking)

由于悲观锁有很大的性能损失。而乐观锁是基于数据版本(version)实现的,因此对于同一个应用程序,它可以很好地实现锁机制,在不浪费太多性能的前提下。

Hibernate中可以如下实现:

在相应的表中加一列叫做version,类型为int

在实体Bean的xml配置文件中加入一列version,并且添加optimistic-lock属性

  1. <hibernate-mapping>
  2.     <class name="example.message" table="t_message" optimistic-lock="version">  
  3.     <version column="version" name="version" type="integer"/>  
  4. ...  

然后就可以使用乐观锁了,Java代码如下:

  1. public class OptimisticLocking
  2. {  
  3.     public static void main(String[] args) throws Exception  
  4.     {  
  5.         Session session = HibernateSessionFactory.getSession();  
  6.         Transaction tx = session.beginTransaction();  
  7.         tx.begin();  
  8.         String hql = "from message as m where id = 1";  
  9.         Query query = session.createQuery(hql);  
  10.         query.setLockMode("m",LockMode.UPGRADE);  
  11.         Message message = (Message)query.uniqueResult();  
  12.         if(message != null)  
  13.         {  
  14.             message.setName("newName");  
  15.             session.saveOrUpdate(message);  
  16.         }  
  17.         System.in.read();//read a char from the console , let the application in a staying state!  
  18.         tx.commit();  
  19.         session.close();  
  20.     }  
  21. }  

同时运行两次上述程序,都不按回车键,这时对其中一个按回车提交事务,这样version就加1了,而另外一个程序的version就和当前记录的version字段值是一样的,因此再提交另外一个事务的时候就会抛出异常:

org.hibernate.StaleObjectStateException

表示提交请求被拒绝。

我觉得总的思路就是:

现实的需求导致数据库ACID的特性出现,不是所有需求都要求满足ACID,针对不同的需求定义了四种隔离级别。而要想实现隔离就要加锁,加锁可以在数据库层实现,也可以在应用程序层实现。Hibernate的悲观锁和乐观锁就是针对不同的隔离级别的需求,让程序员在应用程序层有一个动态调整事务隔离级别的可能性,这样带来的好处是性能的优化。