`
OuYangGod
  • 浏览: 52995 次
  • 性别: Icon_minigender_1
  • 来自: 南京
社区版块
存档分类
最新评论

事务、事务并发

阅读更多
最近工作非常郁闷,天天被领导盯着。主要是系统近来死锁发生在频率很高。最终,经过大家的共同努力,我们成功的定位并解决了问题,所以把过程中学习的知识与经验分享一下

问题背景
系统中有一个账户模块,负责管理和维护会员的各种资金及明细,对外的功能涉及资金的增加与扣减等。通过监控系统发现,当外围系统并发访问和调用同一个会员的账户功能接口的时候,就会发生死锁和响应超时的情况,所以问题的分析还得从事务的并发说起。首先,我们先回顾一下事务。

事务是神马
所谓事务,它是一个操作序列,这些操作要么都执行,要么都不执行,它是一个不可分割的原子单位。例如,银行转帐工作:从一个帐号扣款并使另一个帐号增款,这两个操作要么都执行,要么都不执行。

事务的特性
  • 原子性。一个事务是一个不可分割的工作单位,事务中包括的诸操作要么都做,要么都不做。

  • 一致性。事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。

  • 隔离性。一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

  • 持久性。持续性也称永久性,指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。

四个特性中,与我们问题相关的应该就是事务的隔离性了,要理解隔离性,就不得不说说事务的并发了。

事务的并发及带来的影响
事务并发通俗点讲就是多个事务同时执行,并发的访问或更新数据库中相同的数据。事务并发一般需要相应的隔离措施,否则就会出现各种问题。

事务的并发会造成哪些问题?
  • 丢失更新。第一种情况:当2个事务更新相同的数据源,如果第一个事务被提交,之后另外一个事务也提交了,那么第一个事务所做的更新就失丢了。第二种情况:当2个事务更新相同的数据源,如果第一个事务被提交,而另外一个事务却被撤销,那么会连同第一个事务所做的跟新也被撤销。

  • 脏读。事务读取别的事务未提交的脏数据。如:事务1插入或更新了数据A后,事务2读取了数据A,这时事务1回滚了,那事务2读到的数据A就是脏数据了。

  • 不可重复读。在同一个事务范围内,多次查询同一个数据的值不同。如:事务1查询数据A = 10,这里事务2把数据A的值更新为11后提交了,事务1在完成部分逻辑后再次读取数据A的值变为11了。

  • 幻读。在同一个事务范围内,多次以相同的查询条件查到的数据数量不一样。如:事务1以条件X查询数据,这时事务2插入或删除了数据,之后事务1在完成部分逻辑后再次以条件X查询数据时,返回的结果数量不一样了。

  • 覆盖更新。事务对数据的更新被别的事务覆盖了。如:事务1和2同时读取数据A = 10,事务1设置A = A - 1后更新数据A = 9,事务2设置A = A - 2后更新数据A = 8,最终数据A的值变成了8,但正确情况下A的值应该是7。

知道了事务并发会造成什么影响,那如何避免问题的发生呢?从数据库层面来讲,是通过封锁协议再解决的。

数据库封锁协议
在说封锁协议前,先得讲讲锁。数据库中有两种锁:共享锁(读锁S)与排它锁(写锁X)
  • 读锁:若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放S锁。

  • 写锁:若事务T对数据对象A加上X锁,则只允许T读取和修改A,其他事务不能再对A加任何类型的锁,直到T释放A上的X锁。

再来看看封锁协议
  • 一级封锁协议:事务T再修改数据R之前,必须先对其加X锁,直到事务结束才释放。一级封锁协议可以防止丢失更新

  • 二级封锁协议:在一级封锁协议的基础上,事务T在读取数据R之前,必须先对其加上S锁,读完之后立即释放S锁。二级封锁协议除了能防止丢失更新,还可以防止脏读

  • 三级封锁协议:在一级封锁协议的基础上,事务T在读取数据R之前,必须先对其加上S锁,直到事务结束后才释放。三级封锁协议可以防止丢失更新脏读不可重复读

封议协议并不能解决事务并发的所有问题(覆盖更新),另外,封锁协议是数据库内部实现事务并发控制的一种机制,我们无法在程序中使用。在程序开发中,是通过指定事务的隔离级别来解决并发的各种问题的。

事务的隔离级别
有四种隔离级别,无论哪一种都不会出现丢失更新,因为四种隔离级别都要求在更新数据对象前先要对数据加X锁,直到事务结束后才释放,即一级封锁协议。
  • Read Uncommitted读未提交脏读不可重复读幻读覆盖更新都会发生,但并发性能最好。

  • Read Committed读已提交:能避免脏读,但不可重复读幻读覆盖更新会发生,并发性能好。大部分数据库的默认隔离级别,并发性能较好。

  • Repeatable Read可重复读:能避免脏读不可重复读,但幻读会发生,并发性能会受影响。至于覆盖更新,则视数据库而论。

  • Serializable序列化:能避免脏读不可重复读幻读,并发性能差。至于覆盖更新,则视数据库而论。

这样看来,事务隔离级别好像与封锁协议是一一对应的。读未提交与一级封锁协议对应、读已提交与二级封锁协议对应、可重复读与三级封锁协议对应。但经过本人的验证,这个不完全正确,隔离级别是一个规范,而封议协议是规范的一种实现方式。比如mysq同时使用数据行版本与锁的机制来实现隔离级别的,DB2单纯使用锁的机制来实现隔离级别。我会重新整理一篇文章讲讲关于mysql与DB2在隔离级别方面的细节与区别。

我们已经知道了相关的知识背景,再来看看我们系统的问题。
系统使用的是DB2,所有只读事务使用UR隔离级别(相关于读未提交),其他事务使用RS隔离级别(相关于可重复读)。会员的资产功能简单讲有增加和扣减操作。
增加操作
start transaction
//第一步,查询账户可用余额
select ... from account where account_id = ...
//第二步,余量加上增加量,并赋值给新变量newBalance
set newBalance = dbBalance + changeAmount
//第三步,更新会员账户记录
update account set balance = newBalance where account_id = ...
//第四步,其它操作
commit

扣减操作
start transaction
//第一步,查询账户可用余额
select ... from account where account_id = ...
//第二步,余量扣去增加量,并赋值给新变量NEW_BALANCE,如果余量不足则报错。
set newBalance = dbBalance - changeAmount
if ( newBalance < 0 ) throw new Exception("...");
//第三步,更新会员账户记录
update account set balance = newBalance where account_id = ...
//第四步,其它操作
commit

上面使用伪语言描述,可以看出,不论是增加还是扣减操作,都是先查询出账户记录,根据记录值作计算,最后再更新记录。当外围系统同时访问同一会员做扣减的时候,死锁就有可能发生了,比如:

事务T1执行第一步,查询了会员的账户记录,因为隔离级别是RS,所以会对会员账户记录加S锁;这时候事务T2也执行了第一步,也对会员的账户记录加了S锁;之后事务T1执行第三步,在准备更新会员账户记录前需要先对其加X锁,但发现记录已经被其它事务(事务T2)加了S锁,所以事务T1挂起,等待事务T2释放账户记录的S锁;接着事务T2也执行到第三步,在准备更新会员账户记录前需要先对其加X锁,但发现记录已经被其它事务(事务T1)加了S锁,所以事务T2也挂起等待事务T1。这样死锁就发生了。

知道了死锁发生的原因,那现在看看如何解决这个问题。死锁发生的原因主要是第一步查询的时候对账户记录加了S锁,所以如何我们把隔离级别降为CS(相当于读已提交),能否解决问题呢?答案是否,把隔离级底降为CS确实可以避免死锁的发生,因为查询操作结束后就会释放S锁,但是却会发生覆盖更新的问题,所以这个方案不可行。既然问题出在第一步,那就是第一步出法看看吧,如果查询账户的时候,我们显示的对记录加X锁而不是S锁,那问题就解决了?看看:

事务T1执行第一步,查询了会员的账户记录,显式对会员账户记录加X锁;这时候事务T2执行到第一步,在查询会员账户记录尝试加X锁时会等待,因为记录已经被别的事务(事务T1)加了X锁;之后事务T1执行第三步,顺利的更新了记录,因为它已经占有记录的X锁;在事务T1提交之后,事务T2就可以继续往下执行了,所以死锁的问题解决了。

优化后的扣减操作
start transaction
//第一步,查询账户可用余额
select ... from account where account_id = ... for update with rs
//第二步,余量扣去增加量,并赋值给新变量NEW_BALANCE,如果余量不足则报错。
set newBalance = dbBalance - changeAmount
if ( newBalance < 0 ) throw new Exception("...");
//第三步,更新会员账户记录
update account set balance = newBalance where account_id = ...
//第四步,其它操作
commit

对于增加操作来讲,因为没有上限限制,所以可以直接更新增加量就可以了。优化后的增加操作
start transaction
//第一步,更新会员账户记录
update account set balance = balance + changeAmount where account_id = ...
//第二步,其它操作
commit

经过上面两个优化,死锁不再发生了,另外接口的平均响应时间也有不小的提高。
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics