12 乐观并发
-
只要我们在单个数据库事务中执行与特定应用程序事务对应的所有数据库操作,ODB事务模型(第3.5节,"事务")就保证了一致性。也就是说,如果我们在数据库事务中加载一个对象并在同一事务中更新它,那么我们可以保证我们在数据库中更新的对象状态与我们加载的状态完全相同。换句话说,在这些加载和更新操作之间,另一个进程或线程不可能修改数据库中的对象状态。
-
在本章中,我们使用术语应用程序事务来指代应用程序需要对持久对象执行的一组操作,以实现某些特定于应用程序的功能。术语数据库事务是指在ODB
begin()
和commit()
调用之间执行的一组数据库操作。到目前为止,我们基本上将应用程序事务和数据库事务视为同一件事。 -
虽然此模型易于理解和使用,但它可能不适合具有长应用程序事务的应用程序。这种情况的典型示例是在加载对象和更新对象之间需要用户输入的应用程序事务。这样的操作可能需要任意长的时间才能完成,在单个数据库事务中执行它将消耗数据库资源,并阻止其他进程/线程长时间更新对象。
-
这个问题的解决方案是将长期存在的应用程序事务分解为几个短期存在的数据库事务。在我们的示例中,这意味着在一个数据库事务中加载对象,等待用户输入,然后在另一个数据库交易中更新对象。例如:
cpp
unsigned long id = ...;
person p;
{
transaction t (db.begin ());
db.load (id, p);
t.commit ();
}
cerr << "enter age for " << p.first () << " " << p.last () << endl;
unsigned short age;
cin >> age;
p.age (age);
{
transaction t (db.begin ());
db.update (p);
t.commit ();
}
-
如果我们只有一个进程/线程可以更新对象,这种方法效果很好。然而,如果我们有多个进程/线程修改同一个对象,那么这种方法就不再保证一致性。考虑一下在上面的例子中,如果在我们等待用户输入的同时,另一个进程更新了该人的姓氏,会发生什么。由于我们在发生此更改之前加载了对象,因此我们版本的人员数据仍将使用旧名称。一旦我们收到用户的输入,我们就会继续更新对象,用新的(正确)覆盖旧的年龄,用旧的(不正确)覆盖新的名称。
-
虽然无法在由多个数据库事务组成的应用程序事务中恢复一致性保证,但ODB提供了一种称为乐观并发的机制,允许应用程序检测并可能从这种不一致中恢复。
-
本质上,乐观并发模型检测数据库中当前对象状态与加载到应用程序内存时的状态之间的不匹配。这种不匹配意味着对象被另一个进程或线程更改。有几种方法可以实现这种状态不匹配检测。目前,ODB使用对象版本控制,而未来可能会支持其他方法,如时间戳。
-
为了用乐观并发模型声明一个持久类,我们使用
optimistic
pragma(第14.1.5节,"乐观")。我们还使用version
pragma(第14.4.16节,"version")来指定哪个数据成员将存储对象版本。例如:
cpp
#pragma db object optimistic
class person
{
...
#pragma db version
unsigned long version_;
};
- 版本数据成员由ODB管理。当对象被持久化时,它被初始化为1,每次更新时递增1。ODB不使用0版本值,应用程序可以将其用作特殊值,例如,表示对象是瞬态的。请注意,为了使乐观并发正常工作,在使对象持久化或从数据库加载它之后,应用程序不应修改版本成员,直到从数据库中删除此对象的状态。为了避免对version成员的任何意外修改,我们可以将其声明为
const
,例如:
cpp
#pragma db object optimistic
class person
{
...
#pragma db version
const unsigned long version_;
};
-
当我们调用
database::update()
函数(第3.10节,"更新持久对象")并传递一个状态过时的对象时,会抛出odb::object_changed
异常。此时,应用程序有两个恢复选项:它可以中止并可能重新启动应用程序事务,也可以从数据库中重新加载新的对象状态,重新应用或合并更改,并再次调用update()
。请注意,中止在多个数据库事务中执行更新的应用程序事务可能需要还原已提交到数据库的更改。因此,如果所有更新都在应用程序事务的最后一个数据库事务中执行,则此策略效果最佳。这样,只需回滚最后一个数据库事务,就可以恢复更改。 -
以下示例显示了如何使用第二个恢复选项重新实现上述事务:
cpp
unsigned long id = ...;
person p;
{
transaction t (db.begin ());
db.load (id, p);
t.commit ();
}
cerr << "enter age for " << p.first () << " " << p.last () << endl;
unsigned short age;
cin >> age;
p.age (age);
{
transaction t (db.begin ());
try
{
db.update (p);
}
catch (const object_changed&)
{
db.reload (p);
p.age (age);
db.update (p);
}
t.commit ();
}
-
在上面的代码片段中需要注意的一点是,第二次
update()
调用不能抛出object_changed
异常,因为我们正在重新加载对象的状态并在同一个数据库事务中更新它。 -
根据应用程序采用的恢复策略,更新失败的应用程序事务可能比成功的事务贵得多。因此,乐观并发最适合低到中等争用级别的情况,在这种情况下,大多数应用程序事务在没有更新冲突的情况下完成。这也是为什么这种并发模型被称为乐观的原因。
-
除了更新,当我们从数据库中删除对象时,ODB还会执行状态不匹配检测(第3.11节,"删除持久对象")。要理解为什么这很重要,请考虑以下应用程序事务:
cpp
unsigned long id = ...;
person p;
{
transaction t (db.begin ());
db.load (id, p);
t.commit ();
}
string answer;
cerr << "age is " << p.age () << ", delete?" << endl;
getline (cin, answer);
if (answer == "yes")
{
transaction t (db.begin ());
db.erase (p);
t.commit ();
}
- 再次考虑一下,如果在我们等待用户输入的同时,另一个进程或线程通过更改人的年龄来更新对象,会发生什么。在这种情况下,用户根据特定年龄做出决定,而我们可能会删除(或不删除)一个年龄完全不同的对象。以下是我们如何使用乐观并发来解决这个问题:
cpp
unsigned long id = ...;
person p;
{
transaction t (db.begin ());
db.load (id, p);
t.commit ();
}
string answer;
for (bool done (false); !done; )
{
if (answer.empty ())
cerr << "age is " << p.age () << ", delete?" << endl;
else
cerr << "age changed to " << p.age () << ", still delete?" << endl;
getline (cin, answer);
if (answer == "yes")
{
transaction t (db.begin ());
try
{
db.erase (p);
done = true;
}
catch (const object_changed&)
{
db.reload (p);
}
t.commit ();
}
else
done = true;
}
- 请注意,只有当我们通过将对象实例传递给
erase()
函数删除对象时,才会执行状态不匹配检测。如果我们想删除一个具有乐观并发模型的对象,而不管它的状态如何,那么我们需要使用erase()
函数来删除给定id的对象,例如:
cpp
{
transaction t (db.begin ());
db.erase (p.id ());
t.commit ();
}
-
最后,请注意,对于具有乐观并发模型的持久类,如果数据库中没有这样的对象,则
update()
函数和接受对象实例作为其参数的erase()
函数都不再抛出object_not_persistent
异常。相反,这种情况被视为对象状态的变化,并抛出object_changed
异常。 -
有关如何使用乐观并发的完整示例代码,请参阅
odb-examples
中的optimistic
示例。