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

ASP.NET 乐观并发控制

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (23投票s)

2003年8月20日

9分钟阅读

viewsIcon

243588

downloadIcon

2344

如何在没有 DataSet 的情况下实现乐观并发控制

引言

您是否曾接到用户电话,说他们编辑了一个记录,但所做的更改却丢失了?如果发生过这种情况,那么您的应用程序可能正在实施“最后写入者获胜”的并发控制。

本文重点介绍“乐观并发控制”,并手动实现,不使用 DataSet。在第二部分,我将重点介绍“悲观并发控制”。

背景

有三种类型的并发控制

  • 悲观并发控制 - 从记录被获取到更新到数据库的整个时间段内,该行对用户都不可用。
  • 乐观并发控制 - 只有在数据实际更新时,该行才对其他用户不可用。更新会检查数据库中的行,并确定是否已进行任何更改。尝试更新已更改的记录会导致并发冲突。
  • “最后写入者获胜” - 只有在数据实际更新时,该行才对其他用户不可用。然而,不会进行任何努力来将更新与原始记录进行比较;记录将被简单地写入,可能会覆盖自您上次刷新记录以来其他用户所做的任何更改。

全文可在这里找到。

悲观并发控制

在断开连接的体系结构中,虽然无法使用数据库锁来实现“悲观并发控制”,但可以按以下方式实现:

  • 使用表行级别的锁定位,并在会话池中维护它们。
  • 在用户以程序控制方式离开页面时清除锁定位。必须这样做以尽快释放锁定,但不能保证会发生。
  • 由于用户可以随时离开浏览器或站点,因此我们必须在 Session_End. 中清除存储在会话池中的锁定位。
  • 作为最后的对策,我们可能需要一个运行在服务器上的守护进程来清理旧的锁定。

我们必须记住,长时间持有锁的应用程序无法扩展,但这种并发控制方案可能必须在应用程序的某些部分实现。

“最后写入者获胜”

要实现此并发控制,我们无需执行任何操作。但在某些情况下,如果不同用户开始频繁访问同一记录,这可能不可接受。

正如 MSDN 所解释的

  • 用户 A 从数据库获取一条记录。
  • 用户 B 从数据库获取同一条记录,修改它,然后将更新后的记录写回数据库。
  • 用户 A 修改“旧”记录(或仅单击“接受”)并将其写回数据库。

在上述场景中,用户 A 永远看不到用户 B 所做的更改。如果您计划使用“最后写入者获胜”的并发控制方法,请确保这种情况是可以接受的。

乐观并发控制

本文将重点介绍这种方法,我将深入探讨。

乐观并发控制基于记录版本。多个用户可以打开同一记录的页面,但只有第一个成功保存它的用户才能成功。

工作原理

  • 用户 A 从数据库获取记录及其版本,稍后将详细介绍。
  • 用户 B 从数据库获取同一记录,同样获取其版本,然后将更新后的记录写回数据库,更新版本号。
  • 用户 A 尝试将记录写入数据库,但由于数据库中的版本与用户 A 持有的版本不同,写入失败,从而使用户 B 的更改保持不变。

记录“版本”

NET DataSet 使用“所有值”方法,比较所有字段的旧值与新值。这提供了记录的版本,但如果您不使用 DataSet,这可能是一场噩梦。首先,您必须考虑可能的 null 值,并且如果向表中添加了新字段,则必须更改 WHERE 子句。

我们可以使用单个字段来设置记录版本,而不是“所有值”。为此,我们可以使用 GUID 字符串或日期时间值。我决定使用日期时间,原因如下:

  • 如果我们要将代码移植到其他平台,GUID 则不可移植。
  • GUID 使用 40 字节,而日期使用 8 字节。
  • 日期时间告诉我们记录上次更新的时间。

检查乐观并发

更新记录时,必须将版本添加到 WHERE 子句中

UPDATE table SET fields = values WHERE pk 
= pk_vlaue AND concurrency = concurrency_value

如果用户提供的并发值与数据库中的并发值不匹配,则记录不会被更新,并且返回 0 行受影响。

使用此 SELECT,我们必须考虑到,在用户修改记录的过程中,另一位用户可能已经删除了同一条记录。在这种情况下,受影响的行也将为 0。

如果您想向用户提供关于情况的准确消息,则在记录被删除的情况下,我们无法说记录已被另一位用户更新。要解决此问题,我们必须检查表中是否存在主键相同的记录。

SELECT COUNT(*) FROM table WHERE pk = pk_value

此时,如果记录计数为零,则表示记录已被删除,否则;必须抛出并发异常。

处理用户界面

最后,我们必须通知用户,由于另一位用户更改了数据,他们的更改不成功。

现在该怎么办?只显示一个 JavaScript 警报或弹出窗口?

如果用户 A 更改了 10 个字段,而用户 B(最先保存)只更改了一个字段,会发生什么?用户 A 会丢失所有 10 个字段吗?

此处实现的方法是重新加载页面,仅替换用户 B 修改过的数据,尽可能保留用户 A 的更改。

安装

数据库

示例代码开发用于使用 SQL Server 安装的 Northwind 数据库。需要创建以下存储过程(存储过程在 zip 文件中)

  • CategoriesList
  • ProductsByCategory
  • SuppliersList
  • ProductsRead
  • ProductsUpdate

Products 表添加 Concurrency 字段,类型为 DateTime

Concurrency 字段设置为某个值。例如:所有记录设置为 01/01/2003。它不能与 null 值一起使用。创建 INSERT 方法时,它必须将 Concurrency 设置为 GETTIME()

连接字符串

Web.config 文件中设置适当的 Data Sourceuidpwd 值。

<appSettings>
     <add key="SQLConnString" 
    value="Data Source=(local);uid= ;pwd= ;database=Northwind"/>
</appSettings>

安装应用程序

您必须手动设置应用程序将运行的虚拟目录。这可以通过“Internet Information Services”完成,或者直接从 Windows 资源管理器完成。右键单击文件夹,选择属性,转到“Web 共享”选项卡,然后选择“共享此文件夹”。

工作原理

流程

  • 产品列表加载并显示在 Default.aspx
  • 当在产品上单击“编辑”时,将加载 Product.aspx 页面,并将要编辑的产品 ID 作为参数 ProductID 传递。
  • 调用 Page_Load(),并加载类别和供应商组合框。
  • 加载产品数据并将其设置为 Web 控件。请注意,在该过程的最后,会调用 SetConcurrencyObject(product) 方法。这将对象存储为 BLL.CRUD,因为它从数据库读取,以便以后可以与并发冲突进行比较。
  • 当用户单击“接受”按钮时,将调用 Save() 方法。此时,并发控制开始。

在产品上调用 Update 方法

try
{
    // Update the product

    product.Update();
    
    // Redirect to the product's list

    Response.Redirect("Default.aspx");
}
catch (DeletedRowInaccessibleException)
{
    Response.Redirect("Error.aspx?msg=" + System.Web.HttpUtility.UrlEncode
("The product has been deleted by another user."));
}
catch (DBConcurrencyException)
{
    ConcurrencyException();
}
catch (Exception ex)
{
    throw ex;
}

如果产品已被另一位用户删除,则流程会重定向到错误页面。如果存在并发异常,则调用 ConcurrencyException() 方法来处理异常并显示冲突的字段。

private void ConcurrencyException()
{
    // Get the mapping controls - object properties 

    Hashtable controls = GetControlsMap();
    
    // Update the page to show the fields that have concurrency conflicts

    ShowConcurrencyFields(controls);
    
    // Show the concurrency error label

    lblConcurrencyMsg.Visible = true;
}

该方法 GetControlsMap() 获取 Web 控件与对象属性之间的一对一映射。这允许在发生与任何对象属性的并发冲突时更改控件的外观和感觉。

任何需要处理并发异常的页面都必须继承自 BasePage

并发处理的核心在 ShowConcurrency()

从视图状态中检索原始对象(读取时的状态)。

BLL.CRUD userObject = (BLL.CRUD) ViewState[CONCURRENCY_OBJECT];

然后必须读取对象的最新数据,以便与并发冲突之前读取的原始数据进行比较。这是通过通用代码完成的,在 BLL.CRUD 对象上调用 Read() 方法;为此,对象必须继承自 BLL.CRUD

// Instantiate an object of the same type and read its properties

Type type = userObject.GetType();
BLL.CRUD dbObject = (BLL.CRUD) type.Assembly.CreateInstance(type.FullName);
dbObject.ID = userObject.ID;
if (!dbObject.Read())
    Response.Redirect("Error.aspx?msg=" + System.Web.HttpUtility.UrlEncode
("The record has been deleted by another user."));

读取新对象后,使用反射获取差异。

IList differences = BLL.ObjectDifference.GetDifferences(dbObject, 
userObject);

最后,更改 Web 控件的样式,以向用户显示冲突的字段。

foreach (BLL.ObjectDifference diff in differences)
{
    // Get the control

    WebControl ctrl = controls[diff.PropertyName] as WebControl;
    if (ctrl != null)
    {
        :
        :
        :
    }
}

存储过程

所有存储过程基本上都是 SELECT 语句,用于检索数据。因此,我将只关注 ProductsUpdate

CREATE PROCEDURE ProductsUpdate
(
        @ProductID int,
        @CategoryID int,
        @SupplierID int,
        @Name varchar(40),
        @QuantityPerUnit varchar(20),
        @UnitPrice decimal(19,4),
        @UnitsInStock smallint,
        @UnitsOnOrder smallint,
        @ReorderLevel smallint,
        @Discontinued bit,
        @Concurrency datetime
)
AS
    UPDATE
        Products
    SET
        ProductName = @Name,
        SupplierID = @SupplierID,
        CategoryID = @CategoryID,
        QuantityPerUnit = @QuantityPerUnit,
        UnitPrice = @UnitPrice,
        UnitsInStock = @UnitsInStock,
        UnitsOnOrder = @UnitsOnOrder,
        ReorderLevel = @ReorderLevel,
        Discontinued = @Discontinued,
        Concurrency = GETDATE()        -- When updated, set the 

                                          Concurrency to the server's date
    WHERE
        ProductID = @productID AND
        Concurrency = @Concurrency
        
    IF @@ROWCOUNT = 0
        BEGIN
            IF EXISTS( SELECT ProductID FROM products 
                          WHERE ProductID = @productID )
                RETURN 2    -- Concurrency conflict
            ELSE
                RETURN 1    -- The record has been deleted
        END
    ELSE
        RETURN 0            -- The record could be updated

您可以注意到,UPDATEWHERE 子句中同时查询了 ProductIDConcurrencyProductID 是主键,而 Concurrency 字段保证我们更新的记录不是自数据收集以来被另一位用户修改过的。

如果 @@ROWCOUNT 大于零(因为是主键,它应该是 1),则记录可以在没有并发冲突的情况下被更新。

如果 @@ROWCOUNT 为零,则有两种可能性:

  • 记录已被另一位用户删除,这由 IF EXISTST 检查。
  • 存在并发冲突。

对于每种情况,存储过程都会返回一个值:

  • 0:记录可以被更新
  • 1:记录已被删除
  • 2:并发冲突

执行更新查询时,在 BLL.Product.csUpdate() 方法中检查返回值,并抛出相应的异常。

// Check for success

switch ( (UpdateRecordStatus) parms[11].Value)
{
    case UpdateRecordStatus.Concurrency:
        throw new DBConcurrencyException("The record has 
            been modified by another user or process.");
    case UpdateRecordStatus.Deleted:
        throw new DeletedRowInaccessibleException();
}

试用

尝试并发控制

  • 打开应用程序的两个不同实例。
  • 在两个页面上单击同一产品的“编辑”。
  • 更改第一个页面上的某些值并“接受”它。
  • 更改第二个页面上的某些值并“接受”它。

接受第二个页面时,第一个页面上更改的字段将变为灰色,而未冲突的字段将保持不变。

局限性和改进之处

  • BLL 对象的主键只能有一个字段。这通常是我设计表的方式,但可能存在例外。
  • 使用样式表(CSS)来改进并发冲突的显示。

结论

并发控制会增加应用程序的复杂性、调试和维护成本,但用户在使用它时将获得更好的体验。

请记住,并非必须为所有应用程序更新实现乐观并发控制。您可以根据需要混合使用这三种机制。

历史

  • 2003 年 8 月 20 日 - 版本 1.0
    • 初始版本。
© . All rights reserved.