初学大数据的人大概率都会从Hadoop开始,而Hadoop里最容易上手的编程入口,就是MapReduce。哪怕现在很多生产环境已经用Spark、Flink这类更高效的计算引擎,MapReduce的编程模型依然是理解"分布式计算是怎么把任务拆开再合并"的最佳教材。这篇博文从一个实际可运行的WordCount项目出发,完整走一遍"本地写代码 → Maven打包 → 上传Linux服务器 → Hadoop集群执行 → 查看结果"的全流程。整个过程我踩过的坑都会写出来,比如依赖版本不一致导致的运行时ClassNotFoundException、打包时没设置主类导致的权限错误、第二次运行前忘记删除已有输出目录的java.io.IOException,这些实战问题比代码本身更容易劝退新手。
1. WordCount到底在解决什么问题:先想清楚再动手
1.1 一个朴素需求背后的分布式挑战
假设你手头有几个GB的日志文件,想统计每个单词出现了多少次。单个文件可能在单个节点上能处理,但换成几百个分散在不同服务器上的文件,普通Java程序就力不从心了:你需要考虑文件怎么分割、任务怎么分配、某台机器挂了怎么办、中间结果怎么跨节点传输。WordCount虽然结果看起来只是"统计词频",但它把分布式系统要面对的核心问题全部压进了这个最简单的场景里。
MapReduce解决这个问题的思路是"先拆后合":Map阶段把所有输入数据切成很多小分片,每台机器只负责处理自己拿到的那一小块数据,输出"单词 → 1"这样的中间键值对;Reduce阶段再把相同单词的计数汇总起来,得到每个单词的总次数。这中间的洗牌、排序、分组都由框架自动完成,程序员只需要定义Mapper和Reducer两个函数,这已经是分布式编程里最友好的一层抽象了。
1.2 MapReduce编程模型的三个基本组件
从代码结构上看,一个完整的MapReduce任务必然包含三个部分:
- Mapper类:继承
org.apache.hadoop.mapreduce.Mapper,实现map方法,将输入键值对转换成中间键值对。 - Reducer类:继承
org.apache.hadoop.mapreduce.Reducer,实现reduce方法,把Map阶段输出的中间结果按key分组后合并。 - Driver主类:负责配置Job参数,指定输入输出路径、Mapper和Reducer类、输出key-value类型,然后提交任务到集群执行。
理解这三个组件的分工,你就能看懂任何一个MapReduce程序:输入格式决定怎么读取数据,Mapper决定怎么做初步转化,Reducer决定怎么聚合结果,Driver把所有零件拼装起来。
2. 环境准备与项目搭建:服务器端和本地的常规配置
2.1 集群侧的Java与Hadoop环境检查
开始写代码之前,先确认Linux服务器上的环境。多数Hadoop发行版自带Java环境,但仍建议手动检查:
java -version hadoop version echo $HADOOP_HOMEHADOOP_HOME一定要设置了,因为后面执行hadoop jar命令时靠它定位Hadoop类库和配置目录。如果hadoop version能正常打印版本号,且HADOOP_HOME指向了正确路径,环境基本没问题。我在实际集群上遇到过JAVA_HOME缺失导致hadoop命令行无法启动的情况,错误信息是JAVA_HOME is not set,所以也顺手确认下echo $JAVA_HOME。
2.2 本地开发机:Maven环境与项目结构
开发机上需要JDK和Maven。用Maven管理依赖,主要是为了锁定Hadoop客户端的版本,避免和服务器端版本不一致。推荐的开发环境版本组合是:JDK 8 + Maven 3.6+ + hadoop-client 3.x(具体版本以你的集群为准,比如集群是Hadoop 3.2.x就引入3.2.x的客户端依赖)。
在IntelliJ IDEA里创建一个Java项目,artifactId可以叫wordcount-demo,项目整体结构如下:
wordcount-demo/ ├── pom.xml ├── src/ │ └── main/ │ └── java/ │ └── wordcount/ │ ├── WordCountMapper.java │ ├── WordCountReducer.java │ └── WordCountDriver.java这里有一个容易掉坑的地方:如果你在IDEA里直接新建的是普通Java项目而不是Maven项目,后面打jar包会非常痛苦。老老实实用Maven骨架创建项目,后面打包只需要一条命令。
2.3 pom.xml中核心依赖与打包插件配置
pom里关键是三个部分:hadoop-client依赖、maven-jar-plugin(或者更推荐的maven-shade-plugin)、编译插件版本。我的pom.xml核心配置如下:
<dependencies> <dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>3.2.1</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.2.0</version> <configuration> <archive> <manifest> <mainClass>wordcount.WordCountDriver</mainClass> </manifest> </archive> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>1.8</source> <target>1.8</target> </configuration> </plugin> </plugins> </build>为什么maven-jar-plugin里要配mainClass?因为后面执行hadoop jar wordcount.jar时,如果不指定类名,YARN会去找Manifest里的Main-Class;没配的话就得在命令行手动补上完整类名wordcount.WordCountDriver。配在pom里一劳永逸。也有不少教程推荐maven-shade-plugin把Hadoop依赖打进去,但实践下来完全不必要——服务器上本来就有完整的Hadoop运行环境,打shade包反而可能造成依赖版本冲突,增大jar体积,上传更慢。普通场景用maven-jar-plugin就够了。
3. 代码实现:Mapper、Reducer、Driver逐个拆解
3.1 WordCountMapper:从每一行里切出单词
package wordcount; import java.io.IOException; import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> { private static final IntWritable ONE = new IntWritable(1); private Text word = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); String[] words = line.split(" "); for (String w : words) { if (w.trim().length() > 0) { word.set(w); context.write(word, ONE); } } } }四个泛型参数对应的是:输入key类型(行偏移量)、输入value类型(一行文本)、输出key类型(单词)、输出value类型(计数1)。这里用LongWritable和Text而不是Java的Long和String,是因为Hadoop自定义了一套序列化接口——Writable,比Java原生序列化更紧凑、传输性能更好。你可能会觉得多此一举,但这是MapReduce框架的基本约定,在集群环境下能显著减少网络和磁盘IO。
split(" ")按空格切分是最偷懒的写法,实际生产数据里可能还有制表符、标点符号。可以改成line.split("\\s+")支持任意空白字符。我自己写演示代码时会先过滤掉空串,防止后面Reduce阶段出现空key。
3.2 WordCountReducer:把相同单词的计数加起来
package wordcount; import java.io.IOException; import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> { private IntWritable result = new IntWritable(); @Override protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { int sum = 0; for (IntWritable val : values) { sum += val.get(); } result.set(sum); context.write(key, result); } }Reducer的输入key是Map阶段输出的key本身,而values是这个key对应的所有value集合。这里不需要自己维护HashMap做累加,框架已经把相同key的Map输出结果全部汇聚到同一个reduce调用里了。一个key触发一次reduce方法,迭代values累加统计即可。注意values是Iterable,不是List,它底层可能是分批拉取的,所以不要在循环外保存这个对象再二次遍历,直接在一次循环里完成聚合。
3.3 WordCountDriver:组装Job的"总指挥"
package wordcount; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; public class WordCountDriver { public static void main(String[] args) throws Exception { if (args.length < 2) { System.err.println("Usage: wordcount.WordCountDriver <input path> <output path>"); System.exit(-1); } Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "word count"); job.setJarByClass(WordCountDriver.class); job.setMapperClass(WordCountMapper.class); job.setCombinerClass(WordCountReducer.class); job.setReducerClass(WordCountReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }这短短二十几行代码里藏着好几个决定任务成败的点:
setJarByClass用来告诉Hadoop从哪个jar里找Mapper和Reducer类,在集群模式下必须调用,否则框架不知道去哪个jar加载用户类。setCombinerClass在这里直接复用Reducer类——因为WordCount的局部求和和全局求和逻辑完全一致,这是Combiner最经典的使用场景。Combiner会在Map节点本地先做一次预聚合,把大量<word, 1>的键值对合并成<word, n>,极大减少Shuffle阶段跨节点传输的数据量。很多教程把Combiner当作一个高级特性提鲜,但演示WordCount时不加它,性能差距不大,可一旦跑真实数据,效果立竿见影。
waitForCompletion(true)传入true表示让Driver等作业跑完并打印进度信息;如果不想阻塞等待,传false也能提交,但命令行里就看不到Map和Reduce的进度条了。最后按作业是否成功决定退出码,方便脚本判断。
4. 打包、上传到Linux服务器并运行的完整链路
4.1 Maven打包:跳过测试,直接产出可用jar
在本机项目根目录执行:
mvn clean package -DskipTests-DskipTests不是可选项——如果你项目里有JUnit测试,Maven默认会跑测试阶段。我的经验是Hadoop客户端依赖偶尔会引发测试类加载问题,明明只写了一个单元测试却报类找不到,所以演示项目里最简单粗暴的做法就是跳过测试。构建成功后target目录下会出现wordcount-demo-1.0-SNAPSHOT.jar,这个jar大小通常只有几KB到几十KB,它只包含你自己写的类和Manifest信息,不包含Hadoop库。
如果打包后用jar tf wordcount-demo-1.0-SNAPSHOT.jar查看,能看到wordcount/WordCountMapper.class这类条目。确认Main-Class是否正确,直接执行unzip -p wordcount-demo-1.0-SNAPSHOT.jar META-INF/MANIFEST.MF查看。
4.2 上传jar包到服务器:scp和rz两种方式哪个更顺手
Linux服务器上开启Hadoop集群后,需要把jar包传上去。最通用的命令是scp:
scp target/wordcount-demo-1.0-SNAPSHOT.jar hadoop@your-server-ip:/home/hadoop/如果你本地连的是Windows,用Xshell或FinalShell自带的rz命令也可以,把jar包直接拖进服务器目录即可。注意上传目标目录的用户权限:如果你用hadoop用户上传,目录最好也在该用户有写权限的地方,比如/home/hadoop/。我以前图方便传到/root/目录,结果hadoop jar执行时因为文件所有者是root而hadoop用户无权读取,白白浪费了一轮排查时间。
4.3 准备输入文件并上传到HDFS
WordCount读的不是服务器本地文件,而是HDFS上的文件,这是MapReduce初学者最容易懵的地方。先在Linux本地准备一个示例文本文件:
vim input.txt写入几行测试内容,例如:
hello hadoop hello world hello mapreduce world hadoop is powerful然后在服务器上执行以下命令,把本地文件上传到HDFS:
hadoop fs -mkdir -p /wordcount/input hadoop fs -put input.txt /wordcount/input/可以通过hadoop fs -cat /wordcount/input/input.txt快速确认上传成功。这里还有个常见困惑:为什么不在程序里直接指定服务器本地路径?因为MapReduce的输入读取是由各NodeManager上的任务并发进行的,如果读本地文件,每个节点都只读到自己磁盘上的那一份,无法形成共享数据视图。HDFS正好解决了这个共享问题,它让所有节点都能从同一个分布式存储中读取同一个文件的任意分片。
4.4 提交MapReduce作业的完整命令与执行过程
上传完成后,在服务器上执行:
hadoop jar wordcount-demo-1.0-SNAPSHOT.jar /wordcount/input /wordcount/output命令拆解如下:hadoop jar是提交工具;jar包路径写在第一个参数;后面紧跟两个路径参数,分别对应main方法里args[0]和args[1]——也就是输入目录和输出目录。
执行过程中终端会刷出大量日志,核心信息包括:
INFO mapreduce.Job: Running job: job_... INFO mapreduce.Job: map 0% reduce 0% ... INFO mapreduce.Job: map 100% reduce 100% INFO mapreduce.Job: Job job_... completed successfully INFO mapreduce.Job: Counters: ...看到completed successfully就说明作业跑通了。如果服务器配置了YARN资源管理器,也可以打开ResourceManager的Web界面查看任务历史,不过对于WordCount这种分钟级任务,命令行日志已经足够我们判断状态。
4.5 查看输出结果:HDFS上的part-r-00000
作业成功结束后,输出目录里会生成文件。执行:
hadoop fs -ls /wordcount/output hadoop fs -cat /wordcount/output/part-r-00000正常情况下,你会看到类似下面的统计结果:
hadoop 3 hello 2 is 1 mapreduce 1 powerful 1 world 2注意输出目录下的文件命名规则:part-r-00000是Reduce阶段生成的文件,如果配置了多个Reducer,会有part-r-00000、part-r-00001等多个文件;如果只用了Map没有Reduce阶段,输出就是part-m-00000。WordCount整个过程走完了Map和Reduce,所以看到的是part-r-开头。
5. 运行异常复盘:从表面报错找到根因的排查思路
5.1 输出目录已存在的IOException:最经典的二次运行陷阱
很多新手连续跑两次同样的命令,第二次一定会报错。错误内容通常是:
org.apache.hadoop.mapreduce.lib.output.FileOutputFormat: Output directory /wordcount/output already exists这不是环境问题,而是MapReduce框架刻意设计的保护机制:HDFS上不允许一个作业的输出目录预先存在,防止多个作业覆盖同一目录导致数据混乱。解决办法是每次运行前删除已有输出目录:
hadoop fs -rm -r /wordcount/output但别养成"反正会报错就随手删"的习惯,删HDFS目录是危险操作,确认目录里确实是你上一轮的临时结果再删。
5.2 ClassNotFoundException: wordcount.WordCountMapper
运行作业时出现了类似:
Error: java.lang.RuntimeException: java.lang.ClassNotFoundException: Class wordcount.WordCountMapper not found这类问题几乎都是jar包没传对或打包不完整。可能的原因有几个:上传的jar不是最新版本,本地改完代码忘了重新mvn package;命令行指定的jar包路径不对,执行了老路径下的旧jar;pom里没配setJarByClass导致框架不知道加载哪个jar。我遇到最多的是第一种——IDEA里改了代码后没重新打包,服务器上还是旧版本。排查时可以先用unzip -l查看jar里的类名,确认是否包含wordcount/WordCountMapper.class,再决定是重新打包还是检查上传路径。
5.3 连接被拒:本地没有配置HADOOP_HOME导致集群识别失败
如果你在本地Windows上直接跑hadoop jar命令,大概率会碰到:
java.net.ConnectException: Call From localhost/127.0.0.1 to localhost:8020 failed on connection exception表面看是连接HDFS的8020端口失败,背后原因是本地环境没有加载集群的core-site.xml和hdfs-site.xml配置,Hadoop客户端默认找本地namenode。正确做法:不要把Windows当集群入口,把所有提交动作都放到Linux服务器上执行;如果确实需要在本地开发调试,至少要把服务器端的配置文件拷贝到本地的Hadoop配置目录,这就会牵扯出一堆版本兼容问题,开发体验极差。我给初学者的建议是:本地只负责写代码和打包,所有和集群相关的操作一律在服务器上做,这个边界划清楚会省掉大量调试时间。
5.4 资源不足导致的Container启动失败:申请内存与样例数据的平衡
作业提交后Container反复启动失败,错误日志里出现GC overhead limit exceeded或Container killed by the resource manager。这类情况在小内存单机伪分布式环境里特别常见。检查yarn-site.xml里的yarn.nodemanager.resource.memory-mb和容器申请的内存大小是否匹配。如果你的测试文件只有几KB,WordCount任务的Map和Reduce各一个Container也就够用了,不需要调大内存;但如果在单机环境里同时跑多个作业,就可能出现资源争抢。
验证方法很简单:执行jps看NodeManager是否在运行,用free -h看服务器可用内存是否充足。如果只是做WordCount验证,建议先用小文件跑通流程,再考虑性能调优。真实生产优化方向是调整Container内存、设置合理的Map和Reduce个数,但那已经超出基础编程的范围了。
5.5 上传jar后提示Permission denied:文件所有者与执行用户不一致
有的同学把jar传到服务器后,切换到hadoop用户执行时遇到:
Exception in thread "main" java.io.FileNotFoundException: /home/hadoop/wordcount-demo-1.0-SNAPSHOT.jar (Permission denied)原因是jar文件的所有者是root,而执行用户是hadoop。虽然hadoop对/home/hadoop/目录有写权限,但这种跨用户文件读取经常出现权限位问题。简单的处理是改所有者:
chown hadoop:hadoop /home/hadoop/wordcount-demo-1.0-SNAPSHOT.jar或者干脆把jar上传到hadoop用户自己的家目录下,不要借用其他用户目录转移文件。这个坑很细小,但真实工作里我至少遇到过两次,所以顺便提一下。
5.6 中文乱码问题与应用场景的局限
WordCount如果要处理包含中文的文档,默认的Text输出在终端可能显示为乱码。这是因为Hadoop默认字符集是UTF-8,而hadoop fs -cat打印时没有指定编码。一种快速验证的办法是:
hadoop fs -cat /wordcount/output/part-r-00000 | iconv -f utf-8 -t utf-8或者把输出文件拉回本地用Notepad++打开查看编码。但说句实在话,WordCount只是为了演示编程流程,处理真实业务需要用更多文本预处理手段,MapReduce本身适合粗粒度的统计类任务,这是它设计时的定位决定的。
6. 关于Combiner和Partitioner:WordCount之后值得继续深挖的两个点
6.1 Combiner:让Map节点先做一轮"局部聚合"
上面Driver代码里我特意设置了job.setCombinerClass(WordCountReducer.class)。Combiner的本质是"Map端的Reducer",它运行在Map任务所在的节点上,对Map输出的中间结果先做一次key合并。以单词hello为例,某个Map任务可能在一个分片里遇到了5次hello,没有Combiner时,这5个<hello, 1>都要原封不动地送到Reducer;加了Combiner后,在Map节点本地就变成了<hello, 5>,只需要传一条记录出去。
这个优化在大数据量场景下效果极其显著,因为Shuffle阶段的数据传输往往占作业总耗时的比重很大。有一点要特别注意:Combiner的输入输出类型必须和Mapper的输出类型、Reducer的输入类型兼容。WordCount里Combiner和Reducer逻辑相同,所以直接复用Reducer类完全没问题;但如果你的Reducer逻辑依赖全局上下文(比如计算全局平均值),就不能无脑复用,需要单独写一个逻辑上满足结合律的Combiner类。
6.2 Partitioner:不同单词去向哪个Reducer
Map输出后、Reduce开始前,框架需要决定每个key交给哪个Reducer处理,这个决策者就是Partitioner。默认实现是HashPartitioner,按key的哈希值对Reducer个数取模,保证相同key必然分到同一个Reducer上。WordCount不做自定义Partition就能正确运行,因为每个单词的计数和它落在哪个Reducer上无关。
理解Partitioner的意义在于:当你有多个Reducer时,可以通过自定义Partitioner实现数据倾斜的初步缓解,或者把某一类key强制分配到同一个Reducer做特殊处理。比如处理省份维度的统计,你可以按省份编码分区,让每个Reduce处理一个省份的子集,避免跨区域数据无意义地混在一起。不过这个优化也不是万能的,Partitioner本身只是路由规则,如果某个key的占比实在太大,依然会倾斜,需要再加一层方案(比如加盐)来削峰。
6.3 从MapReduce到更现代的分布式计算模型
第一次跑通WordCount后,紧接着应该做的事是把这套逻辑迁移到其他计算框架里做对照。比如用Spark实现同样功能的代码量只是这里的几分之一,Flink能做到流式统计。但为什么要先在MapReduce上花时间?因为最底层的框架最容易暴露分布式计算的本质问题:当Mapper的输出无法整体放入内存时会发生什么、Reduce阶段数据倾斜该怎么定位、网络传输开销和本地聚合之间的平衡点在哪里。这些概念在高层框架里被封装得很死,理解了MapReduce再看Spark的shuffle机制,你会觉得很多东西似曾相识。
7. 给初学者的几条实操建议:这些是我反复踩坑后的心得
7.1 严格区分本地调试和集群运行的环境差异
本地IDEA里跑Main方法时,Hadoop客户端默认使用本地模式,它不会连接你的服务器集群,输入输出路径都是普通本地文件系统路径。这其实是一个很方便的本地调试手段——你不必每次改完代码都打包上传,就能先验证逻辑正确性。但本地调试通过不等于服务器上一定成功,集群模式下的权限、配置、jar包、HDFS路径都是新的变量。我的习惯是:先在本地用一个小文本文件跑通逻辑,再打包到服务器上验证全过程,两步分开进行,出问题能快速判断是代码问题还是环境问题。
7.2 准备一个固定的测试数据集,不要反复改输入文件
在一个验证流程里,我建议固定一个输入文件,别频繁改动内容。原因很简单:当任务报错时,你希望错误的原因只来自代码或环境,而不是输入数据的格式意外变化。比如你原来用split(" "),后来把测试文件里的单词间改成了多个空格,单词统计结果就变了,容易让人误判是程序出了问题。准备一个足够覆盖各种边界情况的测试文本,比如包含空行、连续多个空格、大小写混排,然后一直用它做回归验证,会省很多判断成本。
7.3 多读日志,不要把问题交给"重试"
MapReduce日志里最有价值的部分就是Counters和各个阶段的任务列表。报错不丢人,丢人的是报错不看日志,直接再跑一遍。我见过太多人遇到Container失败就重启集群,结果发现只是某个输入文件路径写错了。正确姿势是把终端里的ERROR信息完整复制下来,搜索关键字(ClassNotFound、ConnectException、IOException),定位到具体组件后再动手。如果日志太长,可以先用grep -i "error"筛一遍,把异常堆栈收出来看。这个习惯比多写十行代码都值钱。
7.4 命名规范和目录约定的意义大于形式
输出目录用/wordcount/output没问题,但如果在同一个集群上做多个实验,最好按/用户名/项目名/版本号的层级来组织HDFS路径。这样既方便自己排查旧结果,也不容易误删别人的数据目录。我曾经为了省事把所有输出都放/output,一周后完全分不清哪个结果对应哪次实验,花了很多时间重新跑任务。现在想起来,这不是技术问题,纯粹是管理习惯的问题,但管理习惯往往决定了排障效率。
8. 一个让我印象深刻的排查实例:从报错堆栈追到元数据冲突
最后分享一个我印象比较深的实战排查过程,它不算复杂,但很能说明MapReduce排错的基本思路。有一次我帮团队里一位新同学看作业,任务在Map阶段卡了很久,最后Container全部被杀,日志里反复出现某个数据节点上的磁盘写满。我们第一反应是去清理那台机器上的垃圾文件,但清理完重跑还是一样。后来换个思路查了YARN和HDFS的组合日志,才发现是同一批文件被多个作业同时写入了同一个临时目录,产生了元数据级别的锁冲突。
这个问题如果只看MapReduce作业日志,很难发现根因。它告诉我一个道理:分布式环境的问题常常不是你写的代码有问题,而是集群资源、文件状态、并发作业之间的相互影响。WordCount这样的小任务也会碰上这种环境级问题,更凸显了"看全链路日志"的重要性。你先从自己的代码排查起,一步一步往外扩,最终准确定位到交叉点,这个过程本身就是大数据工程能力最有价值的锻炼。
MapReduce WordCount虽然是入门项目,但跑通它所经历的每一个环节——依赖版本、打包格式、HDFS权限、YARN资源分配——都是生产环境一定会遇到的真实问题。把这个流程吃透,后面再接触Spark或Flink,你会发现自己比很多只会用SQL查数的人多了整整一层底层视角。