增量自动获取器 IIncreaseAutoRetriever -- ESBasic 可复用的.NET类库(25)
1.缘起:
假设我们的订单报表系统,需要能够实时地统计当天的已成交订单的报表。最直观的解决方案就是,当每次接收到查询报表的请求时,就从存储设备读取当天所有已成交的订单,然后再进行分析计算给出结果。这是可行的,而且得到的结果也是非常实时的。
但是,这种方式无疑也是非常低效的,因为我们报表数据的统计过程可能相当复杂,而且,可能是成千上万的用户在同时查询报表数据。在这种情况下,将给存储服务器和业务服务器都造成巨大的压力。
现在我们假设需求能够放宽一点――报表数据不用非常实时,可以允许最大为1分钟的延迟。对于绝大多数业务系统来说,报表数据延迟在1分钟以内,都是能够接受的,所以这个假设并不过分,下面我们基于这个假设来继续讨论。
在报表数据能够允许最大延迟为1分钟许可条件下,我们就可以做缓存的动作了。因为如果要求是完全实时的,那么缓存报表数据能做到的程度就非常的有限,但是有了这一分钟的延迟允许,缓存就可以完全地发挥出它的优势了。
现在,我们可以每隔1分钟从存储设备读取上一分钟已成交的所有注单,这就是所谓的“增量数据”,然后将其做报表分析计算,然后将计算的结果“累加”到之前的“累积的报表数据”上。这样,当用户每次来请求报表数据时,只要将当前累积的报表数据直接返回就可以了。因为我们是每隔1分钟对增量进行累加一次,所以,用户看到的报表数据的延迟最多不会超过1分钟。
上述这个例子很好地体现了“增量缓存”的核心思想,上面提到的“累积的报表数据”实际上就是一个增量缓存的典型实例。关于增量缓存,我们会在下一节进行详细介绍。本节我们将先介绍增量缓存会用到的一个基础组件――增量自动获取器 ESBasic.ObjectManagement.Increasing.IIncreaseAutoRetriever。
现在,我们将注意力集中到上述例子中关于数据增量获取的这个焦点上:
(1)增量可能需要从多个数据源获取。比如,不同类型的订单可能存放在不同的数据库中。
(2)如何体现以一天为单位进行轮转?如果要查看的是周报表,那么就需要将一周作为一轮(Round)。
(3)如果是一天为一轮,那么当经过每天的00:00:00时,增量获取器该如何完结前一天的增量,并切换到新的一天?
这些都是IIncreaseAutoRetriever要解决的问题,而IIncreaseAutoRetriever很好的解决了这些问题,并以一种非常简单易用的接口提供给使用者。
增量自动获取器的形象示意图如下:
2.适用场合:
在满足以下这些条件时,你可以使用IIncreaseAutoRetriever:
(1)需要定时从数据源获取新的增量数据。
(2)数据源可能不只一个。
(3)需要支持“轮”(Round)的概念。在Round切换时,需要能够准确识别增量断点。
(4)增量数据有某个字段是递增的。
3.设计思想与实现
在正式解析IIncreaseAutoRetriever的源码之前,我们先将其会涉及到的一些重要概念说明一下。
首先,数据源可能是多个,所以我们需要为每个数据源设置一个唯一标志(我们称之为Source Token),这标志的类型可能是一个整数,也可能是一个字符串或枚举类型等等。那么,我们可以将数据源标志的类型抽象为一个泛型参数。
其次,上面我们提到Round,即表示增量完成累积的一个完整周期,比如,可能是一天、一周或一月,这取决于你系统的需求。同样的,我们需要每个Round都有唯一的标志(我们称之为Round ID),以将当前Round同历史的Round区分开来。Round ID的类型也是根据系统的需求来定的,所以,我们也将Round ID的类型抽象为一个泛型参数。
再次,我们将缘起部分例子中的一分钟的时间间隔称为一个增量阶段Phase。所以,一个Round是由N个Phase构成的,而且,N通常是个比较大的值。
最后,由于获取增量数据的时候,我们需要依据数据的某个key来进行判断,哪些数据是在刚过去的一个Phase中新加入进来的,这就要求key是递增的,这对从数据源中提取增量会带来极大的便利。比如,可以使用订单的产生时间作为key,如果订单的编号是递增的,也可以使用订单编号作为key。在数据源是数据库的情况下,最好是使用主键作为key,当然,这个时候主键必须是递增的。
IIncreaseAutoRetriever 增量自动获取器会每隔一段时间就从各个数据源(TSourceToken)获取上一阶段的增量数据(TObject),然后触发事件将得到的增量数据发布出去。
下面,我们来看IIncreaseAutoRetriever接口定义:
/// IIncreaseAutoRetriever 增量数据自动获取器。每隔一段时间就从各个来源(TSourceToken)获取一次增量(TObject),并触发事件将增量发布出去。
/// (1)一个Round由多个连续的Phase构成。当获取某Round的最后一个Phase增量时,触发的事件中的isLastPhaseOfRound参数为true。
/// (2)假设增量标志是逐渐递增的。
/// zhuweisky 2009.02.24
/// </summary>
/// <typeparam name="TSourceToken">增量来源的标志</typeparam>
/// <typeparam name="TKey">每个增量Object的标志</typeparam>
/// <typeparam name="TObject">增量Object的类型</typeparam>
public interface IIncreaseAutoRetriever<TSourceToken,TRoundID, TKey, TObject>
{
/// <summary>
/// AutoRetrieveSpanInSecs 设置多长时间为一增量阶段。
/// </summary>
int AutoRetrieveSpanInSecs { get; set; }
IPhaseIncreaseAccesser<TSourceToken, TRoundID, TKey, TObject> PhaseIncreaseAccesser { set; }
event CbIncreasementRetrieved<TRoundID ,TObject> IncreasementRetrieved;
/// <summary>
/// ExceptionOccurred 当提取增量数据出现异常或IncreasementRetrieved事件处理器抛出异常时,将触发此事件,并且引擎将停止运行。
/// </summary>
event CbException ExceptionOccurred;
void Initialize();
/// <summary>
/// ManualRefresh 手动刷新获取增量。
/// </summary>
void ManualRefresh();
}
public delegate void CbIncreasementRetrieved<TRoundID ,TObject>(List<TObject> list, TRoundID currentRoundID, bool isLastPhaseOfRound);
这个接口有四个泛型参数:TSourceToken、TRoundID、TKey、TObject。其中TSourceToken就是我们刚刚讲到的数据源标志的类型,TRoundID就是RoundID的类型,TKey就是递增的key的类型,TObject即增量数据对象的类型。
AutoRetrieveSpanInSecs属性指明需要多长时间获取一次增量数据,这个值以秒为单位。
CbIncreasementRetrieved事件用于在每阶段的增量数据获取完毕之后触发,如此,事件预订者便可以处理增量数据。
当从数据源中提取增量数据出现异常,或IncreasementRetrieved事件的预订者在处理增量数据时抛出异常,则IIncreaseAutoRetriever将触发此事件通知我们,并且内部的循环引擎将停止运行,后续将不再去获取增量数据。
ManualRefresh方法用于手动调用以立即获取增量数据。即,当我们需要立刻读取最新的增量数据时,就不必等到引擎的定时触发,而是可以立即获取。通常,该方法只有在紧急情况下才会使用。
IIncreaseAutoRetriever还有一个IPhaseIncreaseAccesser属性我们放到最后来介绍。IPhaseIncreaseAccesser称为“阶段数据访问器”,从其名字就可以了解到,真正与数据源打交道的是它。也就是说,IIncreaseAutoRetriever是通过IPhaseIncreaseAccesser来从数据源中提取数据的。通过这样的分工之后,这两个类的职责都变得相对的简单和单纯――IIncreaseAutoRetriever只需要管理循环引擎,管理Round的切换点,和整合从不同数据源提取的数据,并触发事件;而IPhaseIncreaseAccesser只需要完成从数据源中提取数据。
至于数据源是数据库、还是文件或网络,我们现在不得而知,只有在具体的应用中才会确定下来。所以,IPhaseIncreaseAccesser是被设计为一个接口,ESBasic没有提供它的默认实现,我们需要在我们的应用中根据我们的数据源的类型来实现这个接口。
现在,我们来看看IPhaseIncreaseAccesser接口的定义:
/// IPhaseIncreaseAccesser 用于从各个源访问每一阶段的增量数据。
/// </summary>
/// <typeparam name="TSourceToken">增量来源的标志</typeparam>
/// <typeparam name="TKey">Round的ID的类型</typeparam>
/// <typeparam name="TKey">每个增量Object的标志</typeparam>
/// <typeparam name="TObject">增量Object的类型</typeparam>
public interface IPhaseIncreaseAccesser<TSourceToken, TRoundID, TKey, TObject>
{
/// <summary>
/// GetMaxKeyOfPreviousRound 获取上一轮各个源中的数据的最大标志。
/// </summary>
IDictionary<TSourceToken, TKey> GetMaxKeyOfPreviousRound();
/// <summary>
/// NextIsLastPhaseOfRound 下一增量(基于now时刻)是否为当前Round的最后一个Phase。如果是,则out出每个源的最后Phase的最大标志。
/// </summary>
bool NextIsLastPhaseOfRound(DateTime now ,out TRoundID currentRoundID, out IDictionary<TSourceToken, TKey> lastKeyOfRoundDic);
/// <summary>
/// GetMaxKey 获取指定源中的截止now时刻(可以等于)的最大标志。
/// </summary>
TKey GetMaxKey(DateTime now, TSourceToken token);
/// <summary>
/// Retrieve 获取某一阶段的增量。maxKeyOfPrePhase 《 本阶段增量 《= maxKeyOfThisPhase
/// </summary>
IList<TObject> Retrieve(TSourceToken token, TKey maxKeyOfPrePhase, TKey maxKeyOfThisPhase);
}
该接口有4个方法,都是用于从数据源中提取相关信息的。
GetMaxKeyOfPreviousRound方法用于获取上一Round结束时各个数据源中的数据的最大key值。由于我们前面的约定――key值是递增的,所以本Round的所有增量数据的key一定是大于该key值。
NextIsLastPhaseOfRound方法用于判断下一Phase是否为当前Round的最后一个Phase。如果是,则out出每个源的最后Phase的最大标志值。注意,无论是不是最后一个Phase,第一个out参数currentRoundID都必须赋予正确的值,以表示当前所处的是哪一个Round。
GetMaxKey方法用于获取指定数据源中的数据的最大标志值。如此,和上一Phase的最大标志值结合起来便可以确定本Phase的增量的标志值的范围。
Retrieve是真正获取某一阶段的增量的方法。其中两个参数限定的了增量的标志值的取值范围,它满足以下公式:
maxKeyOfPrePhase < 本阶段增量的key值 <= maxKeyOfThisPhase
有了IPhaseIncreaseAccesser的支持,IncreaseAutoRetriever的实现就水到渠成了,我将其实现的源码中的关键点罗列如下:
(1)IncreaseAutoRetriever从BaseCycleEngine继承,这表明IncreaseAutoRetriever是借助我们前面介绍的循环引擎来完成定时检测动作的。
(2)maxKeyOfLastPhaseDictionary成员用于存储最后一阶段的每个数据源中增量的最大key值。在Initialize方法初始化时,其初始值被设置为上一Round数据源中增量的最大key值。
(3)IncreaseAutoRetriever使用了lock加锁控制,为什么需要锁了?因为循环引擎本身就是在单线程中运行的啊,岂不是多此一举?实际上,该锁的主要作用在于防止手动调用ManualRefresh刷新增量的同时,循环引擎也正好开始进行定时检测动作。如果不加锁,那么当这两个动作同时发生时,可能会导致发布的增量数据出现重复的错误。
(4)IncreaseAutoRetriever中最核心的是DoDetect方法,它的逻辑稍微有些复杂,我这里简单说明一下。
首先,它会判断是否到了Round的切换点,如果不是,则正常获取增量,并将增量整合进一个列表,触发事件。如果是,则根据IPhaseIncreaseAccesser的NextIsLastPhaseOfRound方法out出的当前Round的最大的key值,来确定当前Round最后一个Phase的增量范围。
其次,当在每一Phase的增量获取完毕时,都会将该Phase增量的最大key值存储到maxKeyOfLastPhaseDictionary字典中,该缓存值将会用于下一Phase的增量key范围的起始值。
当增量收集完毕,将触发的IncreasementRetrieved事件,该事件包含了三个参数,其含义分别是:增量数据列表,当前的Round的ID,当前Phase是否为该Round的最后一个Phase。
最后,如果上述过程抛出任何异常,DoDetect方法将返回false,以指示循环引擎不再继续运行。
4. 使用时的注意事项
(1)虽然在缘起部分我们提到,IIncreaseAutoRetriever是给下节要介绍的“增量缓存”使用的一个基础组件,但是,这并不表示IIncreaseAutoRetriever只能给ESBasic中的“增量缓存”使用。实际上,IIncreaseAutoRetriever是一个独立的可复用组件,凡是需要使用定时增量获取的地方都可以使用它。
(2)如果不需要Round支持,或者说所有增量都是在同一个Round里面,那么也可以照样使用IIncreaseAutoRetriever,只是在实现IPhaseIncreaseAccesser接口的NextIsLastPhaseOfRound方法时,全部返回false即可。在这种情况下,IIncreaseAutoRetriever就永远不会发生Round的切换。
(3)如果你的系统只需要一个数据源,那么TSourceToken对你来说就是没有意义的,ESBasic准备好了ESBasic.ObjectManagement.Increasing.SingleSource来应对这种情况,SingleSource被设计为Singleton(单件)。所以,此时你可以使用SingleSource来取代TSourceToken泛型参数,并且使用SingleSource. Singleton来作为唯一数据源的标志对象。
(4)DoDetect方法中如果抛出异常,循环引擎将停止运行,这个设计是经过深思熟虑的。在这个方法中,最可能出现异常的是IPhaseIncreaseAccesser从数据源中提取数据的方法。当这些方法抛出异常时,会导致maxKeyOfLastPhaseDictionary字典中存储的值的状态不一致――即有些项存储的已经是当前Phase的最大key值,而另外一些项存储的还是上一Phase的最大key值。在这种情况下,想要恢复其到正确状态并继续运行下去就非常困难了,所以还不如直接停止引擎。如果这种情况真的发生,最简单的办法就是重新启动应用程序即可。
在我的经验中,IPhaseIncreaseAccesser都是从数据库提取数据,并且它们位于同一局域网内,所以至今还没出现过因IPhaseIncreaseAccesser抛出异常而导致IncreaseAutoRetriever停止运行的情况。
5.扩展
增量自动获取器IIncreaseAutoRetriever暂时没有任何扩展。
注: ESBasic已经开源,点击这里下载源码。
ESBasic开源前言