之前在线上遇上过乱码问题,后来对这块相关的稍作了简单梳理。
对于需要保存和处理大规模数据的Hadoop来说,每一个MapReduce任务都是对几个类型的几十亿对象进行序列化和反序列化,可以说Hadoop序列化是Hadoop的核心部分之一。
而Java内建的序列化机制,由于它在序列化时输出保存大量的附加信息,比如超类的信息也会递归地被保存下来,导致序列化结果膨胀;另外为了减少数据量,同一类后续对象实例只引用第一次的序列化结果句柄,这就会出现在一个上百G的文件中,反序列化某个对象时需要访问文件中前面的某一个记录,导致这个文件不能切割,并通过MapReduce来处理;同时Java序列化也会不断创建新的对象,对于MapReduce任务来说无法重用而是不断创建也会带来大量的系统开销。所以在Hadoop平台,Java的序列化机制并不适用,而是需要一个新的序列化机制。
一、Hadoop的Writable接口
Hadoop定义了一个Writable接口,用于实现序列化格式,作为所有可序列化对象必须实现的接口。这个接口基于DataInput和DataOutput实现了序列化协议。序列化过程由write()方法将对象状态写入二进制的DataOutput流,反序列化的过程则由readFields()方法从DataInput流中读取对象状态反序列化为Hadoop数据。
1 | public interface Writable { |
2 | void write(DataOutput out) throws IOException; |
3 | void readFields(DataInput in) throws IOException; |
Hadoop中的key和value必须是实现了Writable接口的对象,以支持在MapReduce任务中的序列化和反序列化。
Writable接口符合大规模数据处理的Hadoop平台上紧凑和快速的特性。其序列化产生的数据量小,可以高效使用存储空间,充分利用数据中心的带宽。还可以减少MapReduce任务的数据交互中大量序列化和反序列化的开销。
二、Hadoop自身的Writable类
Hadoop自身已经提供多种Writable类,常见的有Java基本类型(boolean、byte、short、int、float、long和double等)和Text等。下面是一个Writable对象序列化之后字节序列结构的演示,通过演示可以知道hadoop上的序列化做到了高效紧凑,对于hadoop上影响计算效率的网络数据的传输这一重要因素,这一序列化方式加快了数据的读取和减少网络的数据传输量。
先定制一个工具类将序列化结果输出到字节数组输出流,这样我们就可以捕获序列化的数据流中的字节。
01 | public class HadoopSerializationUtil { |
02 | public static byte [] serialize(Writable writable) throws IOException { |
03 | ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); |
04 | DataOutputStream dataOut = new DataOutputStream(byteOut); |
05 | writable.write(dataOut); |
07 | return byteOut.toByteArray(); |
09 | public static Writable deserialize(Writable writable, byte [] bytes) throws IOException{ |
10 | ByteArrayInputStream byteIn = new ByteArrayInputStream(bytes); |
11 | DataInputStream dataIn = new DataInputStream(byteIn); |
12 | writable.readFields(dataIn); |
Java基本类型Writable对象序列化字节结果输出:
org.apache.hadoop.io.BooleanWritable(true)
Serialize bytes: 01 length: 1
org.apache.hadoop.io.ByteWritable(5)
Serialize bytes: 05 length: 1
org.apache.hadoop.io.ShortWritable(16)
Serialize bytes: 0010 length: 2
org.apache.hadoop.io.IntWritable(256)
Serialize bytes: 00000100 length: 4
org.apache.hadoop.io.VIntWritable(127)
Serialize bytes: 7f length: 1
org.apache.hadoop.io.VIntWritable(128)
Serialize bytes: 8f80 length: 2
org.apache.hadoop.io.FloatWritable(1.0)
Serialize bytes: 3f800000 length: 4
org.apache.hadoop.io.LongWritable(4096)
Serialize bytes: 0000000000001000 length: 8
org.apache.hadoop.io.VLongWritable(127)
Serialize bytes: 7f length: 1
org.apache.hadoop.io.VLongWritable(128)
Serialize bytes: 8f80 length: 2
org.apache.hadoop.io.DoubleWritable(2.0)
Serialize bytes: 4000000000000000 length: 8
从结果可以看出
基本类型
Writable
对象序列化结果就等于他们的值所占的字节,比较节省空间。
三、
Text的字节序列
当然在我们引擎处理文档数据的MapReduce任务中,Text是一个常用的Writable对象。可以简单的认为Text类是java.lang.String的Writable类型,但是区别于String的是,Text类对于Unicode字符采用的是UTF-8编码,使用变长的1~4个字节对字符进行编码,对于ASCII字符只使用1个字节,而对于High ASCII和多字节字符使用2~4个字节表示。而String类采用的是UTF-16编码,对每个字符采用定长的16位(两个字节)进行编码,对于代码点高于Basic Multilingual Plane(BMP,代码点U+0000~U+FFFF)的增补字符,采用两个代理字符进行表示。Hadoop在设计时选择使用UTF-8而不是String的UTF-16就是基于上面的原因,目的也是为了节省字节长度的空间考虑。
Text类的字节序列表示为一个VIntWritable + UTF-8字节流,VIntWritable为整个Text的字符长度,UTF-8字节数组为真正的Text字节流。下面是一个示例:
2 | Text text = new Text( "Hadoop ±" ); |
3 | String str = new String( "Hadoop ±" ); |
4 | SerializeResultDisplay(text); |
5 | BytesDisplay(str, "UTF-8" , str.getBytes( "UTF-8" )); |
6 | StringBytesDisplay(str); |
程序输出:
org.apache.hadoop.io.Text(Hadoop ±)
Serialize bytes: 094861646f6f7020c2b1 length: 10
String(Hadoop ±)
UTF-8 bytes : 4861646f6f7020c2b1 length: 9
String(Hadoop ±)
UTF-16 bytes : feff004800610064006f006f0070002000b1 length: 18
从上面输出可以看出,Text的序列化结果是文本在UTF-8编码下的字节流,而多出的首个字节表示文本在UTF-8编码下占用的字节长度为9个字节。而String则
采用的是UTF-16编码,占用18个字节,Hadoop设计Text采用
UTF-8
编码可以做到序列化更紧凑。
四、Hadoop中的编码
在MapReduce任务中使用和编码相关的Writable对象,比如Text, 就特别要注意其使用的编码方式,因为序列化的字节流会采用其编码方式,另外字符串在内存中以UTF-16编码方式,在这些对象间的转换操作如果不注意编码则很容易造成乱码。
下面是一个可能会出现乱码的示例:
2 | Text character = new Text( "±" ); |
3 | String chrstr = character.toString(); |
4 | BytesDisplay(character.toString(), "UTF-8" , character.getBytes()); |
5 | BytesDisplay(chrstr, Charset.defaultCharset().name(), chrstr.getBytes()); |
Text中的字节内容为UTF-8编码,UTF-8 bytes : c2b100 length: 3
但是字符串获得字节的getBytes()接口则是依据了目前系统的默认编码。
系统编码为ISO-8859-1时输出,ISO-8859-1 bytes : b1 length: 1
系统编码为GBK时输出,GBK bytes : a1c0 length: 2
所以当系统编码不是UTF-8时,而在MapReduce任务的另一个地方使用UTF-8解码就会出现乱码。所以使用编码无关的接口则可以很好的避免此类的乱码问题。在这个例子中可以使用Text.getBytes()或者String.getBytes(encoding)接口来避免。
对于Hadoop文件的读取和输出,同样可能会存在编码问题。
在写MapReduce程序的时候,总会调用setInputFormatClass方法设置输入格式来保证输入文件按照我们想要的格式被读取,但是这个所谓的格式主要是要解决如何将数据分割分片(inputSplit),以及如何读取分片中的一个个K -V 对,这个过程中并没有编码转换。如果我们待处理的文件不是对应的编码形式时,就需要在进行mapreduce之前先对他进行转码操作。
例如读取文件为GBK编码,则在mapreduce之前进行GBK的转码防止乱码的产生:
01 | public static class MyMapper extends Mapper<IntWritable, Text, Text, IntWritable>{ |
02 | public void map(IntWritable key, Text value, Mapper<IntWritable, Text, Text, IntWritable>.Context context) throws IOException, InterruptedException{ |
04 | String val = new String(value.getBytes(), "GBK" ); |
07 | catch (UnsupportedEncodingException e) { |
在MapReduce中输出文件格式的时候,Hadoop提供了OutputFormat进行转换使用,通过在Job中调用setOutputFormatClass方法来设置格式化类。Hadoop提供了一些格式化类,例如TextOutputFormat,但是这个格式化类对输出的编码进行了UTF-8硬编码,如果在输出是中文的情况下,就会出现乱码。解决这种情况就需要自定义一个格式化类。这个类必须是继承OutputFormat,其中的RecordWriter用于数据怎么输出到输出文件里,在编写输出格式化扩展类主要就是实现对RecordWriter的扩展。
例如输出为GBK文件格式,对key和value都要进行一次GBK转码。
01 | public class GBKRecordWriter<K, V> extends RecordWriter<K, V> { |
02 | protected DataOutputStream out; |
03 | private static final String gbk = "GBK" ; |
04 | private static final byte [] newline; |
05 | private final byte [] keyValueSeparator; |
08 | newline = "n" .getBytes(gbk); |
09 | } catch (UnsupportedEncodingException uee) { |
10 | throw new IllegalArgumentException( "no " + gbk + " encoding" ); |
13 | private void writeObject(Object o) throws IOException { |
14 | out.write(o.toString().getBytes(gbk)); |
16 | public synchronized void write(K key, V value) throws IOException { |
18 | out.write(keyValueSeparator); |
最后在MapReduce的main方法里,加入job.setOutputFormatClass(GBKOutputFormat.class)来设置输出时使用当前的定制格式化类,从而正确的输出GBK文件。