第八章 Caché 使用持久对象

文章目录

  • 第八章 Caché 使用持久对象
  • 保存对象
    • 回滚
    • 保存对象和事务
  • 测试保存对象的存在性
    • 使用ObjectScript测试对象是否存在
    • 使用SQL测试对象是否存在
  • 打开保存的对象
    • 多次调用%OpenId()
    • 并发
  • Swizzling(懒加载)
  • 从磁盘重新加载对象
  • 读取存储值
  • 删除保存的对象
    • %DeleteId()方法
    • %DeleteExtent()方法
    • %KillExtent()方法
  • 访问对象标识符
  • 对象并发选项
    • 为什么指定并发?
    • 并发值
    • 并发性懒加载对象
  • 版本检查(并发参数的替代方法)

第八章 Caché 使用持久对象

%Persistent类是可以保存(写入磁盘)的对象的API。本章描述了如何使用这个API。本章中的信息适用于%Persistent的所有子类。

保存对象

若要将对象保存到数据库,请调用其%save()方法。例如:

/// d ##class(PHA.OP.MOB.Test).TestSaveObject()
ClassMethod TestSaveObject()
{
	Set obj = ##class(Sample.Person).%New()
	Set obj.Name = "姚鑫"
	Set obj.SSN="111-11-1112"
	Set sc = obj.%Save()
	q sc
}
DHC-APP> w ##class(PHA.OP.MOB.Test).TestSaveObject()
1

方法 %Save()的返回的是:返回一个%Status值,该值指示保存操作是否成功。例如,如果对象具有无效的属性值或违反唯一性约束,则可能发生故障;

调用对象上的%Save()会自动保存所有被修改对象:也就是说,如果需要,所有嵌入的对象、集合、流、引用对象和涉及对象的关系都会自动保存。整个保存操作作为一个事务执行:如果任何对象保存失败,整个事务将失败并回滚(对磁盘不做任何更改;所有内存中的对象值都是调用%Save()之前的值。

当对象第一次被保存时,%Save()方法的默认行为是自动为其分配一个对象ID值,该值用于以后在数据库中查找对象。在默认情况下,使用$Increment函数生成ID;另外,类可以根据具有idkey索引的属性值使用用户提供的对象ID(在本例中,属性值不能包含字符串“||”)。一旦赋值,就不能更改特定对象实例的对象ID值(即使它是用户提供的ID)。

可以使用% ID()方法找到保存对象的对象ID值:

ClassMethod TestSaveObjectOpen()
{	
	Set person = ##class(Sample.Person).%OpenId(6)
 	Write "Object ID: ",person.%Id(),!
 	q ""
}
DHC-APP>w ##class(PHA.OP.MOB.Test).TestSaveObjectOpen()
Object ID: 6

更详细地说,%Save()方法执行以下操作:

  1. 首先,它构建一个称为“SaveSet”的临时结构。SaveSet是一个简单的图,它包含对每个对象的引用,这些对象可以从被保存的对象中访问到。(通常,当一个对象类A的一个属性的值是另一个对象类B时,A的一个实例可以“访问”B的一个实例)。SaveSet的目的是确保涉及复杂的相关对象集的save操作得到尽可能有效的处理。SaveSet还解决对象之间的任何save order依赖项。

当将每个对象添加到SaveSet时,如果存在,将调用它的%OnAddToSaveSet()回调方法。

  1. 然后,依次访问SaveSet中的每个对象,并检查它们是否被修改(也就是说,自从打开或最后保存对象以来,它们的属性值是否被修改)。如果一个对象被修改了,那么它将被保存。
  2. 在保存之前,对每个修改后的对象进行验证(对其属性值进行测试;调用它的%OnValidateObject()方法(如果存在);测试唯一性约束);如果对象有效,则执行保存。如果任何对象无效,则调用%Save()将失败,并回滚当前事务。
  3. 在保存每个对象之前和之后,如果存在% OnBeforeSave()和%OnAfterSave()回调方法,则调用它们。

这些回调传递一个Insert参数,该参数指示对象是被插入(第一次保存)还是被更新。

如果其中一个回调方法失败(返回一个错误代码),那么对%Save()的调用将失败,并且当前事务将回滚。

如果当前对象未修改,则%Save()不将其写入磁盘;它返回成功,因为对象不需要保存,因此,不可能存在保存失败。事实上,%Save()的返回值表明,保存操作要么完成了请求的所有操作,要么无法完成请求的所有操作(而且不明确是否向磁盘写入任何内容)。

重要:在多进程环境中,一定要使用适当的并发控制;

回滚

%Save()方法自动将其SaveSet中的所有对象保存为单个事务。如果这些对象中的任何一个保存失败,那么整个事务将回滚。在这个回滚,Caché 做以下工作:

  1. 返回分配的ID。
  2. 可以恢复删除的ID。
  3. 可以恢复修改过的位和数据。
  4. 调用%OnRollBack()回调方法(如果已实现),用于已成功序列化的任何对象。Caché 不会调用此方法,对于未成功序列化的对象(即无效的对象)。

保存对象和事务

如前所述,%Save()方法自动将其SaveSet中的所有对象保存为单个事务。如果这些对象中的任何一个保存失败,那么整个事务将回滚。但是,如果希望将两个或多个不相关的对象保存为单个事务,则必须将对%save()的调用封装在显式事务中:也就是说,必须使用TSTART命令启动事务,并使用TCOMMIT命令结束事务。

例如:

/// d ##class(PHA.OP.MOB.Test).TestSaveTransaction()
ClassMethod TestSaveTransaction()
{
	
	// start a transaction
	TSTART
	Set obj = ##class(Sample.Person).%New()
	Set obj.Name = "姚鑫"
	Set obj.SSN="111-11-1113"
	Set sc = obj.%Save()

	w sc,!
	// save second object (if first was save)
	If ($$$ISOK(sc)) {
	 	Set obj2 = ##class(Sample.Person).%New()
		Set obj2.Name = "姚鑫"
		Set obj2.SSN="111-11-1114"
		Set sc = obj2.%Save()

	}
	w sc,!
	// if both saves are ok, commit the transaction
	If ($$$ISOK(sc)) {
	 TCOMMIT
	}
}
DHC-APP>d ##class(PHA.OP.MOB.Test).TestSaveTransaction()
1
1

再次调用

DHC-APP>d ##class(PHA.OP.MOB.Test).TestSaveTransaction()
0 °?Sample.Person:SSNKey:^Sample.PersonI("SSNKey"," 111-11-1113")??%SaveData+18^Sample.Person.1    DHC-APP??"e^%SaveData+18^Sample.Person.1^7(e^%SerializeObject+9^Sample.Person.1^2e^%Save+8^Sample.Person.1^5.e^zTestSaveTransaction+6^PHA.OP.MOB.Test.1^1d^^^0
0 °?Sample.Person:SSNKey:^Sample.PersonI("SSNKey"," 111-11-1113")??%SaveData+18^Sample.Person.1    DHC-APP??"e^%SaveData+18^Sample.Person.1^7(e^%SerializeObject+9^Sample.Person.1^2e^%Save+8^Sample.Person.1^5.e^zTestSaveTransaction+6^PHA.OP.MOB.Test.1^1d^^^0

关于这个例子有两点需要注意:

  1. %Save()方法知道是否在一个封闭的事务中调用它(因为系统变量$TLEVEL将大于0)。
TL1:DHC-APP>w $tlevel
1

如果事务中的%Save()方法失败,则回滚整个事务(调用TROLLBACK命令)。这意味着应用程序必须测试显式事务中对%Save()的每个调用,如果一个调用失败,则跳过对其他对象调用%Save(),并跳过调用最后的TCOMMIT命令。

测试保存对象的存在性

有两种基本的方法来测试一个特定的对象实例是否存储在数据库中:

  • 使用 ObjectScript
  • 使用 SQL

在这些示例中,ID是一个整数,默认情况下,Caché 就是这样生成ID的.下一章将介绍如何定义一个类,使ID基于对象的惟一属性。

使用ObjectScript测试对象是否存在

%ExistsId()类方法检查指定的ID;如果指定的对象存在于数据库中,则返回true值(1),否则返回false(0)。所有从%Persistent继承的类都可以使用它。例如:

/// d ##class(PHA.OP.MOB.Test).TestObjectScriptExists()
ClassMethod TestObjectScriptExists()
{
 Write ##class(Sample.Person).%ExistsId(1),!   // should be 1 
 Write ##class(Sample.Person).%ExistsId(-1),!  // should be 0
}

TL1:DHC-APP>d ##class(PHA.OP.MOB.Test).TestObjectScriptExists()
1
0

这里,第一行应该返回1,因为是Sample.Person从%Persistent继承,SAMPLES数据库为该类提供数据。
还可以使用%Exists()方法,该方法需要一个OID而不是ID。

使用SQL测试对象是否存在

要测试使用SQL保存的对象是否存在,可以使用SELECT语句来选择其%ID字段与给定ID匹配的行。(已保存对象的标识属性被投影为%ID字段。)

例如,使用嵌入式SQL:

/// d ##class(PHA.OP.MOB.Test).TestSQLExists()
ClassMethod TestSQLExists()
{
	&sql(SELECT %ID FROM Sample.Person WHERE %ID = '1')
	Write SQLCODE,!  

	&sql(SELECT %ID FROM Sample.Person WHERE %ID = '-1')
	Write SQLCODE,!
}
DHC-APP>d ##class(PHA.OP.MOB.Test).TestSQLExists()
0
100

在这里,第一种情况结果SQLCODE为0(表示成功),因为Sample.Person从%Persistent继承,sample数据库为该类提供数据。第二种情况结果SQLCODE为100,这意味着语句成功执行,但是没有返回任何数据。符合预期想象,因为系统不会自动生成小于零的ID值。

打开保存的对象

要打开一个对象(将对象实例从磁盘加载到内存中),使用%OpenId()方法,方法如下:

classmethod %OpenId(id As %String, concurrency As %Integer = -1, ByRef sc As %Status = $$$OK) as %ObjectHandle
  • id 是要打开的对象的ID。在这些示例中,ID是一个整数。下一章将介绍如何定义一个类,使ID基于对象的惟一属性。
  • concurrency 用于打开对象的并发级别(锁定)。
  • sc 通过引用传递的是一个%Status值,该值指示调用是否成功或失败。

如果方法可以打开给定对象,则返回OREF。如果无法找到或以其他方式打开对象,则返回空值("")。

例如:

/// d ##class(PHA.OP.MOB.Test).TestOpeningSavedObjects()
ClassMethod TestOpeningSavedObjects()
{
	Set person = ##class(Sample.Person).%OpenId(10)
	Write "Person: ",person,!   
	Set person = ##class(Sample.Person).%OpenId(-10)
	Write "Person: ",person,!
}

DHC-APP>d ##class(PHA.OP.MOB.Test).TestOpeningSavedObjects()
Person: [email protected]
Person:

注意,在Cache Basic中,OpenId命令相当于%OpenId()方法:

/// d ##class(PHA.OP.MOB.Test).TestOpeningSavedObjectsBasic()
ClassMethod TestOpeningSavedObjectsBasic() [ Language = basic ]
{
	person = OpenId Sample.Person(1)
	PrintLn "Name: " & person.Name
}
DHC-APP>d ##class(PHA.OP.MOB.Test).TestOpeningSavedObjectsBasic()
Name: yaoxin

还可以使用%Open()方法,该方法需要一个OID而不是ID。

多次调用%OpenId()

如果在Caché 进程中多次调用相同ID的%OpenId(),那么在内存中只创建一个对象实例:所有对%OpenId()的后续调用都将返回对已加载到内存中的对象的引用。

下面的例子说明了这一点:

/// d ##class(PHA.OP.MOB.Test).TestMultiple()
ClassMethod TestMultiple() [ Language = basic ]
{
	personA = OpenId Sample.Person(1)
	personA.Name = "Black,Jimmy Carl"
	personB = OpenId Sample.Person(1)
	PrintLn "NameA: " & personA.Name
	PrintLn "NameB: " & personB.Name
}

DHC-APP>d ##class(PHA.OP.MOB.Test).TestMultiple()
NameA: Black,Jimmy Carl
NameB: Black,Jimmy Carl

并发

方法的输入是一个可选的并发参数。此参数指定用于打开对象实例的并发级别(锁的类型)。

如果%OpenId()方法无法获取对象上的锁,则会失败。

若要提高或降低对象的当前并发设置,请使用%OpenId()重新打开该对象并指定不同的并发级别。例如,

 Set person = ##class(Sample.Person).%OpenId(6,0)

打开0的并发和以下有效地升级到4的并发:

Set person = ##class(Sample.Person).%OpenId(6,4)

Swizzling(懒加载)

如果打开(加载到内存中)一个持久对象的实例,并使用它引用的对象,那么这个引用的对象将被自动打开。这个过程被称为swizzling;它有时也被称为“懒加载”。

例如,下面的代码打开Sample.Employee一个实例,并自动懒加载相关Sample.Company对象引用它使用点语:

/// d ##class(PHA.OP.MOB.Test).TestSwizzling()
ClassMethod TestSwizzling()
{
	// Open employee "101"
	Set emp = ##class(Sample.Employee).%OpenId(101)

	// Automatically open Sample.Company by referring to it:
	Write "Company: ",emp.Company.Name,!
}

当一个对象被swizzled时,它将使用默认的原子读并发值打开。

当没有对象或变量引用swizzled对象时,它就从内存中删除。

注意:所谓懒加载,就是对象引用。

从磁盘重新加载对象

要用存储在数据库中的值重新加载内存中的对象,请调用它的%reload()方法。

/// d ##class(PHA.OP.MOB.Test).TestReload()
ClassMethod TestReload()
{
	// Open person "1"
	Set person = ##class(Sample.Person).%OpenId(1)
	Write "Original value: ",person.Name,!

	// 修改对象
	Set person.Name = "Black,Jimmy Carl"
	Write "Modified value: ",person.Name,!

	// 重新加载对象
	Do person.%Reload()
	Write "Reloaded value: ",person.Name,!
}

DHC-APP>d ##class(PHA.OP.MOB.Test).TestReload()
Original value: yaoxin
Modified value: Black,Jimmy Carl
Reloaded value: yaoxin

读取存储值

假设打开了一个持久对象的实例,修改了它的属性,然后希望在保存对象之前查看数据库中存储的原始值。最简单的方法是使用SQL语句(SQL总是针对数据库执行;而不是针对内存中的对象)。

例如:

/// d ##class(PHA.OP.MOB.Test).TestReading()
ClassMethod TestReading()
{
	// Open person "1"
	Set person = ##class(Sample.Person).%OpenId(1)
	Write "Original value: ",person.Name,!

	// 修改对象
	Set person.Name = "Black,Jimmy Carl"
	Write "Modified value: ",person.Name,!

	// 重新加载对象
	Set id = person.%Id()
	&sql(SELECT Name INTO :name
	     FROM Sample.Person WHERE %ID = :id)

	Write "Disk value: ",name,!
}
DHC-APP>d ##class(PHA.OP.MOB.Test).TestReading()
Original value: yaoxin
Modified value: Black,Jimmy Carl
Disk value: yaoxin

删除保存的对象

持久性接口包括从数据库中删除对象的方法。

%DeleteId()方法

方法的作用是:删除存储在数据库中的对象。具体方法如下:

classmethod %DeleteId(id As %String, concurrency As %Integer = -1) as %Status
  • id 是打开的对象的ID
  • concurrency 删除对象时使用的并发级别(锁定)。

例如:

 Set sc = ##class(MyApp.MyClass).%DeleteId(id)

%DeleteId()返回一个%Status值,该值指示对象是否被删除。

%DeleteId()在删除对象之前调用%OnDelete()回调方法(如果存在)。%OnDelete()返回一个%Status值;如果%OnDelete()返回一个错误值,那么对象将不会被删除,当前事务将回滚,%DeleteId()返回一个错误值。

注意,%DeleteId()方法对内存中的任何对象实例都没有影响。

您还可以使用%Delete()方法,该方法需要OID而不是ID。

%DeleteExtent()方法

%DeleteExtent()方法:删除继承内的所有对象(以及对象的子类)。具体来说,它遍历整个继承并在每个实例上调用%DeleteId()方法。

%KillExtent()方法

%KillExtent()方法直接删除存储对象范围的全局变量(不包括与流关联的数据)。它不调用%DeleteId()方法,也不执行引用完整性操作。此方法只是为了在开发过程中为开发人员提供帮助。(它类似于在旧的关系数据库产品中找到的TRUNCATE表命令。)

注意:%KillExtent()仅用于开发环境,不应在实际应用程序中使用。%KillExtent()绕过约束和用户实现的回调,可能会导致数据完整性问题。

访问对象标识符

如果保存了对象,那么它有一个ID和一个OID,这是磁盘上使用的永久标识符。如果对象有一个OREF,则可以使用它来获得这些标识符。
要查找与OREF关联的ID,请调用对象的%ID()方法。例如:

write oref.%Id()

要查找与OREF关联的OID,有两个选项:

  1. 可以调用对象的%Oid()方法。例如:
write oref.%Oid()
  1. 可以访问对象的%%OID属性。由于此属性名包含%字符,因此必须将名称括在双引号中。例如:
 write oref."%%OID"
/// d ##class(PHA.OP.MOB.Test).TestIdentifiers()
ClassMethod TestIdentifiers()
{
	// Open person "1"
	Set person = ##class(Sample.Person).%OpenId(1)
	Write "Original value: ",person.Name,!
	Write person.%Id(),!
	Write person.%Oid(),!
	Write person."%%OID",!
}
DHC-APP>d ##class(PHA.OP.MOB.Test).TestIdentifiers()
Original value: yaoxin
1
1Sample.Person
1Sample.Person
zTestIdentifiers+7^PHA.OP.MOB.Test.1
DHC-APP 2d1>zw person.%Oid()
$lb("1","Sample.Person")
 
DHC-APP 2d1>zw person."%%OID"
$lb("1","Sample.Person")
 
DHC-APP 2d1>

对象并发选项

在打开或删除对象时,适当地指定并发性非常重要。可以在几个不同的级别指定并发性:

  1. 可以为正在使用的方法指定并发参数。

%Persistent类的许多方法都允许您指定这个参数,一个整数。此参数确定如何将锁用于并发控制。如果未指定并发参数,则Caché 将使用正在处理的类的DEFAULTCONCURRENCY类参数的值;

  1. 可以为关联的类指定DEFAULTCONCURRENCY类参数。所有持久类都从%persistent继承此参数,%persistent将该参数定义为获得进程默认并发性的表达式;

可以在类中重写此参数,并指定硬编码的值或表达式,通过自己的规则来确定并发性

  1. 可以设置进程的默认并发性。为此,使用 $system.OBJ.SetConcurrencyMode() 方法(该方法是%SYSTEM.OBJ的SetConcurrencyMode()方法))。

与其他情况一样,必须使用一个允许的并发值。$system.OBJ.SetConcurrencyMode() 方法对为DEFAULTCONCURRENCY类参数指定显式值的任何类无效。

为什么指定并发?

下面的场景演示了在读取或写入对象时适当控制并发性的重要性。考虑以下场景:

  1. 进程A打开一个对象。
SAMPLES>set o=##class(Sample.Person).%OpenId(1)
 
SAMPLES>w o
[email protected]
  1. 进程B从磁盘中删除该对象:
SAMPLES>set sc=##class(Sample.Person).%DeleteId(5)
 
SAMPLES>w sc
1
  1. 进程A使用%Save()保存对象并接收到成功状态。
SAMPLES>set sc=o.%Save()
 
SAMPLES>w sc
1

检查磁盘上的数据表明,对象没有写入磁盘。

这是一个没有足够并发控制的并发操作的例子。例如,如果进程A可能将该对象保存回磁盘,那么它应该以并发3或4打开该对象(这些值将在本章后面讨论)。在这种情况下,进程B将被拒绝访问(并发冲突导致失败),或者必须等待,直到进程a释放该对象。

并发值

可能的并发值如下:

  • 0 没有锁。没有使用锁。
  • 1 原子的读。获取锁并根据需要释放锁,以确保读取的对象将作为原子操作执行。

对于%LoadData(),Caché 获取对象上的共享锁(如果需要确保原子读)。在完成读操作后释放锁。

没有为新对象获取锁。

%SaveData()在保存过程中获得一个独占锁。

  • 2 共享锁。与1(原子读)相同,只是%LoadData()总是获取一个共享锁。

对于%LoadData(),Caché 在所有情况下都获取对象上的共享锁。在完成读操作后释放锁。

没有为新对象获取锁。

%SaveData()在保存过程中获得一个独占锁。

  • 3 共享/保留锁。对于%LoadData(),Caché为对象获取共享锁。当对象被析构(从内存中删除)时,锁被释放。

最初不会为新对象获取锁,但在保存期间会获取锁;

%SaveData()获取任何对象的独占锁,包括新对象。这个锁将一直保持到对象被析构(从内存中删除)。

  • 4 排他锁。当一个现有对象被打开或一个新对象最初被保存到数据库(插入)时,获取一个排他锁;当对象被析构时,锁被释放。

注意:

  • 共享锁(S锁):共享 (S) 用于不更改或不更新数据的操作(只读操作),如 SELECT 语句。

如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。

  • 排他锁(X锁):用于数据修改操作,例如 INSERT、UPDATE 或 DELETE。确保不会同时同一资源进行多重更新。

如果事务T对数据A加上排他锁后,则其他事务不能再对A加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。

我们在操作数据库的时候,可能会由于并发问题而引起的数据的不一致性(数据冲突)

  • 乐观锁
    乐观锁不是数据库自带的,需要我们自己去实现。乐观锁是指操作数据库时(更新操作),想法很乐观,认为这次的操作不会导致冲突,在操作数据时,并不进行任何其他的特殊处理(也就是不加锁),而在进行更新后,再去判断是否有冲突了。

并发性懒加载对象

属性引用的对象在访问时使用默认并发性进行swizzle。(如果正在swizzle的对象已经在内存中,那么swizzle实际上不会打开该对象—它只是引用内存中现有的对象;在这种情况下,对象的当前状态将保持不变,并发性也将保持不变。)

例如,如果一个对象(Obj1)有一个引用另一个对象(Obj2)的属性(Prop1),那么在访问Prop1时,Obj2将被swizzle;Obj2具有默认并发性,除非它已经打开。

有两种方法可以覆盖此默认行为:

  • 通过调用指定新并发性的%Open()方法更新swizzled对象上的并发性。例如:
 Do person.Spouse.%Open(person.Spouse.%Oid(),4,.status) 

其中%Open()的第一个参数指定OID,第二个参数指定新的并发性,第三个参数(通过引用传递)接收方法的状态。

在刷对象之前设置进程的默认并发性。例如:

Set olddefault = $system.OBJ.SetConcurrencyMode(4) 

此方法以新的并发模式作为参数,并返回以前的并发模式。

当不再需要不同的并发模式时,请按以下方式重置默认并发模式:

Do $system.OBJ.SetConcurrencyMode(olddefault)

版本检查(并发参数的替代方法)

在打开或删除对象时,可以实现版本检查,而不是指定并发参数。为此,需要指定一个名为VERSIONPROPERTY的类参数。所有持久类都有这个参数。定义持久类时,启用版本检查的过程是:

  1. 创建类型为%Integer的属性,该属性保存类的每个实例的可更新版本号。
  2. 对于该属性,将InitialExpression关键字的值设置为0。
  3. 对于类,将VERSIONPROPERTY类参数的值设置为该属性的名称。不能由子类将VERSIONPROPERTY的值更改为其他属性。

这将版本检查合并到类实例的更新中。

实现版本检查时,每次更新类的实例(通过对象或SQL)时,由version property指定的属性都会自动递增。在递增属性之前,Caché将其内存值与存储值进行比较。如果它们不同,则指示并发冲突并返回错误;如果它们相同,则属性将递增并保存。

注意:可以使用这组特性来实现乐观并发。

若要对VERSIONPROPERTY引用名为InstanceVersion的属性的类在SQL update语句中实现并发检查,代码如下:

 SELECT InstanceVersion,Name,SpecialRelevantField,%ID
     FROM my_table
     WHERE %ID = :myid

     // Application performs operations on the selected row

 UPDATE my_table
     SET SpecialRelevantField = :newMoreSpecialValue
     WHERE %ID = :myid AND InstanceVersion = :myversion

其中myversion是用原始数据选择的version属性的值。

重要:如果从第二个对象引用具有version property参数的对象,并且第二个对象的保存失败,则默认情况下第一个对象的version属性将递增。你的代码应该检查这个场景,并适当地处理它。特别是,如果无法保存第二个对象,则代码应将第一个对象的version属性还原为其先前的值。否则,当保存第一个对象时,将收到以下错误:

ERROR #5800: Concurrency failure on update: object versions not the same for 'ConcurrencyFail.ClassA:1' 

你可能感兴趣的:(Caché,从入门到精通)