db subsequent and synchronization(reship)

第2章 并发问题及控制手段

什么是并发问题?假设有这么一家书吧,顾客可以到那里喝茶读书。顾客拿着选好要读的图书到柜台登记,然后找个地方去阅读,临走时将图书归还店家。有一天,一个顾客相中了一本书后正要拿去登记,另一个顾客的手也抓住了这仅有的一本书,并发问题出现了。两个顾客要读同一本书,互不相让,这让店主伤透了脑筋。这个案例仅仅是众多并发问题中的一个微小部分,但从中我们可以看出并发问题主要出现在多个用户对有限资源进行访问的时候,如果解决不好会直接影响系统的有效、正常运行。数据库是一个共享的资源,并发问题的出现是必不可免的,如何识别并发类型并加以控制是这一章重点要讲述的内容。

本章将分成两大部分,一部分主要讲Visual FoxPro中并发控制机制。VFP中并发控制相对简单,数据加锁的形式比较单一,非常适合作为初步了解并发问题的切入点。第二部分以SQL Server 2000、ADO.NET以及C#为主要工具,深入了解并发一致性问题、封锁协议、事务隔离等内容,难度相对较深。象一些更为深入的并发控制手段,例如多粒度封锁和意象锁等内容在本章中将不做深入讨论,感兴趣可以参考相关书籍。

2.1 Visual FoxPro中并发控制机制

2.1.1 独占访问

对于上面提出的问题有没有解决办法呢?当然有。一种办法就是"独占访问"―任何时刻你的书吧中只能有一位顾客读书,他可以尽情的挑选钟爱的书籍而决不用担心谁会与他争夺资源。当他读完书走后,下一个顾客才能进来,当然一次只能进一个。你可能会笑,说哪个商家会笨到如此地步。其实如果你的书店一天只有两三个顾客光顾的话,这么做到也无妨:。

如果把书店映射成数据库中的一张表,每本书对应表中的一条记录,那么我们就可以对数据库表应用"独占访问"了。一旦某张表被独占,其它人就无法进行访问,除非独占表的那个人主动将资源释放出来。我们可以使用Visual FoxPro来分别模拟独占方式与共享方式下的数据库行为。请大家完成【实验 2-1 使用Visual FoxPro独占(共享)访问表】。

 

实验 2-1 使用Visual FoxPro独占(共享)访问表

独占方式访问可以确保绝不出现并发问题,但却严重影响了数据库使用效率。我想没有一个商家愿意他的商店一次只能有一个顾客惠顾吧。然而现实世界却仍然不乏"独占访问"的案例。几个月前,老婆生孩子住院,医院每天查房和给婴儿洗澡时,为了防止意外(偷孩子)发生,要将整个病区锁起来,每家只能留一个陪床。如果外面的家属想进去,那好,里面的那个陪床必须出来,任何时候只能有一个人在里面,这就是典型的"独占访问"。另外,象"探监"这样的事情想必也必须是"独占"的。

2.1.2 数据加锁

我们的店主对"独占方式"并不感兴趣,毕竟买卖更为重要,一次只让一个顾客访问的方式实在让人难以接受。还有什么好办法吗?那位可能会说:安个抢答器吧,谁先按下书就给谁。安抢答器的想法虽然有些离奇,但变通一下还是可以的。我们的店家凭借聪明的脑筋很快就提出了一个非常可行的方案:

首先,店家制作了一套标签,每个标签上都注有一个书名,书和标签间是一一对应的。书吧也专门在柜台前开设了一个新的窗口,用来发放这些标签。店家给这些标签起了个名字叫"锁",发放"锁"的窗口叫做"领锁处"。这样,当顾客查询好要读的图书后,他必须先到"领锁处"排队(先来先服务)以领取与书相对应的"锁"。通常情况下顾客都可以领到锁,然后去"领书处"登记,"领书处"将对应的图书交给顾客阅读并留下顾客的"锁"。阅读完后,顾客归还图书,"领书处"负责将对应的"锁"归还"领锁处"以备下次发放。当然,如果两个人都想读同一本书,那就看排队时谁在前面了,排在后面的顾客领不到锁就不能去领书处领书。此时,顾客有两个选择,一是放弃阅读的念头,二是等待前面的顾客归还图书后,领书处将"锁"归还"领锁处"。如图 2-1。

db subsequent and synchronization(reship)

 

图 2-1 书吧运营模式(悲观缓冲模式)

这么一来,就免除了"独占"访问带来的问题。再多的顾客也可以同时选书,只有确认要读的时候再去"领锁处"排队,避免了独占方式下只允许一个顾客访问的问题。"锁"在里面起到了一个"令牌"的作用,只有持有令牌后才有权对其所对应的资源进行访问。另外,每本书都对应一个"锁",张三持有1、3、5的锁,李四还可以持有2、4、6的锁,只要大家访问的资源不冲突,就可以共享图书。即使出现了冲突,那也不怕。谁先排队持有锁,谁就可以访问资源,拿不到锁的人要么等待,要么放弃,就这么简单。

数据库也是通过加锁的方式来解决共享冲突问题的。Visual FoxPro为我们提供了RLOCK()函数对记录进行加锁。一旦某记录被加锁,其它用户就不能再对同一条记录加锁了。只有等待该记录上的锁被释放后才可以再次加锁。请大家完成【实验 2-2 使用Visual FoxPro实现记录锁定与解锁】。

 

实验 2-2 使用Visual FoxPro实现记录锁定与解锁

 

2.1.3 乐观与悲观缓冲策略

2.1.3.1 悲观锁

书吧在引入锁机制后运营了一段时间,但店家很快就发现了新的问题。有些顾客害怕别人抢走自己想看的书,于是就预先领取一大批锁,然后安心的逐一阅读,毕竟领到锁就等于领到了书。如图 2-2:

db subsequent and synchronization(reship)

 

图 2-2 书吧运营出现问题,有人额外占有大量锁,造成领锁处资源紧张

可以从图中看出,顾客占有的锁资源越多,持有锁的时间越长,就造成越多的顾客排队领不到锁,而实际上书的资源并非如此紧张,只是有人"占着茅坑不拉屎"而已,而且是一个人占好几个茅坑

焦头烂额的店主不得已又找来专家,请他们帮忙出主意。这回专家还真帮忙,不但找到了问题的根源,还给我们的店主下了两副药,解决了书吧的燃眉之急。那问题的根源究竟在哪里,这两副药又是什么呢?

经过专家考查,对原有业务流程进行抽象、总结,将原有的业务流程归纳成如下五步:领锁、领书、阅读、还书、还锁(对应数据库操作过程为:记录加锁、读取数据、修改数据、保存修改、记录解锁)。当一个用户持有某本图书(记录)的锁时,其他用户就不能再持有该书(记录)上的锁了,直到等待锁被释放。出现如此流程的原因在于店主过于悲观,担心出现并发冲突,因此要求先加锁再浏览(读取或修改),这么做相当于在书(记录)的粒度上实现了独占访问,带来的好处是决不会出现并发问题,但代价也是惨重的,致使资源没能有效利用。我们的专家给这种模式起了个名字,叫"悲观锁"(其实叫"悲观缓冲"更合适,只不过现在还没说到"缓冲"的概念,所以暂且叫悲观锁)。

悲观锁的特点是:先加锁,再修改,再保存,再解锁。悲观锁在记录级粒度上实现了"独占访问",解决了并发冲突的问题。同时,悲观锁锁定的记录越多,时间越长,资源的使用效率越低,给人的感觉就是性能越低下。

为此专家提供的药方一便是:(1)尽可能减少每个顾客可锁定的资源数量;(2)尽可能减少资源锁定的时间。为此,我们可以限定每个顾客一次只能领取一个锁,同时限定顾客的阅读时间,让锁尽可能早的回到领锁处。但是,从店家的角度来看,让读者一次只领一个锁还算可行,可限制阅读时间恐怕就不尽人意了。既然不限制读者的阅读时间,就不能排除有人领走一本书的锁后,迟迟不去领书,或读起书来三天打鱼,两天晒网(占着一个茅坑不拉屎),资源的使用效率仍然不高。

2.1.3.2 缓冲与乐观锁

我们的专家似乎看出了店主的心事,便又给他出了第二个药方。这次需要店主对他的经营模式进行一番彻底改造。改造之一便是引入"缓冲"机制。

何为"缓冲"机制呢?先让我们看看专家提供的新方案(如图 2-3),在这个方案中,每个顾客都持有一个掌上阅读器。而"借阅图书"并不是真的将图书拿走,仅仅是从书吧的众多终端中将其"下载"到阅读器上,然后各自拿着各自的阅读器去阅读。这样一来,多个人可以同时阅读同一本书而互不干扰。我们可以将"阅读器"理解为"缓冲"。数据库中的数据下载到阅读器后,阅读器便可以"离线"(Off Line)工作了,每个顾客都拥有各自的"缓冲",它们之间不会相互干扰。至于你想缓冲多少数据就看店主的限制了。如果店主限制阅读器一次只能装载一本书(一条记录)的话,我们就管这种缓冲模式叫做"行缓冲";如果店主足够大方,允许将书吧中所有图书都下载到阅读器上的话,我们就管它叫做"表缓冲"。其实我们在【2.1.3.1悲观锁】一节看到的专家方案便是悲观行缓冲的方案。

db subsequent and synchronization(reship)

 

图 2-3 引入新设备、改进业务流程后的书吧

如果我们的书吧仅仅是让顾客读读书,那在引入"缓冲"后就没有什么需要改进的地方了。毕竟所有图书对于顾客而言都是"只读"的罢了。大家各自下载,各自阅读,不会出现什么并发问题。然而我们的专家建议店主为顾客提供一些新的服务:允许在顾客在"还书"时记录该书的阅读次数。同时也允许顾客给每本书添加评论,并且可以将这些评论内容保存起来,以后的读者在下载图书的同时还可以看到该书的阅读次数以及前人的评论。让书吧不但成为一个读书的地方,还成为读者相互交流的场所。

新服务的引入也带来了新问题。假设有两个顾客同时下载了同一本书,同时向数据库提交自己的评论,呵呵,并发问题又来了。为了解决并发问题,我们再次请出"锁"这个法宝。不过这次的锁不同于前面,我们管它叫做"乐观锁"(也叫"乐观缓冲模式")。乐观主义的人往往认为并发冲突发生的可能性非常小,因此可以优先考虑资源利用效率问题,然后再考虑是否会发生冲突。

乐观缓冲模式的特点是:先修改,再加锁,再保存,再解锁。也就是说乐观缓冲模式允许多人同时进行各自的修改操作(因为修改时不加锁),只有保存时才加锁,保存完后立刻解锁。因为用户修改数据往往占用较长的时间,而保存只是一瞬间的事,乐观缓冲模式可以保证锁定资源的时间尽可能的短,从而提高资源的使用效率。然而乐观缓冲模式的缺点就是无法避免并发冲突的发生。

让我们来看一个例子:假设张三和李四同时从书吧下载了同一本图书,该书已经被阅读了10次。在张三和李四的阅读器中都记录了已经阅读10次的信息。张三先读完了该书,并且到吧台要求"还书"以便下载另外一本。在还书的过程中,系统将张三阅读器中的阅读次数加1得到11并存入数据库。此后李四也读完了书,他也来到吧台进行"还书"。系统读出李四阅读器中的阅读次数是10,将其加1得11并存入数据库中。注意,原有图书被阅读了10次,加上张三和李四的阅读次数应当是12次。然而现在我们数据库中记录的却是11次,有一次阅读计数被丢掉了(这是我们在后面要提及的名为"丢失的修改"的并发冲突问题)!

由于乐观缓冲模式允许用户先修改,所以张三、李四各自都可以对数据进行修改,然而在保存时却出现了冲突。乐观缓冲模式在带来了资源使用效率提升的同时也带来了潜在的并发冲突,如果不加以有效的检验,冲突很可能造成很严重的后果。关于Visual FoxPro中如何检测冲突以及如何实现更新将在【2.1.4数据删除与更新】中详细论述。

为了加深乐观缓冲模式的印象,请大家完成【实验 2-3 乐观缓冲模式下更新图书阅读次数及相关并发冲突】。

 

实验 2-3 乐观缓冲模式下更新图书阅读次数及相关并发冲突

2.1.3.3 小结

很难说究竟乐观缓冲好还是悲观缓冲好,其实各有千秋。不过在使用悲观缓冲时应尽量确保锁定尽可能少的资源,锁定时间尽可能短。而使用乐观缓冲时,一定要提供一套完善的冲突检测和解决机制,防止并发问题出现。并且,不同场合可能要求使用不同的缓冲策略。

目前绝大多数数据库操作都采用了乐观缓冲模式以提高数据访问效率,同时也对开发数据库应用程序的人提出了更高的要求,那就是必须学会如何编写程序解决并发冲突。我们会在本章后续内容中对此详细论述。乐观缓冲使用频率更高一些并不意味着悲观缓冲没有用处,下面的实验演示了悲观缓冲的一个应用场景:

在Visual FoxPro 6.0中,没有自动增长型字段(注:Visual FoxPro 8.0中已经允许将某一字段设置为自动增长型字段),当我们需要设置某个表的主键字段是自动增长型时,不得不通过编程的方式实现。同时,为确保两个并发用户申请到的主键不至于重复,我们必须使用悲观行缓冲策略。实验 2-4设计并实现了一套悲观缓冲自动增长型字段机制,可以帮助我们理解悲观缓冲的一些应用。

 

实验 2-4 利用悲观缓冲策略实现自动增长型字段

注:在后面的内容中,除非特别提及,否则使用的全是乐观缓冲模式。

2.1.4 数据删除与更新

2.1.4.1 CurrentValue与OldValue

乐观缓冲模式提高了数据库的并发访问效率,但同时也为我们带来了新的课题,那就是要解决并发带来的数据不一致问题。在上文故事中,我们看到张三、李四各浏览图书一次,而更新数据库时仅仅加了1而不是2。如何才能检测出类似问题,并加以规避呢?绝大多数的数据库访问技术都为我们提供了CurrentValue与OldValue(ADO.NET里叫做OriginalValue),通过比对这些值可以帮助我们发现潜在的冲突(注:此方法并不能保证发现所有并发问题,关于更进一步的分析请参考本章第二部分内容)。为了对其有一个感性认识,请完成【实验 2-3 乐观缓冲模式下更新图书阅读次数及相关并发冲突】的步骤3。

那究竟什么是CurrentValue,什么是OldValue呢。让我们用一个全新的例子来说明这个问题。假设张三、李四同时从数据库中读出某商品的数量并存入本地缓存(如图 2-4)。

db subsequent and synchronization(reship)

 

图 2-4 张三、李四从数据库中读入商品编码为1的记录

张三先卖出去1件商品并更新数据库,将数量改为9。此时李四缓冲中的数量仍然是10,他并不知道有人已经将数据库中的数据修改了(如图 2-5)。

db subsequent and synchronization(reship)

 

图 2-5 张三首先更新数据库,写入9

现在由于新进一批商品,李四要给商品编码为1的商品数量加上10。如果他直接在自己的缓冲中进行操作,然后强行更新数据库的话,他就会将张三卖出1件商品的事实给抹掉,因为更新后数据库中记录的数量是20,而实际情况应当是19。

李四如何确保他不覆盖任何人的更新呢?这时候李四可以采用这样一种策略:当他试图修改缓冲区中的数据时,先对缓冲区的数据进行备份,也就是说保留当时读入的原始数据,而在原始数据的一个副本上进行修改(如图 2-6)。

db subsequent and synchronization(reship)

 

图 2-6 通过保留缓冲备份,得到了三种取值

于是我们便得到了同一个数据的三种不同版本的值,分别是:原始值(Old Value或Original Value),即最初读入缓冲区中的值;当前值(Current Value),即目前数据库中的值;建议值(Proposed Value),即用户打算将数据库中的数据修改成什么值。

现在李四的更新策略可以调整如下:(1)首先锁定数据库中对应的记录,确保在后面的操作过程中数据不会发生改变。(2)比较Current Value与Old Value是否相同。(3-a)如果两个值相同,则说明该数据尚未被人动过,直接完成更新;(3-b)如果两个值不同,说明在李四修改数据的过程中有人修改了数据库中的值,此时可以将当前值、原始值和建议值分别显示给用户,由用户决定是否强制更新。如果强行更新,则不管目前数据库的值是什么,强行写入20。如果不强制更新,用户可用当前值替换掉原始值,并要求用户重新修改好数据。(4)解锁。

在上面的更新策略中,我们又使到了锁,不过数据被加锁的时间是非常短的,因此不会影响数据库的并发效率。通过对原始值与当前值的比对,我们就可以发现是否有人动过数据库中的数据,从而提示用户做进一步的调整。

这么做解决了部分并发问题,但又会引发新的思考。例如图 2-7所示的问题:

db subsequent and synchronization(reship)

 

图 2-7 两人各自更新不同的字段

张三修改了数量,而李四修改了金额。数量字段上的当前值与原始值尽管不同,但李四从来没有动过这个字段。在李四修改了的金额字段上,当前值与原始值却是相同的。那究竟李四是可以保存呢还是不可以保存呢?

我们说这就要看你的更新策略了。如果你认为金额和数量是两个毫不相关的量,可以单独发生变化,这个时候就可以允许李四更新。如果你认为金额和数量密不可分,任何一个变了都会影响另外一个的话,就不能允许李四更新。

话说到这里有些人已经开始头痛了,你如何确保更新策略的实施呢?这正是下面我们要讨论的问题。

2.1.4.2 暗藏机关的Where短语

SQL语言已经成为关系型数据库操作的标准语言,可以说所有的关系型数据库都支持SQL命令。我们在使用各种图形用户界面完成对数据库操作的同时,也应注意到在底层跑的却是非常简单的SQL命令。当你用鼠标按下保存按钮的时候,没准一条UPDATE命令就被发送到数据库中。广义的数据更新命令很多,常用的包括Insert、Delete和Update命令。为了方便起见,在这部分内容里,我们只以Update命令为背景谈一谈"暗藏机关的Where短语"。

在前面的内容中,我们讨论了半天的当前值、原始值和建议值,而在数据库操作的过程中,所有的更新都必须转换成SQL中的UPDATE命令,那么这些值是如何被揉到UPDATE命令中,又是如何检测出并发冲突的呢?其实所有的秘密就在UPDATE的WHERE短语中。让我们看看在图 2-6所示的例子中,WHERE短语是如何发挥秘密武器的作用的。

李四现在要保存所做的修改,他会首先考虑使用UPDATE命令。他书写的命令如下:

UPDATE 商品表 SET 数量=20 WHERE 商品编码=1

这条命令的作用是将商品编码为1的商品数量改成20。命令能否成功执行呢?当然可以。因为商品编号字段是主键,它会唯一定位这条记录,然后将数量改为20,它不会去考虑是否有冲突发生。如果想判断是否有人动过数据,这条UPDATE命令必须写成

UPDATE 商品表 SET 数量=20 WHERE 商品编码=1 AND 数量=10

注意在WHERE短语中添加了一个条件:数量=10。这里使用了数量字段的原始值作为条件,意思是说在数据库中找到这么一行记录,它的商品编码是1,数量是10,然后将其数量改为20。尽管"商品编码=1"的条件就可以唯一定位这条记录了,但还是加上了一个保护条件,就是"数量=10",如果没有人动过这个数据,那它还应当是10,符合WHERE条件的记录就还是1条,更新可以成功执行。如果现在数据库中的数据已经被改为了9,那么加上保护条件后,就再也没有满足WHERE条件的记录了,因此更新也就无法完成。从用户的角度来看,那就是检测到并发冲突了。此时,你可以通过SELECT命令将数据库的当前值读出来,提供给用户做比对。

这回再让我们看看图 2-7所对应的例子。李四在更新时是否考虑数量字段如何通过WHERE短语实现呢?其实道理是一样的。如果李四在更新时只检测金额字段是否有人动过而不关心数量字段,那么他的UPDATE命令可以写成:

UPDATE 商品表 SET 金额=14 WHERE 商品编码=1 AND 金额=12

如果他希望数量和金额两个字段在更新时都没有被别人修改过,那么这回更新命令可以写成:

UPDATE 商品表 SET 金额=14 WHERE 商品编码=1 AND 金额=12 AND 数量=10

数量和金额字段上的原始值都派上了用场。由此也可以看出保留原始数据备份的重要性。

通过上面的例子可以看出,UPDATE命令中的WHERE短语不但可用起到限定更新范围的作用,使用好还可以同时实现检测并发问题的功能。如果在软件设计时精心安排WHERE短语的条件就可以起到意想不到的作用。

然而即便如此,构造WHERE短语仍然比较麻烦,不过很多编程语言都提供好了一组WHERE短语生成策略,将常用的生成方式都包纳进来,我们只要从这些策略中选择合适自己的就可以了。

2.1.4.3 WHERE短语的生成策略

不同语言提供的WHERE短语生成策略可能有微小差异,但基本上不外乎以下几种:关键字、关键字和可更新字段、关键字和已修改字段、时间戳等。

请先完成【实验 2-5 用Visual FoxPro验证WHERE短语生成策略】。

 

实验 2-5 用Visual FoxPro验证WHERE短语生成策略

通过实验我们可以看出,Visual FoxPro利用WHERE短语生成策略简化了使用者手工编写WHERE短语的麻烦,提高了SQL命令的生成效率。在这里值得一提的就是时间戳。

SQL Server中提供了一种数据类型叫timestamp ,在每次更新数据库时,该值都会自动发生变化。这是最严格的更新策略,在使用"时间戳"WHERE短语生成策略时,即使CurrentValue与OldValue完全相同,只要时间戳不同(比如一个人修改了数据,发现错了,又该了回去。尽管从结果上看数据没有发生变化,但时间戳变了),就无法完成数据更新操作。

在一些O/R Mapping软件中(例如Hibernate),使用一个数值型、只增不减的字段来实现时间戳的功能,这与SQL Server中的timestamp大同小异。

2.1.5 小结

从上面的论述中我们可以看到,Visual FoxPro提供了一套比较完善的冲突检测与解决机制,能够在绝大多数情况下完成有效的并发控制。但是,Visual FoxPro提供的解决机制仍然有所欠缺,在处理更复杂的并发问题时(例如不可重复读、幻影读等)显得力不从心。在本章后续部分中,我们将从完善的并发理论着手,更加深入的探询并发控制机制。

你可能感兴趣的:(res)