摘要:ADO.NET为提高数据密集型(data-intensive)应用程序的性能、简化这类程序的建立过程提供了多种技术。数据集(DataSet)作为ADO.NET对象模型的标志,作为一个微型的、不连接(disconnected)的数据源的副本提供服务。虽然使用数据集通过减少对数据库服务器的高花费的访问而提高了性能,但是它也带来了多个用户试图同时访问相同数据的可能性,由此引起数据并发性异常(data concurrency exception)。本文调查了数据并发性异常背后的通常起因,介绍了解决这些问题的技术。
把数据访问层升级到ADO.NET有很多好处,其中之一是使用内部数据集对象。数据集对象基本上是一个不连接的、内存中的数据库的拷贝。数据集对象包含一个或者多个数据表(DataTable),每个数据表一般对应于数据库中的一个表。数据集提供了很多好处,但也带来一些问题,特别是可能遇到与数据并发性异常相关的问题。我建立了一个简单的Windows Forms顾客服务应用程序,用它来解释该问题的潜在的缺陷。本文我将介绍该应用程序并演示怎样解决它所引起的数据并发性问题。
本文建立的顾客服务应用程序示例是使用Visual Basic .NET和SQL Server 2000建立的,但是由于微软.NET框架组件是语言无关(language-agnostic)的,因此任何与.NET框架组件兼容的语言都可以使用。同样,由于数据集对象抽象了数据源,数据源的实际执行并不重要;无论下层的数据源是SQL Server、本地XML文件或者从一个服务中检索到的数据,数据并发性异常同样会出现。
数据集的利弊
数据集提供了很多好处,例如比起数据库层次,它强化了内存中的完整性规则。数据集对象可以定义和强化表之间的关系和列的约束,确保使用的商业规则对数据库没有缺陷。通过数据库抽象,你能建立单个代码集合访问数据集对象而不必考虑填充该数据集的源数据。下层的数据源也许是SQL Server、Oracle甚至XML文件。无论下层数据源是什么,代码使用相同的方法与数据集交互。这使你能改变下层数据源而不改变代码。
但是使用数据集的最大好处是提高了性能。因为数据集与下层数据库断开,代码将更少作数据库的调用,显著地提高了性能。你能向数据集的多个数据表中添加新行、验证每行的有效性和参照完整性。数据适配器(DataAdapter)把数据集连接到下层数据库,能使用一条命令更新下层数据库。每个表中的所有新行都使用命令加入,以确保所有添加到数据库的行都是有效的。
性能的最优化是有代价的。因为数据集对象与下层数据库断开,经常有机会出现数据没有超期(out of date)的情况。因为数据集不保存活动数据,只保存当时填充数据集的活动数据的一个快照,与数据并发性相关的问题就会出现。数据并发性问题出现在多个用户访问相同的数据并且任何一个用户没有其它用户的信息就能更新数据。这就出现了一个用户偶然更新数据而不知道那些数据已经改变了,不是他在程序中看到的了。幸运的是数据集对象拥有捕获数据并发性问题的内建(built-in)支持,因此应用程序能正确地作出反应。
示例程序
一个虚拟的公司使用该顾客服务应用程序建立顾客订单,更新顾客的个人信息。有很多客户销售代表(CSR)在桌面上使用该应用程序。CSR使用电话获取订单,从顾客那儿收集个人信息和支付信息。顾客记录保存在数据库中以提高回头客处理订单的速度,CSR接着建立一个订单并把产品项添加上去,指定数量和目前的价格,所有的信息收集后,CSR点击Place Order按钮,向数据库中插入顾客和订单记录。
CSR也使用应用程序执行通过电子或者缓慢的邮件发送给公司的请求。这些请求在CSR间均匀分开,在每天早晨发送给他们,CSR通过电话执行那些请求。系统设计要提高请求的实现速度,所有的顾客在CSR之间共享。顾客的每个请求,无论通过电话或者邮件,都被不同的CSR处理,增加了发生数据并发性问题的机会。
为了提高性能,应用程序在内存中保持了一个用顾客和订单信息填充的数据集对象。因为很多雇员同时使用该应用程序,就会有许多活动数据的不连接的快照,它们都在雇员的工作站上。所有顾客的维护、订单输入和订单维护都使用名为dsAllData的数据集对象。图1是建立dsAllData的代码,它是全局模块的一部分,因此应用程序中的所有窗体都能使用它。
Const connString = "server=localhost;database=northwind;uid=sa;pwd="
Public connCustSvc As SqlClient.SqlConnection
Public daCustomer As SqlClient.SqlDataAdapter
Public cbCustomer As SqlClient.SqlCommandBuilder
Public daOrders As SqlClient.SqlDataAdapter
Public cbOrders As SqlClient.SqlCommandBuilder
Public daOrderDetail As SqlClient.SqlDataAdapter
Public cbOrderDetail As SqlClient.SqlCommandBuilder
Public dsAllData As DataSet
Public Sub Main()
connCustSvc = New SqlClient.SqlConnection(connString)
daCustomer = New SqlClient.SqlDataAdapter("SELECT * FROM Customer", connCustSvc)
cbCustomer = New SqlClient.SqlCommandBuilder(daCustomer)
daOrders = New SqlClient.SqlDataAdapter("SELECT * FROM Orders", connCustSvc)
cbOrders = New SqlClient.SqlCommandBuilder(daOrders)
daOrderDetail = New SqlClient.SqlDataAdapter("SELECT * FROM OrderDetail", connCustSvc)
cbOrderDetail = New SqlClient.SqlCommandBuilder(daOrderDetail)
dsAllData = New DataSet()
daCustomer.MissingSchemaAction = MissingSchemaAction.AddWithKey
daCustomer.Fill(dsAllData, "Customer")
daOrders.MissingSchemaAction = MissingSchemaAction.AddWithKey
daOrders.Fill(dsAllData, "Orders")
dsAllData.Tables("Orders").Columns("Total").DefaultValue = 0
daOrderDetail.MissingSchemaAction = MissingSchemaAction.AddWithKey
daOrderDetail.Fill(dsAllData, "OrderDetail")
Application.Run(New frmCustomerMaintenance())
End Sub
图1.填充数据集对象
建立dsAllData的代码建立了一个空的数据集对象、三个数据适配器(DataAdapter)和三个命令构造器(CommandBuilder)。每个数据适配器在适当的表上执行一个简单的"SELECT *"操作,而命令构造器用需要的剩余信息填充数据集,使它有插入(insert)、更新(update)和删除(delete)的能力。主程序使用数据适配器对象和所有三个表中的数据填充dsAllData,接着使用Customer Maintenance窗体开始应用程序。
图2显示的是Customer Maintenance屏幕,它有一个绑定到dsAllData的Customers数据表的DataGrid对象。这个简单的表格允许CSR编辑顾客的任意基本属性。因为该表格绑定到了Customers数据表,表格中的任何改变都将自动存储到数据表中。dsAllData将保存这些值,直到CSR点击Save Changes按钮明确地告诉窗体更新下层数据源为止。
图2. Customer Maintenance表格允许编辑
为了输入订单,使用图3中的代码建立了几个新行并添加到dsAllData中。首先建立一个Order记录,接着在数据表OrderDetail中为订单的每个项建立几个记录。当所有必须的行添加到dsAllData后,一个适当的数据适配器的Update方法调用将用新行更新下层数据源。
Private Sub CreateOrder()
Dim dr As DataRow
dr = dsAllData.Tables("Orders").NewRow
With dr
.Item("DateOrdered") = Now
.Item("CustomerID") = 1
.Item("ShipToAddress") = "123 Main"
.Item("ShipToCity") = "Kansas City"
.Item("ShipToState") = "MO"
.Item("ShipToZip") = "12345"
End With
dsAllData.Tables("Orders").Rows.Add(dr)
AddOrderDetail(dr.Item("ID"), 1, 1, 9.99)
AddOrderDetail(dr.Item("ID"), 2, 2, 4.99)
daOrders.Update(dsAllData.Tables("Orders"))
daOrderDetail.Update(dsAllData.Tables("OrderDetail"))
End Sub
Private Sub AddOrderDetail(ByVal OrderID As Integer, _
ByVal ProductID As Integer, ByVal Quantity As Integer, _
ByVal Price As Single)
Dim dr As DataRow
dr = dsAllData.Tables("OrderDetail").NewRow
With dr
.Item("OrderID") = OrderID
.Item("ProductID") = ProductID
.Item("Quantity") = Quantity
.Item("Price") = Price
End With
dsAllData.Tables("OrderDetail").Rows.Add(dr)
End Sub
图3.有细节记录的新订单
因为CSR同时使用应用程序和更新顾客信息,因此好像任何时候一个CSR看到的都是过期的信息。为了防止这种现象,应用程序的设计师决定dsAllData的数据缓存要每隔30分钟刷新一次,以确保CSR通常看到正确信息。应用
程序设计
得很仔细,以确保数据刷新只在空闲时段进行,这样它才不影响性能。数据集刷新因此会比30分钟长一点,这依赖于CSR的行为。
图4.应用程序数据模型
该应用程序的数据模型很简单(图4)。它使用SQL Server 2000存储,只包含三个表,顾客表、订单表、每个订单的细节表,并定义了适当的主键和关系以确保参照的完整性。此外,在OrderDetail上定义了一个触发器来更新Orders 表的Total列。每次插入、更新或者删除一个OrderDetail记录,调用触发器计算该订单的最后销售值,并更新Orders表的适当的行。图5是trg_UpdateOrderTotal触发器的代码:
CREATE TRIGGER trg_UpdateOrderTotal ON [dbo].[OrderDetail]
FOR INSERT, UPDATE, DELETE
AS
DECLARE @OrderID int
SELECT @OrderID=OrderID FROM Inserted
IF @OrderID IS NULL
SELECT @OrderID=OrderID FROM Deleted
UPDATE Orders
SET Total=
(
SELECT Sum(Price*Quantity)
FROM OrderDetail
WHERE OrderID=@OrderID
)
WHERE ID=@OrderID
图5.更新Total列
第一个数据并发性异常
该顾客服务应用程序使用了几个月没有任何问题,但是突然产生了一个没有处理的异常DBConcurrencyException。本段我将解释导致该异常的环境。
第一个使用该应用程序的顾客销售服务人员Joe打开应用程序。这将初始化将数据载入数据集dsAllData并按每30分钟一次的周期来刷新数据。Joe的收件箱中有一堆文件,包括顾客传真、邮寄或者通过电子邮件发送的更改请求。他开始处理更改请求,但是经常被电话订单中断。
其间,第二个客户销售服务人员Sally到达办公室并打开了她的应用程序实例。Sally的应用程序实例也初始化从SQL Server中载数据,包括Joe所作的更新。Sally也接到了一个顾客改变电话号码的请求。该顾客先前用电子邮件发送了地址的改变情况,但是在那时不知道他的新电话号码,现在要更新记录了。Sally使用Customer Maintenance屏幕更新顾客的电话号码。当Sally改变DataGrid中的电话号码时,新号码存储在dsAllData中。当Sally确认其它的顾客信息后,她意识到原地址的改变还没有处理,因此她更新那些信息并点击Save Changes按钮,将新数据发送到SQL Server数据库。
Joe正在处理相同顾客的原始请求。当他打开Customer Maintenance屏幕时,应用程序从缓存数据集对象中读入信息。因为Sally更新顾客地址时,Joe的应用程序没有自动与数据库同步,因此他的Customer Maintenance屏幕仍然显示旧地址。Joe使用电子邮件提供的新信息改正了DataGrid中显示的信息,并点击Save Changes按钮。这样操作后出现了一个错误信息"并发性故障:更新命令影响了0个记录(Concurrency violation: the UpdateCommand affected 0 records)",应用程序崩溃了。在Joe再次打开应用程序时,他发现地址已经更新了,认为他的更改在应用程序崩溃前已经完成了。下面就是问题的代码行:
Private Sub butSave_Click (ByVal sender As System.Object, _
ByVal e As System.EventArgs)
daCustomer.Update(dsAllData.Tables("Customer"))
End Sub
实际的异常是DBConcurrencyException类型产生的,它是数据适配器对象内部建立的特定功能的结果(见图6)。该数据适配器设计为把数据填充到不连接的对象(例如数据集),这样它在执行更新前能自动地检查数据寻找改变。如果下层数据被改变了,数据适配器将引发一个DBConcurrencyException异常而不是执行更新。
图6. DBConcurrency异常
执行完整性检查是相当直接的,它提高了数据表对象的性能,使它能够保持多个数据集合。当数据第一次载入数据表时,数据表中的所有数据行(DataRow)的DataRowVersion属性设置为原始的(Original)。当修改了数据行中的一列时,该行就被复制一份并标记为当前的(Current),标记为原始的行仍然没有改变。后来的所有对该数据的更改都仅仅影响当前行。当为一个数据表(或者一个数据集中的多个数据表)执行数据适配器的更新方法时,它重复所有的当前行来决定要发送给下层数据源的更新语句。作为DataRowVersion属性的补充,数据行有一个用于识别行中数据状态的RowState属性。它的可能值为Unchanged、Added、Modified、Deleted和Detached。
在决定下层数据中的哪些行需要更新后,数据适配器dsCustomer建立更新SQL Server数据库所需要的SQL语句。在图1中我使用数据集和命令构造器对象来建立需要的INSERT、 UPDATE和DELETE语句。命令构造器对象建立的UPDATE语句使用DataRowVersion值为Original的数据行副本来识别和更新数据库中的适当行。这就是说,作为使用主键值简单地识别正确行的代替,命令构造器建立一个SQL语句来查找与数据集中存储的原始值准确匹配的行。下面的代码是建立的用于更新顾客电话号码的UPDATE语句示例:
UPDATE Customer SET Phone = @p1
WHERE ((ID = @p2) AND ((FirstName IS NULL AND @p3 IS NULL)
OR (FirstName = @p4))
AND ((LastName IS NULL AND @p5 IS NULL) OR (LastName = @p6))
AND ((Address IS NULL AND @p7 IS NULL) OR (Address = @p8))
AND ((Address2 IS NULL AND @p9 IS NULL) OR (Address2 = @p10))
AND ((City IS NULL AND @p11 IS NULL) OR (City = @p12))
AND ((State IS NULL AND @p13 IS NULL) OR (State = @p14))
AND ((Zip IS NULL AND @p15 IS NULL) OR (Zip = @p16))
AND ((Phone IS NULL AND @p17 IS NULL) OR (Phone = @p18)))
该UPDATE语句使用参数而不是实际值,但是你能看到行中每列是怎样检查的。
识别了准确的行和下层数据库中的原始值后,数据适配器就可以安全地更新行了。但是,如果自从数据表被填充后数据库中某行的某个列改变了,UPDATE语句将失败,因为数据库中没有与WHERE条件中的标准匹配的行了。数据适配器决定UPDATE是否成功很简单,只需要简单地检查数据库中被更新的行的实际数量。如果没有行被更新,那么下层数据一定被改变或删除了,就产生一个数据并发性异常。这就解释了Joe试图更新顾客电话号码时接收到的有点模糊的错误消息:数据适配器检查到的实际错误不是下层数据改变了,而是没有记录被更新,标志着下层数据必定被改变了。
解决方法
有两种途径解决DBConcurrencyException问题。第一种是确保它永不重现:我可以删除图1中代码使用的SqlCommandBuilder对象,把它们更换为数据适配器对象的UpdateCommand 属性的SqlCommand对象。我将在CommandText属性中建立带有WHERE条件的SQL语句,只进行主键而不是所有列的过虑。这样将排除所有并发性问题(假定主键不会改变)。
但是这种技术带来了几个问题。首先,很明显要更多的代码,因为我还得为每个数据适配器的InsertCommand和 DeleteCommand属性建立SqlCommand对象。另外,如果下层数据库概要(schema)发生了变动,这些硬编码将带来新错误。如果使用SqlCommandBuilder对象,应用程序在运行时决定数据库概要,接受任何改变,相应地建立SQL语句。这不是解决并发性问题,而是完全的避免了该问题,使用户在不知不觉中覆盖了他人的更改。
Try
daCustomer.Update(dsAllData.Tables("Customer"))
Catch dbcEx As Data.DBConcurrencyException
Dim dResult As DialogResult
dResult = MessageBox.Show(messageString, _
"Data Concurrency Exception Occurred", _
MessageBoxButtons.YesNoCancel, MessageBoxIcon.Error, _
MessageBoxDefaultButton.Button1, _
MessageBoxOptions.DefaultDesktopOnly)
If dResult = DialogResult.Yes Then
'两个选择:填充整个表或者刷新该行
'daCustomer.Fill(dsAllData.Tables("Customer"))
UpdateRow("Customer", dbcEx.Row.Item("ID"))
ElseIf dResult = DialogResult.No Then
'保存新行的拷贝
Dim drCopy As DataRow, drCurrent As DataRow
drCopy = dsAllData.Tables("Customer").NewRow()
Dim dc As DataColumn
drCurrent = dsAllData.Tables("Customer").Rows.Find(dbcEx.Row.Item("ID"))
For Each dc In drCurrent.Table.Columns
If dc.ReadOnly = False Then
drCopy.Item(dc.ColumnName) = drCurrent.Item(dc.ColumnName)
Next
'从数据库中获取当前值
UpdateRow("Customer", dbcEx.Row.Item("ID"))
'现在恢复用户输入的值并再次保存
For Each dc In drCurrent.Table.Columns
If dc.ReadOnly = False Then
drCurrent.Item(dc.ColumnName) = drCopy.Item(dc.ColumnName)
Next
daCustomer.Update(dsAllData.Tables("Customer"))
End If
End Try
图7.捕获并发性异常
图7显示了一个更好的捕捉该异常的方案。Try...Catch块捕捉了DBConcurrencyException并给用户显示一个标识该错误的消息窗口,给用户提供一个选择(图8所示)。这样我识别已经出现了一个并发性错误并有两个选择:我可以检索下层数据并显示给用户,强制他们再次作修改,或者我能简单地使用该用户指定的改变覆盖下层数据。这些选项都显示在消息框中(图8):
图8.处理数据并发性异常
Private Sub UpdateRow(ByVal TableName As String, ByVal ID As Integer)
'获取到特定行的引用
Dim dr As DataRow = dsAllData.Tables(TableName).Rows.Find(ID)
'建立命令更新获取新的下层数据
Dim cmd As New SqlClient.SqlCommand("SELECT * FROM " &;amp; TableName " WHERE ID=" &;amp; ID.ToString(), connCustSvc)
'打开连接并建立数据读取器(DataReader)
connCustSvc.Open()
Dim rdr As SqlClient.SqlDataReader = cmd.ExecuteReader()
rdr.Read()
'将新数据从数据库复制到数据行
Dim dc As DataColumn
For Each dc In dr.Table.Columns
If dc.ReadOnly = False Then _
dr.Item(dc.ColumnName) = rdr.Item(dc.ColumnName)
Next
'接受数据行中的改变
dr.AcceptChanges()
connCustSvc.Close()
End Sub
图9. UpdateRow程序更新缓冲的数据行
如果用户决定查看新的下层改变并放弃他们的更改,你只需要简单地刷新存储在Customer数据表中的数据。因为DataGrid绑定到数据表,该表格将自动地显示新数据。为了刷新数据,你有两种选择:第一种是使用数据适配器daCustomer的Fill方法来简单地填充整个数据表。虽然这种技术能够实现,但是它的花费太大,因为本来你只需要刷新一行。我建立了一个叫UpdateRow的程序,它仅仅读入有问题的行的数据(图9)。使用UpdateRow程序时要注意我已经在参数集合中定义了被找到的数据行是单个的、整型关键字的列,如果该表有不同数据类型或者复合键,你必须重载UpdateRow来处理特定键的需求。在数据行和/或数据表用当前数据刷新后,DataGrid在引起并发性异常的行上显示一个小Error图标(图10)。
图10.DataGrid显示Error图标
用户的另一个选择是忽略对下层数据库的更改,强迫他的改变生效。有多种方法可以实现该功能。第一种是SQL Server数据库上直接执行一个SQL语句来使数据表中的数据同步。尽管这种方法可以实现,但是它要求你在数据库更改时为重写所有的SQL语句。使用这种技术编写的SQL是使用的特定数据库版本的硬编码(hard-coded),丢失了数据适配器和数据集对象提供的抽象性。
更好的选择是使用我前面所写的UpdateRow程序并且允许数据适配器处理更新。图9中的代码首先建立了含有新的更改的数据行的拷贝,接着调用UpdateRow程序从数据库中获得新数据。调用UpdateRow过程是必要的,这样你在试图再次执行更新时才不会接收到并发性异常。该代码接着迭代数据行中所有的列,使用用户提供的值更新最近检索到的值。最后,使用数据适配器更新下层数据库。
这些代码解决方法都有一些潜在的问题。首先,默认情况下数据适配器的Update方法在第一个并发性异常时就会失败,不处理后面的数据行。这会导致数据的部分更新或者几个并发性异常,每一个由用户单独处理。图7中的代码显示了另一个潜在的异常,在强迫用户的更改到下层数据库的时候。有很小的机会出现另一个用户在调用UpdateRow程序和执行daCustomer的Update方法之间更改了下层数据库。这将产生一个未处理的DBConcurrencyException。一种可能的解决方法是把Update方法放入Try...Catch块,但是第二个Catch代码可能与第一个相似,并且能潜在的产生它自己的并发性异常,就需要更多的Try...Catch块,直到无穷。更好的解决方法是将这段代码放入一个独立的在多个并发性异常发生的情况下可以调用的程序中。
使用SQL Server触发器
为该公司开发顾客服务应用程序的人员已经处理好Joe更新顾客信息时接收到的DBConcurrencyException异常的代码,应用程序工作又得很好了--直到下午Sally试图输入一个订单为止。
Sally接到一个顾客的订单电话。该顾客信息已经在数据库中了,因此Sally使用订单的基本信息包括邮寄地址。接着她打开OrderDetails屏幕并给订单添加了两个项目。每个OrderDetail记录包括OrderID 、ProductID 、Quantity 和Price。订单填完后,Sally点击Place Order按钮把订单插入数据库。我通过为OrderDetail记录添加硬编码值简化了代码(如图3所示)。
订单成功地插入和数据库,但是Sally接到一个提示,显示一个项没有货了,订单不能在两周内发货。该顾客意识到包裹送达时他不在城里,就询问是否可以更改发货地址。Sally在使用Order Maintenance屏幕前已经更改了订单信息,因此她说乐于帮忙。她打开适当的屏幕并为该顾客改变发货地址。但是她点击Save Changes按钮时,看到了一个与Joe相似的错误对话框,应用程序崩溃了。
理解为什么产生DBConcurrencyException的关键在于下层数据库。回想图5中我在OrderDetail表上设置了一个触发器,所有的INSERTS、UPDATES和DELETES都将调用它。trg_UpdateOrderTotal通过计算Order中每个OrderDetail行的数量乘以价格更新订单的总价格。当Sally建立一个有两个项的订单时,应用程序首先向Orders中插入一个新行,接着向OrderDetail插入两个新行。在每个OrderDetail记录建立后,调用触发器更新Order记录的Total列。但是这个值只在数据库中产生并且不会传递到应用程序。实际上,图3中的代码没有指定Total,因为在图1的代码中已经指定了一个默认值。接着默认值0随着新的Orders记录传递进SQL Server,该值在订单建立时是正确的。数据库接着更新Total列,但是应用程序中的数据表仍然使用0作为订单总价。当Sally试图更新Order记录时,数据适配器认为Total列的值已经改变了并产生一个DBConcurrencyException异常。
这儿的陷阱是过去当Sally已经更新Order记录时,碰巧自从最后一次数据自动刷新发生后,她没有更新刚才建立的新订单。在建立每个订单30分钟内,dsAllData数据集对象被刷新,它将使用正确的合计值更新Orders数据表。任何在数据自动更新(或者应用程序重新启动)后作的更新将按预计的情形工作。
这个问题的解决方法与前面的问题相似:刷新用户看到的数据。但是我能够更主动,不是等待并发性异常发生。我能在建立OrderDetail记录后,自动刷新每一个Orders数据行。我能更改图3中的代码,使它包含一个UpdateRow程序的调用,在调用daOrderDetail的Update方法后指定Orders中的行。SQL Server将正常完成该触发器,但是你不能依赖它,因此开发者也许会添加一个时间延迟,这样触发器有足够的时间完成。
结论
ADO.NET数据集对象的不连接特性提供了很高的性能,也给应用程序带来了数据并发性类型的错误。了解这些错误怎样和为什么出现将允许你很好地捕捉和处理这种错误,给应用程序增加另一个层次的支持。这给用户维护下层数据的完整性更多的选择。伴随着应用程序进一步迁移到互联网和内部网,用户成倍增加,并发性问题的处理面临更大的挑战。ADO.NET为解决该问题提供了所有的必要工具。