2.2 SQL Server 2000+ADO.NET实现并发控制

2.2.1 并发一致性问题

常见并发并发一致性问题包括:丢失的修改、不可重复读、读脏数据、幻影读(幻影读在一些资料中往往与不可重复读归为一类)。

2.2.1.1 丢失修改

下面我们先来看一个例子,说明并发操作带来的数据的不一致性问题。

考虑飞机订票系统中的一个活动序列:

  1. 甲售票点(甲事务)读出某航班的机票余额A,设A=16.
  2. 乙售票点(乙事务)读出同一航班的机票余额A,也为16.
  3. 甲售票点卖出一张机票,修改余额A←A-1.所以A为15,把A写回数据库.
  4. 乙售票点也卖出一张机票,修改余额A←A-1.所以A为15,把A写回数据库.

结果明明卖出两张机票,数据库中机票余额只减少1。

归纳起来就是:两个事务T1和T2读入同一数据并修改,T2提交的结果破坏了T1提交的结果,导致T1的修改被丢失。前文(2.1.4数据删除与更新)中提到的问题及解决办法往往是针对此类并发问题的。但仍然有几类问题通过上面的方法解决不了,那就是:

2.2.1.2 不可重复读

不可重复读是指事务T1读取数据后,事务T2执行更新操作,使T1无法再现前一次读取结果。具体地讲,不可重复读包括三种情况:

  • 事务T1读取某一数据后,事务T2对其做了修改,当事务1再次读该数据时,得到与前一次不同的值。例如,T1读取B=100进行运算,T2读取同一数据B,对其进行修改后将B=200写回数据库。T1为了对读取值校对重读B,B已为200,与第一次读取值不一致。
  • 事务T1按一定条件从数据库中读取了某些数据记录后,事务T2删除了其中部分记录,当T1再次按相同条件读取数据时,发现某些记录神密地消失了。
  • 事务T1按一定条件从数据库中读取某些数据记录后,事务T2插入了一些记录,当T1再次按相同条件读取数据时,发现多了一些记录。(这也叫做幻影读)

2.2.1.3 读"脏"数据

读"脏"数据是指事务T1修改某一数据,并将其写回磁盘,事务T2读取同一数据后,T1由于某种原因被撤消,这时T1已修改过的数据恢复原值,T2读到的数据就与数据库中的数据不一致,则T2读到的数据就为"脏"数据,即不正确的数据。

产生上述三类数据不一致性的主要原因是并发操作破坏了事务的隔离性。并发控制就是要用正确的方式调度并发操作,使一个用户事务的执行不受其它事务的干扰,从而避免造成数据的不一致性。

2.2.2 并发一致性问题的解决办法

2.2.2.1 封锁(Locking)

封锁是实现并发控制的一个非常重要的技术。所谓封锁就是事务T在对某个数据对象例如表、记录等操作之前,先向系统发出请求,对其加锁。加锁后事务T就对该数据对象有了一定的控制,在事务T释放它的锁之前,其它的事务不能更新此数据对象。

基本的封锁类型有两种:排它锁(Exclusive locks 简记为X锁)和共享锁(Share locks 简记为S锁)。

排它锁又称为写锁。若事务T对数据对象A加上X锁,则只允许T读取和修改A,其它任何事务都不能再对A加任何类型的锁,直到T释放A上的锁。这就保证了其它事务在T释放A上的锁之前不能再读取和修改A。

共享锁又称为读锁。若事务T对数据对象A加上S锁,则其它事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这就保证了其它事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。

2.2.2.2 封锁协议

在运用X锁和S锁这两种基本封锁,对数据对象加锁时,还需要约定一些规则,例如应何时申请X锁或S锁、持锁时间、何时释放等。我们称这些规则为封锁协议(Locking Protocol)。对封锁方式规定不同的规则,就形成了各种不同的封锁协议。下面介绍三级封锁协议。三级封锁协议分别在不同程度上解决了丢失的修改、不可重复读和读"脏"数据等不一致性问题,为并发操作的正确调度提供一定的保证。下面只给出三级封锁协议的定义,不再做过多探讨。

  • 1级封锁协议

1级封锁协议是:事务T在修改数据R之前必须先对其加X锁,直到事务结束才释放。事务结束包括正常结束(COMMIT)和非正常结束(ROLLBACK)。1级封锁协议可防止丢失修改,并保证事务T是可恢复的。在1级封锁协议中,如果仅仅是读数据不对其进行修改,是不需要加锁的,所以它不能保证可重复读和不读"脏"数据。

  • 2级封锁协议

2级封锁协议是:1级封锁协议加上事务T在读取数据R之前必须先对其加S锁,读完后即可释放S锁。2级封锁协议除防止了丢失修改,还可进一步防止读"脏"数据。

  • 3级封锁协议

3级封锁协议是:1级封锁协议加上事务T在读取数据R之前必须先对其加S锁,直到事务结束才释放。3级封锁协议除防止了丢失修改和不读'脏'数据外,还进一步防止了不可重复读。

2.2.3 事务隔离级别

尽管数据库理论对并发一致性问题提供了完善的解决机制,但让程序员自己去控制如何加锁以及加锁、解锁的时机显然是很困难的事情。索性绝大多数数据库以及开发工具都提供了事务隔离级别,让用户以一种更轻松的方式处理并发一致性问题。常见的事务隔离级别包括:ReadUnCommitted、ReadCommitted、RepeatableRead和Serializable四种。不同的隔离级别下对数据库的访问方式以及数据库的返回结果有可能是不同的。我们将通过几个实验深入了解事务隔离级别以及SQL Server在后台是如何将它们转换成锁的。

2.2.3.1 ReadUnCommitted与ReadCommitted

ReadUnCommitted是最低的隔离级别,这个级别的隔离允许读入别人尚未提交的脏数据,除此之外,在这种事务隔离级别下还存在不可重复读的问题。

ReadCommitted是许多数据库的缺省级别,这个隔离级别上,不会出现读取未提交的数据问题,但仍然无法避免不可重复读(包括幻影读)的问题。当你的系统对并发控制的要求非常严格时,这种默认的隔离级别可能无法提供数据有效的保护,但对于决大多数应用来讲,这种隔离级别就够用了。

我们使用下面的实验来进行测试:

首先配置SQL Server 2000数据库,附加DBApp数据库。然后在Visual Studio .net中建立一管理控制台应用程序,添加必要的命名空间引用:

using System;
using System.Data;
using System.Data.SqlClient;
using System.Configuration;

然后建立两个数据库链接,并分别采用不同的事务隔离级别:

   private static SqlConnection conn1;
private static SqlConnection conn2;
private static SqlTransaction tx1;
private static SqlTransaction tx2;
private static void Setup()
{
conn1 = new SqlConnection(connectionString);
conn1.Open();
tx1 = conn1.BeginTransaction(IsolationLevel.ReadUncommitted);
conn2 = new SqlConnection(connectionString);
conn2.Open();
tx2 = conn2.BeginTransaction(IsolationLevel.ReadCommitted);
}

其中事务1允许读入未提交的数据,而事务2只允许读入已提交数据。

在主程序中,我们模拟两个人先后的不同操作,以产生并发一致性问题:

   public static void Main()
{
Setup();
try
{
ReadUnCommittedDataByTransaction1();
UnCommittedUpdateByTransaction2();
ReadUnCommittedDataByTransaction1();
tx2.Rollback();
Console.WriteLine("\n-- Transaction 2 rollbacked!\n");
ReadUnCommittedDataByTransaction1();
tx1.Rollback();
}
catch
{
……
}
}

第一步,使用ReadUnCommittedDataByTransaction1方法利用事务1从数据库中读入id值为1的学生信息。此时的信息是数据库的初始信息。

第二步,调用UnCommittedUpdateByTransaction2方法,从第2个事务中发送一UPDATE命令更新数据库,但尚未提交。

第三步,再次调用ReadUnCommittedDataByTransaction1,从事务1中读取数据库数据,你会发现由事务2发布的尚未提交的更新被事务1读取出来(ReadUnCommitted)。

第四步,事务2放弃提交,回滚事务tx2.Rollback();。

第五步,再次调用ReadUnCommittedDataByTransaction1();,读取数据库中的数据,此次是已经回滚后的数据。

程序运行结果如下:

-- Read age from database:
Age:20
-- Run an uncommitted command:
UPDATE student SET age=30 WHERE id=1
-- Read age from database:
Age:30
-- Transaction 2 rollbacked!
-- Read age from database:
Age:20

关于ReadUnCommittedDataByTransaction1()与UnCommittedUpdateByTransaction2()的方法定义如下:

   private static void UnCommittedUpdateByTransaction2()
{
string command = "UPDATE student SET age=30 WHERE id=1";
Console.WriteLine("\n-- Run an uncommitted command:\n{0}\n", command);
SqlCommand cmd = new SqlCommand(command, conn2);
cmd.Transaction = tx2;
cmd.ExecuteNonQuery();
}
private static void ReadUnCommittedDataByTransaction1()
{
Console.WriteLine("-- Read age from database:");
SqlCommand cmd = new SqlCommand("SELECT age FROM student WHERE id = 1", conn1);
cmd.Transaction = tx1;
try
{
int age = (int)cmd.ExecuteScalar();
Console.WriteLine("Age:{0}", age);
}
catch(SqlException e)
{
Console.WriteLine(e.Message);
}
}

从上面的实验可以看出,在ReadUnCommitted隔离级别下,程序可能读入未提交的数据,但此隔离级别对数据库资源锁定最少。

本实验的完整代码可以从"SampleCode\Chapter 2\Lab 2-6"下找到。

让我们再来做一个实验(这个实验要求动作要快的,否则可能看不到预期效果)。首先修改上面代码中的Setup()方法代码,将

tx1 = conn1.BeginTransaction(IsolationLevel.ReadUncommitted);

改为:

tx1 = conn1.BeginTransaction(IsolationLevel.ReadCommitted);

再次运行代码,你会发现程序执行到第三步就不动了,如果你有足够的耐心等下去的话,你会看到"超时时间已到。在操作完成之前超时时间已过或服务器未响应。"的一条提示,这条提示究竟是什么意思呢?让我们探察一下究竟发生了什么:

第一步,在做这个实验之前,先将SQL Server 2000的企业管理器打开,然后再将SQL Server事件探察器打开并处于探察状态。

第二步,运行改动后的程序,程序执行到一半就暂停了。此时迅速切换到企业管理器界面,右击"管理"下面的"当前活动",选择"刷新"(整个过程应在大约15秒内完成即可,如图 2-8所示),我们便得到了数据库当前进程的一个快照。

 

图 2-8 使用企业管理器查看当前活动

我们发现此时进程出现了阻塞,被阻塞者是52号进程,而阻塞者是53号进程。也就是说53号进程的工作妨碍了52号进程继续工作。(不同实验时进程号可能各不相同)

第三步,为了进一步查明原因真相,我们切换到事件探察器窗口,看看这两个进程都是干什么的。如图 2-9所示,事件探察器显示了这两个进程的详细信息。从图中我们可以看出,52号进程对应我们的事务1,53号进程对应我们的事务2。事务2执行了UPDATE命令,但尚未提交,此时事务1去读尚未提交的数据便被阻塞住。从图中我们可以看出52号进程是被阻塞者。

此时如果事务2完成提交,52号进程便可以停止等待,得到需要的结果。然而我们的程序没有提交数据,因此52号进程就要无限等下去。所幸SQL Server 2000检测到事务2的运行时间过长(这就是上面的错误提示"超时时间已到。在操作完成之前超时时间已过或服务器未响应。"),所以将事务2回滚以释放占用的资源。资源被释放后,52号进程便得以执行。

 

图 2-9 事件探察器探察阻塞命令

第四步,了解了上面发生的事情后,我们现在可以深入讨论一下共享锁和排它锁的使用情况了。重新回到企业管理器界面,让我们查看一下两个进程各占用了什么资源。从图 2-10中我们可以看出,53号进程(事务2)在执行更新命令前对相应的键加上了排它锁(X锁),按照前文提到的1级封锁协议,该排它锁只有在事务2提交或回滚后才释放。现在52号进程(事务1)要去读同一行数据,按照2级封锁协议,它要首先对该行加共享锁,然而 该行数据已经被事务2加上了排它锁,因此事务1只能处于等待状态,等待排它锁被释放。因此我们就看到了前面的"阻塞"问题。

 

图 2-10 进程执行写操作前首先加了排它锁

 

 

图 2-11 进程读操作前要加共享锁,但被阻塞

 

当事务1的事务隔离级别是ReadUnCommitted时,读数据是不加锁的,因此排它锁对ReadUnCommitted不起作用,进程也不会被阻塞,不过确读到了"脏"数据。

2.2.3.2 RepeatableRead

RepeatableRead是指可重复读,它的隔离级别要比ReadCommitted级别高。它允许某事务执行重复读时数据保持不变,但是仍然无法解决幻影读的问题。为了更深入的了解RepeatableRead所能解决的问题,我们还是使用下面的实验来加以印证:

第一步,事务1与事务2同时设置为ReadCommitted,并同时开启事务。

 

private static void Setup()
{
conn1 = new SqlConnection(connectionString);
conn1.Open();
tx1 = conn1.BeginTransaction(IsolationLevel.ReadCommitted);
conn2 = new SqlConnection(connectionString);
conn2.Open();
tx2 = conn2.BeginTransaction(IsolationLevel.ReadCommitted);
}

第二步,事务1读取数据库中数据。注意此时并没有通过提交或回滚的方式结束事务1,事务1仍然处于活动状态。

private static int ReadAgeByTransaction1()
{
return (int)ExecuteScalar("SELECT age FROM student WHERE (id = 1)");
}
private static object ExecuteScalar(string command)
{
Console.WriteLine("-- Execute command: {0}", command);
SqlCommand cmd = new SqlCommand(command, conn1);
cmd.Transaction = tx1;
return cmd.ExecuteScalar();
}

第三步,事务2修改年龄数据并提交修改。

private static void ModifyAgeByTransaction2()
{
string command = "UPDATE student SET age=30 WHERE id=1";
Console.WriteLine("-- Modify age by transaction2, command:{0}", command);
SqlCommand cmd = new SqlCommand(command, conn2);
cmd.Transaction = tx2;
try
{
cmd.ExecuteNonQuery();
tx2.Commit();
}
catch(Exception e)
{
Console.WriteLine(e.Message);
tx2.Rollback();
}
}

第四步,事务1重复读取年龄数据,此时会发现读取出来的数据是修改过的数据,与上次读取的数据不一样了!顾名思义,不可重复读。主程序代码如下:

public static void Main()
{
Setup();
try
{
int age1 = ReadAgeByTransaction1();
ModifyAgeByTransaction2();
int age2 = ReadAgeByTransaction1();
Console.WriteLine("\nFirst Read: age={0}\nSecond Read: age={1}", age1, age2);
}
catch(Exception e)
{
Console.WriteLine("Got an error! " + e.Message);
}
finally
{
CleanUp();
}
}

程序的运行结果如下:

-- Execute command: SELECT age FROM student WHERE (id = 1)
-- Modify age by transaction2, command:UPDATE student SET age=30 WHERE id=1
-- Execute command: SELECT age FROM student WHERE (id = 1)
First Read: age=20
Second Read: age=30

之所以出现了重复读时读取的数据与第一次读取的不一样,是因为事务1被设置成了ReadCommitted隔离类型,该隔离级别无法防止不可重复读的问题。要想在一个事务中两次读取数据完全相同就必须使用RepeatableRead事务隔离级别。

让我们修改上面的Setup()方法中的代码,将事务1的隔离级别设置为RepeatableRead:

tx1 = conn1.BeginTransaction(IsolationLevel.RepeatableRead);

再次运行该程序,你会发现程序执行到第二步就暂停了,如果等待一段时间后你就会看到"超时时间已到。在操作完成之前超时时间已过或服务器未响应。"的错误提示,此时,重复读的数据确和第一次读完全一样。程序执行结果如下:

-- Execute command: SELECT age FROM student WHERE (id = 1)
-- Modify age by transaction2, command:UPDATE student SET age=30 WHERE id=1
超时时间已到。在操作完成之前超时时间已过或服务器未响应。
-- Execute command: SELECT age FROM student WHERE (id = 1)
First Read: age=20
Second Read: age=20

为了探明原因,还是象上一个案例一样,再次执行该程序,当出现暂停时迅速切换到企业管理器中查看当前活动的快照,并检查阻塞进程中数据锁定情况,你会发现如图 2-12和图 2-13所示的内容:

 

图 2-12 RepeatableRead在读数据时加S锁,直到事务结束才释放

 

图 2-13 修改数据要求加X锁,但被阻塞

根据3级封锁协议,事务T在读取数据之前必须先对其加S锁,直到事务结束才释放。因此,事务1在第一次读取数据时便对数据加上了共享锁,第一次数据读取完成后事务并未结束,因此该共享锁并不会被释放,此时事务2试图修改该数据,按照2级封锁协议,在写之前要加排它锁,但数据上的共享锁尚未被释放,导致事务2不得不处于等待状态。当事务2等待时间超时后,SQL Server就强制将该事务回滚。尽管事务2执行失败,但保证了事务1实现了可重复读级别的事务隔离。

RepeatableRead事务隔离级别允许事务内的重复读操作,但是这并不能避免出现幻影读的问题,如果您的程序中存在幻影读的潜在问题的话,就必须采用最高的事务隔离级别:Serializable。

2.2.3.3 Serializable

Serializable隔离级别是最高的事务隔离级别,在此隔离级别下,不会出现读脏数据、不可重复读和幻影读的问题。在详细说明为什么之前首先让我们看看什么是幻影读。

所谓幻影读是指:事务1按一定条件从数据库中读取某些数据记录后,事务2插入了一些符合事务1检索条件的新记录,当事务1再次按相同条件读取数据时,发现多了一些记录。让我们通过以下案例来重现幻影读的问题:

第一步,将事务1和事务2均设为RepeatableRead隔离级别,并同时开启事务。

private static void Setup()
{
conn1 = new SqlConnection(connectionString);
conn1.Open();
tx1 = conn1.BeginTransaction(IsolationLevel.RepeatableRead);
conn2 = new SqlConnection(connectionString);
conn2.Open();
tx2 = conn2.BeginTransaction(IsolationLevel.RepeatableRead);
}

第二步,事务1读取学号为1的学生的平均成绩以及所学课程的门数。此时读到学生1学了3门课程,平均成绩为73.67。注意,此时事务1并未提交。

private static double ReadAverageMarksByTransaction1()
{
return (double)ExecuteScalar("SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)");
}
private static int ReadTotalCoursesByTransaction1()
{
return (int)ExecuteScalar("SELECT COUNT(*) AS num FROM SC WHERE (id = 1)");
}
private static object ExecuteScalar(string command)
{
Console.WriteLine("-- Execute command: {0}", command);
SqlCommand cmd = new SqlCommand(command, conn1);
cmd.Transaction = tx1;
return cmd.ExecuteScalar();
}

第三步,事务2向数据库插入一条新记录,让学号为1的同学再学1门课程,成绩是80。然后提交修改到数据库。

private static void InsertRecordByTransaction2()
{
string command = "INSERT INTO SC VALUES(1, 5, 80)";
Console.WriteLine("-- Insert to table SC by transaction 2");
Console.WriteLine("-- Command:{0}\n", command);
SqlCommand cmd = new SqlCommand(command, conn2);
cmd.Transaction = tx2;
try
{
cmd.ExecuteNonQuery();
tx2.Commit();
}
catch(Exception e)
{
Console.WriteLine(e.Message);
tx2.Rollback();
}
}

第四步,事务1再次读取学号为1的学生的平均成绩以及所学课程的门数。此时读到确是4门课程,平均成绩为75.25。与第一次读取的不一样!居然多出了一门课程,多出的这门课程就像幻影一样出现在我们的面前。测试用主程序如下:

public static void Main()
{
Setup();
try
{
Console.WriteLine(">>>> Step 1");
double avg = ReadAverageMarksByTransaction1();
int total = ReadTotalCoursesByTransaction1();
Console.WriteLine("avg={0,5:F2}, total={1}\n", avg, total);
Console.WriteLine(">>>> Step 2");
InsertRecordByTransaction2();
Console.WriteLine(">>>> Step 3");
avg = ReadAverageMarksByTransaction1();
total = ReadTotalCoursesByTransaction1();
Console.WriteLine("avg={0,5:F2}, total={1}\n", avg, total);
}
catch(Exception e)
{
Console.WriteLine("Got an error! " + e.Message);
}
finally
{
CleanUp();
}
}

程序执行结果如下:

>>>> Step 1
-- Execute command: SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)
-- Execute command: SELECT COUNT(*) AS num FROM SC WHERE (id = 1)
avg=73.67, total=3
>>>> Step 2
-- Insert to table SC by transaction 2
-- Command:INSERT INTO SC VALUES(1, 5, 80)
>>>> Step 3
-- Execute command: SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)
-- Execute command: SELECT COUNT(*) AS num FROM SC WHERE (id = 1)
avg=75.25, total=4

大家可以思考一下,为什么RepeatableRead隔离模式并不能使得两次读取的平均值一样呢?(可以从锁的角度来解释这一现象)。

仍然象前面的做法一样,我们看看究竟发生了什么事情。在探察之前,先将Setup方法中事务1的隔离级别设置为Serializable,再次运行程序,当发现程序运行暂停时,查看数据库当前活动快照,你会发现如图 2-14和图 2-15所示的锁定问题:

 

图 2-14 Serializable隔离模式对符合检索条件的数据添加了RangeS-S锁

 

图 2-15 当试图插入符合RangeIn条件的记录时,只能处于等待状态

从图中我们可以看出,在Serializalbe隔离模式下,数据库在检索数据时,对所有满足检索条件的记录均加上了RangeS-S共享锁。事务2试图去插入一满足RangeIn条件的记录时,必须等待这些RangS-S锁释放,否则就只能处于等待状态。在等待超时后,事务2就会被SQL Server强制回滚。

修改后的程序运行结果如下:

>>>> Step 1
-- Execute command: SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)
-- Execute command: SELECT COUNT(*) AS num FROM SC WHERE (id = 1)
avg=73.67, total=3
>>>> Step 2
-- Insert to table SC by transaction 2
-- Command:INSERT INTO SC VALUES(1, 5, 80)
超时时间已到。在操作完成之前超时时间已过或服务器未响应。
>>>> Step 3
-- Execute command: SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)
-- Execute command: SELECT COUNT(*) AS num FROM SC WHERE (id = 1)
avg=73.67, total=3

事务2的运行失败确保了事务1不会出现幻影读的问题。这里应当注意的是,1、2、3级封锁协议都不能保证有效解决幻影读的问题。

2.3 建议

通过上面的几个例子,我们更深入的了解了数据库在解决并发一致性问题时所采取的措施。锁机制属于最底层的保证机制,但很难直接使用。我们可以通过不同的事务隔离模式来间接利用锁定机制确保我们数据的完整一致性。在使用不同级别的隔离模式时,我们也应当注意以下一些问题:

  • 一般情况下ReadCommitted隔离级别就足够了。过高的隔离级别将会锁定过多的资源,影响数据的共享效率。
  • 你所选择的隔离级别依赖于你的系统和商务逻辑。
  • 尽量避免直接使用锁,除非在万不得已的情况下。
  • 我们可以通过控制WHERE短语中的字段实现不同的更新策略,防止出现丢失的修改问题。但不必要的更新策略可能造成SQL命令执行效率低下。所以要慎用时间戳和过多的保护字段作为更新依据。