Kylin在1.6.0版本中提到了TopN的性能提升非常大:https://issues.apache.org/jira/browse/KYLIN-1917
TopN相关的类存放路径:/core-metadata/src/main/java/org/apache/kylin/measure/topn
经过测试之后发现,确实有了很大的提升。在Kylin1.5.4版本中,TopN的性能非常差,有时候cube中如果定义了TopN度量,那么build任务就会需要非常久的时间,甚至直接ERROR。因此阅读了Kylin1.6.0关于TopN的实现源码。
对于Kylin支持的每一个度量,Kylin都会用一个专门的类来实现,这个类必须要继承MeasureType作为父类。我们可以通过Kylin源码大概了解下MeasureType类的作用:
/**
* MeasureType captures how a kind of aggregation is defined, how it is calculated
* during cube build, and how it is involved in query and storage scan.
*
* @param <T> the Java type of aggregation data object, e.g. HyperLogLogPlusCounter
*/
abstract public class MeasureType<T> {
//省略代码主体
......
}
这个类主要定了一些规则,包括:聚合函数是如何定义的,在进行cube构建的时候如何进行计算以及在进行扫描查询和扫描的时候是如何起作用的等等。我们主要是为了了解TopN的实现,因此这里不多做介绍。
Kylin为TopN度量也实现了一个专门的类TopNMeasureType,这个类包括了TopN度量的一些基本信息,主要跟Kylin的度量实现框架有关系,我们也不多做介绍。我们主要看这个类里面的一个方法:
public MeasureAggregator<TopNCounter<ByteArray>> newAggregator() {
return new TopNAggregator();
}
这个函数返回一个TopNAggregator类的对象,我们找到这个类,可以看到这个类继承了MeasureAggregator,并且实现了它的相关方法,其中aggregate方法就是该度量的聚合操作方法:
//TopNAggregator.class
int capacity = 0;
TopNCounter<ByteArray> sum = null;
@Override
public void aggregate(TopNCounter<ByteArray> value) {
if (sum == null) {
capacity = value.getCapacity();
sum = new TopNCounter<>(capacity * 10);
}
sum.merge(value);
}
我们可以看到,这个方法接受一个TopNCounter类的对象,并获取该对象的容量,然后将该容量放大10倍,构造一个新的TopNCounter类对象sum,用于存放聚合操作之后的结果,这里用于进行聚合操作的函数就是merge()函数。
在介绍merge函数之前,需要先了解一下其他的方法和类,这里我将一一介绍:
public class Counter<T> implements Externalizable {
protected T item;
protected double count;
public Counter() {
}
public Counter(T item) {
this.count = 0;
this.item = item;
}
public Counter(T item, double count) {
this.item = item;
this.count = count;
}
//省略其余部分代码
......
}
Counter类是Kylin用于进行TopN统计的基本单元,也就是计数器类。item表示某个对象,count表示该对象对应的计数值(可以理解为出现次数,销售额等)。
public class TopNCounter<T> implements Iterable<Counter<T>> {
//为了减少误差而进行放大计算的倍数
public static final int EXTRA_SPACE_RATE = 50;
protected int capacity;
private HashMap<T, Counter<T>> counterMap;
protected LinkedList<Counter<T>> counterList;
private boolean ordered = true;
private boolean descending = true;
//省略其余代码
......
}
TopNCounter类基本包含了TopN的主要实现方法,capacity用来表示合并之后容器中需要保留的元素个数;counterMap用来保存对象和其对应的计数器;counterList用来保存每个对象代表的计数器;ordered标志来标识counterList中的计时器是否按序排列的,true表示有序,false表示无序;descending标志用来标识counterList中的元素是否按出现次数降序排列,true表示降序,false表示不是降序。
在进行合并的过程中,会涉及到元素的计数器值的更新问题,offer函数就实现了该功能:
//TopNCounter.class
//默认计数器值更新1
public void offer(T item) {
offer(item, 1.0);
}
public void offer(T item, double incrementCount) {
Counter<T> counterNode = counterMap.get(item);
if (counterNode == null) {
//如果是新对象,则直接插入counterMap和counterList中
counterNode = new Counter<T>(item, incrementCount);
counterMap.put(item, counterNode);
counterList.add(counterNode);
} else {
//已经存在的对象,则直接在原先的基础上更新value
counterNode.setCount(counterNode.getCount() + incrementCount);
}
//无论是否为新对象,都会打乱原来容器的状态,因此不再有序,ordered置为false
ordered = false;
}
如果插入的元素在当前容器中已经存在,那么直接更新对应元素的value值;如果插入的元素是新元素,则直接加入到counterList中。因此每次调用offer函数更新容器元素时,都会打乱容器原来的顺序。
下面来看看我们再来看看merge函数,merge函数的主要功能就是将一个TopNCounter对象的容器中的元素合并到另一个TopNCounter的容器中,并且保持合并后的容器元素依然有序。这里的容器指的就是上面TopNCounter类中的counterMap和counterList成员。counterList需要保持有序,counterMap不需要,也不可能。
//TopNCounter.class
public TopNCounter<T> merge(TopNCounter<T> another) {
double m1 = 0.0, m2 = 0.0;
if (this.size() >= this.capacity) {
//取被合并容器的末尾元素的计数器值
m1 = this.counterList.getLast().count;
}
if (another.size() >= another.capacity) {
//取合并容器的末尾元素的计数器值
m2 = another.counterList.getLast().count;
}
//duplicateItems用来保存this.counterMap和another.counterMap都存在的元素
Set<T> duplicateItems = Sets.newHashSet();
//notDuplicateItems 用来保存存在于this.counterMap,但不存在于another.counterMap的元素
List<T> notDuplicateItems = Lists.newArrayList();
for (Map.Entry<T, Counter<T>> entry : this.counterMap.entrySet()) {
T item = entry.getKey();
Counter<T> existing = another.counterMap.get(item);
if (existing != null) {
duplicateItems.add(item);
} else {
notDuplicateItems.add(item);
}
}
//更新两个容器都存在的元素,在this容器中的计数器值
for (T item : duplicateItems) {
this.offer(item, another.counterMap.get(item).count);
}
//将只存在于this,但不存在于another的元素计数器值更新m2
for (T item : notDuplicateItems) {
this.offer(item, m2);
}
for (Map.Entry<T, Counter<T>> entry : another.counterMap.entrySet()) {
T item = entry.getKey();
//如果元素只存在于another,但不存在于this,则将该元素的值累加m1,然后插入到this容器中。
if (duplicateItems.contains(item) == false) {
double counter = entry.getValue().count;
this.offer(item, counter + m1);
}
}
//当进行合并之后,原来容器的顺序会被打乱,因此需要进行排序,并且保留原来的容量,去除多余的元素
this.sortAndRetain();
return this;
}
//对counterList中的元素进行排序,并且保留原来的容量
public void sortAndRetain() {
Collections.sort(counterList, this.descending ? DESC_COMPARATOR : ASC_COMPARATOR);
retain(capacity);
ordered = true;
}
在进行合并过程中,对于this和another的容器元素一共有三种情况:this和another中都存在;存在于this中,但不存在于another中;存在于another中,但不存在于this中。对于这三种情况,Kylin的处理方法是:
1. 对于this和another中都存在的元素,直接将another中的计数器值累加到this中;
2. 对于存在于this中,但不存在于another中的元素,假设这些元素都在another中存在,并且计数器值都是another中的最小值,然后将这个最小值m2累加到所有的存在于this中的这些元素;
3. 对于存在于another中,但不存在于this中的元素,假设这些元素都在this中存在,并且计数器值都是this中的最小值,然后将这个最小值m1与another中的这些元素的计数器值进行累加,然后插入到this中。
这种做法存在一定的误差。因此,为了减小这种误差,Kylin在进行TopN度量计算的时候,进行了放大操作,TopNCounter中的EXTRA_SPACE_RATE成员就是放大的倍数,Kylin把它设置为50。也就是说,如果我们要计算的是Top100,Kylin会求Top5000,然后返回前100给你。这样就在很大程序上较少了误差产生。
Kylin在进行build任务的时候,是通过提交MR任务进行的,因此对于TopN度量,也定义了序列化和反序列化的方法,都在TopNCounterSerializer类中。在序列化函数中,有这样一行代码:
List<Counter<ByteArray>> peek = value.topK(1);
对于传入的TopNCounter对象value调用了topK()方法,我们可以在TopNCounter类中查看这个方法的实现:
public List<Counter<T>> topK(int k) {
//ordered为false表示当前容器是无序的,因此需要进行排序操作
if (ordered == false) {
sortAndRetain();
}
List<Counter<T>> topK = new ArrayList<>(k);
Iterator<Counter<T>> iterator = counterList.iterator();
while (iterator.hasNext()) {
Counter<T> b = iterator.next();
if (topK.size() == k) {
return topK;
}
topK.add(b);
}
return topK;
}
通过代码我们可以看到,如果当前对象的容器元素是无序的,那么会调用sortAndRetain()函数进行排序和保留操作。因此,上面调用topK(1)就是为了在进行序列化操作之前,对容器元素进行排序操作。所以,当map任务在进行序列化的时候,TopNCounter对象中的容器都已经是有序的了。当在进行reduce任务进行反序列化的时候,得到的TopNCounter对象中的容器也都是有序的。
因此,当调用merge函数,对TopNCounter对象之间进行合并时,传入的another中的countList总是有序的。所以在merge的代码中可以直接通过getLast()函数取another.countList容器的最后一个元素值作为m2,因为countList是按顺序排列的。
由于Kylin1.5.4版本中,TopN在build的时候,性能非常差,因此这里就不做介绍,有兴趣的朋友可以自己对比研究一下。
如果错误,敬请指正。