另一个问题则是更新队列的长度。如果不进行任何优化,更新队列理论上是无限长的,甚至会超过数据集的大小。一个优化方法是我们限制住更新队列的最大长度,一旦长度超过限制,则执行合并(Merge)操作。Merge操作将队列中的数据进行两两合并,合并后的版本号以较大的版本号为准,合并后的更新数据集是两个数据集的并。Merge后,新的队列长度下降为原更新队列的一半。
Merge之后的更新队列,我们依然可以使用相同的算法进行同步Diff计算:在队列中找到大于上一次更新版本号的所有数据集。可以看到由于版本号的合并,算出的Diff不再是完全精准的更新数据,在队列中最早的更新数据集有可能包含部分已经同步过的数据——但这样的退化并不影响同步正确性,仅仅会造成少量的同步冗余,冗余的量取决于Diff中最早的数据集经过Merge的次数。
MerkleTree同步——数据集对比算法
基于版本号的同步使用的是类似RedoLog的思想,将业务变动的历史记录下来,并通过回放未同步的历史记录得到Diff。由于记录不断增长的RedoLog需要不小的开销,所以采用了Merge策略来退化原始日志(Log)。对于批量或者微批量的更新来说,基于版本号的同步算法能较好的工作;相反,若数据是实时更新的,将会出现大量的RedoLog,并快速的退化,影响同步的效率。
Merkle Tree同步算法走的是另一条路,简单来说就是通过每次直接比较两个数据集的差异来获取Diff。首先看一个最简单的算法:每次内存副本将所有数据的Hash值发送给数据源,数据源比较整个数据集,对于Hash值不同的数据执行同步操作——这样就精确计算出了两个数据集之间的Diff。但显而易见的问题,是每次传输所有数据的Hash值可能并不比多传几个数据轻松。Merkle Tree同步算法就是使用Merkle Tree数据结构来优化这一比较过程。
Merkle Tree简单来说是就是把所有数据集的hash值组织成一棵树,这棵树的叶子节点描述一个(或一组)数据的Hash值。而中间节点的值由其所有儿子的Hash值再次Hash得到,描述了以它为根的子树所包含的数据的整体Hash。显然,在不考虑Hash冲突的情况下,如果两颗Merkle Tree根节点相同,代表这是两个完全相同的数据集。
Merkle Tree同步协议由副本发起,将副本根节点值发送给数据源,若与数据源根节点hash值一致,则没有数据变动,同步完成。否则数据源将把根结点的所有儿子节点的hash发送给副本,进行递归比较。对于不同的hash值,一直持续获取直到叶子节点,就可以完全确定已经改变的数据。以二叉树为例,所有的数据同步最多经过LogN次交互完成。
2.3.2 客户端缓存技术
当数据规模大,无法完全放入到内存中,冷热数据分明,对于数据时效性要求又不高的时候,通常各类业务都会采用客户端缓存。客户端缓存的集中实现,是特征服务延伸的一部分。通用的缓存协议和使用方式不多说,从在线特征系统的业务角度出发,这里给出几个方向的思考和经验。
接口通用化——缓存逻辑与业务分离
一个特征系统要满足各类业务需求,它的接口肯定是丰富的。从数据含义角度分有用户类、商户类、产品类等等,从数据传输协议分有Thrift、HTTP,从调用方式角度分有同步、异步,从数据组织形式角度分有单值、List、Map以及相互嵌套等等……一个良好的架构设计应该尽可能将数据处理与业务剥离开,抽象各个接口的通用部分,一次缓存实现,多处接口同时受益复用。下面以同步异步接口为例介绍客户端接口通用化。
同步接口只有一步:
向服务端发起请求得到结果。
异步接口分为两步:
向服务端发起请求得到Future实例。
向Future实例发起请求,得到数据。
同步和异步接口的数据处理只有顺序的差别,只需要梳理好各个步骤的执行顺序即可。引入缓存后,数据处理流程对比如下: