晓峰.NET

初到博客园不久,对其中文章爱不释手,即拿来收藏.望原者不要见怪.
 
 

Powered by: 博客园
模板提供:沪江博客
博客园 | 首页 | 发新随笔 | 发新文章 | 联系 | 订阅订阅 | 管理

2006年7月26日

聚簇索引与非聚簇索引的区别以及SQL Server查询优化技术(转)
最多只能有一个聚簇索引。

不过这个定义太抽象了。在SQL Server中,索引是通过二叉树的数据结构来描述的,我们可以这么理解聚簇索引:索引的叶节点就是数据节点。而非聚簇索引的叶节点仍然是索引节点,只不过有一个指针指向对应的数据块。如下图:


非聚簇索引

 


聚簇索引


聚簇索引与非聚簇索引的本质区别到底是什么?什么时候用聚簇索引,什么时候用非聚簇索引?

这是一个很复杂的问题,很难用三言两语说清楚。我在这里从SQL Server索引优化查询的角度简单谈谈(如果对这方面感兴趣的话,可以读一读微软出版的《Microsoft SQL Server 2000数据库编程》第3单元的数据结构引论以及第6、13、14单元)。


一、索引块与数据块的区别

大家都知道,索引可以提高检索效率,因为它的二叉树结构以及占用空间小,所以访问速度块。让我们来算一道数学题:如果表中的一条记录在磁盘上占用1000字节的话,我们对其中10字节的一个字段建立索引,那么该记录对应的索引块的大小只有10字节。我们知道,SQL Server的最小空间分配单元是“页(Page)”,一个页在磁盘上占用8K空间,那么这一个页可以存储上述记录8条,但可以存储索引800条。现在我们要从一个有8000条记录的表中检索符合某个条件的记录,如果没有索引的话,我们可能需要遍历8000条×1000字节/8K字节=1000个页面才能够找到结果。如果在检索字段上有上述索引的话,那么我们可以在8000条×10字节/8K字节=10个页面中就检索到满足条件的索引块,然后根据索引块上的指针逐一找到结果数据块,这样IO访问量要少的多。


二、索引优化技术

是不是有索引就一定检索的快呢?答案是否。有些时候用索引还不如不用索引快。比如说我们要检索上述表中的所有记录,如果不用索引,需要访问8000条×1000字节/8K字节=1000个页面,如果使用索引的话,首先检索索引,访问8000条×10字节/8K字节=10个页面得到索引检索结果,再根据索引检索结果去对应数据页面,由于是检索所有数据,所以需要再访问8000条×1000字节/8K字节=1000个页面将全部数据读取出来,一共访问了1010个页面,这显然不如不用索引快。

SQL Server内部有一套完整的数据检索优化技术,在上述情况下,SQL Server的查询计划(Search Plan)会自动使用表扫描的方式检索数据而不会使用任何索引。那么SQL Server是怎么知道什么时候用索引,什么时候不用索引的呢?SQL Server除了日常维护数据信息外,还维护着数据统计信息,下图是数据库属性页面的一个截图:

从图中我们可以看到,SQL Server自动维护统计信息,这些统计信息包括数据密度信息以及数据分布信息,这些信息帮助SQL Server决定如何制定查询计划以及查询是是否使用索引以及使用什么样的索引(这里就不再解释它们到底如何帮助SQL Server建立查询计划的了)。我们还是来做个实验。建立一张表:tabTest(ID, unqValue,intValue),其中ID是整形自动编号主索引,unqValue是uniqueidentifier类型,在上面建立普通索引,intValue 是整形,不建立索引。之所以挂上一个没有索引的intValue字段,就是防止SQL Server使用索引覆盖查询优化技术,这样实验就起不到作用了。向表中录入10000条随机记录,代码如下:

CREATE TABLE [dbo].[tabTest] (
 
[ID] [int] IDENTITY (1, 1) NOT NULL ,
 
[unqValue] [uniqueidentifier] NOT NULL ,
 
[intValue] [int] NOT NULL 
) 
ON [PRIMARY]
GO

ALTER TABLE [dbo].[tabTest] WITH NOCHECK ADD 
 
CONSTRAINT [PK_tabTest] PRIMARY KEY  CLUSTERED 
 (
  
[ID]
 )  
ON [PRIMARY] 
GO

ALTER TABLE [dbo].[tabTest] ADD 
 
CONSTRAINT [DF_tabTest_unqValue] DEFAULT (newid()) FOR [unqValue]
GO

CREATE  INDEX [IX_tabTest_unqValue] ON [dbo].[tabTest]([unqValue]) ON [PRIMARY]
GO

declare @i int
declare @v int

set @i=0
while @i<10000
begin
    
set @v=rand()*1000    
    
insert into tabTest ([intValue]) values (@v)
    
set @i=@i+1
end

然后我们执行两个查询并查看执行计划,如图:(在查询分析器的查询菜单中可以打开查询计划,同时图上第一个查询的GUID是我从数据库中找的,大家做实验的时候可以根据自己数据库中的值来定):



从图中可以看出,在第一个查询中,SQL Server使用了IX_tabTest_unqValue索引,根据箭头方向,计算机先在索引范围内找,找到后,使用Bookmark Lookup将索引节点映射到数据节点上,最后给出SELECT结果。在第二个查询中,系统直接遍历表给出结果,不过它使用了聚簇索引,为什么呢?不要忘了,聚簇索引的页节点就是数据节点!这样使用聚簇索引会更快一些(不受数据删除、更新留下的存储空洞的影响,直接遍历数据是要跳过这些空洞的)。

下面,我们在SQL Server中将ID字段的聚簇索引更改为非聚簇索引,然后再执行select * from tabTest,这回我们看到的执行计划变成了:

SQL Server没有使用任何索引,而是直接执行了Table Scan,因为只有这样,检索效率才是最高的。


三、聚簇索引与非聚簇索引的本质区别

现在可以讨论聚簇索引与非聚簇索引的本质区别了。正如本文最前面的两个图所示,聚簇索引的叶节点就是数据节点,而非聚簇索引的页节点仍然是索引检点,并保留一个链接指向对应数据块。

还是通过一道数学题来看看它们的区别吧:假设有一8000条记录的表,表中每条记录在磁盘上占用1000字节,如果在一个10字节长的字段上建立非聚簇索引主键,需要二叉树节点16000个(这16000个节点中有8000个叶节点,每个页节点都指向一个数据记录),这样数据将占用8000条×1000字节/8K字节=1000个页面;索引将占用16000个节点×10字节/8K字节=20个页面,共计1020个页面。

同样一张表,如果我们在对应字段上建立聚簇索引主键,由于聚簇索引的页节点就是数据节点,所以索引节点仅有8000个,占用10个页面,数据仍然占有1000个页面。

下面我们看看在执行插入操作时,非聚簇索引的主键为什么比聚簇索引主键要快。主键约束要求主键不能出现重复,那么SQL Server是怎么知道不出现重复的呢?唯一的方法就是检索。对于非聚簇索引,只需要检索20个页面中的16000个节点就知道是否有重复,因为所有主键键值在这16000个索引节点中都包含了。但对于聚簇索引,索引节点仅仅包含了8000个中间节点,至于会不会出现重复必须检索另外1000个页数据节点才知道,那么相当于检索10+1000=1010个页面才知道是否有重复。所以聚簇索引主键的插入速度要比非聚簇索引主键的插入速度慢很多。

让我们再来看看数据检索的效率,如果对上述两表进行检索,在使用索引的情况下(有些时候SQL Server执行计划会选择不使用索引,不过我们这里姑且假设一定使用索引),对于聚簇索引检索,我们可能会访问10个索引页面外加1000个数据页面得到结果(实际情况要比这个好),而对于非聚簇索引,系统会从20个页面中找到符合条件的节点,再映射到1000个数据页面上(这也是最糟糕的情况),比较一下,一个访问了1010个页面而另一个访问了1020个页面,可见检索效率差异并不是很大。所以不管非聚簇索引也好还是聚簇索引也好,都适合排序,聚簇索引仅仅比非聚簇索引快一点。

posted @ 2006-07-26 17:00 晓峰 阅读(283) 评论(0) 编辑
 
并发操作的一致性问题

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命令执行效率低下。所以要慎用时间戳和过多的保护字段作为更新依据。
posted @ 2006-07-26 15:42 晓峰 阅读(516) 评论(2) 编辑
 
spring.net-----(5)

六、利用Ioc在不修改任何原有代码的情况下实现Remoting

上文我们提到,为了实现对HelloGenerator.dll的分布式调用,我们不得不修改了原有程序的多处代码。那么有没有可能在不动任何原有代码的情况下,单纯靠添加组件、修改配置文件实现远程访问呢?当然可以。这次我们还是使用Spring.net完成这个工作。 经过调整后的系统组件构成如下图所示:

该方案没有修改“src\Step3”中的任何代码,仅仅通过修改配置文件和添加了若干个组件就实现了远程访问。修改方案如下:

(1)使用Proxy模式代理原有HelloGenerator

如果要让某个对象具有分布式的功能,必须使其继承自MarshalByRefObject。但是由于不能修改任何原有代码,所以这次我们只能绕道而行, 借助Proxy模式代理原有的HelloGenerator。在RemotingServer项目中,我们定义了一个新类HelloGeneratorProxy继承自MarshalByRefObject,通过委派的方式对原有的HelloGenerator进行调用,代码如下:

using System;
namespace IocInCSharp
{
public class HelloGeneratorProxy : MarshalByRefObject, IHelloGenerator
{
private IHelloGenerator _helloGen;
public IHelloGenerator HelloGenerator
{
get { return _helloGen; }
set { _helloGen = value; }
}
public string GetHelloString(string name)
{
if(_helloGen != null)
return _helloGen.GetHelloString(name);
return null;
}
}
}

仔细观察,我们会发现HelloGeneratorProxy持有一个对IHelloGenerator的引用,该属性是可以Set的,因此我们可以借助Ioc的威力,通过调整Sping.net的配置文件动态决定远程服务器究竟发布EnHelloGenerator还是CnHelloGenerator。

(2)发布HelloGeneratorProxy

通过RemotingServer.exe,我们将HelloGeneratorProxy发布出去,客户端实际上调用的是Proxy对象(不用担心,由于“针对接口编程”,客户端只知道它是IHelloGenerator类型对象)。服务器端代码如下:

using System;
using System.Configuration;
using System.Collections;
using System.Runtime.Remoting;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Channels.Http;
using System.Runtime.Serialization.Formatters;
using Spring.Context;
namespace IocInCSharp
{
public class Server
{
public static void Main()
{
int port = Convert.ToInt32(ConfigurationSettings.AppSettings["LocalServerPort"]);
try
{
BinaryServerFormatterSinkProvider serverProvider = new BinaryServerFormatterSinkProvider();
BinaryClientFormatterSinkProvider clientProvider = new BinaryClientFormatterSinkProvider();
serverProvider.TypeFilterLevel = TypeFilterLevel.Full;
IDictionary props = new Hashtable();
props["port"] = port;
props["timeout"] = 2000;
HttpChannel channel = new HttpChannel(props, clientProvider, serverProvider);
ChannelServices.RegisterChannel(channel);
IApplicationContext ctx = ConfigurationSettings.GetConfig("spring/context") as IApplicationContext;
HelloGeneratorProxy proxy = (HelloGeneratorProxy)ctx.GetObject("myHelloGeneratorProxy");
RemotingServices.Marshal(proxy, "HelloGenerator.soap");
Console.WriteLine("Server started!\r\nPress ENTER key to stop the server...");
Console.ReadLine();
}
catch
{
Console.WriteLine("Server Start Error!");
}
}
}
}

注意其中的几条命令:

IApplicationContext ctx = ConfigurationSettings.GetConfig("spring/context") as IApplicationContext;
HelloGeneratorProxy proxy = (HelloGeneratorProxy)ctx.GetObject("myHelloGeneratorProxy");
RemotingServices.Marshal(proxy, "HelloGenerator.soap");

我们使用Ioc向HelloGeneratorProxy注入具体的HelloGenerator对象,并通过RemotingServices.Marshal(proxy, "HelloGenerator.soap")命令将该实例发布出去。服务器端的配置文件如下:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<sectionGroup name="spring">
<section name="context" type="Spring.Context.Support.ContextHandler, Spring.Core" />
<section name="objects" type="Spring.Context.Support.DefaultSectionHandler, Spring.Core" />
</sectionGroup>
</configSections>
<spring>
<context>
<resource uri="config://spring/objects" />
</context>
<objects xmlns="http://www.springframework.net">
<object id="myHelloGeneratorProxy" type="IocInCSharp.HelloGeneratorProxy, RemotingServer">
<property name="HelloGenerator">
<ref object="myCnHelloGenerator" />
</property>
</object>
<object id="myEnHelloGenerator" type="IocInCSharp.EnHelloGenerator, HelloGenerator" />
<object id="myCnHelloGenerator" type="IocInCSharp.CnHelloGenerator, HelloGenerator" />
</objects>
</spring>
<appSettings>
<add key="LocalServerPort" value="8100" />
</appSettings>
</configuration>

用户可以尝试将配置文件中<ref object="myCnHelloGenerator" />更改为<ref object="myEnHelloGenerator" />,重新启动服务后看看客户端调用结果是什么?

(3)客户端实现技术-1

客户端实现起来要麻烦一些。由于不允许修改MainApp中的任何代码,我们必须能够在合适的时机拦截代码运行并创建远程连接,同时确保在Ioc注入时注入的是远程对象。所有这些工作Sping.net都考虑的很周到。它提供了depends-on属性,允许在执行某一操作前强制执行某段代码。在客户端的配置文件中,我们可以看到如下的配置选项:

         <object id="mySayHello" type="Spring.Aop.Framework.ProxyFactoryObject" depends-on="force-init">
.........
<object id="force-init" type="Spring.Objects.Factory.Config.MethodInvokingFactoryObject, Spring.Core">
<property name="TargetType" value="IocInCSharp.ForceInit, ForceInit" />
<property name="TargetMethod" value="Init" />
</object>

这表示,当我们初始化mySayHello时,要先去调用ForceInit.dll文件中ForceInit类的Init方法。ForceInit是一个新编写的类,其主要目的就是创建并注册一个用于远程通讯的Channel。代码实现如下:

using System;
using System.Collections;
using System.Runtime.Remoting;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Channels.Http;
using System.Runtime.Serialization.Formatters;
namespace IocInCSharp
{
public class ForceInit
{
public static void Init()
{
//建立连接
BinaryServerFormatterSinkProvider serverProvider = new BinaryServerFormatterSinkProvider();
BinaryClientFormatterSinkProvider clientProvider = new BinaryClientFormatterSinkProvider();
serverProvider.TypeFilterLevel = TypeFilterLevel.Full;
IDictionary props = new Hashtable();
props["port"] = 8199;
props["name"] = "myHttp";
HttpChannel channel = new HttpChannel(props, clientProvider, serverProvider);
//获得当前已注册的通道;
IChannel[] channels = ChannelServices.RegisteredChannels;
//关闭指定名为MyHttp的通道;
foreach (IChannel eachChannel in channels)
if (eachChannel.ChannelName == "myHttp")
ChannelServices.UnregisterChannel(eachChannel);
ChannelServices.RegisterChannel(channel);
}
}
}

(4)客户端实现技术-2

剩下的工作就是为mySayHello的HelloGenerator注入远程对象。通常情况下我们需要使用Activator.GetObject方法调用远程对象,不过Spring.net已经将其封装起来,我们只需修改一下配置文件,就可以确保调用到远程对象。配置文件对应部分如下:

         <object id="mySayHello" type="Spring.Aop.Framework.ProxyFactoryObject" depends-on="force-init">
<property name="target">
<object id="myLocalSayHello" type="IocInCSharp.SayHello, SayHello">
<property name="HelloGenerator">
<ref object="myHelloGenerator" />
</property>
</object>
</property>
......
</object>
<object name="myHelloGenerator" type="Spring.Remoting.SaoFactoryObject, Spring.Services">
<property name="ServiceInterface">
<value>IocInCSharp.IHelloGenerator, ICommon</value>
</property>
<property name="ServiceUrl">
<value>http://127.0.0.1:8100/HelloGenerator.soap</value>
</property>
</object>

借助Spring.Remoting.SaoFactoryObject,我们轻松实现了远程对象访问,不必书写一行代码。(目前SAO在Spring.net的实现尚不完整,按照Spring.net帮助手册上的做法,通过配置文件只能实现客户端访问远程对象,还做不到服务器端发布远程对象)

(5)使用AOP拦截调用

Sping.net目前已经实现AOP功能,我们可以很容易的对方法进行拦截和调用。需要做的工作就是设计相应的Interceptor,然后修改配置文件。目前Sping.net使用的AOP功能是AopAlliance的实现,因此代码编写时命名空间引用让人感觉多少有些别扭,不是以Sping开头。我编写的MethodInterceptor代码如下:

using System;
using AopAlliance.Intercept;
namespace IocInCSharp
{
class MethodInterceptor : IMethodInterceptor
{
public object Invoke(IMethodInvocation invocation)
{
Console.WriteLine("Before Method Call...");
object returnValue = invocation.Proceed();
Console.WriteLine("After Method Call...");
return returnValue;
}
}
}

在方法调用前打印"Before Method Call...",在方法调用后打印"After Method Call..."。剩下的工作就是修改配置文件,将其应用到相应的方法上。配置文件片断如下:

         <object id="MethodAdvice" type="Spring.Aop.Support.RegexpMethodPointcutAdvisor">
<property name="pattern" value="SayHelloTo" />
<property name="advice">
<object type="IocInCSharp.MethodInterceptor, MethodInterceptor" />
</property>
</object>
<object id="mySayHello" type="Spring.Aop.Framework.ProxyFactoryObject" depends-on="force-init">
......
<property name="interceptorNames">
<list>
<value>MethodAdvice</value>
</list>
</property>
</object>

通过以上操作,我们在没有修改任何原有代码的情况下,让原有系统实现了远程分布式访问。

请大家访问示例代码的“bin\Step5"目录,下面有3个子目录:Server、Client、WithoutRemoting。首先运行Server目录下的RemotingServer.exe,然后运行Client目录下的MainApp.exe进行远程调用。系统通过Remoting完成远程调用。关闭所有程序后,进入到WithoutRemoting目录,里面有个Readme.txt文件,按照操作步骤将文件:

..\Server\HelloGenerator.dll
..\Client\MainApp.exe
..\Client\ICommon.dll
..\Client\SayHello.dll
..\Client\Spring.Core.dll
..\Client\log4net.dll
 

拷贝到该目录,再次运行MainApp.exe,你会发现它是一个地地道道的本地应用程序!本地与远程唯一的区别就是配置文件的不同以及增加了几个其它的DLL。这正式我们这个示例的价值体现。

到此为止,我们完成了对Ioc应用的一系列模拟。Ioc写得多一些,AOP写得少了点。欢迎大家批评指正。

posted @ 2006-07-26 15:39 晓峰 阅读(869) 评论(1) 编辑