dataset并发.doc

来源:互联网 发布:项目数据分析师 广州 编辑:程序博客网 时间:2024/04/29 14:42

ADO.NET 提供的许多技术可以提高 DataSet 中型应用程序的性能,并且使构建这样的应用程序更加容易。DataSet ADO.NET 对象模型的标志,可以用作数据源的小型、断开连接的副本。在使用 DataSet 通过减少往返数据库服务器的巨大开销来提高性能的同时,也引入了因多个用户同时尝试更改同一数据而发生数据并发异常的可能性。本文考查了引起数据并发异常的常见原因,并提出了用于解决这些异常的技术。

将数据访问层升级至 ADO.NET 有许多优点,大多数 ADO.NET 涉及使用内部 DataSet 对象。从根本上讲,DataSet 对象是数据库位于内存中已断开连接的副本。DataSet 对象包含一个或多个数据表,每个数据表通常都与基础数据库中一个表相对应。DataSet 提供了许多优点,但也提出了一些挑战。特别是您可能遇到与数据并发异常相关的问题。我已经创建了一个简单的 Windows®窗体客户服务应用程序,它演示了这一特定问题的潜在缺陷。在本文中,我将向您演示该应用程序,并说明如何解决出现的数据并发问题。

此处展示的客户服务应用程序示例是使用 Visual Basic® .NET SQL Server™ 2000 构建的;但是由于 Microsoft® .NET Framework 与语言无关,因此任何 .NET Framework 兼容语言都能使用。同样,由于 DataSet 对象将数据源抽象化,因此数据源的实际实现并不重要;无论基础数据源是 SQL Server、本地 XML 文件,还是从通过服务检索的数据派生而来的,同样都会发生数据并发异常。

DataSet 优点和缺点

数据集提供了许多优点。例如,您可以获得在内存中而不是在数据库级强制完整性规则的能力。DataSet 对象可以定义并强制表之间的关系以及对列的约束,以确保业务规则在不必往返数据库的情况下便可得以应用。通过数据库抽象,您可以编写一组单独的代码来访问 DataSet 对象,而不管填充 DataSet 的数据来源如何。基础数据源可以是 SQL Server?Oracle,甚至可以是 XML 文件。不管基础数据的来源如何,您的代码都以同样的方式与 DataSet 交互。这样您就可以更改基础数据源,而不用更改代码。

然而,使用 DataSet 的最重要的优点是提高性能。由于 DataSet 与基础数据库断开连接,代码无需经常调用数据库,因而显著地提升了性能。您可以将许多新行添加到在一个 DataSet 中的多个数据表,并在创建 DataSet 时检查每一行的有效性和引用的完整性。DataAdapter 对象将 DataSet 连接到基础数据库,然后就可以用一条命令来更新基础数据。每个表中的所有新行都是通过该命令添加的,这确保了添加到数据库中的所有行的有效性。

同大多数性能优化一样,这种优化也是有代价的。由于 DataSet 对象与基础数据库断开连接,因而数据有可能过时。由于 DataSet 不能保持实时数据,而是填充 DataSet 时的实时数据的快照,因而有可能出现与数据并发有关的问题。在多个用户访问同一数据时,如果在其他用户不知道的情况下任何一个用户都能更新数据,则可能会发生数据并发问题。然后就有可能某个用户意外地更新数据,而不知道自己正在更改的数据已不是在应用程序中看到的同一数据了。幸运的是,当数据并发问题产生时,DataSet 对象拥有捕获数据并发问题的内置支持功能,以便您的应用程序能作出相应的反应。

示例应用程序

某个虚构的公司使用我创建的客户服务应用程序来代表他们的客户创建定单,同时更新客户个人信息。许多客户销售代表 (CSR) 从自己的桌面上使用该应用程序。这些 CSR 通过电话接受定单,从客户收集个人和付款信息。客户记录保存在数据库中以加快处理回头客户的定单的速度。然后 CSR 创建一个定单,并为每个定单添加单独的产品,指定数量和当前价格。一旦收集到所有的信息,CSR 便按下“Place Order”按钮,这样便将客户和定单记录插入到实际的数据库中。

CSR 也使用该应用程序来完成通过电子邮件或普通邮件发送至公司的请求。这些请求将平均分配给各 CSR,并于每天早上转发给 CSRCSR 通过往来电话完成这些请求。该系统旨在提高完成请求的速度,然而为此付出的代价便是使 CSR 分享所有的客户。单个客户不管是通过电话还是通过邮件提出的每一个请求都有可能被不同的 CSR 处理,这样便提高了产生数据并发问题的可能性。

为了提高性能,该应用程序在内存中保留一个填有客户和定单的 DataSet 对象。由于许多员工同时使用该应用程序,因而便会有实时数据的许多已断开连接的快照,每一个员工的工作站都有一个这样的快照。所有的客户维护、定单条目以及定单维护都使用这个名为 dsAllData DataSet 对象。图 1 中显示了用于构建 dsAllData 的代码,且该代码是全局模块的一部分,因而可用于应用程序中的所有窗体。

构建 dsAllData 的代码创建了一个空的 DataSet 对象、3 DataAdapter 以及 3 CommandBuilder。每个 DataAdapter 都从适当的表中执行简单的“SELECT *”操作,而 CommandBuilder 则填充必要的其他信息,以便 DataSet 具有完整的插入、更新和删除功能。Main 例程使用 DataAdapter 对象用来自 3 个表的数据填充 dsAllData,然后采用客户维护窗体启动应用程序。

2 显示了 Customer Maintenance 屏幕,该屏幕含有一个绑定到 dsAllData 中的 Customers 数据表的 DataGrid 控件。这个简单的网格允许 CSR 编辑客户的任何基本属性。由于该网格绑定到 Customers DataTable,因而对该网格中的值进行的任何更改都将自动地存储到 DataTable 中。在 CSR 通过单击 Save Changes 按钮显式通知该窗体更新基础数据源之前,dsAllData 将一直存储这些值。

2 客户维护网格允许编辑

对于定单条目,使用图 3 中的代码来创建一些新行并将其添加到 dsAllData。首先是创建一个 Order 记录,然后在 OrderDetail 数据表中为定单定单的每个项创建记录。将所有必需的行添加到 dsAllData 后,只需调用一次适当的 DataAdapter 更新方法即可用这些新行更新基础数据源。

由于有许多 CSR 同时使用该应用程序和更新客户信息,因而在任何特定的时间 CSR 所看到的信息都可能是过时的信息。为此作计划时,应用程序架构师决定 dsAllData 中缓存的数据应该每 30 分钟刷新一次,以确保 CSR 通常查看的信息是正确的。应用程序经过了仔细设计,以便确保仅在空闲时间执行数据刷新,这样就不会影响性能。因此,DataSet 的刷新时间间隔将稍稍超过 30分钟,这取决于 CSR 的活动。

4 应用程序模型

该应用程序的数据模型非常简单(请参见 4)。它存储在 SQL Server 2000 中并只由 3 张表组成,一张用于客户,一张用于定单,一张用于每个定单的细节。已定义适当的主键和关系以确保引用的完整性。另外,还在 OrderDetail 表中定义了触发器以更新 Orders 表中的 Total 列。每次 OrderDetail 记录插入、更新或删除时,触发器都将触发,计算定单的总销售额,并更新 Orders 表的适当行。在图 5 中显示了触发器 UpdateOrderTotal 的代码。

第一次数据并发异常

客户服务应用程序在生产中正常运行了几个月,后因突然遭遇未经处理的 DBConcurrencyException 而停机。在这一部分,我将分别讲解导致这一新的异常的几种情况。

第一位客户销售代表 Joe 启动了应用程序。这将强制对 dsAllData DataSet 进行初始数据加载,并开始每隔 30 分钟左右的数据刷新循环。Joe 的收件箱中有一堆书面工作要做,包括更改客户以传真、邮寄或者电子邮件的方式发来的请求。他开始着手完成这些更改请求,但却时常被电话定单打断。

与此同时,第二位客户销售代表 Sally 到了办公室,然后启动了自己的应用程序实例。Sally 的应用程序实例从 SQL Server 进行初始数据加载,其中包括 Joe 到目前为止所做的任何更新。Sally 接到一个客户打来的电话,请求更改电话号码。这个客户先前曾通过电子邮件形式请求更改地址,但当时还不知道新的电话号码,现在他们想更新他们的记录。Sally 使用 Customer Maintenance 屏幕更新客户电话号码。当 Sally 在该数据网格中更改电话号码时,新的电话号码已存储到 dsAllDataSally 确认客户的其他信息时发现原始的地址更改请求尚未处理,于是她便更新了信息,然后单击 Save Changes 按钮,将新的数据发送至 SQL Server 数据库。

Joe 在旁边的隔间里勤勉地处理以电子邮件方式发来的请求,这时他遇到同一客户的原始更改请求。当他打开 Customer Maintenance 屏幕时,应用程序像设计的那样工作,从缓存的 DataSet 对象中提取信息。Joe 的应用程序本身并不与数据库自动同步,因为 Sally 更新了客户地址,所以他的 Customer Maintenance 屏幕仍然显示旧地址。Joe 用电子邮件中提供的新信息更正了 DataGrid 中显示的信息,然后单击了 Save Changes 按钮。然而当 Joe 这样操作时,却接收到一个错误消息,即“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 类型,而且是 DataAdapter 对象内置的特定功能的结果(参见 6)。DataAdapter 设计用来将数据入已断开连接的对象(如 DataSet),这样它就可以在执行更新之前自动仔细检查数据是否更改。如果基础数据已更改,则 DataAdapter 会引发 DBConcurrencyException 而不执行请求的更新。

6 DBConcurrency 异常

这种完整性检查的实现相当简单,而且可以利用 DataTable 对象保存多组数据的能力。当数据第一次加载到数据表中时,数据表中的所有数据行的 DataRowVersion 属性都设置为 Original。当您在修改数据行中的某个列值时,会创建 DataRow 的副本并将其标记为 Current。标记为 Original 的数据行仍然以其未更改的形式存在。随后对数据进行的所有更改仅影响 Current 数据行。当对数据表(或 DataSet 中的多个数据表)执行 DataAdapter Update 方法时,它会循环访问所有的 Current 行,以确定应该应用于基础数据源的 Update 语句。除了 DataRowVersion 属性之外,数据行还具有 RowState 属性,该属性标识行中数据的状态。可能的值包括 UnchangedAddedModifiedDeleted Detached

现在,已确定基础数据中的哪些行需要更新,dsCustomer DataAdapter 构建更新 SQL Server 数据库所需的 SQL 语句。回顾一下,在图 1 中我使用 CommandBuilder 对象及 DataSet 来构建必要的 INSERTUPDATE DELETE 语句。CommandBuilder 对象创建的 UPDATE 语句使用在 DataRowVersion 值为 Original 的数据行副本中存储的值来识别和更新数据库相应的行。即,CommandBuilder 创建一个SQL 语句来查找与存储在 DataSet 中的所有原始值完全匹配的行,而不是简单地使用主键值识别正确的行。以下代码是为更新客户电话号码而构建的 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 语句使用的是参数而不是实际值,但您可以看到行中每一列的值是如何被检查的。

现在,准确的行和基础数据库中的原始值已识别,DataAdapter 可以安全地更新该行。然而,假如在 DataTable 填充之后数据库中的行有任何的列值更改,UPDATE 语句将失败,因为数据库中没有与 WHERE 子句中的条件相匹配的行。只需简单地检查数据库中已更新的实际行数,DataAdapter 就可以很容易地确定 UPDATE 是否成功。假如没有行被更新,那么基础数据肯定已被更改或删除,并引发数据并发异常。这解释了 Joe 在尝试更新客户电话时接收到的有点模糊的错误信息,DataAdapter 检测到的准确错误不是已更改的基础数据,而是没有记录被更新,这指示基础数据肯定已被更改。

解决方案

这里有几种处理 DBConcurrencyException 的方法。第一种是确保它从未出现。可以消除图 1 中所用的 SqlCommandBuilder 对象,并用为 DataAdapter 对象的 UpdateCommand 属性自定义构建的 SqlCommand 对象来替代。我将用一个仅仅基于主键而不是所有可用列进行筛选的 WHERE 子句来创建用于 CommandText 属性的 SQL。这将消除所有并发问题(假定主键是不可变的)。

然而,这种技术将引入几个问题。首先,这将需要相当多的其他代码,因为我将不得不为每一个 DataAdapter 中的 InsertCommand DeleteCommand 属性创建 SqlCommand 对象。另外,如果基础数据库架构更改,则这些硬编码的 SQL 语句将引入新的错误。使用 SqlCommandBuilder 对象,应用程序可以在运行时确定数据库架构,接受可能已引入的任何更改,并相应地创建 SQL 语句。这不能解决并发问题,但总之可以避免该问题,使用户在不知情的情况下可以重写他人所作的更改。

更好的解决方案是捕获异常,如图 7 所示。Try...Catch 块捕获 DBConcurrencyException,并为用户显示一个用于识别异常并给他们提供一个选项的消息框,如 8 所示。这时,我已确定并发错误已出现并有两个选择:可以检索新的基础数据并显示给用户,迫使他们再次进行更改。或者,可以简单地用用户指定的更改重写新基础数据。这些是在 8 中所示的消息框中显示的选项。

8 处理数据并发异常

如果用户决定查看新的基础更改并放弃当前的更改,您只需简单地刷新存储在 Customer 数据表中的数据即可。由于数据网格绑定到该数据表,所以网格将自动地显示新数据。要刷新数据,您有两个选择。首先,用 daCustomer DataAdapter Fill 方法再次简单地填写数据表中的全部内容。如果这种技术能起作用,问题就完全解决了,因为您只对单个数据行中的数据刷新感兴趣。我已创建了一个名为 UpdateRow 的例程,它只为相关的单个数据行提取数据。(参见 图 9)。使用 UpdateRow 例程时需要注意的一个问题是,我已在参数集合中定义找到的数据行将有单个整数键列。如果该表有一个不同的数据类型的键或一个组合键,那么您将需要重载 UpdateRow 以满足特定的键要求。用当前的数据对数据行和/或数据表进行刷新后,在引发并发异常的行的旁边,DataGrid 将显示一个小的 Error 图标(参见 10)。

10 数据网格显示 Error 图标

用户的另一个选择是放弃对基础数据库所作的更改并强制他们的更改生效。这有几种实现方式。第一种方式是直接针对 SQL Server 数据库执行 SQL 语句,以便与数据表中的数据同步。虽然这个方法执行得不错,但它可能有问题,因为它将导致您对每一个数据库更改都重新编写 SQL 语句。为该技术编写的 SQL 也有可能需要对正在使用的特定数据库版本进行硬编码,从而失去了 DataAdapter DataSet 对象提供的抽象功能。

更好的选择是使用我先前编写的 UpdateRow 例程并允许 DataAdapter 处理更新。图 9 中的代码首先创建带有新的更改的数据行的副本,然后调用 UpdateRow 例程从数据库中获取新数据。当您再次尝试执行更新时,调用 UpdateRow 过程是必要的,这样您就不会接收到另一个并发异常。然后该代码循环访问数据行中的所有列,从而以用户提供的值来替代新检索到的值。最后,DataAdapter 用于更新带有用户的更改的基础数据库。

这两种代码解决方案都有些潜在的问题。首先,在默认的情况下,DataAdapter Update 方法在第一次发生并发异常时会失败,而且不会继续处理其他的数据行。这有可能导致数据的部分更新或几次并发异常,需要用户逐个地进行处理。另一个潜在的异常可能会在如图7 所示的代码中引发,也有可能会在将用户的更改强加到基础数据库的分支中引发。在调用 UpdateRow 例程与对 daCustomer 执行 Update 方法之间,另一个用户更改基础数据的可能性很小。这将产生一个未经处理的 DBConcurrencyException。一个可能的替代方案是在 Try...Catch 块中放置一个 Update 方法调用,但是您的第二个 Catch 代码将与第一个相似,而且有产生自己的并发异常的潜在可能,这需要更多的 Try...Catch 块,以至无限。一个更优雅的解决方案是将这个代码分解成一个独立的例程,在万一引发多个并发异常的情况下,可以递归调用它。

使用 SQL Server 触发器

我这个虚构公司的客户服务应用程序的开发人员现在已经更正了代码,以处理 Joe 在更新一个客户时接收到的 DBConcurrencyException,而且所有的事情都重新进行得很好(即直到那个午后 Sally 尝试输入一个定单)。

Sally 接到一个客户打来的电话,他想下一个定单。数据库中已有这个客户,于是 Sally 提取了该定单的基本信息,包括送货地址。然后,她打开 OrderDetails 屏幕,在定单中添加了两个项。每个 OrderDetail 记录都包括 OrderIDProductIDQuantity Price。如果定单正确,Sally 便单击 Place Order 按钮,以在数据库中插入该定单。在如图 3 所示的代码中,我已经添加 OrderDetail 记录的硬编码值来简化代码。

已成功地下定单并将其插入到数据库中,但是 Sally 接收到一个通知,提示这些项之一目前缺货,因而两周内无法为该定单送货。客户知道当包裹可以交付时,他将不在城里,于是便询问是否可以更改该定单的送货地址。在此以前,Sally 已通过 Order Maintenance 屏幕对该定单信息作了更改,因而她回答说很愿意提供帮助。她打开适当的屏幕,然后为该客户更改了送货地址。然而,当她单击 Save Changes 按钮时,她看到一个类似 Joe 之前看到的错误对话框,之后应用程序崩溃。

理解为什么会引发 DBConcurrencyException 的关键在于基础数据库。回顾一下图 5,我在为所有的 INSERTUPDATE DELETE 触发 OrderDetail 表中放置了一个触发器。通过为绑定到该 Order 每个 OrderDetail 行计算 Quantity Price 的总数,触发器 UpdateOrderTotal 更新定单的总成本。当 Sally 创建带有两个项目的定单时,应用程序首先在 Orders 中插入一个新行,然后在 OrderDetail 中插入两个新行。在每个 OrderDetail 记录创建之后,触发器会被触发,这样就可以更新 Order 记录的 Total 列。然而,该值仅在数据库中生成,而不会自动地传递到应用程序。事实上,图 3 中的代码并未指定一个 Total,因为在图 1 中已指定了默认值。.然后,默认值 0 与新的 Orders 记录被传递到 SQL Server,在创建定单时新的 Orders 记录是正确的。数据库然后更新 Total 列,但是应用程序中的数据表仍然将 0 作为定单总数。当 Sally 尝试更新 Orders 记录时,DataAdapter 识别 Total 列已更改,然后引发 DBConcurrencyException

问题在于 Sally 过去更新了 Order 记录,但碰巧由于上一次自动数据刷新已进行,所以她便没有更新已创建的新定单。在创建每一个定单的 30 分钟内,dsAllData DataSet 对象被刷新,这将以正确的总数更新 Orders 数据表。在自动数据刷新(或者应用程序重新启动)后所作的任何定单更新将按计划进行。

这一问题的解决方案与前面的问题相似:刷新用户已看到的数据。但是,这里我可以更加主动些。我不是等待并发异常发生,而是在创建 OrderDetail 记录后自动刷新每个新 Orders 的数据行。可以修改图 3 中的代码,以便在调用 daOrderDetail Update 方法之后为 Orders 中的指定行包含对 UpdateRow 例程的调用。通常,SQL Server 将几乎同时完成该触发器,但是您不能依赖于此,所以一个谨慎的开发人员可能会添加一个延时,以便触发器有足够的时间来完成。

小结

新的 ADO.NET DataSet 对象的断开连接的性质提供了一些重要的性能方面的好处,而与此同时付出了在应用程序中引入新的数据并发错误类型的代价。通过了解这些错误的发生方式与原因,您可以优雅地捕获和处理它们,但这也给应用程序添加了附加级别的支持。这在维护基础数据库的完整性方面给予了用户更多的选择。当应用程序开发进一步转向有着成百或成千同时操作的用户的 Internet Intranet 应用程序的世界时,处理并发问题必将成为越来越大的挑战。ADO.NET 为您提供了战胜这一挑战必需的所有工具。

原创粉丝点击