65.9K
CodeProject 正在变化。 阅读更多。
Home

在 ADO.NET 中使用事务

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.43/5 (76投票s)

2005 年 4 月 27 日

9分钟阅读

viewsIcon

484055

downloadIcon

3515

SQL Server 事务的介绍以及如何在 ADO.NET 中使用它们。

什么是事务?

在数据库应用程序中,您经常会遇到一种情况,即需要以这样的方式执行两个或多个 SQL 命令,以至于如果任何一个语句失败,则没有其他语句可以更改数据库。经典的例子是将钱从一个银行账户转到另一个银行账户。

UPDATE Accounts SET Balance = Balance – 10 WHERE Customer = 1;
UPDATE Accounts SET Balance = Balance + 10 WHERE Customer = 2;

如果第一个 SQL 语句执行成功而第二个 SQL 语句失败,那么十美元将从第一个客户的账户中扣除,但永远不会存入第二个客户的账户。这自然是很糟糕的——十美元就这样凭空消失了。

一种处理这种情况的方法是处理数据库访问代码中的情况。您可以通过在进行数据库访问时捕获 System.Data.SqlClient.SqlException 来做到这一点。然而,情况并不像最初看起来那么简单。SQL 语句可能在多个地方失败

  1. 在第一个 SQL 语句执行之前。
  2. 在第一个 SQL 语句执行之后。
  3. 在第二个 SQL 语句执行之后。

这意味着您必须确定转账前两个账户的余额是多少,并在抛出异常后,确定哪个账户的当前余额与初始余额不符,然后执行一个 UPDATE 语句。这可能需要大量的代码——尤其是当您的示例并非微不足道时。

更糟糕的是,SQL Server 是一个多用户环境。在任何给定时间,其他用户都可能正在访问 Accounts 表——如果他们在您的语句失败和您纠正表之间访问该表,他们很可能会访问无效数据。这将是极其糟糕的,并可能导致代码中难以找到的错误。(与多个线程或进程之间数据同步相关的错误非常难以查找,以至于它们有自己的名称:“Heisenbugs”)

现代数据库理论认为,在一个完美的事务世界中,数据库会拥有一系列称为 ACID 的属性。这些属性是

  • 原子性 (Atomic) – 组中的所有语句都必须执行,或者组中的任何语句都不能执行。
  • 一致性 (Consistent) – 这自然源于原子性——一组 SQL 语句必须将数据库从已知的起始状态带到已知的结束状态。如果语句执行,数据库必须处于已知的结束状态。如果语句失败,数据库必须处于已知的起始状态。
  • 隔离性 (Isolated) – 一组语句必须独立于同时执行的任何其他语句组。如果不是这样,那么语句组就不可能是一致的——已知的结束状态可能会被您无法控制或了解的代码所更改。这是理论上很棒的概念之一,但在现实世界中,完全隔离具有重要的性能影响。稍后将详细解释 SQL Server 如何实现这一点。
  • 持久性 (Durable) – 一旦一组 SQL 语句执行完毕,结果就需要存储在永久性介质中——如果数据库在 SQL 语句组执行完毕后立即崩溃,应该能够将数据库状态恢复到最后一个已提交事务之后的状态。

在 SQL Server 中,ACID 性是通过事务的概念提供的。简单地说,事务就是一种将 SQL 语句分组的方法,以便在执行时,事务遵循 ACID 原则。事务在单个数据库连接上启用(在 .NET 术语中,在 SqlConnection 对象的生命周期内),并且将应用于在该连接上执行的所有命令,直到事务结束。一旦有了事务,您可以对其进行两件事。您可以在事务结束时将事务提交到数据库,或者可以放弃事务并回滚事务中所做的更改。

就 Transact-SQL 而言,有三个重要的命令来管理事务。BEGIN TRANSACTION 将启动一个事务,COMMIT TRANSACTION 将事务提交到数据库,而 ROLLBACK TRANSACTION 将回滚事务。这些语句实际上可能更复杂——您可以随意参考 MSDN 文档来了解这些语句。

.NET 事务

在 .NET 中,事务通过 System.Data.SqlClient.SqlTransaction 类进行管理。同样,事务存在于 SqlConnection 对象上——因此,您使用该连接创建的所有 SqlCommand 对象都属于事务。让我们看一个简单的示例:

public class TransactionDemo
{
   public TransactionDemo()
   {

   }

   [STAThread]
   public static void Main() 
   {
      Demo1();
   }

   private static void Demo1() 
   {
      SqlConnection db = new SqlConnection("connstringhere");
      SqlTransaction transaction;

      db.Open();
      transaction = db.BeginTransaction();
      try 
      {
         new SqlCommand("INSERT INTO TransactionDemo " +
            "(Text) VALUES ('Row1');", db, transaction)
            .ExecuteNonQuery();
         new SqlCommand("INSERT INTO TransactionDemo " +
            "(Text) VALUES ('Row2');", db, transaction)
            .ExecuteNonQuery();
         new SqlCommand("INSERT INTO CrashMeNow VALUES " +
            "('Die', 'Die', 'Die');", db, transaction)
            .ExecuteNonQuery();
         transaction.Commit();
      } 
      catch (SqlException sqlError) 
      {
         transaction.Rollback();
      }
      db.Close();
   }
}

从这个示例中可以看到,我们首先打开到 SQL 数据库的连接。然后,我们调用连接上的 BeginTransaction 方法,并保留其返回对象的引用。此时,连接已绑定到返回的 SqlTransaction 对象。这意味着在该连接上执行的任何 SqlCommand 都将在事务中。您可以看到,在 try 块中,我们创建并执行了三个 SqlCommand 对象。但请注意,在这种情况下,我们使用的是 SqlConnectionSqlTransaction 重载的 SqlCommand 构造函数。这是因为 SqlCommand 对象需要传入绑定到连接的事务——否则会导致异常。在我看来,这是该模型的一个弱点——因为事务是按连接绑定的,而 SqlCommand 对象应该能够直接从提供的 SqlConnection 中提取 SqlTransaction 对象。

在上面的示例中,前两个 SqlCommand 执行是完全有效的——TransactionDemo 存在于数据库中。然而,CrashMeNow 表不存在。由于表不存在,将在 ExecuteNonQuery 对象上抛出 SqlException 对象。认识到事务“不替换标准的异常处理机制”非常重要。如果您认为您的语句可能不会执行,您必须 catch SqlException,并在您的 catch 块中回滚事务。

您可以在 SqlTransaction 对象上使用两种操作。Rollback 将取消您的事务,撤销所有已做的更改。Commit 将导致事务永久写入数据库。无论哪种情况,都将结束事务。

如果您执行上述代码并查看 TransactionDemo 表,您会发现没有行被添加——事务在抛出异常后被回滚了。但是,如果您删除有问题的 SQL 语句,运行程序并再次查看,您会发现添加了两行。这本质上就是事务的作用。

高级事务 – 隔离级别

但这并不是事务的极限。正如我在描述 ACID 属性时所说,事务不一定满足最严格的“隔离”定义。这是因为事务的隔离级别可以在创建事务时由您配置。

为什么要这样做?性能。虽然完全隔离在理论上很棒,但共产主义在理论上也很棒。在现实中,在事务处理一组行时独占锁定这些行可能因性能原因而不可行——您不一定想因为您的事务锁定了整个表而阻止系统中的所有读取者。

为了缓解这个问题,.NET(通过 SQL Server)允许您在创建事务时指定隔离级别。这仅需将 System.Data.IsolationLevel 值传递给 BeginTransaction 方法即可。SQL Server 2000 的可用值是:

  1. ReadUncommitted – 本质上,这是没有隔离。任何人都可以读取在 SQL 语句导致更改后立即在表中放置或更新的数据——无需提交。这可能导致进程拥有过时的数据:它可能正在使用已被回滚出表的数据版本!
  2. ReadCommitted – 这稍微更具隔离性。在这种情况下,事务只能读取表中已提交的数据。当事务想要更新数据时,它会获取对该数据的共享锁,并在(成功获取锁后)更新数据。在该事务之外的事务在锁定事务提交之前无法更新该表中的数据。然而,这只是稍微更具隔离性:在事务中执行两次的 SQL 语句可能会返回不同的结果集,如果第二个事务在两次语句之间更改并提交了 SQL 语句执行的数据。
  3. RepeatableRead – 逐渐变得更具隔离性。在这种情况下,对事务中查询的所有数据应用共享锁。这意味着没有其他事务可以更改您事务中使用的数据。这可以防止您曾经查询过的数据在后续查询中发生变化。然而,它并没有阻止向表中添加可能会在后续查询中返回的行。
  4. Serializable – 对您使用的表范围放置锁,防止其他用户更改您的数据或在您下方添加新行。这是最具隔离性的隔离级别,但其缺点是锁定了比您的事务严格需要的数据更多的资源。

在 SQL Server 2005 中,将添加一个新的隔离级别:快照隔离。在快照隔离中,一旦事务访问行,就会对行进行版本化。这基本上意味着一旦事务访问了一组值,它们将保持不变,直到您提交或回滚事务。在第一个事务中间开始的其他事务将获得数据库的“副本”来操作。然而,在任何事务提交之前,SQL Server 将测试以确保它们操作的原始数据与数据库中的当前数据相同。如果情况属实,事务将提交。否则,事务将回滚,用户将不得不再次尝试批处理。

结论

事务对其他几件事情很有用。首先,它们提供了一种在单个 SQL 语句应回滚时回滚一组 SQL 语句的方法。请记住,失败不仅仅意味着返回错误。失败也可能在逻辑上相关——在上例中,也许被转账的账户没有足够的钱来进行存款。在这种情况下,当您发现该事实时,可以回滚事务。其次,它们提供了一种隔离事务正在处理的数据的方法,这样您就不必担心意外。但无论如何,您都应该检查您真正需要的隔离级别,并注意所有这些级别的性能影响。

© . All rights reserved.