数据库是一个共享资源,可以供多个用户使用。然而,对于大多数程序员来说,单处理机系统是我们接触最多的系统,运行在其上的数据库事务也并非真正意义上的并行,实际上它是这些并行事务的并行操作轮流交叉运行,这种并行执行方式称为交叉并发方式(Interleaved Concurrency),这样可以减少处理机的空闲时间,提高系统的效率。而在多处理机系统中,每个处理机可以运行一个事务,多个处理机可以同时运行多个事务,实现真正意义上的并行运行,而这种并行执行方式,我们称之为同时并行方式(Simultaneous Concurrency)。在这篇文中,我将自己在开发中遇到的数据库并发控制的程序设计经验介绍给大家(当然了,这里我用的是单处理机,并且抛弃具体的编程语言,只从SQL上进行详细讲解),希望能够抛砖引玉。
我们都知道,事务是并发控制的基本单位,保证事务的ACID(Atomicity,Consistency,Isolation,Durability)特性是事务处理的重要任务。而程序员在具体的程序设计中,又是如何做到的呢?市面上目前流行的数据库书籍或者是针对某种编程语言的项目实践指南,都只是从理论上对我们进行一番说教,可真正到了实战中,往往三言两语,整的我们大家往往丈二和尚摸不着头脑。天花乱坠猛砍一顿,可是实际应用中又是如何呢,在这,我将带领大家一起分享我的经验,一起进入神秘的并发控制之旅吧!(对了,在这,我用的数据库主要是MySQL,并配之以MS SQL Server,Oracle,PostgreSQL比较讲解!)
仔细分析并发操作给数据库带来的不一致,我将其分为三类(注意,大家也许见过不同的分类,但万变不离其宗,在这,也是为了后面文章叙述方便,按照自己的理解将其归类):第一类,丢失修改;第二类,不可重复读;第三类,脏读。下面,我将结合实例,带大家一起看看具体的数据库操作中,这些不一致现象是如何产生的,再将应对措施介绍给大家。
如上图,我们简单地模拟一下火车站售票系统的数据库,表seat只包含三列,分别代表座位编号,座位位置,座位订票人。
现在我们来看看什么是丢失修改(Lost Update),假设订票员X查看哪个座位空闲,这时,她开启一个事务操作,如下所示:
订票员X动作只比Y略微慢了一点,几秒钟之后,她开始订票:
怎么样,从图中,我们很明显看到订票员Y做的订票操作丢失了,这就是所谓的丢失修改。当然,这是一个严重的编程错误。那么我们怎么解决这种数据库操作带来的不一致呢?就目前,主流的解决方案有两种,一种就是将数据库事务隔离级别设置到Serializable,当然,这种方式我是不推荐使用的,原因很简单,隔离级别指明了在事务中如何获得锁(恩,什么是锁?稍安勿躁,等会我会详细告知),隔离级别越高,带来的性能损失越大,这个在大数据集,高并发应用中,将会体现的淋漓尽致。第二种就是所谓的悲观锁机制,在Oracle,MySQL和PostgreSQL中,你可以使用如下语句:
在MS SQL Server中,语句略有不同,如下:
在这种情况下,订票员X先运行select…for update ,锁住了所有可用座位,这时,订票员Y运行select… for update时,被强制等待,直到X运行提交为止。当然,如果你想控制的更为精细,即订票员X在订前排位置,而订票员Y在订后排位置时,订票员Y能同时处理订票,可以使用如下语句:
select chaired from seat where booked is null and location=’front’ for update
一个良好的算法或许是订票员X订中心线左边的座位,Y订中心线右边的座位,以这种方式行事,只有在座位快满员时才可能发生阻塞。
好了,来看看,数据库不一致的第二种情况(Non-repeatable Read),不可重复读包括三种情况:分别对应着DML的Insert,Delete,Update三种操作,其中前两种,在有些书籍上又称之为幻影读(Phantom read),为了演示这个特效,需要对MySQL的默认设置进行修改(set transaction isolation level read committed),为了节省时间,我在这就不贴效果图了。如果我们使用Oracle或者是MS SQL Server的话,在不修改任何配置的情况下,就可以看到效果。原因在于其默认的事务隔离级别是read committed。
同理,要想看到脏读,也需要进行设置(set transaction isolation level read uncommitted),如下图所示:
首先,订票员Z发现chairId的座位没有别人预定,于是她发出如下语句:
这时,订票员X开始发出如下语句,
这时,订票员Z回滚操作,原因是有人退票了。那么订票员X读到的数据就属于脏数据(因为booked列还是Z,与实际不符)。
下面使用乐观锁进行一些实验:
在这里,引入新的一列(这里是updateid),作为版本控制。现在我们来看看如何现实生活中的例子吧。比方说订票员X要求保留两个座位,订票员则要求三个座位。为了确保三个座位可用,我们可以使用如下方式:
这个结果表明,三个在一起的座位,可以从座位号为1订起,分别为1,2,3。或者是从4开始订起(记住我们一共有6个座位),即4,5,6。
好了,在订票员X和Y的两次订票过程中,updateid作为版本控制字段被返回,并且在任何后继的select和update操作中使用。这样,如果订票员X需要预订两个座位,那么她将做出如下操作:
如上所示,订票员X操作后2 rows affected,这是正确的座位个数。因此,通过提交完成该事务。如果订票员Y此刻(注意:前一时刻 – 也就是在x还未提交更新数据前,Y也开启了一个事务,并发现连续的三个位置可以从1开始),准备预订3个座位,那么他的操作将如上图上所示,他应该能更新三条数据,但是受影响的只有一条,此刻,回滚事务,并且重新启动一个事务处理过程。如下图所示:
如此,我们执行预订三个座位的操作,如下:
至此,乐观锁的实战我们也看完了。在实际应用中,可以通过有效地算法,划分不同区域,使得搜索从不同空间开始,这样可以避免上述并发带来的一系列问题。
备注:
(1) Oracle中关闭自动提交功能的命令如下:set autocommit off。
(2) MySQL中,MyISAM数据库引擎不支持事务,所以我们可以在create table语句中指定engine=InnoDB来确保在指定表上应用事务。在MySQL中,我们可以这样开始一个事务:start transaction;
(3) 在PostgreSQL中,autocommit也称作链接模式(Chained Model),这是默认模式,需要使用begin trasaction开启事务。
(4) MS SQL Server也使用与PostgreSQL相同的语句开启事务。
(5) 幻读(Phantom Read):当两个一致的查询在一个事务内执行,第二个查询返回的行集不同于第一个查询返回的行集时,就发生了幻读。(主要与delete和insert操作有关)。
(6) 不可重复读(Nonrepeatable Read):当一个事务中的一个查询从数据库读取数据,但随后同一个事务的第二个操作欲查询相同数据时,却发现数据被另一个已经提交的事务修改,就发生了不可重复读。(和update操作有关)。
(7) 脏读(Dirty Read):除了可能读到第二个事务中未提交的数据外,脏读几乎与不可重复读完全一样。
(8) 如何查询当前数据库事务隔离级别:mysql中我们需要使用下面的语句:select @@tx_isolation。postgresql中则需要输入:show transaction isolation level。ms sql server中则需要输入:set transaction isolation level serializable。Oracle 中则需要使用v$session和v$transaction的组合得到隔离级别。