内容


使用 IBM FileNet P8 实现序列号分发器

Comments

简介

很多小商店都按照客户到来的大约顺序给他们发放一个序列号,从而让他们有序地排队。序列号通常由一台机器打印在一小段 纸条上。如果多个客户同时光临,出于礼貌原因,排队的次序可能受到干扰。

软件系统中也通常出现类似的问题。我们需要为事件分配一个号码,同时要确保该号码是唯一的,并且遵从一定的模式。可以通过一些常见的解决方案解决这个问题,但分布式系统让问题变得更加复杂。如果您要使用 ECM 系统为小面包店的客户分配号码,这是非常不现实的。不过,您可能需要分配客户 ID、零件号码或其他更简单的号码。数据库供应商为这类问题实现了序列化列类型。不过,P8 不提供对数据库序列号类型的直接访问,因此您必须使用其他机制。

在本文中,我们将探索如何在 P8 环境中解决该问题。我们先了解 需求:

  1. 我们需要确保分配的号码是绝对唯一的。同一个号码被分配两次是不可接受的。
  2. 我们想要号码遵从某种模式,避免号码分配中出现间断。模式可能包含很多东西,但为了进行演示,本文仅使用简单的递增模式。我们获取到的下一个号码总是大于前一个号码。
  3. 我们希望号码分配能够在包含多线程、多处理器、多服务器、多层和多用户 P8 环境中可靠地工作,并且实现出色的性能。
  4. 当到达面包店的食品台时,我们希望拿到自己点的美观可口的小蛋糕!

在描述我们喜欢的实现之前,我们首先看几项还不能很好地发挥作用的技术。尽管您永远没有必要实现这个特殊的用例,但是本文描述的要点适用于许多 P8 编程领域。

Java 或 .NET 同步

如果您是企业开发或分布式开发的新手,您首先想到的可能是使用一个大对象,它能够在某种程度上同步访问更新计数器的部分。在 Java 中,这可能是一个 synchronized 方法或代码块。在 C# 中,这可能是一个标记为 synchronized 的方法或受 lock() 保护的代码块。能够进行同步访问的代码块有时称为关键部分。清单 1 显示了实现该代码块的方式之一。

清单 1. 同步代码块
/**
 * *** DON'T DO IT THIS WAY ***
 */
public class Dispenser
{
    /** static access only, so private constructor */
    private Dispenser() {}
    private static int counter = 0;
    public static final synchronized int getNextValue()
    {
        return ++counter;
    }
}

使用同步代码能够很好地解决某些问题,但它的缺点在某些情况下也特别明显。因为计数器的值仅存在运行程序的内存中,如果程序重启,那么这些值将重新开始。您可以更改 Dispenser 类以将更新的计数器值保存到一个文件中,但这可能导致新的问题,因为没有在各个进程之间协调同步。对于独立运行的不同应用程序(或相同应用程序的副本),即使使用了 Dispenser 类,也可能将读或写交叉存取到文件中。更糟糕的是,它们可能访问不同机器上名称相同的应用程序。这两种情况都会导致违背我们的用例需求。

下面给出一个交叉存取读写可能导致的问题。假设记录在文件中的当前值为 7,然后发生:

  • A 从该文件读取值 7。
  • B 从该文件读取值 7。
  • A 向该文件写入值 8。
  • C 从该文件读取值 8。
  • C 向该文件写入值 9。
  • B 向该文件写入值 8。

至此,A 和 B 都拥有它们认为是唯一但实际上相同的值。下一个读取程序获得的值将与 C 已有的值相同。您此时 可能想到使用文件锁或其他特定于操作系统的技巧,以在分布式环境中同步对文件的访问。不过,在计算机领域中这种类型的解决方案遇到的困难是大家承认的,要想获得成功需要花很大的功夫,因此我们不想过多地谈论这个场景。如果您要获得更多的证据,可以在搜索引擎中输入 “NFS lock problem” 进行查询。

P8 分发器对象

解决这些问题的一个流行解决方案是使用数据库来储存分发器。企业级关系数据库的资源本质上就是带有可靠的锁语义的分布式资源。在 P8 架构中,应用程序不能直接访问后端数据库。换句话说,必须独立为每个应用程序安排数据库访问,不管是通过 J2EE 数据源、直接的 JDBC 连接,还是其他方式。这对于某些场景非常适用,但如果仅为了访问分发器数据而这样做,显然是过于麻烦。

我们都知道的是,基于 P8 的应用程序能够访问 P8 ObjectStores 和对象,但要受到 P8 强制执行的访问检查。因此我们能够将分发器建模成 P8 对象。尤其是,我们可以创建 CustomObject 的子类 WjcDispenser 的一个实例,它包含一个整型定制属性 WjcCounter。(名称添加了前缀 “Wjc” 以避免与其他类和属性名冲突)。图 1 显示了这个简单子类的 UML 图。

图 1. WjcDispenser 的 UML 图
显示 WjcDispenser 作为 CustomObject 的子类的 UML 图

我们假设对该对象的安全访问能够方便地安排给需要访问它的应用程序的所有用户。现在,我们仅假设所有人都能够连接到 ObjectStore 并更新分发器对象。查看侧栏 使用 J2EE servlet 了解实现这种安全场景的有趣方法。

另外,我们将忽略分发器对象的初步创建过程。一种好办法是让附带的 Java 和 .NET 类检测分发器对象是否缺少,您可以根据需要或在该类的静态初始化方法中创建它。查找分发器对象的两种常见方法是使用预定义的 ID 值或将对象 储存在 ObjectStore 内部的预定义路径中。还可以使用查询来查找 WjcDispenser 类的所有实例。在下面的例子中,我们假设 ObjectStore 的身份和分发器对象的特定位置针对应用程序进行了某些配置。

FileNet Content Engine (CE) 合作锁

在使用基于 P8 的分发器对象时,获取序列号的方法在本质上是一样的:读取旧值、更新并储存新值,然后将新值返回给调用方。很明显,特定的逻辑实现改变了。Java 或 .NET 同步的所有缺点仍然存在。

CE 服务器和 API 实现一个称为合作锁的特性。这个特性的初衷是为了提供与 RFC-2518 (WebDAV) 兼容的合作锁语义。FolderDocumentCustomObject 的 API 类包含锁定和解锁这些对象的方法。因为这是一个在服务器中实现的内置 P8 特性,所以您可以开发一个类似的实现,如清单 2 所示。这个例子显示了一个内部方法,并假设另一个代码段识别了分发器对象。

清单 2. P8 合作锁
private static final String COUNTER_PROPERTY_NAME = "WjcCounter";
/**
 * *** DON'T DO IT THIS WAY ***
 */
private static int getNextValue(CustomObject dispenser)
{
    final Properties dispenserProperties = dispenser.getProperties();
    // Object might be locked by someone else, so try a few times
    for (int attemptNumber=0; attemptNumber<10; ++attemptNumber)
    {
        dispenser.lock(15, null);  // LOCK the object for 15 seconds
        try
        {
            // Because we use a refreshing save, the counter property
            // value will be returned.
            dispenser.save(RefreshMode.REFRESH);  // R/T
            break;
        }
        catch (EngineRuntimeException ere)
        {
            ExceptionCode ec = ere.getExceptionCode();
            if (ec != ExceptionCode.E_OBJECT_LOCKED)
            {
                // If we get an exception for any reason other than
                // the object already being locked, rethrow it.
                throw ere;
            }
            // already locked; try again after a little sleep
            try
            {
                Thread.sleep(100); // milliseconds
            }
            catch (InterruptedException e) { /* don't worry about this rarity */ }
            continue;  
        }
    }
    int oldValue = dispenserProperties.getInteger32Value(COUNTER_PROPERTY_NAME);
    int newValue = oldValue + 1;
    dispenserProperties.putValue(COUNTER_PROPERTY_NAME, newValue);
    dispenser.unlock();  // UNLOCK the object
    dispenser.save(RefreshMode.NO_REFRESH);  // R/T
    return newValue;
}

假设分发器对象进行无获取实例化(即通过 Factory.CustomObject.getInstance() 方法),该技术则导致需要再次回到 CE 服务器,以应用锁并获取当前的属性值。如果该对象已经锁定,就不能获得当前值,所以我们将多次遍历分发器对象以等待锁定的机会。此外,还需要再次回到 CE 服务器以储存新的计数器值。该技术导致的性能开销还是合理的,锁定/解锁特性的总体使用也是合理的。

在该用例中使用 P8 合作锁的主要问题是它仅是合作锁。在仅出现该锁的情况下,CE 服务器将不阻止任何更改。您可能会乐观地假设所以应用程序都使用合作锁。但是在现实中,您可能不能阻止应用程序 bug 绕过锁。这种场景不难想像,某人编写了一段使用分发器对象的独立代码,但忘记了使用锁。

使用事件处理程序

这可能有些无关紧要,但如果您阅读了上一小节对合作锁的讨论就会发现,要可靠地获得一个 序列号必须至少两次回到服务器。能不能通过一次返回就解决问题呢?要实现这个想法,我们必须至少将一部分计算转移到 CE 服务器上。能够实现该目的的 CE 机制就是事件处理程序。下面是想法论证:

  • 无获取实例化分发器对象(没有循环)。
  • 我们需要对分发器对象进行某些更改,使它能够触发事件。CE 有一个允许您定制事件的特性。事实上,这个类称为 CustomEvent。作为实现该目的的一次性设置的一部分,我们定义一个新的定制事件,并将其持久化在保存分发器对象的 ObjectStore 中。
  • 定制事件不会作为其他事件的副作用启动,而是在某人调用可订阅对象上的 raiseEvent() 方法时被触发。在服务器上,订阅和事件处理对系统定义的事件是一样的。在分发器对象上调用 raiseEvent(),然后通过刷新调用 save()(获得 WjcCounter 的当前值)。
  • save() 将触发 CustomEvent
  • 作为一次性设置的一部分,在 WjcDispenser 实例或类上提供一个订阅到特定类型的 CustomEvent 的事件处理程序。事件处理程序将为 WjcCounter 属性计算和保存新值。因此属性不能在同步事件处理程序中进行更新,所以我们将把事件处理程序更改为异步的(在订阅中指定同步或异步)。
  • 客户端应用程序知道事件处理程序如何更新 WjcCounter,因此它执行相同的计算以预测 WjcCounter 的新值。

在这段想法论证过程中,您认为对分发器对象的更新在每个时间点只能发生一次,不管有多少个独立的客户端应用程序正在请求更新。CE 服务器没有 “优化” 中间(冗余)更新。记住,异步事件处理程序的执行是有保证的。事实上,您可能还了解到异步事件处理程序是通过队列进行处理的(这是正确的)。所有这些结合起来似乎能够 在每次循环经过 CE 服务器时都能够对 WjcCounter 进行一次可靠的、可预测的更新。

您可能从上文的描述基调中猜到这里存在其他一些问题。事实上,这里存在两个问题。首先,在更新 ObjectStore 中的分发器对象和执行异步事件处理程序之间存在一段很短的时间。如果多个独立的客户端刚好在这段时间内同时进行更新,它们将看到相同的 WjcCounter 刷新值并计算出相同的更新值。即使您能够克服这个问题,并将特定的客户端 save() 活动绑定到事件处理程序的特定触发器中,您仍然还需要解决另一个问题。第二个问题是,尽管异步事件处理程序是通过队列进行处理的,但一个队列有多个读取程序。因此,不能够保证异步事件处理程序的执行顺序与触发更新的顺序一样。

第一个写优先

CE 服务器有一个能够可靠地检测交叉更新的内置特性。CE 实现一个称为第一个写优先 的策略。这意味着如果两个请求同时更新相同的对象,那么第一个请求将成功,第二个请求将失败。对于失败的更新,服务器将抛出一个 EngineRuntimeException,它带有 E_OBJECT_MODIFIEDExceptionCode。附带的异常消息为 “The object ... has been modified since it was retrieved.”。但是,这是什么意思?

ObjectStore 中的每个独立的可持久化对象都是以一个 Update Sequence Number (USN) 进行标记。这不是常规意义上的属性,但它的值是通过 IndependentlyPersistableObject.getUpdateSequenceNumber() 方法公开的。当每次 ObjectStore 中的对象被更新时,CE 服务器将自动递增 USN。当您从服务器获取一个对象时,USN 也被获取并传递到客户端。API 将 USN 作为 save() 对象的一部分发送回到服务器。如果发送的 USN 值与储存库中储存的值不匹配,CE 服务器就知道已经(由其他调用方)进行了更新,因此该对象已被获取。这是数据库调用乐观(optimistic)锁的简单方式。

如果 CE 服务器检测到交叉更改,它将合理地进行利用,这与合作锁特性不同。通过 CE API,您可以使用称为无获取实例化(在其他地方也提到)绕过服务器检查,但在这个用例中您要为此付出更多的努力。从本地在 API 中实例化一个对象而没有从服务器获取它称为无获取实例化。在这些情况中,USN 值有一个表示 CE 服务器跳过 USN 检查的特殊值。这有时被称为未保护更新。如果您稍后从服务器获取或刷新任何属性,也会获取当前 USN。

不过,在我们的例子中执行无获取实例化和未保护更新没有任何意义。要获取计数器属性的当前值,必须从服务器获取它。可能有人会恶意地通过未保护更新破坏计数器属性,但这种破坏也可以发生在常规的更新中。因此,这里没有出现新的威胁。因为用例存在语义,所以通过编写错误代码搞破坏的机会是很低的。

要利用 USN 检查和 CE 服务器的第一次写优先策略,您需要尝试在分发器对象中更新计数器,然后检查报告交叉更改的错误。清单 3 显示了该用例的一个例子。

清单 3. 第一次写优先
private static final String COUNTER_PROPERTY_NAME = "WjcCounter";
/** 
 * This property filter is used to minimize data returned in fetches and refreshes.
 */
private static final PropertyFilter PF_COUNTER = new PropertyFilter();
static
{
    PF_COUNTER.addIncludeProperty(1, null, null, COUNTER_PROPERTY_NAME, null);
}

/**
 * Get the next value efficiently by exploiting First Writer Wins
 */
public int getNextValue(boolean feelingUnlucky)
{
    final Properties dispenserProperties = dispenser.getProperties();
    // Object might be updated by someone else, so try a few times
    for (int attemptNumber=0; attemptNumber<10; ++attemptNumber)
    {
        // If cached data invalid, fetch the current value
        // from the server.  This also covers the fetchless
        // instantiation case.
        if (feelingUnlucky
        ||  dispenser.getUpdateSequenceNumber() == null 
        ||  !dispenserProperties.isPropertyPresent(COUNTER_PROPERTY_NAME))
        {
            // fetchProperties will fail if the USN doesn't match, so null it out
            dispenser.setUpdateSequenceNumber(null);
            dispenser.fetchProperties(PF_COUNTER);  // R/T
        }
        int oldValue = dispenserProperties.getInteger32Value(COUNTER_PROPERTY_NAME);
        int newValue = oldValue + 1;
        dispenserProperties.putValue(COUNTER_PROPERTY_NAME, newValue);
        try
        {
            // Because we use a refreshing save, the counter property's
            // new value will be returned from the server.
            dispenser.save(RefreshMode.REFRESH, PF_COUNTER);  // R/T
            return newValue;
        }
        catch (EngineRuntimeException ere)
        {
            ExceptionCode ec = ere.getExceptionCode();
            if (ec != ExceptionCode.E_OBJECT_MODIFIED)
            {
                // If we get an exception for any reason other than
                // the object being concurrently modified, rethrow it.
                throw ere;
            }
            // Someone else modified it.  Invalidate our cached data and try again.
            dispenser.setUpdateSequenceNumber(null);
            dispenserProperties.removeFromCache(COUNTER_PROPERTY_NAME);
            continue;  
        }
    }
    // too many iterations without success
    throw new RuntimeException("Oops");
}

/**
 * Set by constructor or some other means.
 * Fetchless instantiation is OK.
 */
private final CustomObject dispenser;

粗略一看,这好像使用了两次服务器循环,和前面的合作锁代码一样。第一个计数器更新确实是这样。我们必须从服务器获取当前的计数器值,但在成功更新之后将保存计数器的状态。如果没有其他人同时更新分发器对象,我们随后的更新仅需一次循环。另一方面,如果两个应用程序轮流更新分发器对象,保留的计数器状态就导致不好的结果。对于这种情况,通常需要 3 次循环才能执行一次更新(首次更新尝试失败;获取当前的计数器值;最后成功更新)。 即使同时进行更新的应用程序之间有大量时间,额外的开销都是不可避免的(例如,A 在一小时后获得计数器的值,B 在半小时后获取计数器的值)。是否发生额外的开销取决于独立的读应用程序的数量和它们之间的重叠等。布尔参数 feelingUnlucky 控制该方法是否需要两次循环才能更新,或者在一次或三次循环间进行博弈。

其他考虑事项

下面是关于实现和部署的其他注意事项。

使用 USN 取代属性

既然储存库中的每个独立的可持久化对象都有一个递增的 Update Sequence Number,为什么不使用 USN 取代计数器值的定制属性?如果您愿意接受一些条件的话,这是可行的。但除了不需要定义计数器属性本身之外,其他工作省不了多少。

  • USN 的正常递增基数为 1。目前您还不可以对序列号使用其他递增基数。
  • 事实上,CE 对 USN 的递增模式的指定是模糊的。它的正式用法包括将 USN 与 null 进行比较,或在两个 USN 值之间进行比较,以得出它们的小于、等于和大于关系。在未来的 CE 发行版中,以 1 作为递增基数可能会发生改变,尽管递增的行为没有改变。
  • 严格来说,USN 的递增是不受我们控制的。除了某人请求新的序列号之外,CE 服务器还在每次更新持久化对象之后都将 USN 增加 1。
  • 您仍然需要多次到达服务器以更新分发器对象或获取 USN 值。这没有任何性能优势。

多个分发器

不管您使用什么技术来实现代码,重复地更新一个对象会导致高访问率储存库的性能下降。这里指的 “高访问率 ” 是以数据库标准为依据的。因此每天执行数千条更新很可能并不构成任何问题。当访问率非常高时,您可能想要使用多个分发器对象,让它们分别负责响应一定范围的值。换句话说,不同分发器实例生成的值在全局环境中仍然是唯一的。

关于 3.x Java API 的特别说明

CE 3.x Java API 没有公开以上使用的 Update Sequence Number。事实上,对大多数操作而言,该 API 通常改变其初衷,转而使用最后写优先策略。这是可行的,但是要使用 3.x Java API 编写出性能出色、功能正常的分发器相当困难。如果您的业务条件允许的话,最佳的选择是将应用程序(部分或全部)迁移到 CE 4.x API。

结束语

本文讨论了用于实现序列号分发器的各种技术。和其他开发一样,编写分发器遇到的困难与业务需求和实现需求有很大的关系。如果某人要求我实现一个漂亮灵活的分发器,那么它应该是这样的:

  • 使用与清单 3 类似的第一次写优先代码。
  • 将该代码放到一个简单的 J2EE servlet 中。通过将它部署到一个 J2EE Web 容器中,我们将享受到 J2EE 基础设施的可伸缩性、故障转移和隔离等优点。
  • 限制 P8 对分发器对象或对象的写访问,从而使一般用户不能绕过 servlet 更新值。
  • 配置拥有 RunAs 角色的 servlet,它具有更新分发器对象所需的 CE 权限(参见 使用 J2EE servlet 侧栏)。
  • 如果有必要,实现一个机制来验证请求方有权限向分发器请求序列号。这仅在考虑号码浪费的场景中比较有意思。
  • 开始使用 servlet 时乐观地假设仅需访问服务器一次就可以从分发器获取到序列号。换句话说,开始时使用 feelingUnlucky 是错误的。在实践中跟踪它的错误率,如果错误率达到一定比例之后,切换到乐观的方法,它能弥补两次访问服务器带来的开销。定期切换回到乐观的方法,看看有没有发生变化。
  • 如果有必要的话,提供客户端实用程序代码,用于调用 servlet 以获取序列号。

相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Information Management, Java technology
ArticleID=453684
ArticleTitle=使用 IBM FileNet P8 实现序列号分发器
publish-date=12072009