抱大腿参加了一次中间件比赛,受益匪浅。学到了很多东西,更重要的是认识到了差距在哪。针对具体业务场景的优化就不提了,写一下比较通用的优化策略。
1. Java
1.1 Split()
Java原生的split方法在此次使用中性能有很大的问题,主要在于两点。首先Split中会创建一个arraylist,将切割后的子字符串放入arraylist中,list的创建和扩容是一处开销,尤其是在使用中并不需要切割后的所有子字符串的时候。还有一点,split中使用的是正则去匹配多个char的regex,此处也会造成很大的性能问题。改进的方法是使用indexOf()和subString()结合的方式。
String splitted = line;
p = splitted.indexOf(':');
key = splitted.substring(0, p);
1.2 BufferedReader
BufferedReader在初始化的时候可以指定缓冲区的大小,此处可根据页的大小以及数据的实际排布更改。除此之外,还有一个默认参数 defaultExpectedLineLength。如果对于行的长度比较确定,可以自定义一个长度,减少readline() 内部StringBuffer扩容的开销。不过BufferReader的构造器并不提供这个参数的设置,所以需要重写一下。
2. 多线程
索引建立阶段:
初始的策略是将源数据文件平均分给3个线程去处理,读取和写入均不考虑位于哪个磁盘。由于比赛是三块硬盘,8核CPU,后来考虑用3个线程的在分别去读写三块硬盘,减少多线程访问对磁盘IO的影响。本地测试没有提升,这个是可以理解的。但是后来线上测试的时候,建索引的时间也没有太大的提升。猜测的原因可能是原来的策略磁盘争用比较有限,因此分开读写稍有改进但影响不大。
查询阶段:
查询阶段使用多线程效果很明显,提升了大概80%。
最开始的策略是3个线程分别去三个硬盘去找,然后把结果合并。然而测试的结果并不好,提升比较有限。在测出查询阶段每部分的时间之后发现一个现象:每个线程搜索的时间相比原来的单线程有大幅减小 -- 这样很合理因为每个线程需要查找的文件少了;但是在合并结果,也就是等待所有线程结束的开销是很高的;不论是CountLatchDown还是Future,都有相似的现象。
大赛在后半段明确CPU是8核,意识到对于多线程的利用比较有限之后,对线程的数量进行了调整。对于三种查询设置三个线程池,每个线程池8个线程。对于多线程,坊间常见的一种说法是线程数最好不要超过核心数。在之前的一个项目中,使用OpenMP框架进行多线程编程,测试结果也佐证了这一说法。大赛规则中提到会有并发查询,也就是同一时间最多会有24个线程在跑。但是因为并发查询的密度是未知的,因此这种策略算是试出来的,假定并发是少数的。
多线程这种东西跟机器的关系很大,本地测试和线上测试的结果经常有很大差异,事情不能太想当然,还是要多试试。
3. Byte
字节算是这次比赛的一个痛处。在比赛的前期,对于索引文件的读写采用的都是字符串。到了比赛末期,意识到字节比字符串是要快一些的,因为在读取和写入的时候省去了byte和string之间的转换;单元测试中,byte使用BufferedInputStream(out),字符串使用BufferedReader(writer)的情况下,byte的读写要比字符串要快将近1倍,但是因为比赛后期时间不太够了,只是把其中一级索引应用了字节流。
3.1 ByteBuffer & ByteArrayOutputStream
ByteBuffer提供自身的put/getInt之类的方法;ByteArrayOutputStream搭配DataOutputStream也可以实现便捷的byte读写。对于二者的性能,下面的链接给了很好的解释。点击打开链接 下面的链接详细比较ByteBuffer heap和direct的区别。点击打开链接 在实际的应用中,ByteBuffer也确实是快的。
3.2 MappedByteBuffer
另一个针对大文件,尤其字节流读写的工具是内存映射。
<span> </span>FileInputStream fis = new FileInputStream("sdf");
MappedByteBuffer buffer = fis.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, 100);
byte[] b = new byte[5];
buffer.get(b, 0, 5);
单元测试中,使用内存映射的方式读取字节流文件,要比BufferedInputStream快2倍。
文件读写还是要尽量使用bytes,以后有时间要把原来的程序全部改为字节 。
4. IO
4.1 RandomAccessFile
Java的RandomAccessFile可以从文件特定位置开始访问而无需读取整个文件。这对于大文件的读取是非常重要的。我们可以通过seek()在一个大文件中跳跃。在实际使用中,我们发现:如果seek跳跃的距离很远或者很随机的话,读的效率会大大降低。我们猜想这一现象是因为硬盘的磁头需要通过大范围的移动来读取数据。因此,我们在读取数据进行了分组和排序。我们首先将要读取的数据根据文件进行group;然后对于单个文件,将要读取的数据的offset进行排序。这样,在假定源数据是顺序写的情况下,磁头可以用最少的移动去读取所需数据。
5. hash
这次比赛之后最大的感触就是,hash才是终极方案,排序能不用就不用。在我们的解决方案中,排序也仅有很小的一部分应用。查询效率的几次质变,都是由于hash的应用。