复赛的题目是百万级别的消息引擎的设计与实现,最后成绩是65名。就个人感觉而言,复赛是比初赛要热闹的多,第四届的初赛是dubbo mesh,要考察方方面面,但是第五届的主要复写负载均衡算法,考察面相对较窄,复赛就不一样了,文件I/O,内存映射,零拷贝,堆外内存操作,甚至GC优化,操作系统层面的pageCache都要涉及到,中间还不断去翻RocketMQ的源码用于参考,不由得感慨,RocketMQ真是大师级的作品。
实现一个进程内消息持久化存储引擎,要求包含以下功能:
A. 查询一定时间窗口内的消息
B. 对一定时间窗口内的消息属性某个字段求平均,以及求和
消息内容简化成两个字段,一个是业务字段a(整数),一个是时间戳(long)。实际存储格式用户自己定义,只要能实现对应的读写接口就好
评测:
发送阶段:假设发送消息条数为N1,所有消息发送完毕的时间为T1;发送线程多个,消息属性为: a(随机整数), t(输入时间戳模拟值,和实际时间戳没有关系, 线程内升序).消息总大小为50字节,消息条数在20亿条左右,总数据在100G左右
查询聚合消息阶段:有多次查询,消息总数为N2,所有查询时间为T2; 返回以t和a为条件的消息, 返回消息按照t升序排列
查询聚合结果阶段: 有多次查询,消息总数为N3,所有查询时间为T3; 返回以t和a为条件对a求平均的值
消息队列的题目,提供读写接口的引擎,Jvm相关参数-Xmx4g -XX:MaxDirectMemorySize=2g -XX:+UseConcMarkSweepGC
1.消息20亿条,每条50字节,总共100G,和去年一样,100G的数据量是无法全部放到内存里面,需要落盘以支持消息堆积;
2.发送线程是12个线程并发发送,。每个消息的时间戳线程内有序,不同线程之间不保证顺序,但整体递增,换句话,线程间的无序是并发导致的,并不是独立生成的,这点很重要;
3.系统SSD性能大致如下: iops 1w 左右;块读写能力(一次读写4K以上) 在200MB/s 左右,这个主要关系读写性能,优化存储结构来充分利用iops,实际上我最后一般吞吐率是达不到最大,IOPS会达到瓶颈;
4.压缩算法,支持压缩,但是不能过分利用数据特性,特别比赛到后面白热化阶段,大佬们居然可以16G的数据全压进堆内内存,然后全内存操作,来提高第三阶段的TPS, 也是非常大胆有想法的思路,最后这种办法被主办方禁用了,可能考察I/O优化和存储结构才是复赛的本意吧,毕竟,生产的数据不可能全放内存,比赛更多是模拟生产的状态,来产生更好创意和解决方案,而不是就比赛论比赛。
总体上是三个队列,2个线程
一般文件I/O有四种,一个是传统BIO,一个是RandomAcessFile的文件NIO接口,还有一个FileChannel,另外,还有一个是mmp,内存映射,第一种肯定不考虑,第二种的接口可用的定制参数太少,API用起来不方便,最后采用的FileChanel +堆外内存的方式进行;为什么没有使用mmp,虽然mmp号称是内存读写,并使用了零拷贝,减少一次内核态向用户态的转换,但实测的性能,在高压力请求(指IOPS接近打满)的情况下,mmp的性能并不比FileChannel好,甚至还出现了小幅的下滑,这里面可能的原因:
有资料的标签,在大量小文件块操作的时候,mmp 比filechanel的效果可能要好,但这个目前在这次比赛中无处验证,有兴趣的同学可以研究下;
另外,这里面附篇大佬的文章,大家可以参考下
1.https://www.cnkirito.moe/file-io-best-practise/
I/O的问题先讨论到这,接着看下代码
索引block的字段, 内存索引,没有落库,大概每2000个消息一个块,20亿调消息大概是100W块,内存大概是200M,这个4G内存,够用
public class Block {
private long positionOffset;
private long TOffset;
private long startTimeTramp;
private long endTimeTramp;
private int mesNum;
private long sum;
}
buffer池,准确说是数组池的设计
public class ArrayListPool {
private static final int total_size = 1000;
private LinkedBlockingQueue> bufferList = new LinkedBlockingQueue<>();
private AtomicInteger usedCount = new AtomicInteger(0);
public ArrayList borrow() throws Exception{
if (usedCount.get() <= total_size){
ArrayList list = new ArrayList(4000);
usedCount.incrementAndGet();
return list;
}
return bufferList.take();
}
public void returnBack(ArrayList list)throws Exception{
bufferList.put(list);
}
}
QueueExecutor.execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
while (true){
try {
Message message1= null;
ArrayList cacheList = arrayListPool.borrow();
RemainingCounter.temp_list = cacheList;
for (Map.Entry> entry: listQueue.entrySet()){
LinkedBlockingQueue queue =entry.getValue();
if (queue.peek() == null){
Thread.sleep(100);
}
while (queue.peek()!= null && queue.peek().getT() < num0){
if (dataBuffer.count < cacheList.size() ){
message1 = queue.peek();
cacheList.get(dataBuffer.count).setT(message1.getT());
cacheList.get(dataBuffer.count).setA(message1.getA());
cacheList.get(dataBuffer.count).setBody(message1.getBody());
queue.poll();
} else {
cacheList.add(queue.poll());
}
dataBuffer.count++;
}
if (queue.peek() == null){
if (!listBool.get(entry.getKey())){
Thread.sleep(500);
if (queue.peek() == null){
listBool.put(entry.getKey(),true);
}
}
}
}
if (checkMan()){
break;
}
if (dataBuffer.count < cacheList.size()){
for (int k= cacheList.size()-1;k>=dataBuffer.count;k--){
cacheList.remove(k);
}
}
cacheList.sort(new Comparator() {
@Override
public int compare(Message o1, Message o2) {
if (o1.getT() > o2.getT()) {
return 1;
}
if (o1.getT() == o2.getT()) {
return 0;
}
return -1;
}
});
dataBuffer.beginTimeTramp = cacheList.get(0).getT();
dataBuffer.endTimeTramp = cacheList.get(cacheList.size() - 1).getT();
Block block = new Block();
block.setStartTimeTramp(dataBuffer.beginTimeTramp);
block.setEndTimeTramp(dataBuffer.endTimeTramp);
block.setPositionOffset(fileOffset);
block.setTOffset(numOffset);
block.setMesNum(dataBuffer.count);
msgMap.add(block);
//放到一个队列里异步刷盘
WriteModule writeModule = new WriteModule();
writeModule.setMesNum(block.getMesNum());
writeModule.setList(cacheList);
workQueue.put(writeModule);
fileOffset += message_length * dataBuffer.count;
numOffset += T_length * dataBuffer.count;
dataBuffer.count = 0;
dataBuffer.aMin =0;
dataBuffer.aMax=0;
dataBuffer.block_sum =0;
num0 = num0 + fix_length;
} catch (Exception e) {
e.printStackTrace();
}
}
}
});
}
这个是异步排序线程的代码,主要是取数据,排序,生成索引,丢入落盘队列,应该是这个思路的最主要的设计所在了
后面的代码不在一一帖进去了,已放到github上开源,有兴趣的同学可以拉下代码看下
这次名称不高的原因:
后来看了大佬的分享,有几个思路,这里面记下,后面如果有长期赛,可以继续实践下:
最后,在说说mmp,其实最后几天,没有改进算法和压缩,更多时间花在研究文件I/O的API上了,虽然最后并没有实际进展
1.mmp在第三阶段的性能上,实际比FileChannel + 堆外内存的性能还要差,大概在6000分左右,这个确实是不太好理解,个人认为可能跟映射的文件大小有关的,这个具体可以参考之前大佬的文章。
2.中间参考了RocketMQ的源码,发现MQ本身是既有mmp,又有FileChannel的,写入的时候更像是用后者实现的,MQ里面为什么会这么设计,可能在压测时候的发现了某些深层次的原因,这个就静待大佬解答了
最后在说几句题外话,对于上班族而言,做这个比赛确实比较累,但也很开心,平时看MQ的源码,总感觉泛泛而谈,只有真正自己去设计,去思考和实践才能发现里面的问题,以及好的思路是怎么诞生的。
另外,沟通交流真的学习的重要途径,闭门造车很容易陷入局部战争出不来,思维的碰撞,往往更能产生创意的火花。