所见即所得更新
这是“所见即所得更新”的替代方案
引言
本文是 SQL Server 特有文章 "所见即所得更新" 的替代方案,展示了 Oracle 环境中的问题(以及解决方法)。因此,如果您在 SQL Server 环境中工作,请使用原始文章。
有关问题的描述,请参阅 “引言,问题”,有关演示程序的图片,请查看 “演示程序”。
目录
基本更新
确保您更新的是正确的行很简单,只需根据唯一键更新记录即可。让我们来看看程序中的基本更新。
假设您有一个如下所示的表
CREATE TABLE Concurrency1 (
Id number NOT NULL,
TextColumn varchar2(100) NULL,
ValueColumn number NULL
);
获取数据是通过以下代码完成的
/// <summary>
/// Fetch the data from the database and return as a collection
/// </summary>
/// <returns>Collection of data items</returns>
override public ObservableCollection<Data.TheData> FetchData() {
try {
using (OracleCommand command = new OracleCommand()) {
command.Connection = this.connection;
command.CommandText = @"
SELECT Id,
TextColumn,
ValueColumn
FROM Concurrency1";
using (OracleDataReader reader = command.ExecuteReader()) {
this.CurrentDataCollection = new ObservableCollection<Data.TheData>();
while (reader.Read()) {
this.CurrentDataCollection.Add(new Data.TheData() {
Id = (decimal)reader["Id"],
TextValue = reader["TextColumn"] != System.DBNull.Value
? (string)reader["TextColumn"] : null,
NumberValue = reader["ValueColumn"] != System.DBNull.Value
? (decimal?)reader["ValueColumn"] : null
});
}
}
}
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return this.CurrentDataCollection;
}
return this.CurrentDataCollection;
}
更新是通过以下方式完成的
/// <summary>
/// Saves a single data item
/// </summary>
/// <param name="data">Data to save</param>
/// <returns>True if succesful</returns>
override public bool SaveItem(Data.TheData data) {
int rowsAffected;
try {
if (this.transaction == null) {
this.transaction = this.connection.BeginTransaction();
}
using (OracleCommand command = new OracleCommand()) {
command.Connection = this.connection;
command.Transaction = this.transaction;
command.CommandText = @"
UPDATE Concurrency1
SET TextColumn = :TextColumn,
ValueColumn = :ValueColumn
WHERE Id = :Id";
command.Parameters.Add(":TextColumn", data.TextValue);
command.Parameters.Add(":ValueColumn", data.NumberValue.HasValue
? (object)data.NumberValue.Value
: System.DBNull.Value);
command.Parameters.Add(":Id", data.Id);
rowsAffected = command.ExecuteNonQuery();
if (rowsAffected != 1) {
throw new Exception(string.Format("Wrong number of rows ({0}) affected", rowsAffected));
}
}
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return false;
}
return true;
}
与原始文章一样,为了演示问题,请执行以下操作
- 打开程序的两个实例
- 实例 1
- 获取数据
- 选择第一行
- 实例 2
- 获取数据
- 选择第一行
- 实例 1
- 将 number 字段修改为
123
- 按保存按钮
- 将 number 字段修改为
- 实例 2
- 将 number 字段修改为
456
- 按保存按钮。请注意,程序会冻结,因为它在等待锁
- 将 number 字段修改为
- 实例 1
- 按提交按钮,实例 2 的更新现在可以继续执行
- 实例 2
- 按提交按钮
- 实例 1
- 获取数据
- 实例 2
- 获取数据
查看网格中的数据时,您会注意到实例 2 中更新的数据,number 字段的值仍然是 456
。实例 1 中更新的数据被简单地覆盖了。
更新时检查行版本
问题的解决方案是确保您同时更新了正确的行和行的正确版本。SQL Server 有一种特殊的自动在每次更新时更改的数据类型,而 Oracle 没有直接的等效项。但是,在 Oracle 中轻松实现相同的功能。在此示例中,我使用了一个触发器,该触发器在每次更新时简单地递增版本号。现在表可能看起来像这样
CREATE TABLE Concurrency2 (
Id number NOT NULL,
CurrentVersion number NOT NULL,
TextColumn varchar2(100) NULL,
ValueColumn number NULL
);
可以看到,有一个名为 CurrentVersion
的额外列。此列现在存储当前的行版本。更新值的触发器可能如下所示
CREATE OR REPLACE TRIGGER Concurrency2Trigger
BEFORE INSERT OR UPDATE ON Concurrency2
FOR EACH ROW
BEGIN
:NEW.CurrentVersion := COALESCE(:OLD.CurrentVersion, 0) + 1;
END;
/
触发器只是取旧行的值并加一。插入时,旧行不存在,因此旧值是 NULL
。在这种情况下,使用 0
来获得“种子”值。
获取数据时,代码与之前基本相同,但也会检索 rowversion
列的值。
/// <summary>
/// Fetch the data from the database and return as a collection
/// </summary>
/// <returns>Collection of data items</returns>
override public ObservableCollection<Data.TheData> FetchData() {
Data.TheData data;
try {
using (OracleCommand command = new OracleCommand()) {
command.Connection = this.connection;
command.CommandText = @"
SELECT Id,
TextColumn,
ValueColumn,
CurrentVersion
FROM Concurrency2";
using (OracleDataReader reader = command.ExecuteReader()) {
this.CurrentDataCollection = new ObservableCollection<Data.TheData>();
while (reader.Read()) {
data = new Data.TheData();
data.Id = (decimal)reader["Id"];
data.TextValue = reader["TextColumn"] != System.DBNull.Value
? (string)reader["TextColumn"] : null;
data.NumberValue = reader["ValueColumn"] != System.DBNull.Value
? (decimal?)reader["ValueColumn"] : null;
data.CurrentRowVersion = reader["CurrentVersion"] != System.DBNull.Value
? (decimal?)reader["CurrentVersion"] : null;
this.CurrentDataCollection.Add(data);
}
}
}
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return this.CurrentDataCollection;
}
return this.CurrentDataCollection;
}
因此,重要的是现在程序中始终包含您在用户界面中显示的所有行的 CurrentVersion
列的值。当您想更新行时,您可能会有如下代码
/// <summary>
/// Saves a single data item
/// </summary>
/// <param name="data">Data to save</param>
/// <returns>True if succesful</returns>
override public bool SaveItem(Data.TheData data) {
int rowsAffected;
try {
if (this.transaction == null) {
this.transaction = this.connection.BeginTransaction();
}
using (OracleCommand command = new OracleCommand()) {
command.Connection = this.connection;
command.Transaction = this.transaction;
command.CommandText = @"
UPDATE Concurrency2
SET TextColumn = :TextColumn,
ValueColumn = :ValueColumn
WHERE Id = :Id
AND CurrentVersion = :CurrentVersion";
command.Parameters.Add(":TextColumn", data.TextValue);
command.Parameters.Add(":ValueColumn", data.NumberValue.HasValue
? (object)data.NumberValue.Value : System.DBNull.Value);
command.Parameters.Add(":Id", data.Id);
command.Parameters.Add(":CurrentVersion", data.CurrentRowVersion);
rowsAffected = command.ExecuteNonQuery();
if (rowsAffected != 1) {
throw new System.Exception("Row versions do not match.");
}
}
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return false;
}
return true;
}
代码与之前的代码非常相似,但在此代码中,您还检查 CurrentRowversion
列是否仍然具有从数据库读取记录时的相同值。
重要!
如果底层行版本在数据库中已更改,则该语句不会引发错误。这是因为在这种情况下,该语句是有效的,它只是不更新任何行。因此,调查更新的行数是否正确至关重要。如果您更新单行,更新应影响一行且仅一行。
现在,如果您使用此代码版本重复第一个示例中的相同测试,您将收到如下错误消息:
当您再次将数据获取到网格并重复修改时,它将成功保存,因为现在程序知道当前的行版本。
在存储过程中进行检查
许多人使用存储过程来更改数据。在这种情况下,原理是相同的,但您只需要将行版本的当前值传递给过程即可进行检查。过程可能如下所示
------------------------------------------
-- Procedure for saving to concurrency 2
------------------------------------------
CREATE OR REPLACE PROCEDURE procConcurrency2(
pId number,
pCurrentVersion number,
pTextColumn varchar2,
pValueColumn number) AS
BEGIN
UPDATE Concurrency2
SET TextColumn = pTextColumn,
ValueColumn = pValueColumn
WHERE Id = pId
AND CurrentVersion = pCurrentVersion;
IF SQL%ROWCOUNT != 1 THEN
RAISE_APPLICATION_ERROR(-20001, 'Row versions do not match.');
END IF;
END;
/
更新行的语句与上一个示例相同,检查更新的行数也是如此。使用此过程,调用方可能如下所示
/// <summary>
/// Saves a single data item
/// </summary>
/// <param name="data">Data to save</param>
/// <returns>True if succesful</returns>
override public bool SaveItem(Data.TheData data) {
int rowsAffected;
try {
if (this.transaction == null) {
this.transaction = this.connection.BeginTransaction();
}
using (OracleCommand command = new OracleCommand()) {
command.Connection = this.connection;
command.Transaction = this.transaction;
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandText = @"procConcurrency2";
command.Parameters.Add(":Id", data.Id);
command.Parameters.Add(":CurrentVersion", data.CurrentRowVersion.HasValue
? (object)data.CurrentRowVersion.Value : System.DBNull.Value);
command.Parameters.Add(":TextColumn", data.TextValue);
command.Parameters.Add(":ValueColumn", data.NumberValue.HasValue
? (object)data.NumberValue.Value : System.DBNull.Value);
rowsAffected = command.ExecuteNonQuery();
}
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return false;
}
return true;
}
一个很大的区别是您不再在客户端检查更新的行数。如果过程更新的行数不正确,它将引发错误,客户端会收到一个可以显示的异常。
使用触发器强制进行行版本检查
现在,您知道了如何检查行版本,但如何确保该检查始终进行。一个复杂的系统可能有很多数据库操作,如何保证其中的每一个都在进行检查?答案是您无法对所有代码做出这样的保证,但您可以在数据库中强制执行检查。
想法是在一个表中保留两个行版本列,一个存储当前行版本值,另一个在执行更新时从客户端提供。新的表布局可能如下所示
CREATE TABLE Concurrency3 (
Id number NOT NULL,
CurrentVersion number NOT NULL,
PreviousVersion number NULL,
TextColumn varchar2(100) NULL,
ValueColumn number NULL
);
在前一个示例中,触发器如下所示
CREATE OR REPLACE TRIGGER Concurrency2Trigger
BEFORE INSERT OR UPDATE ON Concurrency2
FOR EACH ROW
BEGIN
:NEW.CurrentVersion := COALESCE(:OLD.CurrentVersion, 0) + 1;
END;
/
相同的触发器用于 INSERT
和 UPDATE
操作。由于我们只需要在更新期间检查版本值,因此最简单的方法是将此拆分为两个单独的触发器。第一个处理 INSERT
CREATE OR REPLACE TRIGGER Concurrency3_Insert
BEFORE INSERT ON Concurrency3
FOR EACH ROW
BEGIN
:NEW.CurrentVersion := 1;
END;
/
第二个触发器在 UPDATE 上触发
CREATE OR REPLACE TRIGGER Concurrency3_Update
BEFORE UPDATE ON Concurrency3
FOR EACH ROW
BEGIN
:NEW.CurrentVersion := :OLD.CurrentVersion + 1;
IF (:OLD.CurrentVersion != COALESCE(:NEW.PreviousVersion, -1)) THEN
RAISE_APPLICATION_ERROR(-20001, 'The given row versions do not match old row versions.');
END IF;
END;
/
当更新发生时,触发器期望客户端代码已将行版本的值提供给 PreviousVersion
列。由于触发器对所有更新的行都会触发,因此它只是检查旧行中 CurrentVersion
列的值是否与新行中 PreviousVersion
列的值相同。如果值不同,则会引发错误。
现在,如果您查看客户端代码
/// <summary>
/// Saves a single data item
/// </summary>
/// <param name="data">Data to save</param>
/// <returns>True if succesful</returns>
override public bool SaveItem(Data.TheData data) {
int rowsAffected;
try {
if (this.transaction == null) {
this.transaction = this.connection.BeginTransaction();
}
using (OracleCommand command = new OracleCommand()) {
command.Connection = this.connection;
command.Transaction = this.transaction;
command.CommandText = @"
UPDATE Concurrency3
SET TextColumn = :TextColumn,
ValueColumn = :ValueColumn,
PreviousVersion = :CurrentVersion
WHERE Id = :Id";
command.Parameters.Add(":TextColumn", data.TextValue);
command.Parameters.Add(":ValueColumn", data.NumberValue.HasValue
? (object)data.NumberValue.Value : System.DBNull.Value);
command.Parameters.Add(":CurrentVersion", data.CurrentRowVersion.HasValue
? (object)data.CurrentRowVersion.Value : System.DBNull.Value);
command.Parameters.Add(":Id", data.Id);
rowsAffected = command.ExecuteNonQuery();
if (rowsAffected != 1) {
throw new Exception(string.Format("Wrong number of rows ({0}) affected", rowsAffected));
}
}
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return false;
}
return true;
}
您可以看到 WHERE
子句中没有额外的条件,只有主键的条件。相反,在 UPDATE
语句中设置 PreviousVersion
列,使其具有您当前在客户端的行版本值。
另外,请注意,仅当主键无效时,检查更新的行数的检查才会失败。
现在,如果您再次重复开始时列出的测试,您将收到类似以下的错误
如果您也尝试更新所有行,例如使用 SQL*Plus,您也会收到相同的错误
-- Try to update several rows at a time
UPDATE Concurrency3 SET ValueColumn = ValueColumn + 1;
您将收到以下错误
ORA-20001: The given row versions do not match old row versions.
ORA-06512: at "SYSTEM.CONCURRENCY3_UPDATE", line 5
ORA-04088: error during execution of trigger 'SYSTEM.CONCURRENCY3_UPDATE'
与 Entity Framework 进行相应的检查
现在我们已经仅使用纯 SQL 讨论了这个问题,那么 Entity Framework 呢?在使用 EF 时,我们如何强制执行相同的检查?
这个想法自然是相同的,将条件嵌入到执行的 SQL 语句中。为了使示例更直观,请将 Id 列定义为主键,以便 EF 生成器能够正确获取表的实际键。
CREATE TABLE Concurrency5 (
Id number NOT NULL,
CurrentVersion number NOT NULL,
TextColumn varchar2(100) NULL,
ValueColumn number NULL
);
CREATE OR REPLACE TRIGGER Concurrency5Trigger
BEFORE INSERT OR UPDATE ON Concurrency5
FOR EACH ROW
BEGIN
:NEW.CurrentVersion := COALESCE(:OLD.CurrentVersion, 0) + 1;
END;
/
下一步是从数据库生成模型。我将不详细介绍,因为这主要是通过在向导中单击“下一步”按钮完成的…… 重要!
EF 的连接字符串位于 app.config
文件中,因此请修改它以包含您环境中的正确 TNS 别名和用户名。
模型创建后,让我们看看获取和保存的方法
/// <summary>
/// Fetch the data from the database and return as a collection
/// </summary>
/// <returns>Collection of data items</returns>
override public ObservableCollection<Data.IData> FetchData() {
try {
if (this.CurrentDataCollection == null) {
this.CurrentDataCollection = new ObservableCollection<Data.IData>();
foreach (CONCURRENCY5 item in this.CurrentContext.CONCURRENCY5) {
this.CurrentDataCollection.Add(item);
}
} else {
foreach (Data.IData item in this.CurrentDataCollection) {
this.CurrentContext.Entry(item).Reload();
}
this.CurrentDataCollection = new ObservableCollection<Data.IData>(this.CurrentDataCollection);
}
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return this.CurrentDataCollection;
}
return this.CurrentDataCollection;
}
上面的代码从 DbContext
获取项目集合。代码片段很简单,只需用 Concurrency5
表中的项目填充集合即可。代码中有几处特殊之处。首先,如果集合已存在,我只需单独从数据库重新加载项目。另一件事是返回的集合始终是一个新集合。由于我没有实现 INotifyPropertyChanged
接口,这只是一个快速的解决方法,用于强制网格响应可能的更改。
重要!
与之前的示例一样,在这个示例中,上下文和事务的生命周期也过长。原因是这样更容易测试各种场景,但在实际情况中不应保留上下文。
那么保存看起来是怎样的?最简单的形式可以是
/// <summary>
/// Saves a single data item
/// </summary>
/// <param name="data">Data to save</param>
/// <returns>True if succesful</returns>
override public bool SaveItem(Data.IData data) {
int rowsAffected;
try {
this.BeginTransaction();
rowsAffected = this.CurrentContext.SaveChanges();
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return false;
}
return true;
}
您可以看到,没有代码强制检查行版本,也没有修改 WHERE
子句,并且会存储受影响的行数,但不会检查,因为它没有意义。如果您跟踪执行的 SQL 语句,您会注意到 WHERE
子句只包含主键的条件,没有其他任何内容。
那么如何进行行版本检查呢?关键是修改您的 EF 模型。如果您打开模型并单击 CurrentVersion
列,您会看到 ConcurrencyMode
属性已更改为 Fixed
。这是通知 EF 执行乐观并发检查的方法。
那么代码中发生了什么,如何检查是否已更新正确数量的行?答案是您无需检查。如果更新未更新行,EF 将抛出 DbUpdateConcurrencyException
。因此,完整的代码可能如下所示
/// <summary>
/// Saves a single data item
/// </summary>
/// <param name="data">Data to save</param>
/// <returns>True if succesful</returns>
override public bool SaveItem(Data.IData data) {
int rowsAffected;
try {
this.BeginTransaction();
rowsAffected = this.CurrentContext.SaveChanges();
} catch (System.Data.Entity.Infrastructure.DbUpdateConcurrencyException concurrencyException) {
System.Windows.MessageBox.Show("Row versions do not match ("
+ concurrencyException.Message + ")",
concurrencyException.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return false;
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message,
exception.Source,
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
return false;
}
return true;
}
现在,如果您重复前面描述的相同测试,您应该会看到类似这样的消息“存储更新、插入或删除语句影响了意外数量的行(0)。自加载实体以来,实体可能已被修改或删除……”
参考文献
历史
- 2017 年 4 月 2 日:已创建替代方案