记一次数据处理效率优化过程
我们最原始的产品使用hive来进行数据分析和处理,由于我们的业务模型所限制,往往需要经过多轮的MR来完成任务,经过多轮的优化,虽然取得了一定的成果,但是执行速度还是不能满足产品的要求。
其实,当时考虑使用hive,是由于其基于SQL良好的扩展性为前提的,也就是说,以后我们在增加功能的时候,修改的部分很少,只需增加一些where,group by条件,就可以达成目的,hive也确实能够满足这个需求。但随着业务场景越来越复杂,其执行效率就越来越被诟病,正如shell相对于java,写起来做demo容易,但是系统越复杂,shell的效率和维护行就会被java拉开得越远。
我们也考虑过使用Tez来替换MapReduce作为底层的实现,因为这对于Hive简直就是透明的。但是tez对于性能提升得比较有限。对于分解成多轮MapReduce的任务,tez由于其DAG模型,对于性能有一定的提升,但是如果是单轮MapReduce保存中间表数据,其性能不仅没有提升反而会有些下降,而我们有的大任务就是因为数据量大,日志多,卡在了这个步骤。
此外,对于Hive,由于其需要执行一些DDL语句和SQL语句都需要有一个metastore service,不仅需要配置一个数据库,还需要在某台机器上启动一个metastore service,通过Thrift Server与后端的数据库通信,而这个service其实远算不上稳定,在运行时间长了之后经常会报DDL错误,
比如下面:
hive> show databases; FAILED: Execution Error, return code 1 from org.apache.hadoop.hive.ql.exec.DDLTask. MetaException(message:Got exception: org.apache.thrift.transport.TTransportException java.net.SocketTimeoutException: Read timed out)
这就会导致产品相当地不稳定,尤其是没有保存到中间表的中间纪录,需要每过一段时间重启Metastore service。
于是乎,尝试在分析业务场景的情况下,使用标准的MapReduce程序来替换原有hive部分,经过几天的分析,最多也就需要两轮MapReduce就可以完成以前在hive中可能会启动多轮的MR任务们,Hive原来之所以做这些事情比较费劲,也是因为其在进行group by操作(一般就会执行reduce)的时候,以前的信息不会得到保存,如果想要保存,就需要中间表,但中间表过多仍然是瓶颈...
MapReduce程序的不足就是受限于业务场景,必须要采用两轮MapReduce(如果考虑用tez或更新的Spark可能会解决这个问题),当第一轮MapReduce的中间结果数据过大,会占用很多的HDFS空间,而且会有耗时非常长的序列化写以及第二轮序列化读操作(我们当前的中间结果由于是复杂的对象,暂时使用json作为中间结果保存,效率虽然不差但也不高,有优化的空间)。
MapReduce程序中由于不容易调试,使用其内置的Counter作为计数器来统计处理的记录是个非常好而且直观的方式:
context.getCounter(GroupName, CounterName).increment(1);
这样就可以在页面上的Counter列表栏中显示出对应所有的Counters。
中间结果不宜过多,如果能在第一轮Reducer端将其进行合并最好。
MapReduce中最好用的就是Map和Reduce之间的排序阶段,会根据Map输出的Key进行排序(取决于WritableComparable的compareTo方法,如果返回0则代表两者值是相同的,而不是使用equals/hashCode来判断),并在Reducer端,将key相同的合并成统一的Iterable<Value>对象做汇总。如果说我们修改后的程序为什么不能减少为一个Reducer,那么只有一个原因:在一轮MapReduce中不能享受两次这种排序过程...
在大数据处理的情况下,所有微小的耗时过程都会被放大,由于分布式的程序不容易进行性能分析,我们通过原始的手段jstack查看出几个比较耗时的执行函数(停留的几率较大):
- SimpleDateFormat.parse(String) 函数,涉及到从字符串解析成Date对象,效率非常低,解决方法是都统一成一种DateFormat,按照字符串进行比较操作(慎重);
- String.split(regex),由于可能涉及到正则表达式的匹配,String中并没有不对应正则表达式的相关切分函数。对于没有切分必要的字符串,先使用String.contains(String)方法来查看是否有必要来进行切分
- Integer.parseInt(String),Long.parseLong(String),表面上看这些函数都不会有重大问题,但放到多层循环中,就会变慢,尽量建立中间层对象来帮助率先将这些可能会耗时的操作独立到循环外面来进行。
此外,判断的逻辑顺序是否合理也非常关键,会迫使你不断重构代码改善执行顺序以及过程(可能会因此使得代码变的丑陋),因此,必要的测试用例不可少(除非你对你的代码非常有信心),毕竟一切的速度提升都是以数据正确性为前提。
总之,优化无止境,不管怎么做,总会遇到新的问题。