C#数据库事务原理及实践(下)
另一个走向极端的错误
满怀信心的新手们可能为自己所掌握的部分知识陶醉不已,刚接触数据库库事务处理的准开发者们也一样,踌躇满志地准备将事务机制应用到他的数据处理程序的每一个模块每一条语句中去。的确,事务机制看起来是如此的诱人——简洁、美妙而又实用,我当然想用它来避免一切可能出现的错误——我甚至想用事务把我的数据操作从头到尾包裹起来。
看着吧,下面我要从创建一个数据库开始:
using System; using System.Data; using System.Data.SqlClient; namespace Aspcn { public class DbTran { file://执行事务处理 public void DoTran() { file://建立连接并打开 SqlConnection myConn=GetConn(); myConn.Open(); SqlCommand myComm=new SqlCommand(); SqlTransaction myTran; myTran=myConn.BeginTransaction(); file://下面绑定连接和事务对象 myComm.Connection=myConn; myComm.Transaction=myTran; file://试图创建数据库TestDB myComm.CommandText="CREATE database TestDB"; myComm.ExecuteNonQuery(); file://提交事务 myTran.Commit(); } file://获取数据连接 private SqlConnection GetConn() { string strSql="Data Source=localhost;Integrated Security=SSPI;user id=sa;password="; SqlConnection myConn=new SqlConnection(strSql); return myConn; } } public class Test { public static void Main() { DbTran tranTest=new DbTran(); tranTest.DoTran(); Console.WriteLine("事务处理已经成功完成。"); Console.ReadLine(); } } } //--------------- |
未处理的异常: System.Data.SqlClient.SqlException: 在多语句事务内不允许使用 CREATE DATABASE 语句。
at System.Data.SqlClient.SqlCommand.ExecuteNonQuery() at Aspcn.DbTran.DoTran() at Aspcn.Test.Main() |
注意,如下的SQL语句不允许出现在事务中:
ALTER DATABASE | 修改数据库 |
BACKUP LOG | 备份日志 |
CREATE DATABASE | 创建数据库 |
DISK INIT | 创建数据库或事务日志设备 |
DROP DATABASE | 删除数据库 |
DUMP TRANSACTION | 转储事务日志 |
LOAD DATABASE | 装载数据库备份复本 |
LOAD TRANSACTION | 装载事务日志备份复本 |
RECONFIGURE | 更新使用 sp_configure 系统存储过程更改的配置选项的当前配置(sp_configure 结果集中的 config_value 列)值。 |
RESTORE DATABASE | 还原使用BACKUP命令所作的数据库备份 |
RESTORE LOG | 还原使用BACKUP命令所作的日志备份 |
UPDATE STATISTICS | 在指定的表或索引视图中,对一个或多个统计组(集合)有关键值分发的信息进行更新 |
除了这些语句以外,你可以在你的数据库事务中使用任何合法的SQL语句。
事务回滚
事务的四个特性之一是原子性,其含义是指对于特定操作序列组成的事务,要么全部完成,要么就一件也不做。如果在事务处理的过程中,发生未知的不可预料的错误,如何保证事务的原子性呢?当事务中止时,必须执行回滚操作,以便消除已经执行的操作对数据库的影响。
一般的情况下,在异常处理中使用回滚动作是比较好的想法。前面,我们已经得到了一个更新数据库的程序,并且验证了它的正确性,稍微修改一下,可以得到:
//RollBack.cs using System; using System.Data; using System.Data.SqlClient; namespace Aspcn { public class DbTran { file://执行事务处理 public void DoTran() { file://建立连接并打开 SqlConnection myConn=GetConn(); myConn.Open(); SqlCommand myComm=new SqlCommand(); SqlTransaction myTran; file://创建一个事务 myTran=myConn.BeginTransaction(); file://从此开始,基于该连接的数据操作都被认为是事务的一部分 file://下面绑定连接和事务对象 myComm.Connection=myConn; myComm.Transaction=myTran; try { file://定位到pubs数据库 myComm.CommandText="USE pubs"; myComm.ExecuteNonQuery(); myComm.CommandText="UPDATE roysched SET royalty = royalty * 1.10 WHERE title_id LIKE 'Pc%'"; myComm.ExecuteNonQuery(); file://下面使用创建数据库的语句制造一个错误 myComm.CommandText="Create database testdb"; myComm.ExecuteNonQuery(); myComm.CommandText="UPDATE roysched SET royalty = royalty * 1.20 WHERE title_id LIKE 'Ps%'"; myComm.ExecuteNonQuery(); file://提交事务 myTran.Commit(); } catch(Exception err) { myTran.Rollback(); Console.Write("事务操作出错,已回滚。系统信息:"+err.Message); } } file://获取数据连接 private SqlConnection GetConn() { string strSql="Data Source=localhost;Integrated Security=SSPI;user id=sa;password="; SqlConnection myConn=new SqlConnection(strSql); return myConn; } } public class Test { public static void Main() { DbTran tranTest=new DbTran(); tranTest.DoTran(); Console.WriteLine("事务处理已经成功完成。"); Console.ReadLine(); } } } |
首先,我们在中间人为地制造了一个错误——使用前面讲过的Create database语句。然后,在异常处理的catch块中有如下语句:
myTran.Rollback();
当异常发生时,程序执行流跳转到catch块中,首先执行的就是这条语句,它将当前事务回滚。在这段程序可以看出,在Create database之前,已经有了一个更新数据库的操作——将pubs数据库的roysched表中的所有title_id字段以“PC”开头的书籍的royalty字段的值都增加0.1倍。但是,由于异常发生而导致的回滚使得对于数据库来说什么都没有发生。由此可见,Rollback()方法维护了数据库的一致性及事务的原子性。
使用存储点
事务只是一种最坏情况下的保障措施,事实上,平时系统的运行可靠性都是相当高的,错误很少发生,因此,在每次事务执行之前都检查其有效性显得代价太高——绝大多数的情况下这种耗时的检查是不必要的。我们不得不想另外一种办法来提高效率。
事务存储点提供了一种机制,用于回滚部分事务。因此,我们可以不必在更新之前检查更新的有效性,而是预设一个存储点,在更新之后,如果没有出现错误,就继续执行,否则回滚到更新之前的存储点。存储点的作用就在于此。要注意的是,更新和回滚代价很大,只有在遇到错误的可能性很小,而且预先检查更新的有效性的代价相对很高的情况下,使用存储点才会非常有效。
使用.net框架编程时,你可以非常简单地定义事务存储点和回滚到特定的存储点。下面的语句定义了一个存储点“NoUpdate”:
myTran.Save("NoUpdate");
当你在程序中创建同名的存储点时,新创建的存储点将替代原有的存储点。
在回滚事务时,只需使用Rollback()方法的一个重载函数即可:
myTran.Rollback("NoUpdate");
下面这段程序说明了回滚到存储点的方法和时机:
using System; using System.Data; using System.Data.SqlClient; namespace Aspcn { public class DbTran { file://执行事务处理 public void DoTran() { file://建立连接并打开 SqlConnection myConn=GetConn(); myConn.Open(); SqlCommand myComm=new SqlCommand(); SqlTransaction myTran; file://创建一个事务 myTran=myConn.BeginTransaction(); file://从此开始,基于该连接的数据操作都被认为是事务的一部分 file://下面绑定连接和事务对象 myComm.Connection=myConn; myComm.Transaction=myTran; try { myComm.CommandText="use pubs"; myComm.ExecuteNonQuery(); myTran.Save("NoUpdate"); myComm.CommandText="UPDATE roysched SET royalty = royalty * 1.10 WHERE title_id LIKE 'Pc%'"; myComm.ExecuteNonQuery(); file://提交事务 myTran.Commit(); } catch(Exception err) { file://更新错误,回滚到指定存储点 myTran.Rollback("NoUpdate"); throw new ApplicationException("事务操作出错,系统信息:"+err.Message); } } file://获取数据连接 private SqlConnection GetConn() { string strSql="Data Source=localhost;Integrated Security=SSPI;user id=sa;password="; SqlConnection myConn=new SqlConnection(strSql); return myConn; } } public class Test { public static void Main() { DbTran tranTest=new DbTran(); tranTest.DoTran(); Console.WriteLine("事务处理已经成功完成。"); Console.ReadLine(); } } } |
很明显,在这个程序中,更新无效的几率是非常小的,而且在更新前验证其有效性的代价相当高,因此我们无须在更新之前验证其有效性,而是结合事务的存储点机制,提供了数据完整性的保证。
隔离级别的概念
企业级的数据库每一秒钟都可能应付成千上万的并发访问,因而带来了并发控制的问题。由数据库理论可知,由于并发访问,在不可预料的时刻可能引发如下几个可以预料的问题:
脏读:包含未提交数据的读取。例如,事务1 更改了某行。事务2 在事务1 提交更改之前读取已更改的行。如果事务1 回滚更改,则事务2 便读取了逻辑上从未存在过的行。
不可重复读取:当某个事务不止一次读取同一行,并且一个单独的事务在两次(或多次)读取之间修改该行时,因为在同一个事务内的多次读取之间修改了该行,所以每次读取都生成不同值,从而引发不一致问题。
幻象:通过一个任务,在以前由另一个尚未提交其事务的任务读取的行的范围中插入新行或删除现有行。带有未提交事务的任务由于该范围中行数的更改而无法重复其原始读取。
如你所想,这些情况发生的根本原因都是因为在并发访问的时候,没有一个机制避免交叉存取所造成的。而隔离级别的设置,正是为了避免这些情况的发生。事务准备接受不一致数据的级别称为隔离级别。隔离级别是一个事务必须与其它事务进行隔离的程度。较低的隔离级别可以增加并发,但代价是降低数据的正确性。相反,较高的隔离级别可以确保数据的正确性,但可能对并发产生负面影响。
根据隔离级别的不同,DBMS为并行访问提供不同的互斥保证。在SQL Server数据库中,提供四种隔离级别:未提交读、提交读、可重复读、可串行读。这四种隔离级别可以不同程度地保证并发的数据完整性:
隔离级别 | 脏 读 | 不可重复读取 | 幻 像 |
未提交读 | 是 | 是 | 是 |
提交读 | 否 | 是 | 是 |
可重复读 | 否 | 否 | 是 |
可串行读 | 否 | 否 | 否 |
可以看出,“可串行读”提供了最高级别的隔离,这时并发事务的执行结果将与串行执行的完全一致。如前所述,最高级别的隔离也就意味着最低程度的并发,因此,在此隔离级别下,数据库的服务效率事实上是比较低的。尽管可串行性对于事务确保数据库中的数据在所有时间内的正确性相当重要,然而许多事务并不总是要求完全的隔离。例如,多个作者工作于同一本书的不同章节。新章节可以在任意时候提交到项目中。但是,对于已经编辑过的章节,没有编辑人员的批准,作者不能对此章节进行任何更改。这样,尽管有未编辑的新章节,但编辑人员仍可以确保在任意时间该书籍项目的正确性。编辑人员可以查看以前编辑的章节以及最近提交的章节。这样,其它的几种隔离级别也有其存在的意义。
在.net框架中,事务的隔离级别是由枚举System.Data.IsolationLevel所定义的:
[Flags] [Serializable] public enum IsolationLevel |
其成员及相应的含义如下:
成 员 | 含 义 |
Chaos | 无法改写隔离级别更高的事务中的挂起的更改。 |
ReadCommitted | 在正在读取数据时保持共享锁,以避免脏读,但是在事务结束之前可以更改数据,从而导致不可重复的读取或幻像数据。 |
ReadUncommitted | 可以进行脏读,意思是说,不发布共享锁,也不接受独占锁。 |
RepeatableRead | 在查询中使用的所有数据上放置锁,以防止其他用户更新这些数据。防止不可重复的读取,但是仍可以有幻像行。 |
Serializable | 在DataSet上放置范围锁,以防止在事务完成之前由其他用户更新行或向数据集中插入行。 |
Unspecified | 正在使用与指定隔离级别不同的隔离级别,但是无法确定该级别。 |
显而意见,数据库的四个隔离级别在这里都有映射。
默认的情况下,SQL Server使用ReadCommitted(提交读)隔离级别。
关于隔离级别的最后一点就是如果你在事务执行的过程中改变了隔离级别,那么后面的命名都在最新的隔离级别下执行——隔离级别的改变是立即生效的。有了这一点,你可以在你的事务中更灵活地使用隔离级别从而达到更高的效率和并发安全性。
最后的忠告
无疑,引入事务处理是应对可能出现的数据错误的好方法,但是也应该看到事务处理需要付出的巨大代价——用于存储点、回滚和并发控制所需要的CPU时间和存储空间。
本文的内容只是针对Microsoft SQL Server数据库的,对应于.net框架中的System.Data.SqlClient命名空间,对于使用OleDb的情形,具体的实现稍有不同,但这不是本文的内容,有兴趣的读者可以到.net中华网(www.aspcn.com)的论坛里找到答案。