记一次序列化导致的MR统计问题

类似简单的wordcount,MapReduce输出的统计结果却不符预期。部分key丢失了,未丢失的key,其value值也不对。
经查,是因为代码中指定了SortComparator,导致key比较时所用的反序列化方式与序列化方式不符,这影响了map在merge时对key的排序,也影响了reduce对key的分组。

下面结合代码,对以下步骤进行梳理(代码来自hadoop-mapreduce-client-core-2.6.0-cdh5.5.0, hadoop-common-2.6.0-cdh5.5.0, avro-1.7.6)。

  1. map端key的序列化器;
  2. map端merge时key的比较器;
  3. reduce端对key的分组;
  4. reduce端key分组时的比较器;
  5. key的比较。

map端key的序列化器——keySerializer

Map的输出主要由org.apache.hadoop.mapred.MapTask的内部类MapOutputBuffer实现,MapOutputBuffer使用keySerializer对key进行序列化。

keySerializer是什么呢?
keySerializer本身是个接口,具体实现类是在MapOutPutBuffer的init()方法中指定的,代码如下:

public void init(Context context) throws ... {
  ...
  this.keySerializer = this.serializationFactory.getSerializer(this.keyClass);
  ...
}

SerializationFactory又是如何根据keyClass得到对应的Serializer的呢?
SerializationFactory维护了一个链表serializations,SerializationFactory会依次遍历serializations中的serialization,找到第一个accept keyclass的serialization。代码如下:

public <T> Serialization<T> getSerialization(Class<T> c) {
    Iterator i$ = this.serializations.iterator();

    Serialization serialization;
    do {
      if(!i$.hasNext()) {
        return null;
      }

      serialization = (Serialization)i$.next();
    } while(!serialization.accept(c)); //是否accept class

    return serialization;
  }

serializations又有哪些呢?
SerializationFactory的构造函数会读取io.serializations配置来初始化serializations。若没有配置io.serializations,则取默认的serializations,依次为WritableSerialization、AvroSpecificSerialization和AvroReflectSerialization。代码如下:

private List<Serialization<?>> serializations = new ArrayList();

public SerializationFactory(Configuration conf) {
  ...
  String[] arr$ = conf.getStrings("io.serializations", new String[]{WritableSerialization.class.getName(), AvroSpecificSerialization.class.getName(), AvroReflectSerialization.class.getName()});
  ...
}

一个serialization是否accept某个class,则是由具体的serialization自己实现的,如WritableSerialization,其接受的是Writable的子类。WritableSerialization的accept()代码如下:

public boolean accept(Class<?> c) {
    return Writable.class.isAssignableFrom(c);
}

map端merge时key的比较器

MapOutputBuffer在对数据进行merge时,使用了比较器对key进行排序。

Merger.merge(this.job, this.rfs, this.keyClass, this.valClass, this.codec, segmentList, this.job.getInt("io.sort.factor", 100), new Path(mapId.toString()), 
this.job.getOutputKeyComparator(),  //获取key的比较器
this.reporter, (Counter)null, this.mapTask.spilledRecordsCounter);

getOutputKeyComparator()获取的又是什么呢?获取的是mapreduce.job.output.key.comparator.class配置的比较器。

reduce端对key的分组

reduce端按key分组处理的主流程在org.apache.hadoop.mapreduce.Reducer的run()方法中(这个Reducer就是我们在写MR应用程序reduce的部分时,继承的Reducer),run()方法的代码如下:

public void run(Reducer<KEYIN, VALUEIN, KEYOUT, VALUEOUT>.Context context) throws ... {
    this.setup(context);

    try {
      while(context.nextKey()) {
        this.reduce(context.getCurrentKey(), context.getValues(), context);
        ...
      }
    } finally {
      this.cleanup(context);
    }

  }

这个run()方法里的setup(), reduce()和cleanup()就是经常被我们重写的三个方法。

key分组的关键,在于理解context的具体实现——ReduceContextImpl。

ReduceContextImpl

ReduceContextImpl的关键成员如下(其中KEYIN和VALUEIN都是泛型)。

public class ReduceContextImpl ... {
  private KEYIN key;
  private VALUEIN value;
  private boolean nextKeyIsSame = false;
  public boolean nextKey();
  public boolean nextKeyValue();
  protected class ValueIterator ... {
   public boolean hasNext();
   public VALUEIN next();
  }
}

其中,四个主要方法的含义如下

  1. nextKeyValue():读取下对(key, value)
  2. nextKey():读取下一个key,其实是调用了nextKeyValue(),同时也读取了下一个value(其实,map写过来的就是一对对(key, value),读的话,也应该是一对对的读)
  3. values.hasNext():即ValueIterator.hasNext(),判断是否还有下一个value
  4. values.next():即ValueIterator.next(),读取下一个value,其实也是调用了nextKeyValue(),同时也读取了下一个key

nextKeyValue()每次读取下一对(key, value),都将key值和value值存储在KEYIN keyVALUEIN value中(这就是reduce端,key/value的复用)。

这里主要阐述ReduceContextImpl 是如何实现对key的分组处理。为了说明方便,这里将上文中Reducer.run()方法展开如下(其中,假设用户实现的reduce()使用了iterator的形式对value进行了遍历)。

public void run(Reducer<KEYIN, VALUEIN, KEYOUT, VALUEOUT>.Context context) throws ... {
  while(context.nextKey()) { //外部while
    while(values.hasNext()) { // 内部while,即用户重写的reduce()方法
      context.write(key, values.next());
    }
    ...
  }
}

key的分组处理,关键在于nextKeyValue()方法。上述代码中,未直接体现nextKeyValue()方法,而我们知道values.next()调用了该方法。nextKeyValue()方法在读取到当前key后,会将当前key与下一个将要处理的key进行比较,若发现不是同一个key,就会将nextKeyIsSame置为false。此后,values.hasNext()方法发现nextKeyIsSame为false,也会返回false,从而跳出循环,结束一个key的处理。这样,就保证了每一次内部while,处理的都是同一个key。
下面,将几处关键代码摘要如下(这里面省略了firstValue以及很多其他的处理逻辑)。

public boolean nextKeyValue() {
  DataInputBuffer nextKey = this.input.getKey(); //获取当前key
  this.currentRawKey.set(nextKey.getData(), nextKey.getPosition(), nextKey.getLength() - nextKey.getPosition());
  this.key = this.keyDeserializer.deserialize(this.key); //反序列化当前key
  
  DataInputBuffer nextVal = this.input.getValue(); //获取当前value
  this.value = this.valueDeserializer.deserialize(this.value); //反序列化当前value
  
  this.hasMore = this.input.next(); //是否还有下一个(key, value)
  if(this.hasMore) {
    //如果有下一个(key,, value),获取下一个key
    nextKey = this.input.getKey();
    //比较当前key和下一个key,设置nextKeyIsSame
    this.nextKeyIsSame = this.comparator.compare(this.currentRawKey.getBytes(), 0, this.currentRawKey.getLength(), nextKey.getData(), nextKey.getPosition(), nextKey.getLength() - nextKey.getPosition()) == 0;
  } else {
    this.nextKeyIsSame = false;
  }
}

public boolean hasNext() {
  ...
  return ReduceContextImpl.this.nextKeyIsSame;
}

reduce端key分组时的比较器

我们注意到,nextKeyValue()在比较当前key和下一个key时,用到了一个比较器。这个比较器是在org.apache.hadoop.mapred.ReduceTask.run()方法中获取的。

public void run(JobConf job, TaskUmbilicalProtocol umbilical) throws ... {
      ...
      RawComparator comparator = job.getOutputValueGroupingComparator(); // 获取比较器
      if(useNewApi) {
        this.runNewReducer(job, umbilical, reporter, rIter, comparator, keyClass, valueClass);
      } else {
        ...
      }
      ...
}

getOutputValueGroupingComparator()按如下优先级获取比较器:

  1. 首先,获取mapreduce.job.output.group.comparator.class配置的分组比较器;
  2. 其次,获取mapreduce.job.output.key.comparator.class配置的key比较器(这里就和上文的“map端merge时key的比较器”相同了);
  3. 最后,获取如Text、IntWritable这些类型自己定义的比较器。

这里再对第三点做点补充说明,像Text、IntWritable这些类型,都有一段static代码,将自己定义的Comparator注册到WritableComparator中。

static {
  // register this comparator
  WritableComparator.define(Text.class, new Comparator());
}

key的比较

在对key进行比较时,是根据序列化后的字节码进行比较的。comparator.compare()的形式如下:

public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2);

假设key的类型是Text,获取到的比较器是Text定义的Comparator。Text在比较时,会先读取一个整数,这个整数存储了Text的长度,再根据这个长度比较Text的内容(也就是说Text在存储的时候,先是存储了Text的长度,再存储Text内容本身)。

public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) {
  int n1 = WritableUtils.decodeVIntSize(b1[s1]);
  int n2 = WritableUtils.decodeVIntSize(b2[s2]);
  return compareBytes(b1, s1+n1, l1-n1, b2, s2+n2, l2-n2);
}

可以看到,在对Text类型的key进行比较时,没有将整个Text都反序列化,而时尽量在字节码的层面上进行比较。

经过上面的梳理,我们知道了

  1. key在map端输出时,用keySeriailzer进行了序列化;
  2. key在map端merge时,用comparator在序列化后的结果上进行比较;
  3. key在reduce端分组时,用comparator在序列化后的结果上进行比较;
  4. comparator在比较的时候,因为基于的是序列化后的结果,所以涉及到一些反序列化。

下面,我们来详细阐述下本文开头的问题。

问题是如何发生的?

问题代码中

  1. 配置了用户自定义的SortComparator(也就是配置了mapred.output.key.comparator.class)。这个用户定义的比较器在比较时,用的是org.apache.avro.io.BinaryData的compare,而BinaryData在比较时,序列化和反序列化用的是BinaryEncoder和BinaryDecoder。
  2. key是Text类型,没有对keySerializer进行特殊配置,所以获取到的是默认的WritableSerialization,最终调用的是Text的序列化方法。

这导致key使用Text进行了序列化,但在比较时,却尝试使用BinaryDecoder进行反序列化。序列化和反序列化不符,导致map端的merge和reduce端的key分组,在进行比较时都出错了,所以最后统计结果不对。

最后再补充一些细节。

BinaryData的比较方法

BinaryData是根据BinaryDecoder(可以读取key的值)和key的schma进行比较的。这里的schema在avro中用来标识类型,如RECORD可理解为复杂的Class类型,而SRING、LONG等的含义显而易见。
下面的代码摘取了对RECORD和STRING类型进行比较时的一些细节(省略了一些和avro Field处理有关的细节)。我们可以看到,在对RECORD进行处理时,使用了递归的方式,并且在遇到第一个不相等的字段时,便返回结果,不再处理后续的字节码,也就节省了部分反序列化的时间。而对STRING的处理,和Text很相似,都是先读取文本的长度,再根据该长度比较文本本身。

private static int compare(Decoders d, Schema schema) throws IOException {
    Decoder d1 = d.d1; Decoder d2 = d.d2;
    switch (schema.getType()) {
    case RECORD: {
      for (Field field : schema.getFields()) {
        int c = compare(d, field.schema()); //递归调用
        if (c != 0) //一旦遇到一个字段不相等,直接返回
          return (field.order() != Field.Order.DESCENDING) ? c : -c;
      }
      return 0;
    }
  ...
    case STRING: {
      int l1 = d1.readInt();
      int l2 = d2.readInt();
      int c = compareBytes(d.d1.getBuf(), d.d1.getPos(), l1,
                           d.d2.getBuf(), d.d2.getPos(), l2);
      d.d1.skipFixed(l1);
      d.d2.skipFixed(l2);
      return c;
    }
...

BinaryData的序列化方式

可见avro官网的Binary Encoding一节。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,219评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,363评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,933评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,020评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,400评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,640评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,896评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,597评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,327评论 1 244
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,581评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,072评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,399评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,054评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,849评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,672评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,585评论 2 270