SQL并发处理方案——乐观锁和悲观锁

(一)乐观锁和悲观锁的概念

悲观锁

在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作都某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。
悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。

悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度(悲观),因此,在整个数据处理过程中,将数据处于锁定状态。 悲观锁的实现,往往依靠数据库提供的锁机制 (也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)

在数据库中,悲观锁的流程如下:

在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。

如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。

如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。

其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。

优点与不足

悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;另外,在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;还有会降低了并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数

 

乐观锁

在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早是由孔祥重(H.T.Kung)教授提出。

乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。

相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。

数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。

实现数据版本有两种方式,第一种是使用版本号,第二种是使用时间戳。

优点与不足

乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。但如果直接简单这么做,还是有可能会遇到不可预期的结果,例如两个事务都读取了数据库的某一行,经过修改以后写回数据库,这时就遇到了问题。

 

(二)实例说明

假如两个线程同时修改数据库同一条记录,就会导致后一条记录覆盖前一条,从而引发一些问题。

举个典型电商例子例如:

  一个电商平台,某个时间做秒杀活动或者促销活动,客户端每下单一个并付款成功库存减一

情景: 

  来个极端点的情况,现在库存只剩1个,假设两个用户同一时刻进行下单(并发),它们做的操作都是先查询库存数,然后减一。

 

一般的sql语句:

declare @count as int
 
begin TRAN
    select @count=total from TbTicket  WHERE GoodID ='001'
    WAITFOR DELAY '00:00:05' --模拟并发,故意延迟5秒
	IF(@count >= 1)
	begin
    update TbTicket set total=@count-1,CountNum=CountNum+1 ,updatedate=GETDATE()  WHERE GoodID ='001'
	end 
commit TRAN

看上去好像没啥问题,逻辑也很正确,但是如果并发来了,会出现什么情况?

同一时间获取的库存都为1,每个用户都做了一次更新为0的操作,导致数据库库存少了1个,而实际出库出了2个。

打开两个查询窗口,分别快速运行以上代码即可看到效果(CountNum这个的目的就是为了明确知道执行了几次,按照上面的程序CountNum会变成2,修改两次数据,因为进到程序获取库存的时候都会获得到库存为1)。 

 

那接下来就可以用到我们上面提到的悲观锁和乐观锁,怎么处理呢?

悲观锁处理方式:

在查询的时候加了一个更新锁,保证自查询起直到事务结束不会被其他事务读取修改,避免产生脏数据。

declare @count as int
 
begin TRAN
    select @count=total from TbTicket  WITH(UPDLOCK) WHERE GoodID ='001'
    WAITFOR DELAY '00:00:05' --模拟并发,故意延迟5秒
	IF(@count>=	1)
	begin
    update TbTicket set total=@count-1,CountNum=CountNum+1 ,updatedate=GETDATE()  WHERE GoodID ='001'
	end 
commit TRAN

很明显的可以发现,和上面的脚本对比其实就是加了一个 WITH(UPDLOCK),但是区别就很明显。我们将上面两个未做任何加锁的脚本上面加上WITH(UPDLOCK),再短时间内一起执行,看下会不会执行两次,CountNum会不会变成2?会不会还是出现数据库库存减1,而实际要出库2个

乐观锁处理方式:

乐观锁常用方式就是加版本号和时间戳,我们接下来就用时间戳的方式来来说明。

首先在数据表中加入一列timestamp,附带的说一下这个类型有点类似数据库自增列的二进制类型,每次对数据操做都会修改此数据,数据结构如下:

declare @count as int
DECLARE @flag AS TIMESTAMP
DECLARE @rowCount AS int

declare @d datetime
set @d=getdate()

begin tran
    select @count=total,@flag=timesflag from TbTicket WHERE  GoodID ='001' 
    WAITFOR DELAY '00:00:05'
    update TbTicket set total=@count-1,CountNum=CountNum+1 ,updatedate=GETDATE() WHERE GoodID ='001' AND  timesflag=@flag --这里加了条件
    SET @rowcount=@@ROWCOUNT  --获取被修改的行数
commit TRAN
 
--对行数进行判断即可
 
IF @rowCount=1
    PRINT '更新成功'
ELSE
    PRINT '更新失败'

select [语句执行花费时间(毫秒)]=datediff(ms,@d,getdate())	

同样可以按照上面的操做执行下脚本,看下效果,最后一行其实就是想看下执行脚本的耗费的具体时间,想和悲观锁进行对比下效率,以上仅供参考,欢迎朋友们一起来讨论和学习。

 

(三)总结

悲观锁一定成功,但在并发量特别大的时候会造成很长堵塞甚至超时,仅适合小并发的情况。

乐观锁不一定每次都修改成功,但能充分利用系统的并发处理机制,在大并发量的时候效率要高很多。

 

posted @ 2018-06-21 16:22  醉夕彦  阅读(1815)  评论(0编辑  收藏