前言
我前段时间在写代码的时候,经常考虑并发问题,对事物的安全性、隔离级别需要更深的了解,所以翻看了网上绝大部分关于事务的文章。但是看了之后还是有些疑惑,例如事务的四种隔离级别,虽然有些文章举出了生动的例子,但并没有提到编程中的如何选择使用。
大部分介绍事务的文章,都是介绍什么事务隔离级别的、各种锁的概念,好像举得概念越多,就显示作者了知识更丰富一样,然而并没有实际编程的例子,就像英文教科书般将本该实际运用的东西变成一种学术,就算看懂讲是什么东西也没办法使用。这种教科书式、百科式的文章害人不浅,因此我才写这篇文章。
事务隔离级别
事务简单来说就是要么一起过,要么全部取消,保证数据的完整性,在普通情况下,事务就是这么简单,提交回滚罢了。然而遇到并发问题,数据安全问题,这点了解是不够的。
事务有4种隔离级别,为什么是4种而没有5种、6种?可能是研究数据库的鼻祖们总结最后得出来的吧。那么下面我要先引用网上绝大部分关于这4种级别的介绍,以下为网上摘抄。
在介绍4种事务隔离级别前,需要三个概念:
1. 脏读:一个事务读取到另一个事务尚未提交的数据。
2. 不可重复读:同一事务,两次读取同一数据,得到不同的结果。
3. 幻读:同一事务,用相同的条件读取两次,得到的结果集数据条数不同(数据条数多了或者少了)。
然后为了解决这些个问题,数据库有了4种隔离级别:
1. TRANSACTION_READ_UNCOMMITTED:防止更新丢失,允许脏读、不可重复读、幻读。
2. TRANSACTION_READ_COMMITTED:防止脏读,允许不可重复读、幻读,这也是多数数据库默认的隔离级别。
3. TRANSACTION_REPEATABLE_READ:防止脏读、不可重复读,允许幻读。
4. TRANSACTION_SERIALIZABLE:防止脏读、不可重复读,幻读。(事务完全串行化执行,事务一个一个按顺序依次执行,可以不会产生并发问题。)
还附带了一张表:
丢失更新 | 脏读 | 不可重复读 | 幻读 | |
未提交读 | N | Y | Y | Y |
已提交读 | N | N | Y | Y |
可重复读 | N | N | N | Y |
串行化 | N | N | N | N |
还有一些文章对隔离级别的理解:“级别越高越能保证数据完整性一致性”,“一个级别解决一个问题”,有的还画出隔离级别与并发的关系坐标图。。。
更合理的级别划分
网上几乎所有文章都是这么写,其实有些数据库官方的文档也是这么解释的。这么解释从某个角度是正确的,但是我就有些疑问了,“READ_UNCOMMITTED”这个隔离级别不就是废物了么?为什么Oracle和SQLservice的默认隔离级别是READ_COMMITTED,Mysql的默认隔离级别是REPEATABLE_READ,Oracle这种商业的数据库默认隔离级别安全性比开源的数据库还要低?java项目使用的Hibernate为的是跨数据库,能从Oracle和Mysql之间切换,那么切换后默认事务隔离级别变了不会影响安全性么?解决幻读一定要使用SERIALIZABLE么,怎么我看很多项目基本上都没用这个呢?等等各种疑问。
通过结合实际案例研究,我认为事务隔离级别应该这么划分:
REPEATABLE_READ < READ_COMMITTED < READ_UNCOMMITTED < SERIALIZABLE
而且,我要说的是我这种隔离级别的划分才是合理的!可能你从没见过有人这么定义隔离级别,而且官方都不这么定义的!我只用事实说话,下面我结合实际编程的例子,分析为什么事务隔离级别为什么按我那样划分才合理。
举个简单的例子:注册。
我们在做注册这个功能的时候,通常要进行这么个步骤:
在这个需求下,是不能简单说使用哪种事务隔离级别好的,这是分开几个步骤,并不是一瞬间,有可能在这几个步骤之间,其他事务操作了数据库,所以必须结合时间顺序!
场景1:
假如使用REPEATABLE_READ ,例如Mysql,它在实现该隔离级别是用MVCC,即读取记录的时候过滤时间戳,即使在当前线程的过程中其他事务对数据库进行增加、修改或删除,也是看不到的,达到了数据的一致性。然而这个”一致性“在这个场景并没有好处,因为若线程1和线程2都注册同一个用户名,线程2在时间1的时候提交了数据线程1看不到,会认为该用户名还没注册,允许用户注册,当插入数据库的时候,由于unique约束报错了。
在这种情况下,使用READ_COMMITTED ,能看到其他事务提交了的数据,明显更具有准确的检验结果!
场景2:
线程2的提交时间变成了时间3,此时READ_COMMITTED 就没什么作用了,这种场景下下READ_UNCOMMITTED 就发挥作用了。READ_UNCOMMITTED 其实是一个更加强大的事务隔离级别,连其他事务未提交的东西都能看到,尤其在一些简单的逻辑上,插入数据库紧接着下一步就是提交无误,这种情况下是比较适合使用READ_UNCOMMITTED 这个隔离级别的。
经过我在Mysql测试当隔离级别为READ_UNCOMMITTED 的时候,同时具有READ_COMMITTED 和READ_UNCOMMITTED 的能力,即能看到其他线程提交了的数据和未提交的数据!
场景3:
此场景中两个事物在检查用户名是否存在的时候,数据库确实没有记录,连未提交的也没有,它们在插入数据库的时候仅仅相差了一步!在Mysql中的测试情况下是,线程1和2都会进行插入记录这个动作,但是由于有唯一约束,线程1较晚插入会受到阻塞而不是报错,因为它要看另外一个线程是打算提交还是回滚,如果回滚,线程1将继续执行,如果线程2提交了,线程1报错。
这种情况除了使用SERIALIZABLE隔离级别,其他隔离级别都不能防止另一个线程报错,然而SERIALIZABLE隔离级别是完全阻塞,如该场景线程2先开启事务,线程1连检查用户名是否存在这一步都会阻塞,但其他查询业务也一样受到阻塞,从效率来说来是非常不好的。
举这个例子,是想借此介绍下各个事务隔离级别在同一个场景下的表现,而不是探讨怎么解决该例子的问题。我举的这个例子,“用户名”有唯一约束,这已经是保证数据正确性的终极防线了,检查用户名是否存在的作用,只是在页面注册的时候用Ajax提前检查,增加用户体验而已,即使没有数据的安全性也是能达到的,所以我觉得这个功能使用REPEATABLE_READ、READ_COMMITTED或READ_UNCOMMITTED都是可以的。
并发问题
了解了事务隔离级别的真正意义后,我们要探讨下并发问题,因为事物隔离级别也是因为并发而产生的。上面我举了一个注册的例子,具有并发问题,但是问题并不严重,因为“用户名”这个字段有unique约束,即使不做插入前的校验步骤,也不会导致出现两个相同的用户名这种错误。
那么现在来讨论一个没有约束的例子,没有约束的话,就必须靠解决并发问题来保证数据安全性。
例子:买票
我们在做这个功能的时候,需要进行这么个步骤:
由于数据库没有正整数类型,没有约束来防止数据出错,如果出现注册那个例子中的场景3,是会导致余票变为负数。
那么,其他三个事物隔离级别都没有100%的作用,是不是得出杀手锏SERIALIZABLE了?答案是否的,因为使用SERIALIZABLE隔离级别会完全阻塞其他事物,会导致当有一个人进行购票的时候,其他对该事务涉及的表的查询都要等待,这种情况怎么能忍?除非只有该事务对涉及的表有操作,但这种情况几乎是不可能的,即使有也不能保证将来业务扩充。
解决办法1:利用synchronized让方法同步执行,是解决这个并发场景的常见办法。
解决办法2:Hibernate乐观锁,也就是@Version注解。在存放着“余票”字段的表中,增加一个int型字段,同时使用@Version注解,这个字段就表示版本号,每次修改版本号就会递增。当前线程持久化时如果检测到版本号变化,即有其他线程修改过该记录,将会抛异常,我们可以在抛异常的时候做一些其他措施,或者什么都不做。
结语
我对事物隔离级别的看法,还有解决并发问题的思路可能比较浅薄,但还是希望这篇文章能帮到大家,同时欢迎留言探讨共同进步!