赛题分析
赛题分为3个阶段: 发送阶段、查询聚合消息阶段、查询聚合结果阶段。
- 发送阶段:发送线程多个(线上评测程序为12个发送线程),消息属性为: a(long类型、8字节、随机), t(输入时间戳模拟值、long类型、8字节、线程内升序),body(byte数组,大小固定34字节,内容随机)。消息条数在20亿条左右,总数据在100G左右,也就是a和t分别16G、body 68G。可用内存堆内4G、堆外2G,也就是数据内存肯定存不下,需要落盘。SSD盘,性能大致是iops 1w 左右,块读写能力(一次读写4K以上) 在200MB/s 左右。
接口定义如下:
- 查询聚合消息阶段:有多次查询(线上评测程序大概是3w多次,12个查询线程),给定t的范围[tMin,tMax]和a的范围[aMin,aMax],返回以t和a为条件的消息, 返回消息按照t升序排列。
接口定义如下:
- 查询聚合结果阶段: 有多次查询(线上评测程序大概是3w多次,12个查询线程),给定t的范围[tMin,tMax]和a的范围[aMin,aMax],返回以t和a为条件对a求平均的值。
接口定义如下:
需要选手按照题目要求实现这三个接口。
赛题思路
拿到赛题之后,根据以往的比赛经验就是先实现一个简单的版本,把三个阶段跑通,然后在此基础上统计数据去分析数据特点,比如t的间隔分布(每个线程的t都是满足非递减的),a的随机性以及body的随机性等。所以整个比赛过程中主要有两个版本:分线程版本和全局版本。
分线程版本
整体思路如下图
实现之后在这个版本基础上开始统计数据,发现分线程的间隔t很小,主要集中在0,1等小间隔内,全局的间隔更小更是集中在0和1等小间隔内,说明t和内存化。a和body随机性太大,而且无法压缩(使用snappy压缩之后比不压缩还大点,java自带的压缩太耗cpu,而且这次比赛的数据也没法压缩)。
既然t可以压缩,接下来就是把t压缩到内存上。压缩t先是采用的zigzag压缩算法存差值,后面和别人交流发现存deltaOfDelta差值的差值((t3-t2)-(t2-t1))压缩率更大。由于压缩是版本通用的,后面再单独讲。
全局版本
put和getMessage阶段和分线程版本之前一样(区别在于t内存化了以及get avg阶段),如下图所示
t内存化做法如下图
不管是分线程还是全局版本,t内存化的做法都是一样的,对t排序(分线程本身就是有序的不用再排,接下来的讲解的和t有关的都是全局排序之后的),然后按照固定个数进行分区,每个分区n个t,有辅助索引来记录每个分区在内存中的起始偏移位置以及第一个t,这样对每个分区编码t的时候,第一个t就不需要在编码了,然后每个分区的第一个值就存delta,其它的全存deltaOfDelta。
全局版本排序是在put阶段完成之后,getMessage阶段刚开始的时候去做的,对每个线程所有的t和a进行一个merge sort,并将t按照上图的思路内存化,每个分区数是:1024 * 24,整个下来编码内存占用252M,辅助索引内存占用不到1M。(从工程角度上来看,肯定是在put阶段进行merge sort更具有意义性)
t内存化了,给定一个t查询,先在辅助索引上进行二分查询找到t所在对应的分区块,然后在分区上进行解码查找具体t就可以了。
这样只解决了t,a还要是从磁盘中读取在过滤。在比赛过程中可以看出,get avg阶段是最重要的阶段,前排选手都是get avg阶段得分高。按照先找符合[tMin,tMax]t的位置,再去磁盘读取a过滤不符合的a再去算平均值,一次查询只需要读一次磁盘,但是读取的数据量太多,根据统计出来的数据可以得到其中大部分的a其实都不满足条件。 get avg阶段提分的做法就是需要降读取的a数量,降读取a的数量必然带来读盘次数的增加(本次给的磁盘的iops是1w),所以需要去找一个平衡点。
既然t已经分区了,是不是可以对分区中的a做一个排序来减少数据量呢,答案肯定是可以的,后面具体会详解。对a排序再去求符合条件的平均值有两种做法,分别是:
- 分区内的a排序,并记录a对应的t,这样求平均值就是先找符合条件的分区t,再去每个分区中找符合条件的a,对于一个分区t要么全部满足条件,要么部分满足条件。如果全部满足条件,就只需要从分区排序后的a找到满足条件的a即可;如果是部分满足,还会多一步,从分区排序后的a找到满足条件的a之后,还需要在从符合条件的a中再去过滤不符合条件的t。
- t排序后t对应的a存一份,分区内的a排序,但不记录a对应的t,这样求平均值也是先找符合条件的分区t,再去每个分区中找符合条件的a,同样对于一个分区t要么全部满足条件,要么部分满足条件。如果全部满足条件,就只需要从分区排序后的a找到满足条件的a即可;如果是部分满足,就读t排序对应的a再去过滤a即可。
实际做的时候采用的方案2,接下来重点阶段方案2,两个方案其实比较类似,所有优化细节类似,所以方案2理解了方案1同样可以理解。(从赛后总结来看:方案1会更优,并且更具有工程意义性)
方案2的思路如下图,t排序,然后按照1024*48分区,每个分区内的a进行排序,然后再按照128进行分组。
首先根据[tMin,tMax]可以确定t所在的区间,这样区间可以分为两种情况:
- 整个区间t都符合条件,只需要利用分组内a的辅助索引二分来确定a的所在分组,对于首尾分组的a是部分满足,最多需要两次读盘,每次读盘都是128个a,对于中间的分组中的所有a肯定都满足条件,直接累加和就行。
- 区间部分满足,区间部分满足也有3种情况
t完全在中间,直接读取t对应的a来找符合条件的a;对于t在左半部分或者右半部分,如果符合条件的t会大于等于整个区间的3/4,可以进行一个求反来优化,用整个区间内所有符合条件的a减去不符合条件t中符合条件的a,来降低读取数据只是最多多了两次128的磁盘读取。
为什么t分区内a排序采用的128来进行分组呢,经过测试,一次读取128个a时间最短。12个线程每个线程读取1w次,随机读取一个分区中的首k个a,测试结果如下。
读取a的个数 | 读取时间(单位s) |
---|---|
1024*32 | 22.2 |
1024*16 | 15.7 |
512 | 6.19 |
256 | 5.7 |
128 | 5.57(最快) |
64 | 5.72 |
32 | 6.03 |
16 | 5.65 |
分组之后,原本一次读取变成了最多8次读取(分组合理t最可以最多跨3个分区),分组完之后,每次读取最终换成几次读取是可以计算出来的,然后再进行比对看是一次读取还是划分成多次读取节省时间,当时并没有去做这个分析。当时队友提到说可以进行分次分组,一开始没有采纳,觉得实现比较麻烦,后面仔细思考了下,还是很好实现的,所以后面就开始搞多层分组。
多层的思路如下图所示,1024*48里面分1024*24,1024*24再分1024*12,每一层按照a排序都需要存一份文件到磁盘中,并且有一份辅助索引在内存中。
来一个查询从大的分组开始找,找到最合适的分层,最合适的分层就是t可以跨了3个分区,关键代码如下图所示,再按照前面讲的思路去做。
public long getAvgValue(long aMin, long aMax, long tMin, long tMax) {
if (aMin > aMax || tMin > tMax) {
return 0;
}
GetAvgItem getItem = getItemThreadLocal.get();
ByteBuffer tBufDup = tBuf.duplicate();
//对t进行精确定位,省去不必要的操作,查找的区间是左闭右开
int beginTPos = findLeftClosedInterval(tMin, getItem.tDecoder, tBufDup);
int endTPos = findRightOpenInterval(tMax, getItem.tDecoder, tBufDup);
if (beginTPos >= endTPos) {
return 0;
}
//t符合提交的个数
int tCount = endTPos - beginTPos;
IntervalSum intervalSum = getItem.intervalSum;
intervalSum.reset();
if (tCount <= Const.T_INDEX_INTERVALS[Const.T_INDEX_INTERVALS.length - 1]) {
//读取的数量比最小层的间隔还小,直接从排序后的t对应的a文件读取过滤a求平均值返回
readAndSumFromAPartition(beginTPos, tCount, aMin, aMax, getItem);
} else {
//走分层索引查询
sumByPartitionIndex(beginTPos, endTPos, tCount, aMin, aMax, getItem);
}
return intervalSum.avg();
}
private void sumByPartitionIndex(int beginTPos, int endTPos, int tCount, long aMin, long aMax, GetAvgItem getItem) {
PartitionIndex partitionIndex = findBestPartitionIndex(beginTPos, endTPos);
if (partitionIndex == null) { //没有找到最合适的索引,直接从排序后的t对应的a文件读取过滤a求平均值返回
readAndSumFromAPartition(beginTPos, tCount, aMin, aMax, getItem);
return;
}
int interval = partitionIndex.getInterval();
int doubleHalfInterval = interval / 4; //4分1的分区大小
int beginPartition = beginTPos / interval, endPartition = endTPos / interval;//求首尾所在分区
int firstPartitionFilterCount = beginTPos % interval, lastPartitionNeedCount = endTPos % interval;//求首分区需要过滤的个数,尾分区需要读取的个数
long sum = 0;
int count = 0;
if (firstPartitionFilterCount > 0) {//处理首分区
int firstReadCount = interval - firstPartitionFilterCount;
if (firstPartitionFilterCount < doubleHalfInterval) {
//求反,先减后加,防止溢出
inverseReadAndSumFromAPartition(beginTPos - firstPartitionFilterCount, firstPartitionFilterCount, aMin, aMax, getItem);
partitionIndex.partitionSum(beginPartition, aMin, aMax, getItem);
} else {
sumByPartitionIndex(beginTPos, beginTPos + firstReadCount, firstReadCount, aMin, aMax, getItem);
}
beginPartition++;
}
if (lastPartitionNeedCount > 0) {//处理尾分区
if (interval - lastPartitionNeedCount < doubleHalfInterval && (endTPos - endPartition * interval) >= interval) {
//求反,先减后加,防止溢出
inverseReadAndSumFromAPartition(endTPos, interval - lastPartitionNeedCount, aMin, aMax, getItem);
partitionIndex.partitionSum(endPartition, aMin, aMax, getItem);
} else {
sumByPartitionIndex(endTPos - lastPartitionNeedCount, endTPos, lastPartitionNeedCount, aMin, aMax, getItem);
}
}
//首尾区间处理之后,[beginPartition, endPartition)中的t都是符合条件,不用再判断
while (beginPartition < endPartition) {
partitionIndex.partitionSum(beginPartition, aMin, aMax, getItem);
beginPartition++;
}
getItem.intervalSum.add(sum, count);
}
private PartitionIndex findBestPartitionIndex(int beginTPos, int endTPos) {
for (int i = 0; i < Const.T_INDEX_INTERVALS.length; i++){
int interval = partitionIndices[i].getInterval();
int beginPartition = beginTPos / interval;
int endPartition = endTPos / interval;
if (endPartition - beginPartition > 1) {//找到了最合适的分区
return partitionIndices[i];
}
}
return null;
}
分层的好处就是可以找最合适的层去处理查询,缺点就是每一层都需要存一份文件以及一份辅助索引。
压缩算法
zigzag压缩算法
zigzag特别适合用来压缩小整数,虽然题目给的t是long型,范围很大,但是t的间隔很小,这样就可以存delta差值,就可以使用zigzag算法了。
zigzag的思想是每次使用固定的比特位编码(至少2位以上),一位标志位,其它位位数据位,比如用8个比特位编码,其中第1位位标志位,用来标志是否结束(下一个8比特是否还有数据),剩余7位来表示数据。下面举一个8比特位编码的例子,其中标志位如果位为1表示还有数据,0表示结束。
比如0b_1000_1010_1110使用zigzag的思想来编码,最终的编码就是 0b_(1_1000101)_(0_0001110),也就是16个比特位就能表示。
到这里会发现如果是一个负数用zigzag又怎么编码呢?zigzag会将数左移一位,进行异或来进行编码,比如-10:
-10的二进制是0b1111 1111 1111 1111 1111 1111 1111 0110,
0b1111 1111 1111 1111 1111 1111 1111 1111 符号位移到末尾
0b1111 1111 1111 1111 1111 1111 1110 1100 -10左移一位
0b0000 0000 0000 0000 0000 0000 0001 0011 异或的结果,然后再按照上面的方式进行编码。
int intToZigzag(int n){
return (n << 1) ^ (n >> 31);
}
int zigzagToInt(int n) {
return (n >>> 1) ^ -(n & 1);
}
因为这里t的delta差值都是正数,所以不需要考虑负数,这样使用zigzag的思想来编码,分线程版本内存占用是600来M左右,全局版本需要500M。
Beringei压缩算法
不管是分线程还是全局版本,t的delta分布中,其中0的占比最多,由于zigzag编码有1位标志位,0最少也要使用2个比特位来编码,有没有其它的编码方式更省内存呢?
在和别人的交流中,得知了Beringei压缩算法(zigzag和这种编码都是从fackbook开源的),这种编码特别适合时序数据库的压缩,t的差值很小,t的差值的差值就更小也就是deltaOfDelta。后面在github上找到了一个相关的实现,在实现编码的地方十分精巧,在比赛中使用到了里面移位编码的代码。
本次比赛在使用的时候的简化了这种编码,并不完全一致,下面大概介绍下这种编码思想。
在编码的时候有标记位,标记位标识的是数据位的有效长度(可标识数的范围)和数据位,数据位不是一定要有,可以只有控制位,这样0就可以采用一个比特位来编码。下面用例子来讲解(只考虑正数,负数的话只需要加一个符号位就行):
控制位:0b0表示0,控制位0就是数据位
控制位:0b10表示数据有效长度为1,可表示的数据范围是0~1
控制位:0b110表示数据有效长度为2,可表示的数据范围是0~3
控制位:0b1110表示数据有效长度为3,可表示的数据范围是0~7
以此类推(控制位可以根据实际场景自己设定)编码如下:
对于数字串:0 1 7 6 0 5 4 2 3 1 0 1的编码如下
感兴趣的可以再去研究下代码:https://github.com/jecyhw/ByteBuffer-Beringei-Compress 从https://github.com/haidfs/ByteBuffer-Beringei-Compressfork过来的。
使用这种编码,分线程版本内存占用是440M左右(节省将近1/3的内存),全局版本只需要252M(节省一半的内存)。对于限制内存的比赛,内存使用就会特别重要,内存使用的越少,就有越多内存可以当缓存使用。
缓存
按照前面讲的思路,整个内存使用情况:分线程版本内存占用是440M(编码占用内存)+5.8M(辅助索引内存),这一部分是可以省掉的;全局版本内存占用是252M(编码占用内存)+1M(辅助索引内存)。每一层a的辅助索引内存占用是240M。也就是整个下来内存占用不到1G,甚至可以到500M,总共内存6G(堆内4G+堆外2G),还有将近5G的内存来做缓存,需要预留一部分的内存给jvm。
缓存比较简单,对t合并排序的时候从头开始缓存a知道将所有内存填满为此。实际使用了2.5g来缓存,统计出来的缓存命中率在1/6,但由于代码写得有问题,不是在一开始就先查缓存,还是先走分层,最后才会走缓存,比赛结束之后才发现缓存没怎么用上,当时缓存写好提交之后分数提高在1k左右,效果不好当时也觉得奇怪但没有细查代码,还有内存可用作缓存也就没再搞了。
总结
- 采用t全局排序分区,分区内a排序在分组这种思路时,当时写代码的时候队友就是想将a排序后的t编码到内存中。但是当时考虑到a排序后的t要编码到内存中,还需要去统计怎么做编码才能将t全部编码到内存中,而将原来t排序后对应的a保存一份,t排序后分区内a排序的a保存一份一样可以做,而且写起来还简单点,还有一个原因是想快速验证下,所以就选择了方案2,并且一直陷入到最后。
- 忽略了t排序后分区内a排序可以压缩,赛后和别人交流的时候发现压缩的提分最高。由于方案2的会至少保留2份文件,其中一份是不能压缩的,所以压缩带来的效果肯定没有只存一份a而且还能压缩的效果好。之前评测数据没改的时候,一直在搞压缩,改完评测数据采用全局的方案的时候就把压缩也忽略了,到比赛最后一天才想起来可以压缩。赛后和别人交流,a压缩可以从16g压缩到9g多点,自己当时最后一天也统计过a的delta的有效数据比特位的分布,但是已经晚了。
- 本赛题的瓶颈点就是磁盘io,get两个阶段cpu完全富裕。当时一直在降读取数量并且用尽可能少的io读取次数,没能够好好分析一次读取a的数量的耗时和转换成几次小io的耗时比对。
- 前期debug耗的时间最多,基本都是在debug,一个小小的细节没注意到就可以让你debug到崩溃,后面才慢慢找到感觉。
- 附上决赛答辩视频地址:https://tianchi.aliyun.com/course/video?liveId=41090
赛题
赛题链接:
https://tianchi.aliyun.com/competition/entrance/231714/information
比赛排名
最终排名截图
各阶段得分截图
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。