并发事务导致的丢失更新及处理方式详解
前言
在事务的隔离级别内容中,能够了解到两个不同的事务在并发的时候可能会发生数据的影响。细心的话可以发现事务隔离级别章节中,脏读、不可重复读、幻读三个问题都是由事务A对数据进行修改、增加,事务B总是在做读操作。如果两事务都在对数据进行修改则会导致另外的问题:丢失更新。
丢失更新的定义
丢失更新就是两个不同的事务(或者Java程序线程)在某一时刻对同一数据进行读取后,先后进行修改。导致第一次操作数据丢失。
丢失更新产生的原因(两种)
- 第一类丢失更新(回滚丢失,Lost update) (通过设置隔离级别可以防止 Repeatable Read)
- 第二类丢失更新(覆盖丢失/两次更新问题,Second lost update) A事务覆盖B事务已经提交的数据,造成B事务所做操作丢失
回滚丢失(Lost update)
A事务撤销时,把已经提交的B事务的更新数据覆盖了。这种错误可能造成很严重的问题,通过下面的账户取款转账就可以看出来:
Time | 事务A取款 | 事务B取款 |
---|---|---|
T1 | 开启事务 | |
T2 | 开启事务 | |
T3 | 查询账户余额为1000元 | |
T4 | 查询账户余额为1000元 | |
T5 | 汇入100元把余额改为1100元 | |
T6 | 提交事务 | |
T7 | 取出100元把余额改为900元 | |
T8 | 撤销事务 | |
T9 | 余额恢复为1000 元 (丢失更新) |
A事务在撤销时,“不小心”将B事务已经转入账户的金额给抹去了。 SQL92没有定义这种现象,标准定义的所有隔离界别都不允许第一类丢失更新发生。
覆盖丢失/两次更新问题(Second lost update)
A事务覆盖B事务已经提交的数据,造成B事务所做操作丢失:
Time | 事务A取款 | 事务B取款 |
---|---|---|
T1 | 开启事务 | |
T2 | 开启事务 | |
T3 | 查询账户余额为1000元 | |
T4 | 查询账户余额为1000元 | |
T5 | 取出100元把余额改为900元 | |
T6 | 提交事务 | |
T7 | 汇入100元 | |
T8 | 提交事务 | |
T9 | 把余额改为1100 元 (丢失更新) |
上面的例子里由于汇入事务覆盖了取款事务对存款余额所做的更新,导致银行最后损失了100元,相反如果转账事务先提交,那么用户账户将损失100元。(实际上和不可重复读是同一种问题)。
丢失更新处理方式
基本两种思路,一种是悲观锁,另外一种是乐观锁;
简单的说就是一种假定这样的问题是高概率的,最好一开始就锁住,免得更新老是失败;另外一种假定这样的问题是小概率的,最后一步做更新的时候再锁住,免得锁住时间太长影响其他人做有关操作。
数据库的锁
解决丢失更新的方法有好几个,先来了解下数据库里面的"锁"。从数据库功能上面来看,数据库设计上分为两种锁:读锁(共享锁)和写锁(排它锁)。
数据库在设计这两种锁的时候,这两种锁间的关系如下:读锁与读锁可以共存,读锁与写锁互斥,写锁与写锁互斥。(这种设计跟Java线程锁机制是一样的)。
使用数据库添加读锁和写锁的方法很简单 , 但需要注意的是锁必须在事务内进行声明。在事务外声明的锁将不具备效应。
为表添加读锁的方法:select * from t_account lock in share mode; (读锁与他人共享读操作,很容易导致死锁。)
为表添加写锁的方法:select * from t_account for update;
通过数据库的锁机制解决丢失更新
-
使用排它锁。
经过上面基于数据库锁的介绍可知,丢失更新可以使用写锁(排它锁)进行控制。因为排它锁添加到某个表的时候,事务未经提交,其他的事务根本没法获取修改权,因此排它锁可以用来控制丢失更新。需要说明的是有时候,当知道某一行会发生并发修改的时候,可以把锁定的范围缩小。例如使用select * from t_account t wheret.id=‘1’ for update; 这样能够比较好地把控上锁的粒度,这种基于行级上锁的方法叫"行级锁"。 -
使用乐观锁.
乐观锁的原理是:认为事务不一定会产生丢失更新,让事务进行并发修改,不对事务进行锁定。发现并发修改某行数据时,乐观锁抛出异常。让用户解决。可以通过给数据表添加自增的version字段或时间戳timestamp。进行数据修改时,数据库会检测version字段或者时间戳是否与原来的一致。若不一致,抛出异常。
悲观锁
-
传统的悲观锁法(不推荐):
以上面的例子来说明,在弹出修改工资的页面初始化时(这种情况下一般会去从数据库查询出来),在这个初始化查询中使用select …for update nowait, 通过添加for update nowait语句,将这条记录锁住,避免其他用户更新,从而保证后续的更新是在正确的状态下更新的。然后在保持这个链接的状态下,在做更新提交。当然这个有个前提就是要保持链接,就是要对链接要占用较长时间,这个在现在web系统高并发高频率下显然是不现实的。 -
现在的悲观锁法(推荐优先使用):
在修改工资这个页面做提交时先查询下,当然这个查询必须也要加锁(select …for update nowait),有人会说,在这里做个查询确认记录是否有改变不就行了吗,是的,是要做个确认,只是你不加for update就不能保证你在查询到更新提交这段时间里这条记录没有被其他会话更新过,所以这种方式也需要在查询时锁定记录,保证在这条记录没有变化的基础上再做更新,若有变化则提示告知用户。
页面做提交时先加锁查询下,不要一开始就加锁,增加效率。
乐观锁
-
旧值条件(前镜像)法:
就是在sql更新时使用旧的状态值做条件,SQL大致如下 Update table set col1 =newcol1value, col2 = newcol2value…. where col1 =oldcol1value
and col2 =
oldcol2value….,在上面的例子中我们就可以把当前工资作为条件进行更新,如果这条记录已经被其他会话更新过,则本次更新了0行,这里我们应用系统一般会做个提示告知用户重新查询更新。这个取哪些旧值作为条件更新视具体系统实际情况而定。(这种方式有可能发生阻塞,如果应用其他地方使用悲观锁法长时间锁定了这条记录,则本次会话就需要等待,所以使用这种方式时最好统一使用乐观锁法)。 -
使用版本列法(推荐优先使用):
其实这种方式是一个特殊化的前镜像法,就是不需要使用多个旧值做条件,只需要在表上加一个版本列,这一列可以是NUMBER或DATE/TIMESTAMP列,加这列的作用就是用来记录这条数据的版本(在表设计时一般我们都会给每个表增加一些NUMBER型和DATE型的冗余字段,以便扩展使用,这些冗余字段完全可以作为版本列用),在应用程序中我们每次操作对版本列做维护即可。在更新时我们把上次版本作为条件进行更新。 -
使用校验和法(不推荐)
-
使用ORA_ROWSCN法(不推荐)
总结
综上所述,我们对丢失更新问题建议采取上面的悲观锁第2种方法或乐观锁第2种方法(红字体已标注),其实这两种方式的本质都一样,都是在更新提交时做一次查询确认在更新提交,我个人觉得都是乐观的做法,区别在于悲观锁2方法是通过select…for
update方式,这个可能会导致其他会话的阻塞,而乐观锁2方法需要多一个版本列的维护。
个人建议:在用户并发数比较少且冲突比较严重的应用系统中选择悲观锁2方法,其他情况首先乐观锁版本列法。
感谢观看,如果觉得不错请点个赞!!! |