最近不是很忙,把之前写的代码review了一遍,发现一个关于mapPartitions算子小问题。在我们的业务中有一个需求就是要把收集的日志里面的Long型时间戳转换成年月日String类型,代码很简单如下(Java写习惯了,Scala实在脑壳痛,下面的代码都是Java):

.map(value -> {
     SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd");
     format.setTimeZone(TimeZone.getTimeZone("GMT+8:00"));

     String dateStr = format.format(value.getTimestamp());
     value.setTime(dateStr);
     return value;
})

这个代码很简单,但是它有一点小问题,它的问题就在于算子里面定义的方法每调用一次就会生成一个SimpleDateFormat实例,这意味这个每条数据就会生成一个独立的实例,而生成和销毁实例是需要时间的。那么这里的SimpleDateFormat实例能不能实现共用呢?答案是可以的,这里我想到了三种方法。第一种就是使用mapPartitions算子,把数据存进List里面,这样一个数据分区就能公用一个SimpleDateFormat实例,代码如下:

.mapPartitions((FlatMapFunction<Iterator<Data>, Data>) input -> {

     List<Data> list = new ArrayList<>();
     SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd");
     while (input.hasNext()) {
          Data value = input.next();
          String dateStr = format.format(value.getTimestamp());
          value.setTime(dateStr);
          list.add(value);
    }
    return list.iterator();
})

第二种方法是把SimpleDateFormat定义成算子的实例字段,代码如下:

.map(new Function<Data, Data>() {
    final SimpleDateFormat format;

    {
           format = new SimpleDateFormat("yyyyMMdd");
           format.setTimeZone(TimeZone.getTimeZone("GMT+8:00"));
    }

    @Override
    public Data call(Data value) {
           String dateStr = format.format(value.getTimestamp());
           value.setTime(dateStr);
           return value;

    }
})

这样虽然每条数据都会调用一个call方法,但是一个task里面的数据能公用同一个SimpleDateFormat实例,因为这里SimpleDateFormat定义成了实例字段,而不是局部变量。但是这个代码需要注意序列化的问题,这个实例字段必须是可实例化的,不然要报错。

        这三段代码哪个最好呢,经过测试发现时间最短的是最后一种,最慢的其实是第二种。为什么使用mapPartitions是最慢的呢,不是说mapPartitions可以优化性能吗?经过分析发现对比普通的map算子,mapPartitions算子的这种实现至少存在两个问题。

首先对于普通的map算子的具体实现源代码如下:

def map[U: ClassTag](f: T => U): RDD[U] = withScope {
    val cleanF = sc.clean(f)
    new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.map(cleanF))
}  


override def compute(split: Partition, context: TaskContext): Iterator[U] =
   f(context, split.index, firstParent[T].iterator(split, context))

       因为RDD的不可变性,每个RDD算子都会生成一个新的RDD实例,而且每个RDD的具体类型都会去实现compute方法,这个方法会返回一个Iterator,通过这个迭代器就能够通过迭代获取某个分区的数据,对于map算子对应生成的RDD类型就是MapPartitionsRDD,而对于map的算子的具体实现是通过实现一个抽象迭代器来实现的,具体如下:

def map[B](f: A => B): Iterator[B] = new AbstractIterator[B] {
    def hasNext = self.hasNext
    def next() = f(self.next())
}

还比如对于filter算子的具体实现如下:

def filter(p: A => Boolean): Iterator[A] = new AbstractIterator[A] {
    // TODO 2.12 - Make a full-fledged FilterImpl that will reverse sense of p
    private var hd: A = _
    private var hdDefined: Boolean = false

    def hasNext: Boolean = hdDefined || {
      do {
        if (!self.hasNext) return false
        hd = self.next()
      } while (!p(hd))
      hdDefined = true
      true
    }

    def next() = if (hasNext) { hdDefined = false; hd } else empty.next()
  }

通过这两个算子我们可以看出,对于一些普通算子,按照我的理解,在Spark中,同一个stage中的算子处理可以类比为工厂的流水线作业,或者是丢手绢游戏,当前算子会通过上一个RDD对应的迭代器获取一个数据,然后使用定义好的函数处理,然后把生成的新的数据马上丢给下一个算子,然后以此类推,直到遇到shuffle或者输出最终结果。这种迭代式的计算有两个好处,第一个好处就是RDD的每个数据项的生命周期较短,只要它被当做下一个算子的输入,下一个算子计算完了,这个数据项的生命周期就结束了,就可以被垃圾回收;而且这样计算因为不需要等待上一个算子把当前分区所有数据计算完才可以开始,可以节省时间。

        但是使用MapPartitionsRDD把数据存进List里面的这种实现就把这种流水线操作给打断了,我们看mapPartitions的一般实现是把上一个RDD的一个分区数据全部处理完后放在一个容器里面,然后返回这个容器的迭代器供下一个算子使用,就好比流水线上的工人,在处理完一个工件后并没有把这个工件放在传送带上传递给下一个工人,而是把它放在了一个桶里,当他把所有工件处理完了才把桶交给下一个工人。这样其实就破坏了普通map的迭代式计算,这样存在两个问题,一个是比较占用内存,容易出现out of memory,因为这样要把同一个分区的数据全部存在内存里;而且还会增加等待时间,下一个算子需要等待当前算子把所有分区数据都处理完才开始计算,等待的时间白白给浪费了。

        那么怎么改进mapPartitions算子呢,我们可以模仿map算子的实现,把代码改成这样:

.mapPartitions((FlatMapFunction<Iterator<Data>, Data>) input -> new Iterator<Data>() {
     final SimpleDateFormat format;

     {
          format = new SimpleDateFormat("yyyyMMdd");
          format.setTimeZone(TimeZone.getTimeZone("GMT+8:00"));
    }

     @Override
     public boolean hasNext() {
          return input.hasNext();
     }

     @Override
     public Data next() {
          Data value = input.next();
          String dateStr = format.format(value.getTimestamp());
          value.setTime(dateStr);
          return value;
    }
})

我们也去实现一个Iterator,把SimpleDateFormat定义为迭代器的实例字段,在迭代器的next方法里面实现我们的时间转换,这样既没有破坏迭代式计算,也能实现SimpleDateFormat的共用。

        通过对比测试这四种代码,执行时间上: 定义成实例字段< 自定义迭代器 < map算子 < 存容器的mapPartitions算子。所以如果类是可以被序列化的就可以把它定义成算子的实例字段,或者使用自定义迭代器。

        最后还有就是关于线程安全的问题,我们知道SimpleDateFormat是线程不安全的,把线程不安全的类定义为类的实例字段会存在潜在的线程安全问题,那么这里把SimpleDateFormat定义为实例字段的实现存在线程安全问题吗?答案是没有,因为Spark中每个分区会生成一个独立task,而每个task会通过反序列化生成独立的算子实例,每个task只会在一个线程里运行,所有这里的SimpleDateFormat虽然是实例字段,但是这个实例是线程间不共享的,所以不用担心线程安全问题的。