在 ADO.NET 中使用事务






4.43/5 (76投票s)
2005 年 4 月 27 日
9分钟阅读

484055

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 语句可能在多个地方失败
- 在第一个 SQL 语句执行之前。
- 在第一个 SQL 语句执行之后。
- 在第二个 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
对象。但请注意,在这种情况下,我们使用的是 SqlConnection
、SqlTransaction
重载的 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 的可用值是:
ReadUncommitted
– 本质上,这是没有隔离。任何人都可以读取在 SQL 语句导致更改后立即在表中放置或更新的数据——无需提交。这可能导致进程拥有过时的数据:它可能正在使用已被回滚出表的数据版本!ReadCommitted
– 这稍微更具隔离性。在这种情况下,事务只能读取表中已提交的数据。当事务想要更新数据时,它会获取对该数据的共享锁,并在(成功获取锁后)更新数据。在该事务之外的事务在锁定事务提交之前无法更新该表中的数据。然而,这只是稍微更具隔离性:在事务中执行两次的 SQL 语句可能会返回不同的结果集,如果第二个事务在两次语句之间更改并提交了 SQL 语句执行的数据。RepeatableRead
– 逐渐变得更具隔离性。在这种情况下,对事务中查询的所有数据应用共享锁。这意味着没有其他事务可以更改您事务中使用的数据。这可以防止您曾经查询过的数据在后续查询中发生变化。然而,它并没有阻止向表中添加可能会在后续查询中返回的行。Serializable
– 对您使用的表范围放置锁,防止其他用户更改您的数据或在您下方添加新行。这是最具隔离性的隔离级别,但其缺点是锁定了比您的事务严格需要的数据更多的资源。
在 SQL Server 2005 中,将添加一个新的隔离级别:快照隔离。在快照隔离中,一旦事务访问行,就会对行进行版本化。这基本上意味着一旦事务访问了一组值,它们将保持不变,直到您提交或回滚事务。在第一个事务中间开始的其他事务将获得数据库的“副本”来操作。然而,在任何事务提交之前,SQL Server 将测试以确保它们操作的原始数据与数据库中的当前数据相同。如果情况属实,事务将提交。否则,事务将回滚,用户将不得不再次尝试批处理。
结论
事务对其他几件事情很有用。首先,它们提供了一种在单个 SQL 语句应回滚时回滚一组 SQL 语句的方法。请记住,失败不仅仅意味着返回错误。失败也可能在逻辑上相关——在上例中,也许被转账的账户没有足够的钱来进行存款。在这种情况下,当您发现该事实时,可以回滚事务。其次,它们提供了一种隔离事务正在处理的数据的方法,这样您就不必担心意外。但无论如何,您都应该检查您真正需要的隔离级别,并注意所有这些级别的性能影响。