第三届阿里中间件性能挑战赛 初赛记录及源码分享

来源:互联网 发布:乒乓球底板 知乎 编辑:程序博客网 时间:2024/06/05 09:24

初赛:《基于Open-Messaging实现进程内消息引擎》

塞梯

我们手里有程序demo,有大部分的接口,主要需要实现,创建消息,发送消息,创建消费的信息,消费对象。
本地有一个单线程和多线程程序,在线测评程序未知,符合题目的描述,且提供日志。测试环境是4核,4G内存,虚拟机堆初始和最大值设置为2560m,看日志,新生代300m,使用ParNew垃圾收集器,老年代2G多一点,使用cms收集器。

版本迭代

  • 因为生产者进程会被kill,所以必须要落盘。考虑100个左右的消息种类(大概10个左右queue,90个topic,queue只会被消费一次,topic可以被重复消费),如果按每种一个文件,需要来回操作不同文件,所以设计了最初方案为
    • 每个生产者线程写一个文件,那么啥也不用想,有对象来了就使用序列化写进文件,使用BufferedInputStream/Output操作文件。
    • 消费者初始化要消费的queue和topic,topic可以重复。
    • 消费者一次读一个文件。这里读取文件夹所有文件,使用一个AtomicInteger控制下标可以避免同步,使得一个线程唯一获得一个文件。然后创建消息信息时,初始化存储的对象,HashMap,key为消息种类,value为消息对象的list,需要加锁,双重检查锁定。 然后反序列化对象,这个时候就必须要给消息对象分类,考虑到多线程,所以对list加锁后add。
    • 一个消费者线程读取完毕,需要等待读完了所有文件,才允许开始消费。这里同样使用AtomicInteger,读完一个就+1,直到读完。
    • 最后全部对象在内存中,直接消费,也不需要同步。
  • 查找资料,发现RocketMQ,还有一些序列化框架,速度快的原因主要是持久化的速度,即转换成字节数组和从字节数组转成对象,以及使用了内存映射。所以接下来两步就是使用内存映射,发送消息数量少时不明显,消息多时快了很多倍。然后使用自定义方案持久化,基本上就是用的字节越少越好。
  • 做完这个预热赛就开始了,可以提交代码到线上测试。结果根本跑不通,看日志全是错,在本地调试没有发现的,还有一些接口是不能动的,除了错,还有消费者消费一段时间就开始的全部concurrent mode failure,full gc,消费者线程都没执行完,因为耗时太长被测试程序kill掉。
    • 然后才考虑内存的问题。设置了jvm参数后,确实程序到后面一点内存都没有了,直接卡住。
    • 然后使用了Java自带的J visual VM观察堆的内存使用情况,查看堆内的对象。
      • 首先发现String特别多,而且很多重复。查找原因是每个对象都有类别,但是因为新建对象时每次都是用new String,导致都是新的String放入对象,改为一个HashMap存储类别,对象里都持有hashMap中String的引用就行了。
      • 做完这个程序仍然跑不完,看内存难以简单找到哪里有浪费的情况。在群里与别人讨论,才发现4kw的数据,内存就是存不下,我们的方案有问题。没办法全部读入到内存再开始消费。所以这里又要进行大的调整
  • 解决方案是消费时,记录消费的进度,定期清理已经消费过的。问题是queue只会消费一次,马上就可以清除,topic需要考虑其他线程还要消费。所以需要在初始化消费信息时,初始化一个全局的消费偏移,对于topic记录每个消费者已经消费的偏移,定期读取清理掉所有消费者都已经消费过的topic。做完这一步,修改下其他bug,程序即可成功的运行完毕。只是速度非常慢。所以接下来继续优化。(109s,350s)
  • 接下来的重要一步是消除了同步。之前的方案中,由于多个线程要同时写一个queue或者topic的list,那么需要加锁。但是每个线程之间的消息是没有顺序的,那么就可以将原来的一个list,改为一个hashMap,key为线程,value为对于的list,这样每个线程只操作自己的对应的那个list,所以除了初始化时,真正消费时并没有同步问题,性能大大提高。(4kw消息,109s,130s,本地一亿数据的两倍)
  • 消除了同步,一直卡住,没有太大的优化了。直到看到别人的成绩都是消费者时间更短。重新考虑在生产者部分分类消息,每类消息写一个文件。这样做的好处是,可以持久化时少传一个属性,即消息的种类和名称。同时消费时有些topic是不用读的,打日志看可以少读1/4的文件。而因为大概有100个文件,而且可能同时会有多个线程写一个文件,使用每个文件对应一个AtomicBoolean变量,每次要写某个文件,使用compareAndSet,现在为false就改为true,然后就可以写文件,获取不到则将list加入到一个队列中,以后每次会检测队列不为空就先处理队列中待写的list。连着加了3天班,每天12点才回,写完代码,线上调了一天bug,这次又减少了1/5的时间。
  • 最后几天,设计了一个重复利用对象的方案,在发送时,对于一个list,生产了一定对象就使用内存映射持久化对象,然后继续生产消息时,不用新建消息对象,获取以前的对象,清理下就可以继续使用,这样减少废弃对象,减少gc。91s,50s
  • 然后想优化下序列化时,使用了一个StringBuilder,查看它的源码,发现每次append都会拷贝一次字符串,最后我们想要获得字节数组还需要转换成String,然后获得字节数组。但是线上一直有bug,不能全对,最后也没查出原因。

最终方案

1、创建消息初始化用于存储的变量等等,需要双重锁定加锁
HashMap<String, HashMap<Integer, List<DefaultBytesMessage>>> data
首层hashmap的key对应消息的种类queue/topic,然后为消除同步,第二层hashmap的key对应生产者线程,这样每个线程只操作自己对应的list,这样消除同步。
每个queue/topic需要建立一个内存映射对象,使用hashMap存储,并针对每个对象建立一个atomicBoolean,要写某文件之前先compareSet(false, true)
HashMap<String, MappedByteBuffer> allMapBuffer 所有的内存映射对象
HashMap<String, Integer> storeOff 存储本线程对于所有消息的存储偏移
为了复用对象,首先获取本线程生产某种消息的偏移,偏移小于list的size就获取到以前的对象,清理下用于本次创建消息。偏移等于size说明消息都还没被持久化,新建对象加入到list中。
2、发送消息,每个queue/topic存一个文件,那么可以message减少这个属性。检查偏移是否到阈值,到了就尝试设置该消息的内存映射对象对应的atmicBoolean值,compareAndSet,本身是false,然后设置为true则代表可以写该对象。传入list和偏移。如果设置失败,说明其他线程占用,把list添加到待写的队列中,以后每次先检测队列不为空就先尝试写。
如果偏移没到阈值,偏移加1,返回继续生产消息。由于序列化性能太低,自己实现的持久化转换成直接数组,使用内存映射落盘。消息对象中有一部分消息头是固定的key,可以hardcode。其他的就是越简单越好了。
3、kill -9
4、启动消费者,初始化消费者信息。确定每个消费者要消费的queue/topic,queue是唯一的,topic可以多个消费者消费。先每个消费者选定各自的queue,完全不受其他影响,从内存映射读入,组装成对象,同样为了复用对象。
每个线程只有一个queueList,一次读入设定个数的消息list,因为不会有竞争,每次都可以确定肯定读多少个 ,也不用队列存储。
5、最后消费topic,主要要循环遍历要消费的消息类型,一旦没有了就要从文件中取。考虑到多线程都要读topic,所以需要维护一个变量,存储对于每个topic,所有线程已经消费的偏移,方便清理和复用对象。读入文件时查找这个偏移,优先使用不在用的对象,清理下,添加到list最后。
考虑到消费时经常一段时间都是从一个list取对象,如果每次都直接遍历比较耗时,那么可以把上次消费的list存下来,每次先用这个list和偏移进行快速查找,成功就返回,失败则遍历查找,再找不到则读入文件,读文件时尽量复用对象。90s,50s
6、继续优化,序列化时,不在将所有消息头,属性取出来先放在StirngBuilder,直接操作字节数组。然后继续优化,看日志发现总有一两个concurrent mode failure。考虑到现有模型的缺点,为了消除同步,更加复杂,难以控制内存,如下缺点中描述,解决之后性能明显提升。

最后4kw消息,生产88s,消费42s,tps 30.5w。(虚拟机不稳定,同样代码,生产可以到80s,消费30s。但是时间不够,没有跑到两个碰在一起。)

缺点

对于每个线程分开存储,那么实际上存储的对象在内存中多了很多,很难控制内存,一旦full gc 或者concurrent mode failure,极大的影响性能。在读文件时,复用以前的对象,同时对已经读过的对象进行清理。
但是看日志仍然有一到两个concurrent mode failure,每次大概会有5s的停止,而整个消费才60s。说明只在读文件时进行清理操纵已经来不及了,所以必须在其他地方处理。选择了在读某个list没有获得值时清理。由于这里会改动偏移,所以还必须先设置该topic对应的atomicBoolean变量为false,防止其他线程同时在写,获得已经被清理的对象,出现空指针异常。这样做了最好的时候30s可以消费完,提升了2/5,但是虚拟机不稳定,最终成绩因为这个只上升了1/5.

继续优化

生产消息时,将消息头和属性放在HashMap中,但是其实这个场景并不需要快速查找,全部要写盘的,直接用List,或者字节数组就行。但是线上测试出错了,没找到原因。
读写磁盘还是顺序读写最快的,并发读写都会有性能问题。一个线程读写
压缩数据,一般的压缩得不偿失,cpu损耗太多,得专门的算法吧
索引

总结

对Java的使用有更深的认识,在多线程方面,同步的影响,合理使用Atomic包,消除同步。
JVM方面需关注gc状况,查看线程和堆是否正常。自带有Java visual VM。
写代码时注意内存使用,合理的复用对象,不用的对象及时清除。了解常用的类源码,知道底层实现,根据场景进行合理的选择。
以前真不知道内存映射,操作大文件效果很好。
发现自己Java知识还不够全面,计算机基础知识更是匮乏,以后接着努力!


github 源码

阅读全文
0 0
原创粉丝点击