凌晨两点,任务跑完,打开part-r-00000看了一眼,满屏中文全是“锟斤拷”或者问号。这种场景做过MapReduce的同学应该都不陌生。你说代码按照教科书写的,输出格式没动过,HDFS文件也落下来了,为什么偏偏中文就变成了一堆天书?更头疼的是,去网上搜“mapreduce输出乱码”,答案零零散散,有说是环境变量的,有说是输入格式的,还有说让改终端的,试了一圈可能还是不行。
这篇文章我就把“mapreduce输出乱码”这件事完整拆开讲。从字节层面定位,到输入输出链路的编码规则,再到常见根因的逐个排查,最后给出一个可以直接用的编码加固模板和真实案例复盘。全文不堆理论,每条结论都能对应到具体命令和代码,按步骤走,基本能把这类问题一次性解决干净。
1. 定位乱码前的第一件事:区分“文件坏了”还是“看错了”
很多人一遇到乱码,第一反应是改代码。但乱码问题跟其他bug有个本质区别:它可能在任何一个环节出错,而且现象完全一样。你在终端看到乱码,未必代表HDFS里的文件内容真的坏了,也可能只是显示端编码不匹配。
所以第一步不是改代码,而是先确认一个问题:文件里的字节,到底是不是你想要的那一串?
1.1 三个命令快速判断
假设输出目录是 /user/hadoop/output/,文件是 part-r-00000`,我一般按这个顺序敲:
bash复制# 1. 先看文件类型,file命令会识别文本编码
hdfs dfs -get /user/hadoop/output/part-r-00000 /tmp/result.txt
file /tmp/result.txt
# 2. 再看原始字节
xxd /tmp/result.txt | head -20
# 3. 最后对照看显示
cat /tmp/result.txt
file 命令的输出很有参考价值。如果显示 UTF-8 Unicode text,说明文件本身是合法的UTF-8;如果显示 ISO-8859 text 或 Non-ISO extended-ASCII text,说明文件里可能藏着GBK等其他编码的字节;如果显示 ASCII text,那大概率中文已经被替换成问号了,这个后面细说。
xxd 看字节是终极大招。比如你期望输出“你好”,UTF-8编码下字节是 e4 bd a0 e5 a5 bd,GBK编码下则是 c4 e3 ba c3。看一眼字节,心里基本就有数了。
1.2 常见误判:cat乱码但文件没问题
这个情况特别容易发生。
你在一台Linux服务器上执行 hdfs dfs -cat /user/hadoop/output/part-r-00000,看到中文乱码,以为任务挂了。但如果用 hdfs dfs -get 把文件拉到本地再用 file 检查,发现是标准的UTF-8编码,那就说明HDFS上的文件完全正常,乱码只是终端显示编码问题。
这么说可能有点抽象,我打个比方:你写了一张纸条,纸上写的是“你好”这两个字的正确字形,但递给一个戴了蓝色墨镜的人看,他看到的是蓝底蓝字,觉得“这纸条有问题”。纸条本身没坏,是看的工具不对。
所以遇到乱码,不要急着动代码,先花五分钟做上面三步。这一步能帮你砍掉至少一半的排查分支,后面所有工作都是在这个基础上展开的。
1.3 一个容易被忽略的细节:hdfs dfs -text
很多教程里会推荐用 hdfs dfs -text 代替 cat 查看文本文件。这个命令的实际行为是:普通文本文件直接输出内容,SequenceFile等二进制文件则解析内部键值对后输出。它方便,但并不能帮你解决编码问题——它不负责把GBK转成UTF-8。如果文件本身是GBK编码,-text 在UTF-8终端照样显示乱码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MapReduce文本链路里的编码真相:为什么明明写了UTF-8还是乱
搞清楚文件本身有没有问题之后,下一步就是理解MapReduce从输入到输出的整条编码链路。很多人在这一步栽跟头,是因为他们默认“代码里写的是UTF-8,输出就一定是UTF-8”,但实际流程不是这么简单。
2.1 从TextInputFormat到TextOutputFormat的字节旅程
一个典型的WordCount任务,数据流是这样的:
- TextInputFormat读取HDFS文件,按行切分,把每行的字节交给Mapper
- Mapper处理Text对象,输出键值对
- Reducer接收、聚合,再输出Text对象
- TextOutputFormat负责把Text对象写进part文件
关键在第一步和最后一步。
TextInputFormat的RecordReader在读取文件时,并没有做复杂的编码转换。它只是把每一行的字节切片,基本上原封不动地塞进一个 Text 对象。你可以把它理解成一个搬运工:你给我什么字节,我就往箱子里放什么字节,不做翻译。
真正开始“翻译”的地方,是你调用 Text.toString() 的时候。Text 内部是按UTF-8字节数组存储的,toString() 方法默认按UTF-8解码。如果源文件是GBK,那么 value.toString() 就会把一个GBK字节序列按UTF-8解码,结果自然是一堆乱码字符。
到了输出端,TextOutputFormat写出的逻辑也很有讲究。对于 Text 类型的key或value,它直接调用 write(Text.getBytes(), 0, Text.getLength()),也就是把Text内部保存的原始字节原样写出去。换句话说:Text对象里存的是什么字节,输出文件里就是什么字节。
这条链路理解透了,就能回答一个经典疑问:为什么我不做任何代码改动,输入GBK文件,输出文件里的中文看起来还是乱的?因为TextInputFormat没转码,TextOutputFormat也没转码,你只是把GBK字节从输入搬到了输出。文件里的字节就是GBK的,终端按UTF-8看当然乱。
2.2 Text类型到底是什么,为什么它和String不一样
很多刚从传统Java转过来的人,会下意识把 Text 当成 String 用。这俩在编码行为上有个非常大的差异。
String 内部是 char[],也就是已经解码后的字符序列。当你要转字节时,必须指定字符集,否则就用JVM默认字符集。
Text 内部是 byte[],本质上是字节容器,设计目标是高效处理UTF-8数据。它不保证存储的一定是合法UTF-8字节,你往里面塞什么字节都可以。Text.getBytes() 返回的就是你塞进去的原始字节。
所以,如果你在Mapper里写了类似这样的代码:
java复制String line = value.toString();
// 对line做一些处理
context.write(key, new Text(line));
这里其实发生了两次隐性编码操作。第一次是 value.toString(),把Text里的字节按UTF-8解码成String;第二次是 new Text(line),把String再按UTF-8编码成字节。两次都是UTF-8,链路是一致的。但只要输入文件不是UTF-8,value.toString() 这一步就已经把字节解码错了,后面再编码回来,错误被固化,后面再挽救就难了。
2.3 约定被打破的三种情况
整条链路的隐形约定是:所有环节都按UTF-8处理。任何一步打破这个约定,就会产生乱码。我自己总结下来,最常见的有三种:
第一种,输入文件本身不是UTF-8,比如GBK、GB2312、Latin-1等。这是源头问题,占比最高。
第二种,代码里用了依赖JVM默认字符集的操作,比如 String.getBytes() 不传参数,或者 new String(bytes) 不传参数。JVM默认字符集一旦不是UTF-8,这一步就把数据毁了。
第三种,显示层编码和文件编码不匹配,比如UTF-8的文件在GBK终端里看。这类问题不算数据损坏,但表现出来同样是乱码,很容易让人误判。
看到这里你应该明白,mapreduce输出乱码不是一个单一问题,而是一整条链路里某个环节出了岔子。接下来几章,我按根因逐个拆。
3. 根因一:输入文件编码和代码假设不一致,最常被忽略的源头
假设你有一个中文日志文件,是从Windows生产机导出的,编码是GBK。你把它传到Linux,put到HDFS,然后写了一个MapReduce任务去统计词频。Mapper里第一行代码八成是:
java复制String line = value.toString();
就这一行,乱码的命运已经注定了。
3.1 现象与字节证据
任务跑完后,你打开part文件,中文乱码,但又不是那种“锟斤拷”或者“???”的乱码,而是一种看起来有规律但完全读不懂的怪异文字,比如“浣犲ソ”“涓枃”之类。实际上这些也是特定现象:把UTF-8字节按GBK解读(或反过来),就会出现“锟斤拷”“浣犲ソ”这类特征字符。
用 xxd 看到的是类似这样的字节:
bash复制c4 e3 ba c3 20 ca c0 bd e7
对照GBK编码表,c4 e3 是“你”,ba c3 是“好”。文件里的字节其实是正常的中文GBK编码。问题不是说字节坏了,而是后续代码按UTF-8解释它们。
这也是我特别想强调的一点:如果是这个根因,文件内容并没有“坏”,只是编码不对。你可以用下面的命令验证并直接转码:
bash复制iconv -f GBK -t UTF-8 /tmp/result.txt
如果输出正常中文,基本可以确定源文件编码就是GBK。
3.2 离线转码:快,但治标不治本
最省事的方案是在数据入HDFS之前先转码。比如本地文件先用iconv处理:
bash复制iconv -f GBK -t UTF-8 input.log > input_utf8.log
hdfs dfs -put input_utf8.log /user/hadoop/input/
这个方案对小文件没毛病,但有两个限制。第一,你需要预知源文件的编码,而真实环境里一个目录下可能混着GBK和UTF-8两种文件;第二,如果文件已经进了HDFS,或者数据是业务系统实时写入的,你没法在源头拦截。所以离线转码只适合一次性数据迁移,不适合常态化的数据管道。
3.3 自定义GBKInputFormat:治本的方案
更可靠的方案是,在MapReduce里自定义一个InputFormat,读取时就把GBK字节解码成UTF-8的Text。这样下游所有逻辑都按UTF-8处理,和标准流程完全一致。
核心思路是重写RecordReader的nextKeyValue,在读取一行字节后,先按GBK解码成String,再按UTF-8编码成Text。
核心代码大概是这个模样:
java复制public class GbkLineRecordReader extends RecordReader<LongWritable, Text> {
private LineReader lineReader;
private LongWritable key = new LongWritable();
private Text value = new Text();
private long pos = 0;
@Override
public boolean nextKeyValue() throws IOException {
int newSize = lineReader.readLine(value);
if (newSize == 0) {
return false;
}
// 关键:把GBK字节解码后,按UTF-8重新编码
byte[] rawBytes = new byte[value.getLength()];
System.arraycopy(value.getBytes(), 0, rawBytes, 0, value.getLength());
String decoded = new String(rawBytes, "GBK");
value.set(decoded.getBytes("UTF-8"));
pos += newSize;
return true;
}
}
注意一个细节:value.set(decoded.getBytes("UTF-8")) 这一步必须显式指定UTF-8。如果不传字符集,decoded.getBytes() 会用JVM默认字符集,在Linux生产环境里很可能是UTF-8,但万一环境是C/POSIX,中文会被转成问号,那就从“乱码”变成了“数据丢失”,更严重。
原理上说,这一步把源文件统一成UTF-8,往后所有流程都不会再遇到编码陷阱。代价是你需要多写一个类,但一劳永逸。
3.4 如果只是“透传”而不处理,也要注意什么
有些场景不需要对中文做任何计算,只是从A表搬到B表,Mapper和Reducer里完全不调用 toString()。这时候Text里保留的还是GBK原始字节,输出文件里的中文字节也还是GBK。从数据完整性角度讲,内容没问题;但从数据标准化角度讲,整个管道里就会出现“同一份数据两种编码”的混乱状态。后面只要有下游任务拿它当UTF-8解析,又会乱。
所以我的建议是:如果团队里有统一编码规范,尽量在管道入口就转成UTF-8,不要抱着“反正只是透传”的心态绕过去。编码问题最怕的就是不一致,全链路统一成一种编码,能省掉大量排查时间。
4. 根因二:JVM默认字符集与String.getBytes()埋下的隐雷
如果说输入文件编码问题是“明枪”,那JVM默认字符集导致的问题就是“暗箭”。它不一定每次触发,但只要触发,你很难第一时间联想到。
4.1 file.encoding是怎么被决定的
Java进程运行时的默认字符集,由 file.encoding 系统属性决定,也就是 Charset.defaultCharset() 的返回值。在Linux环境下,JVM在启动时会参考环境变量 LANG、LC_ALL 等。如果服务器设置的是 LANG=en_US.UTF-8,那JVM默认字符集就是UTF-8;但如果设置的是 LANG=C 或者 LANG=POSIX,JVM默认字符集会退化,在老版本JDK上尤其明显。
Hadoop的NodeManager或ResourceManager进程里跑着各种任务,每个Task执行时也会继承这些环境变量。有时候你在本机开发一切正常,一上集群就乱码,很大概率就是环境变量差异造成的。
怎么确认?有两种方式:
bash复制# 方式一:在Mapper代码里打印
System.out.println("default charset = " + Charset.defaultCharset());
# 方式二:看YARN日志里是否有提示
如果输出显示类似 ANSI_X3.4-1968 或 US-ASCII,那恭喜你,踩中雷了。
4.2 你代码里那个没写编码参数的getBytes
一个很典型的踩坑代码:
java复制String word = ... // 从Text.toString()得到的中文
byte[] bytes = word.getBytes(); // 没指定编码
当JVM默认字符集不是UTF-8时,这一步会把中文转成非UTF-8字节。如果再往里传给某个需要字节的接口,后续就全乱了。
更隐蔽的版本是这个:
java复制String line = value.toString();
String replaced = line.replace("old", "new");
context.write(key, new Text(replaced));
表面上没有 getBytes(),但 new Text(replaced) 内部对String编码时用的就是UTF-8。这个还好,因为Text构造器写死UTF-8。真正危险的是那些“不经意”的转换,比如把String拼接到URL、传给JDBC、写入第三方接口时,底层隐式调用的 getBytes()。
我的经验是:在涉及跨进程、跨系统传输的代码路径上,所有字符集转换必须显式指定编码。
4.3 从Hadoop启动参数到代码习惯的修复方案
如果想在环境层面兜底,可以修改 $HADOOP_HOME/etc/hadoop/hadoop-env.sh,在 HADOOP_OPTS 里加上:
bash复制export HADOOP_OPTS="$HADOOP_OPTS -Dfile.encoding=UTF-8"
这样YARN启动的Java进程会默认使用UTF-8。如果你用的是Spark/Flink等跑在YARN上的计算引擎,也同样要留意Driver和Executor的JVM参数。
但环境变量只是兜底,最稳妥的还是在代码层面处理。凡是涉及到字节和字符互转,一律使用如下方式:
java复制// 推荐:显式指定
String decoded = new String(bytes, StandardCharsets.UTF_8);
byte[] encoded = str.getBytes(StandardCharsets.UTF_8);
// 不推荐:依赖默认字符集
String decoded = new String(bytes);
byte[] encoded = str.getBytes();
这个习惯一旦养成,以后在本地、测试、生产环境都不会因为环境差异出现诡异乱码。我的原则是:String 和 byte[] 互相转换时,永远不带默认值。问题不是“这次没出问题”,而是“这次没出问题只是因为运气”。
5. 根因三:显示层和工具链乱码,让系统背了锅
前面说过,有时候文件本身没问题,是显示端编码不匹配造成误判。这个误判非常伤——你对着完全正常的文件疯狂排查,甚至准备重写任务,结果只是终端字体不对,这种浪费很冤。
5.1 Linux终端与hdfs命令显示
如果你在Linux终端执行:
bash复制hdfs dfs -cat /user/hadoop/output/part-r-00000
输出乱码,但用 file 检查下载到本地的文件是 UTF-8 Unicode text,那就是终端编码问题。
解决方法很朴素,先把当前终端的编码切到UTF-8。检查方式:
bash复制locale
如果 LC_ALL 或 LANG 不是UTF-8结尾,比如是 POSIX 或 C,那很多工具会按ASCII处理显示,中文自然乱。可以临时设置:
bash复制export LANG=en_US.UTF-8
注意,只对当前会话有效。如果希望永久生效,要改 /etc/locale.conf 或 ~/.bashrc。不过服务器上的环境变量最好和运维团队协调,不要自己乱改,免得影响其他服务。
5.2 Windows下SSH工具和IDEA控制台
Windows下用SecureCRT、Xshell、MobaXterm这类工具连服务器,工具本身的“终端编码”设置非常关键。默认情况下有些工具用的是系统本地编码,在中文Windows上就是GBK。如果服务器输出UTF-8字节,GBK终端强行按GBK解码,乱码就出现了。
解决方式是在工具的会话属性里,把终端编码改成UTF-8。不同工具菜单位置不同,但搜索“encoding”或“编码”基本都能找到。
另外,如果你是在本地IDEA里跑Hadoop程序(比如本地模式调试),控制台输出中文乱码,检查这几个地方:
Settings -> Editor -> File Encodings,把Global Encoding、Project Encoding、Default encoding for properties files全设成UTF-8- 如果是Maven项目,检查
project.build.sourceEncoding是否设置了UTF-8 - IDEA控制台自身也有一个编码设置,在
Help -> Edit Custom VM Options里加-Dfile.encoding=UTF-8
IDEA对这块比较敏感,默认配置下有时会继承系统区域设置,需要手动统一。
5.3 YARN日志和Log4j也来凑热闹
还有一个隐蔽的显示层问题:任务日志里的中文乱码。
你查YARN日志发现中文全是乱码,以为是MapReduce代码问题,其实是Log4j输出时的编码问题。排查方向是把log4j配置里的输出编码指定为UTF-8:
properties复制log4j.appender.console.encoding=UTF-8
log4j.appender.RA.encoding=UTF-8
文件日志的Appender同理。这个跟MapReduce输出文件本身无关,但容易和“mapreduce输出乱码”混在一起。如果日志乱码和文件乱码同时出现,先各查各的,不要混为一谈。
6. 一套可以直接抄的编码加固模板(附完整代码)
聊了那么多根因,是时候给出一套可以直接落地的代码模板了。无论你是新写一个MapReduce任务,还是接手一个已经乱码的旧任务,按这个模板改,基本能覆盖95%以上的场景。
6.1 通用编码工具类
我习惯写一个极简的编码工具类,避免到处散落 new String(bytes, "GBK") 这种魔法值:
java复制import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
public final class EncodingUtils {
private EncodingUtils() {}
public static final Charset UTF8 = StandardCharsets.UTF_8;
public static final Charset GBK = Charset.forName("GBK");
public static String decodeGBK(byte[] bytes) {
return new String(bytes, GBK);
}
public static String decodeUTF8(byte[] bytes) {
return new String(bytes, UTF8);
}
public static byte[] encodeUTF8(String str) {
return str.getBytes(UTF8);
}
public static byte[] encodeGBK(String str) {
return str.getBytes(GBK);
}
}
代码虽短,但好处是团队里所有人共享一套命名,不会一会儿写 "utf-8" 一会儿写 "UTF8" 一会儿又写 "utf8",避免字符集名称不统一导致的低级错误。
6.2 Mapper/Reducer的编码安全写法
在不确定输入编码的情况下,我一般先显式获取原始字节,再做一次判断或转换。前提是你能确认源编码。如果确实知道源文件是GBK,Mapper里可以这样处理:
java复制public class SafeMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
private Text outKey = new Text();
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
// 方案一:如果输入仍是GBK字节,先转成String,再重新编码为UTF-8
byte[] raw = new byte[value.getLength()];
System.arraycopy(value.getBytes(), 0, raw, 0, value.getLength());
String line = new String(raw, "GBK");
outKey.set(line.getBytes("UTF-8"));
// 后续逻辑照常
}
}
如果你已经通过自定义InputFormat在源头转好了,那Mapper里直接 value.toString() 也没问题。两种方案选一个就行,不要重复转,转来转去反而容易出错。
Reducer端相对简单,因为进入Reducer的key/value已经经过shuffle,是Text对象。只要不是故意制造乱码字节,正常输出即可。
6.3 自定义InputFormat代码示例
前面第3节给了RecordReader的核心逻辑,这里补全一个完整的InputFormat骨架:
java复制import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.JobContext;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.LineRecordReader;
import java.io.IOException;
public class GbkTextInputFormat extends FileInputFormat<LongWritable, Text> {
@Override
public RecordReader<LongWritable, Text> createRecordReader(
InputSplit split, TaskAttemptContext context) {
return new GbkLineRecordReader();
}
public static class GbkLineRecordReader extends RecordReader<LongWritable, Text> {
private LineRecordReader reader = new LineRecordReader();
@Override
public void initialize(InputSplit split, TaskAttemptContext context)
throws IOException, InterruptedException {
reader.initialize(split, context);
}
@Override
public boolean nextKeyValue() throws IOException, InterruptedException {
if (!reader.nextKeyValue()) {
return false;
}
Text value = reader.getCurrentValue();
byte[] raw = new byte[value.getLength()];
System.arraycopy(value.getBytes(), 0, raw, 0, value.getLength());
String decoded = new String(raw, "GBK");
value.set(decoded.getBytes("UTF-8"));
return true;
}
@Override
public LongWritable getCurrentKey() {
return reader.getCurrentKey();
}
@Override
public Text getCurrentValue() {
return reader.getCurrentValue();
}
@Override
public float getProgress() throws IOException, InterruptedException {
return reader.getProgress();
}
@Override
public void close() throws IOException {
reader.close();
}
}
}
这里偷了个懒,借用了Hadoop自带的 LineRecordReader,它内部已经处理了行读取逻辑。唯一要做的就是在每行读进来之后,把GBK字节重编码成UTF-8。
注意:这个方案直接修改了传入的 value 对象,所以之后所有Mapper逻辑拿到的都是UTF-8编码的Text。如果你还开着combiner或者二次排序,也都会基于UTF-8数据,逻辑一致。
6.4 配置项防坑:哪些参数和编码无关
最后提醒一件事:网上搜“mapreduce输出乱码”时,经常会看到有人提到 mapreduce.output.textoutputformat.record.separator,其实这个参数只控制输出记录的分隔符,默认是 \n,跟中文编码一点关系都没有。千万别被带偏,改了也没有任何作用。
另外还有一个常见的误区是设置 mapred.child.java.opts 或 mapreduce.map.java.opts 里的编码参数。给Map/Reduce Task加 -Dfile.encoding=UTF-8 本质上是在JVM层面兜底,这个可以做,但如果你代码里已经显式指定了编码,这个配置就是锦上添花,不是救火队员。真正的编码安全必须靠代码里的显式转换来保证。
7. 全流程复盘:从GBK日志到词频统计乱码的排错实战
理论说再多,不如完整走一遍案例。下面这个场景我做过不止一次,很有代表性。
7.1 现象记录
业务方给了一批Windows服务器上的日志文件,里面每行是一条访问记录,包含中文用户名和URL。需求是按用户统计访问次数。
我拿到文件后用 file 检查,显示是 Non-ISO extended-ASCII,但当时没在意,直接put到HDFS上跑了标准词频任务。结果part文件打开一看,中文用户名一团乱麻。
7.2 第一步:看字节
我没有立刻改代码,先把输出文件拉到本地看字节:
bash复制hdfs dfs -get /user/hadoop/output/part-r-00000 /tmp/out.txt
xxd /tmp/out.txt | head -5
字节片段是 c4 e3 ba c3,对照GBK码表就是“你好”。这说明什么?输出文件里的字节其实是GBK编码的中文,数据本身没丢,但统一按UTF-8的终端展示,自然乱码。
这个结论很重要——它告诉我问题不在输出端,而是输入端的GBK字节被原样搬运到了输出端。
7.3 第二步:查代码
再回头看代码。我的Mapper第一行写的是:
java复制String line = value.toString();
这一行的行为是:把Text里的字节按UTF-8解码成String。但实际字节是GBK,按UTF-8解,结果必然乱。后面所有处理都基于这个错误的String,再输出时就是“乱码字符的UTF-8编码”,比原始GBK更乱。
这里有个关键区别:如果我只是“透传”Text而不调用 toString(),输出文件里的字节还是GBK,终端看还是乱,但至少字节没坏;一旦调用了 toString(),GBK被错误解码成乱码字符,再编码成UTF-8字节,字节层面就真的“坏”了,再也还原不回来。
7.4 第三步:修复与验证
修复方案我用的是自定义InputFormat,在RecordReader里做GBK到UTF-8的转换。这样所有下游代码不感知编码,逻辑最简单。
改完重新提交任务,再拉下来看:
bash复制file /tmp/out.txt
# 输出:UTF-8 Unicode text
cat /tmp/out.txt
# 中文正常显示
问题解决。
7.5 额外收获:那个问号乱码其实来自getBytes()
同一批任务里,还有一个数据源是另外一台机器上传的,文件本身已经是UTF-8了,但结果里出现了大量问号。这个就更隐蔽。
排查后发现,代码里有一段:
java复制byte[] b = line.getBytes(); // 这里没指定编码
当时的Task JVM默认字符集是US-ASCII(因为 LANG=C),中文直接被转成了 0x3f,也就是问号。这种情况下文件字节变成了ASCII,数据已经不可逆丢失。修改方式就是第6节的模板,把 getBytes() 改成 getBytes(StandardCharsets.UTF_8)。
所以两个案例对比着看,规律就非常清晰:
- 如果乱码是“可读的怪字”,大多是编码被错误解读,数据还能救
- 如果乱码是问号
?,说明字符已经被替换成ASCII问号,数据在字节层面就丢了
遇到问号乱码,基本不用指望修复数据,只能回到上游重新处理。
最后说一点个人体会。MapReduce乱码这东西,说难不难,说简单也不简单,核心就一句话:不要在字节和字符之间做隐式转换。编码问题最怕的就是“某个环节隐式用了某个字符集”,因为是隐式的,你看不到它在哪,也就无从定位。把全链路统一成UTF-8,所有转换显式声明,再从字节层面验证,百分之九十九的乱码都能在半小时内定位清楚。
