前几篇学习笔记里,我的日常操作基本被HDFS Shell命令承包了:上传用hdfs dfs -put,下载用hdfs dfs -get,查目录用hdfs dfs -ls,提交任务用hadoop jar。工具用顺手之后,我开始琢磨一个很现实的问题:如果要在Java服务里直接读写HDFS,这些Shell命令就完全帮不上忙了。Hadoop的Java API承载的正是这件事,它也是我Hadoop学习历程中的第9篇记录。
这一篇不打算把官方文档从头到尾抄一遍,那样没有意义。我更想记录的是自己真正用到的部分:环境怎么搭、依赖怎么引、HDFS的增删改查怎么写、流怎么读怎么写、MapReduce任务怎么从客户端提交上去,以及那些让初学者卡壳的报错到底该怎么排查。如果你也刚跨过Shell这道坎,或者正准备把HDFS能力接进自己的Java项目,这份记录应该能让你少绕几个弯。
1. 从Shell命令到Java API:编程视角的转变
1.1 Shell命令力不从心的真实场景
Shell命令适合运维人员手动操作,但一旦进入“程序自动完成任务”的场景,它的局限立刻暴露。
我在学完Shell之后试着做了两个练习,第一个是“把程序生成的日志实时写入HDFS”。模拟程序每隔几秒产出一条日志,用Shell手工执行put显然不现实,因为手动操作赶不上生成速度,也不可能让程序去调用Shell进程。第二个练习是“批量清洗数据后写回HDFS”,前置步骤需要先判断目标目录是否存在,如果存在就清空重建,再把清洗结果按日期分区写进去。这一套逻辑用Shell脚本也能写,但纯脚本处理权限、超时、文件句柄、异常恢复都格外别扭。
更常见的场景是产品化。业务系统把数据上传到某中间服务,服务端再把数据落进HDFS,或者读HDFS做后续计算。这种调用发生在Web服务内部,后端代码只能通过编程接口去访问文件系统,Shell命令在这个链路里没有位置。总结下来就是:Shell负责“人指挥机器”,Java API负责“程序指挥机器”,两者解决的问题不一样,但后者才是数据平台被业务系统调用的基础。
1.2 FileSystem抽象:理解整个API的钥匙
刚开始学Java API时,我花了不少时间找“HDFS自己的类”,翻来翻去总觉得入口很散。后来才意识到,整个Hadoop客户端API的核心不是某个叫“HDFSClient”的类,而是一个抽象类:org.apache.hadoop.fs.FileSystem。
这个类把所有文件系统统一成一套操作模型。你在代码里调FileSystem.get(),它会根据URI里的协议返回对应的实现:hdfs://开头返回DistributedFileSystem,file://开头返回LocalFileSystem,s3a://开头返回S3对象存储的客户端。也就是说,你不需要针对“HDFS”去学一套专属API,学会FileSystem之后,本地文件、HDFS、甚至部分对象存储都能用同一套增删改查逻辑操作。建立这个认知以后,后续所有代码都变得好理解了:一切都是Path,一切操作都发生在FileSystem上。
2. 环境准备:依赖坐标、版本对应与FileSystem获取
2.1 Maven依赖该怎么引入
学习阶段强烈建议直接就引一个hadoop-client,它把hadoop-common、hadoop-hdfs、hadoop-mapreduce-client-core等常用模块打包在一起,一个依赖就能覆盖HDFS和MapReduce两部分的API,省去很多挨个找坐标的麻烦。
我在模拟项目X里用的是服务端一致的版本。因为Hadoop客户端跟服务端的版本存在兼容问题,如果服务端是3.x,客户端引用一个2.x的包,运行时很容易出现ProtocolException或者某些类找不到。稳妥做法是去集群上执行hadoop version,把输出的版本号填到pom里。我这边集群是伪分布式,版本是3.3.x,于是pom里就这样写:
<dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>3.3.4</version> </dependency>引入之后IDEA会下载一大批依赖,这是正常的。唯一要注意的是依赖冲突:如果项目里还引了别的Hadoop相关组件,版本不一致时优先跑mvn dependency:tree捋一遍,把重复的旧版本排除掉。
2.2 获取FileSystem实例的三种方式
获取FileSystem实例是每次操作的第一步,方式不同,行为和适用场景也不同,我整理了一张表:
| 方式 | 关键写法 | 适用场景 |
|---|---|---|
| 只传Configuration | FileSystem.get(conf) | 本机已有core-site.xml,能自动读取fs.defaultFS |
| 显式URI | FileSystem.get(URI.create("hdfs://..."), conf) | 生产中最常用,不依赖本地配置文件 |
| 指定用户 | FileSystem.get(uri, conf, "hdfs") | 需要以特定用户身份访问HDFS |
我一开始图省事只写FileSystem.get(conf),结果因为本机core-site.xml没有配置,它默认返回了本地文件系统,后续所有路径都按file:///解析,报了一堆奇怪的错误。后来改成显式传入URI,问题立刻消失。最重要的代码大概是这样的:
Configuration conf = new Configuration(); conf.set("fs.defaultFS", "hdfs://localhost:9000"); conf.setInt("dfs.replication", 1); FileSystem fs = FileSystem.get(URI.create("hdfs://localhost:9000"), conf);注意这里的端口不是固定不变的。你less自己hdfs-site.xml里的dfs.namenode.rpc-address是多少,就用多少。我这边安装时把它改成了9000,所以示例里都按这个来。
另外要养成一个习惯:一个进程里尽量复用同一个FileSystem实例。每次调用都new一个Client,会反复建立RPC连接,浪费大量时间和资源。客户端本身是线程安全的,一个应用初始化一次,后面传给各个方法使用就够。
2.3 本地开发连接集群的隐形门槛
在Windows或Mac本机连Linux虚拟机上的HDFS,环境问题往往比代码本身更磨人。我踩过三个:一是主机名解析不了,报连接超时,需要在hosts文件里加一条虚拟机主机名和IP的映射;二是虚拟机的防火墙挡掉了NameNode的RPC端口;三是HDFS客户端日志默认是INFO级别,一跑起来满屏滚动,看着吓人,其实都是正常信息。前两个属于网络问题,用ping 主机名和telnet 主机IP 9000就能定位。第三个加一个log4j配置文件就能安静下来,我用的配置是:
log4j.rootLogger=WARN, stdout log4j.appender.stdout=org.apache.log4j.ConsoleAppender log4j.appender.stdout.Target=System.out log4j.appender.stdout.layout=org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %p %m%n这样只输出WARN以上的警告,控制台干净很多,紧急错误也不会被淹没。
3. 把Shell命令翻译成Java代码:核心API实战
3.1 目录与文件的生命周期操作
有了FileSystem实例以后,Shell里的常用命令基本都能找到对应的Java方法。我建议把这两个概念挂在脑子里:Shell里的hdfs dfs -mkdir -p对应fs.mkdirs(),hdfs dfs -rm -r对应fs.delete(path, true),第二个布尔参数表示是否递归删除。
我在本地练习时写了一段常用的初始化和清理逻辑:
Path dir = new Path("/user/mydata/input"); if (!fs.exists(dir)) { fs.mkdirs(dir); } Path oldPath = new Path("/user/mydata/old"); if (fs.exists(oldPath)) { fs.rename(oldPath, new Path("/user/mydata/archive")); } Path trash = new Path("/user/mydata/trash"); if (fs.exists(trash)) { fs.delete(trash, true); }需要特别提醒的是Path用的是Hadoop自己的org.apache.hadoop.fs.Path,不是JDK里的java.nio.file.Path,写import的时候千万别搞混。我第一次就写错了包名,IDE直接报“找不到符号”,排查了一会儿才反应过来。
如果想查文件的详细信息,用fs.getFileStatus(path),返回的FileStatus里有文件长度、块大小、副本数、修改时间、权限等属性。做校验、做同步任务的时候这方法很常用。
3.2 文件上传与下载的参数陷阱
上传和下载看起来各自只要一个方法调用,但参数形式有讲究。我练习时用的方案是:
Path localPath = new Path("C:/data/test.txt"); Path hdfsPath = new Path("/user/mydata/test.txt"); fs.copyFromLocalFile(false, true, localPath, hdfsPath); Path downloadDest = new Path("D:/backup/"); fs.copyToLocalFile(false, hdfsPath, downloadDest);copyFromLocalFile(false, true, src, dst)里第一个布尔参数表示是否删除本地源文件,第二个表示HDFS上目标已存在时是否覆盖。我第一次用两参数的默认重载,结果HDFS上已经存在同名文件时报了FileAlreadyExistsException,研究之后才发现可以显式传overwrite参数。
下载时同样要注意:copyToLocalFile(false, src, dst)里的false表示不删除HDFS上的源文件。下载的目标路径在部分版本中不能是一个已经存在的文件,如果本地文件已经在了,先删掉或者换个路径再执行。
Windows开发环境还有个不大不小的问题:文件路径里的反斜杠。Java字符串里写"C:/data/test.txt"是最省事的方式,统一用正斜杠,避免转义出错。到了Linux服务器上编译执行,路径风格本身没问题,但需要注意文件分隔符,生产环境我一般先new Path("file:///data/test.txt")这样的URI形式,更严谨。
3.3 文件遍历:listFiles与listStatus怎么选
遍历目录有两个方向:一个是fs.listFiles(path, recursive),它返回RemoteIterator<LocatedFileStatus>,支持递归;另一个是fs.listStatus(path),一次性返回FileStatus[]数组,默认不递归。什么时候用哪个,我用一次实际需求来举例:模拟一个“按日期统计HDFS上文件总大小”的程序。
RemoteIterator<LocatedFileStatus> iterator = fs.listFiles(new Path("/user/mydata"), true); long totalSize = 0; while (iterator.hasNext()) { LocatedFileStatus status = iterator.next(); if (status.isFile()) { totalSize += status.getLen(); System.out.println(status.getPath().toString() + " -> " + status.getLen()); } } System.out.println("total size: " + totalSize);之所以用listFiles而不是listStatus,是因为我需要递归扫描多层目录,而且LocatedFileStatus额外携带了每个文件的数据块位置信息,打印出来能看到当前Block所在的数据节点。这个Block位置在将来做数据本地化、判断数据倾斜、排查热点文件时都很有用。
如果只是列一下某目录下有哪些文件和子目录,不需要递归,那listStatus更合适,代码也更简单:
for (FileStatus st : fs.listStatus(new Path("/user/mydata"))) { System.out.println((st.isDirectory() ? "[dir] " : "[file] ") + st.getPath()); }两个方法底层都有RPC开销,但在合理范围内。要记住一点:listFiles的递归如果层级特别深、文件特别多,遍历时间会比较长,生产环境建议分目录过滤,一次不要全量扫。
4. 绕不开的流式读写:FSDataInputStream与FSDataOutputStream
4.1 用OutputStream往HDFS写入数据
FileSystem给文件读写提供了两个流类:FSDataOutputStream用于写入,FSDataInputStream用于读取。它们的用法跟普通Java IO流几乎一样,所以只要会写本地文件,迁移到HDFS的成本不高。
写文件最常见的方式是fs.create(path),我强烈建议显式传第二个参数,代表是否覆盖已存在文件:
Path outPath = new Path("/user/mydata/out.txt"); try (FSDataOutputStream out = fs.create(outPath, true)) { out.writeBytes("line-01\n"); out.writeBytes("line-02\n"); out.writeBytes("line-03\n"); }看到这里有人可能会有疑问:写了之后数据是立刻落盘吗?并不是。客户端的写操作先进入缓冲区,由DFSClient分批发送到流水线上的DataNode,最终close()时才会完整落地并关闭租约。所以写完必须关流,不关流的情况下另一个进程去读,可能读不全;去追加写,可能拿不到租约直接报错。
如果只是想在文件末尾追加内容,可以改用fs.append(path)。需要注意,HDFS默认开启append特性,但如果没开,会报AppendNotSupportedException,这种情况需要检查dfs.support.append配置。
4.2 用InputStream读数据,以及seek定位的妙用
读取文件我先从最朴素的方式开始:一行一行或者按字节读。不同的是FSDataInputStream继承自DataInputStream,还额外实现了Seekable接口,也就是说它天然支持“跳到某个偏移量再读”。
我做过一个“只读取文件后半部分”的练习:
Path inPath = new Path("/user/mydata/out.txt"); try (FSDataInputStream in = fs.open(inPath)) { long fileLen = fs.getFileStatus(inPath).getLen(); long half = fileLen / 2; in.seek(half); // 直接跳到中间位置 byte[] buffer = new byte[512]; int len; while ((len = in.read(buffer)) != -1) { System.out.print(new String(buffer, 0, len, StandardCharsets.UTF_8)); } }这个能力初看不值钱,但它在分布式计算里很关键。Hadoop的MapReduce拆分输入分片时,每个Split需要知道自己的起始偏移量和长度,Map任务就是靠seek定位到自己那块数据的。我之前看源码时一直疑惑“怎么做到从某个Block开始读”,弄懂seek的作用后,整个数据分片机制通了一半。
此外,FSDataInputStream还提供readFully(long position, byte[] buffer)这种“指定位置读取”的方法,不需要先seek再read。在实现随机读取、抽样检查、校验数据时很好用。
4.3 一个实用组合:合并小文件
文件流读写最典型的组合实践是“合并小文件”。HDFS上如果堆积了大量几KB到几十KB的文件,NameNode内存会被海量元数据吃掉,所以把零碎小文件合并成大块是常见的维护操作。
我用Java API写过一个通用合并逻辑:扫描指定目录下的所有文件,逐个读入,统一写到一个输出文件里。核心代码如下:
Path srcDir = new Path("/user/mydata/small"); Path mergedPath = new Path("/user/mydata/merged.bin"); try (FSDataOutputStream out = fs.create(mergedPath, true)) { RemoteIterator<LocatedFileStatus> iterator = fs.listFiles(srcDir, true); while (iterator.hasNext()) { LocatedFileStatus st = iterator.next(); if (!st.isFile()) { continue; } try (FSDataInputStream in = fs.open(st.getPath())) { IOUtils.copyBytes(in, out, 4096, false); } } }这里有个细节必须解释一下:IOUtils.copyBytes的最后一个参数是“是否自动关闭输出流”,我传了false,因为out需要在循环结束后统一关闭。如果把true传进去,第一轮循环结束就会把out关掉,后面的文件就全写不进去。这个坑是我实际运行时才发现的,第一版代码在合并到第二个文件时就抛了IOException: Stream closed。
IOUtils用的是org.apache.hadoop.io.IOUtils,这个工具类在处理Hadoop流时会自动处理一些客户端细节,比手动循环读字节数组省心。
5. 把MapReduce任务也用Java跑起来:从客户端到集群
5.1 最小可运行WordCount的完整代码
第9篇之前的记录里,我用Shell跑过自带的WordCount示例,但那只是“执行别人写好的程序”。想用好Java API,必须能自己写一个MapReduce出来。我从最经典的WordCount开始,Hadoop官方示例的骨架非常稳定,理解之后可以套用到绝大多数统计逻辑。
我练习时把Mapper、Reducer、Driver三个部分写在了一个类里,方便在IDE里直接调试。正式项目建议拆成独立文件,但学习阶段这样写更紧凑,能看清三者关系。
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.Mapper; import org.apache.hadoop.mapreduce.Reducer; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; import java.io.IOException; import java.util.StringTokenizer; public class WordCountDriver { public static class TokenizerMapper extends Mapper<Object, Text, Text, IntWritable> { private static final IntWritable one = new IntWritable(1); private final Text word = new Text(); @Override protected void map(Object key, Text value, Context context) throws IOException, InterruptedException { StringTokenizer itr = new StringTokenizer(value.toString()); while (itr.hasMoreTokens()) { word.set(itr.nextToken()); context.write(word, one); } } } public static class IntSumReducer extends Reducer<Text, IntWritable, Text, IntWritable> { private final 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); } } public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "word count"); job.setJarByClass(WordCountDriver.class); job.setMapperClass(TokenizerMapper.class); job.setCombinerClass(IntSumReducer.class); job.setReducerClass(IntSumReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); job.setMapOutputKeyClass(Text.class); job.setMapOutputValueClass(IntWritable.class); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }这里有两个初学者容易忽略的点。一个是job.setJarByClass(WordCountDriver.class),少了这一行,在集群上提交时YARN无法定位用户的jar包,会报“找不到主类”或“ClassNotFound”。另一个是setMapOutputKeyClass和setMapOutputValueClass。这个例子里Mapper的输出类型和Reducer的最终输出类型恰好一致,省略掉也能跑,但一旦Map阶段和Reduce阶段类型不同,省略就会导致类型不匹配,所以建议每次都显式写上,养成习惯。
waitForCompletion(true)会阻塞客户端直到作业结束,同时把Map任务和Reduce任务的进度百分比打印到控制台。学习阶段用这个最直观,能清楚看到作业推进到哪一步。
5.2 本地模式与集群模式的切换
很多教程会让人一上来就把任务提交到YARN,但我觉得先搞清楚运行模式更高效。MapReduce支持本地模式和集群模式,切换开关是mapreduce.framework.name。
在IDE里调试阶段,我使用本地模式,配置很简单:
conf.set("mapreduce.framework.name", "local");本地模式并不会真的在“本地”把数据从HDFS拉下来,输入输出路径仍然可以用hdfs://localhost:9000的HDFS地址。区别在于,任务不会提交到YARN,而是在当前JVM里用本地线程池模拟多个Map和Reduce任务执行。这样跑起来极快,非常适合验证Map逻辑和Reduce逻辑是否正确。我发现控制台会打出类似Map task ...的进度,但进程只有一个,不会真的启动多容器。
把这段配置去掉,或者显式设置成yarn,任务就会走正式提交流程。我在本机跑通WordCount后,第二步是用Maven打成jar包,传到服务器上用yarn jar命令提交。这里有一个我踩过的坑:本地模式下waitForCompletion结束后,输出目录可以重复利用;但集群模式下,如果输出目录已经存在,任务会直接报Output directory ... already exists。可以理解为一个保护机制,防止误覆盖,但确实让第一次跑的人困惑。所以每次跑集群任务前,记得先把上一次的输出目录删掉。
把两种模式的区别整理成一张表,方便对照:
| 对比项 | local模式 | yarn模式 |
|---|---|---|
| 任务调度 | 当前JVM内线程模拟 | YARN资源管理器调度容器 |
| 部署环境 | IDE里直接跑 | 需要jar包提交到集群 |
| 执行速度 | 快,适合调试 | 慢,适合正式生产 |
| 配置 | mapreduce.framework.name=local | mapreduce.framework.name=yarn |
| 输出目录判断 | 较宽松 | 存在即报错 |
5.3 从提交到查看结果:完整闭环
包打成wcdemo-1.0.jar之后,我在服务器上执行:
yarn jar wcdemo-1.0.jar WordCountDriver /user/mydata/input /user/mydata/wc-output执行期间,控制台会持续输出Map和Reduce的进度条,结束时能看到一堆Counter统计,包括读取的行数、字节数、Spill次数、Shuffle字节数等。很多初学的人看到计数器只当作一堆数字,但这些其实是最好的排障信息。最典型的是Map input records和自己预期的输入行数不一致时,说明上游数据有问题,或者文件没有按预期切分。
任务跑完后去HDFS上看结果:
hdfs dfs -cat /user/mydata/wc-output/part-r-00000正常会看到类似word 2这样按字母排序的统计结果。如果想确认作业在YARN上的资源使用,打开ResourceManager的Web界面,任务结束也能在应用历史列表里找到对应记录。
6. 学习Java API时踩过的坑与排查思路
6.1 Permission denied并不是HDFS权限配错了
第一次在Windows本机跑Java程序往HDFS写入,就撞上一个典型的报错:
org.apache.hadoop.security.AccessControlException: Permission denied: user=Administrator, access=WRITE, inode="/user/mydata":hdfs:supergroup:drwxr-xr-x我第一反应是HDFS权限配置有问题,跑到Linux上检查了hdfs-site.xml,发现权限完全正常。后来才明白,HDFS的权限验证跟传统用户名密码不同,它默认不校验密码,而是“你声明你是谁,你就是谁”。Java客户端默认会把当前操作系统的用户名作为HDFS用户身份上报,Windows下这个用户名是Administrator,在HDFS看来就是一个陌生的普通用户,没有/user/mydata目录的写权限。
解决思路不是去改HDFS全局权限,而是让客户端以有权限的身份说话。我用了三种方法,按推荐程度排列:
第一种,在IDE或运行环境里设置环境变量HADOOP_USER_NAME=hdfs,Java客户端启动时会读取这个变量作为用户身份。这是最快的方式,适合本地调试。
第二种,用UserGroupInformation.createRemoteUser在代码里切换身份:
UserGroupInformation.createRemoteUser("hdfs").doAs((PrivilegedExceptionAction<Void>) () -> { // 在这里执行原有的文件系统操作 return null; });第三种,最正规的,是开启Kerberos之后做认证,然后以票据身份访问HDFS。这一步涉及keytab和认证配置,生产环境才需要,学习阶段可以先不碰。
这个报错的排查思路其实比解决本身更重要:看到Permission denied,不要先怀疑服务端权限配置,先看报错里的user=是谁,再判断这个用户是否有对应目录权限。
6.2 Wrong FS:连接不上HDFS的“假故障”
另一个高频报错长得很有迷惑性:
java.lang.IllegalArgumentException: Wrong FS: hdfs://localhost:9000/user/mydata, expected: file:///字面意思是要访问的HDFS路径,被强制要求用本地文件系统去解析。我第一次看到直接懵了,觉得“我明明写了hdfs协议,怎么会期望file协议”。排查之后发现根因在获取FileSystem实例的那一步:我当时用的是FileSystem.get(conf),而conf里没有fs.defaultFS这个配置,于是底层按照默认值返回了本地文件系统的客户端。后面传入的路径带hdfs://前缀,自然就跟当前客户端协议对不上。
解决办法有两个方向。一个是在conf里显式设置fs.defaultFS:
conf.set("fs.defaultFS", "hdfs://localhost:9000");另一个是获取时显式传URI,这也是我更推荐的方式:
FileSystem fs = FileSystem.get(URI.create("hdfs://localhost:9000"), conf);除此之外,还要注意保持路径风格一致。拿到HDFS客户端之后,内部路径写不带协议的形式,比如/user/mydata,完全没问题;但如果一会儿写hdfs://localhost:9000/user/mydata,一会儿写/user/mydata,某些内部调用仍然可能触发协议判断错误。我的习惯是:获取Client时用带协议的URI,后面所有业务路径统一用不带协议的Path。这个习惯坚持下来,基本没有再遇到Wrong FS。
6.3 Jar包冲突与依赖版本不一致
学习阶段容易把各种新技术堆在同一个项目里,一旦引入的Hadoop组件版本混杂,就会出现一些非常难查的问题。我遇到过NoSuchMethodError: org.apache.hadoop.fs.FileSystem.get,当时搜遍网络都没解决,最后用mvn dependency:tree一看,发现项目里同时存在2.7和3.3两个版本的hadoop-commonjar包,一个类在高版本里改了签名,另一个低版本类被加载进来,倒是方法直接找不到了。
Hadoop的jar包设计其实有点“重”,里面夹带了大量依赖。如果你引入hadoop-client后又单独引了一个别的Hadoop生态组件,比如某个解析库,它内部可能带一份不同的Hadoop版本,冲突概率会明显增加。遇到这种问题,第一件事就是不要怀疑代码逻辑,先做依赖树分析,看看同一个类在classpath里出现了几次、来自哪个jar。找到之后用<exclusions>把低版本或多余的那个排除掉,保留下与服务端一致的那个。
日志轰炸也算一个变体的“环境坑”。不加log4j配置时,即使一个简单的文件上传都会刷出几百行INFO,让人误以为程序卡死。我建议所有学习项目从一开始就放一份log4j.properties,直接把根级别设成WARN,等真正需要排查问题时再临时打开INFO日志。
6.4 流没有关闭导致租约无法释放
前面写流式读写时提到过“必须关流”,这里补充一个我印象很深的真实过程。
有一次我写了一个程序循环写入文件,第二遍运行时莫名报错,提示目标文件“does not exist”,可我明明刚创建过。排查了很久才发现,是上一次运行里有一个FSDataOutputStream没有关闭。HDFS为了保证并发写安全,会为正在写的文件建立租约,没关流之前,租约不会释放。第二次运行想重新创建同名文件,NameNode认为这个文件还在被另一个客户端控制,于是拒绝操作。程序里没有打印异常堆栈,只显示“文件不存在”,极具迷惑性。
后来我给自己定了两条规矩。第一,所有流都用try-with-resources方式写在括号里,让JVM保证关闭:
try (FSDataOutputStream out = fs.create(path, true)) { // 写入逻辑 }第二,如果某些场景下确实没法用try-with-resources,就在finally块里调用IOUtils.closeStream(stream)。另外还要注意,一个连接对应着不少底层资源,用完的FileSystem客户端如果长期不再使用,也应调用fs.close()释放,不过绝不能在每个方法里反复创建和关闭,那会导致大量RPC连接建立和销毁,性能很差。
最后再分享一个小技巧:调试Java API时,我喜欢在代码里临时打印当前客户端的类名:
System.out.println(fs.getClass().getName()); System.out.println(fs.getScheme());看到DistributedFileSystem和hdfs,说明连的是HDFS;看到LocalFileSystem和file,说明连的是本地文件系统。这个几秒钟的检查能避开大量路径协议、权限、连接类的低级问题,也是我后续写更复杂的Hadoop客户端代码时一直保留的排查习惯。