网盘类项目在课程设计和毕业设计里一直很热门,但很多同学拿出来的方案基本都长一个样:Spring Boot + MyBatis 做 CRUD,文件直接丢本地磁盘或者塞进 MySQL 的 BLOB 字段,再套个 LayUI / Vue 前端就完事了。这套东西交作业没问题,但离“云存储”这三个字其实差得很远。我这次做的这个基于 Spring Cloud + Hadoop + SSM 的云存储网盘文件管理系统,核心思路就是把存储层彻底换成 HDFS,业务层用 SSM 拆分微服务,让文件数据真正落到分布式文件系统里,同时把秒传、分片上传、断点续传、分享、回收站这些网盘该有的能力补齐。这篇文章我会把整套系统的设计思路、核心实现、环境搭建和踩坑经验完整捋一遍,适合正在做网盘 / 云存储类项目的同学,也适合想搞懂 Spring Cloud 微服务怎么和 Hadoop 生态配合的开发者。
用一句话概括这个项目:MySQL 管业务元数据,HDFS 管文件内容,Spring Cloud 管服务治理,SSM 管具体业务逻辑。这个分工是本项目最关键的设计决策,后面所有代码和配置都是围绕它展开的。
1. 为什么选这套组合:Spring Cloud + Hadoop + SSM 的职责拆解
先说结论:这套技术栈不是为了堆名词,而是把“网盘文件管理系统”这件事拆成了三个层次的问题,每一层刚好对应一个技术体系。
1.1 存储层为什么必须选 HDFS
网盘的核心痛点其实是文件存储,而不是文件列表。一个文件管理系统如果上线跑了几个月,产生了几十万个文件,你很快会遇到三个很现实的问题:
- 文件存本地磁盘,一台服务器的磁盘迟早被打满,扩容只能换更大硬盘,成本和停机时间都受不了。
- 文件存 MySQL 的 BLOB 字段,数据库文件体积暴涨,备份慢、查询慢,而且大文件的读写会让 InnoDB 的缓冲池彻底失效,整个系统的 SQL 性能都会被拖垮。
- 文件如果存在应用服务器本地,服务一重启、重新部署,历史文件全丢,完全不具备可靠性。
HDFS 正好解决这几个问题。它的设计目标天生就是“存储海量大文件”,默认块大小 128MB,数据以多副本方式冗余存储(默认 3 副本),NameNode 管元数据、DataNode 管数据块,数据节点挂掉之后系统会自动把缺失的副本补回来。对本项目来说,不需要像 FastDFS 那样再单独维护一套 tracker 和 storage 集群,HDFS 一台伪分布式机器就能模拟出“分布式存储”的效果,还保留了将来横向扩展的可能性。
更关键的是,HDFS 把数据分成块,这个块的概念正好可以支撑网盘的“分片上传、秒传、断点续传”。内容相同的大文件,只要计算出相同的摘要,HDFS 底层就能复用同一份数据块,业务层只需要在数据库里新建一条引用关系,这就是秒传的本质。
1.2 SSM 和 Spring Cloud 各自扮演什么角色
很多人看到 SSM 和 Spring Cloud 同时出现会有点懵,觉得这俩是同一层次的东西。其实它们解决的是两个完全不同维度的职责:
- SSM(Spring + SpringMVC + MyBatis)解决的是单服务内部的开发效率。Spring 管 Bean 生命周期和事务,SpringMVC 处理 HTTP 请求路由和参数绑定,MyBatis 把 SQL 和 Java 方法映射起来。在文件管理这种业务里,用户表、文件表、分享表、回收站表的 CRUD 操作,用 MyBatis 写 XML 比 JPA 更直观可控。
- Spring Cloud 解决的是多个服务之间的协作问题。当我把系统按业务边界拆成用户服务、文件服务、分享服务之后,服务之间怎么互相找到地址?请求怎么统一入口?配置怎么集中管理?服务挂了怎么熔断降级?这些都属于 Spring Cloud 的范畴。
在实际实现里,我用的是 Spring Cloud Alibaba 体系下的 Nacos 做注册中心和配置中心,Spring Cloud Gateway 做统一网关,OpenFeign 做服务间调用。也就是说,SSM 负责把每个微服务内部写扎实,Spring Cloud 负责把服务之间的“路”修通,两者并不冲突。
1.3 这套架构的定位和适用范围
这套组合特别适合两类场景:
一类是课程设计 / 毕业设计里要求“体现大数据技术元素”的项目,比如标题里就点名 Hadoop,那么存储层选 HDFS 是最稳妥的,评审老师看到你的文件是真的落到 HDFS 上了,而不是装个 Hadoop 摆样子,分数档次完全不同。
另一类是个人练手项目,想搞明白微服务 + 分布式存储这套东西到底怎么串起来的。通过这个项目,你相当于把 Spring Cloud 微服务治理、HDFS 分布式文件存储、SSM 业务开发、Vue 前后端分离这些技能点全部串了一遍,比零散地看教程系统得多。
需要说明的是,如果项目只是为了快速跑通,单机 Spring Boot 就够了;如果追求“云存储味道”,这套组合是性价比较高的选择,既有技术深度,实现复杂度又在可接受范围内。
2. 系统整体架构设计与功能规划
架构设计这件事,最忌讳的就是一开始就闷头写代码。我在动手之前先把整个系统的服务边界、数据表、调用链路画了一遍,确定不会出现“改一个功能要动三个服务”的情况,才开始搭建工程。
2.1 微服务模块怎么划分
网盘类系统的业务边界其实很清晰,我按照“不过度拆分”的原则,把系统拆成了四个微服务和一个网关入口:
- 用户服务(user-service):负责用户注册、登录、鉴权、个人信息管理。核心是 JWT 令牌的签发与校验,其他服务在网关层做统一鉴权后,通过传递用户 ID 来识别身份。
- 文件服务(file-service):这是最核心的服务,负责文件上传、下载、文件列表、秒传、分片合并、回收站管理。它直接对接 HDFS 客户端,同时操作 MySQL 里的文件元数据表。
- 分享服务(share-service):负责文件分享链接的生成、取消分享、访问分享外链、转存文件。分享码用短字符串生成,方便前端拼链接。
- 存储服务(storage-service):做了一个很薄的 HDFS 封装层,对外提供文件写入、读取、删除、判断存在的接口。之所以单独拎出来,是因为文件服务和分享服务都有可能操作 HDFS,避免重复代码,也方便将来把 HDFS 换掉或加一层缓存。
网关用 Spring Cloud Gateway 统一接收前端请求,路由规则很简单:/api/user/**打到用户服务,/api/file/**打到文件服务,/api/share/**打到分享服务。
2.2 核心数据表设计思路
数据表是整个系统的地基,我按业务一个个拆,核心的表就这么几张:
| 表名 | 字段要点 | 设计说明 |
|---|---|---|
| user | id, username, password(BCrypt), nickname, created_time | 用户表,密码必须加密,不能明文存 |
| file_metadata | id, file_md5, file_name, file_size, file_type, hdfs_path, upload_time | 文件实体表,HDFS 只存一份物理文件,多个用户引用同一条记录 |
| user_file | id, user_id, file_id, parent_id, file_name, is_dir, is_deleted, delete_time | 用户文件表,描述“哪个用户拥有哪个文件/文件夹”,支持目录树结构 |
| share_info | id, share_code, user_id, file_id, expire_time, visit_count | 分享表,share_code 是唯一分享码 |
| chunk_upload | id, upload_id, file_md5, chunk_index, chunk_size, is_uploaded, create_time | 分片上传记录表,用来实现断点续传 |
这里面最关键的细节是file_metadata和user_file分离。一个文件本身只存一份在 HDFS 里,file_metadata记录它的物理位置和 MD5 值;user_file记录的是用户视角下的文件目录结构。这样两个人同时上传同一个文件,MD5 相同,HDFS 里只有一份数据,两个用户各自关联一条user_file记录,也就是说每个用户拥有的是同一个物理文件的“虚拟引用”,存储成本直接减半。这个设计同时支撑了秒传。
2.3 一次完整的上传请求走完哪些服务
为了把服务间协作讲清楚,我拿一次文件上传请求为例,梳理一下完整链路:
- 前端调用网关
/api/file/upload,网关根据路由规则转发到文件服务。 - 文件服务收到请求,先从请求头解析 JWT,得到用户 ID(这个动作也可以在网关统一完成,我这里选了网关做全局鉴权,文件服务只信任网关传过来的用户头字段)。
- 文件服务计算文件 MD5,先查
file_metadata表看是否已存在相同文件。如果存在,直接走秒传逻辑:创建一条user_file记录,返回“上传成功,秒传”。 - 如果不存在,文件服务调用存储服务提供的
writeFile接口,底层用 HDFS 客户端把输入流写入 HDFS 对应路径。 - 写入成功后,文件服务往
file_metadata插入文件实体记录,再往user_file插入用户关联记录,事务提交,上传完成。
下载请求的逻辑类似:文件服务拿到用户文件 ID,查到file_metadata里的hdfs_path,再调用存储服务 Open HDFS 文件流,包装成 HTTP 响应输出给前端。
这套链路设计的妙处在于:业务服务不关心文件字节是怎么分布的,只关心“存到哪、取回来”,HDFS 的细节全部封装在存储服务里。将来如果想把底层的 HDFS 换成 OSS 或 Ceph,只需要改存储服务内部实现,文件服务一行代码都不用动。
3. 核心功能模块的实现细节
功能模块是项目的主菜,我重点讲几个“网盘系统没有这些功能就不好意思叫网盘”的核心点,包括 HDFS 读写、秒传、分片断点续传、分享和回收站。
3.1 HDFS 上传下载的完整代码流程
HDFS 的 Java API 其实不难,核心就是org.apache.hadoop.fs.FileSystem这个类。第一次接触的同学容易把 HDFS 操作写得很复杂,实际上掌握三个方法就够了:
fs.create(Path)获取输出流,把本地或内存里的数据写进 HDFS。fs.open(Path)获取输入流,从 HDFS 读出数据。fs.delete(Path)删除文件。
我封装在存储服务里的核心上传方法逻辑是这样的:
public String writeFile(InputStream inputStream, String fileName) throws Exception { // 1. 构建 HDFS 配置,指定 NameNode 地址 Configuration conf = new Configuration(); conf.set("fs.defaultFS", "hdfs://localhost:9000"); // 2. 获取 FileSystem 实例,这里用的是 HDFS 分布式文件系统 FileSystem fs = FileSystem.get(conf); // 3. 构造存储路径,按日期分目录,避免单目录文件过多 String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); Path hdfsPath = new Path("/file-storage/" + datePath + "/" + UUID.randomUUID() + "_" + fileName); try (FSDataOutputStream out = fs.create(hdfsPath, true)) { // 4. 用 IOUtils 工具类把输入流拷贝到 HDFS 输出流 IOUtils.copyBytes(inputStream, out, 4096, true); } // 5. 返回 HDFS 上的完整路径,业务层存到 file_metadata 表 return hdfsPath.toString(); }注意这里有个细节,我故意用了UUID.randomUUID()拼在文件名前面。原因是 HDFS 上是允许同名文件的(不同目录下),但同名容易造成混淆,而且将来做下载时,HDFS 路径上的文件名和用户展示的文件名最好是解耦的。用户看到的文件名存 MySQL,HDFS 里的文件名只保证唯一就行。
读取文件很简单,核心就两行:
FileSystem fs = FileSystem.get(conf); FSDataInputStream in = fs.open(new Path(hdfsPath)); // 从这个输入流读数据,写给 HttpServletResponse 的输出流 IOUtils.copyBytes(in, response.getOutputStream(), 4096, true);如果只是做课程设计,写到这个程度就已经是“真正操作了 HDFS”的项目了,不是摆设。但如果你想让文件服务再稳一点,可以多考虑几个点:流用完必须关闭、文件路径一定要做防目录穿越校验、上传的文件类型和大小要提前拦截。
3.2 秒传和断点续传的实现方案
秒传和断点续传是两个容易混淆的概念,我先说清楚区别:
- 秒传指的是:要上传的文件在服务器上已经存在,那么客户端不需要真的传数据,直接告诉服务器“我要引用那个已有的文件”,瞬间完成。
- 断点续传指的是:一个文件很大,网络中断后下次只需要传剩余的部分,不需要重新整传。
秒传的实现依赖 MD5。前端在文件选择后先计算整个文件的 MD5(可以用 SparkMD5 这个库),然后调用/api/file/check-md5接口,把 MD5 发给后端。后端先查file_metadata表:
FileMetadata metadata = fileMetadataMapper.selectByMd5(md5); if (metadata != null) { // 秒传:不写 HDFS,直接建立用户文件关联 UserFile userFile = new UserFile(); userFile.setUserId(userId); userFile.setFileId(metadata.getId()); userFile.setFileName(originalName); userFile.setParentId(parentId); userFileMapper.insert(userFile); return "上传成功(秒传)"; } else { // 正常走上传流程 }这里就体现了第 2 章设计的file_metadata和user_file分离的好处,没有这个分离,秒传根本做不了。
断点续传我用了“分片上传 + 合并”的方案。前端把文件切成固定大小(比如每片 5MB),逐片上传。每一片上传时后端记录分片序号和上传状态到chunk_upload表。当后端发现一个upload_id的所有分片都传完了,就触发合并操作:按分片序号把所有临时分片文件依次追加到一个最终文件里,然后写入 HDFS。
合并代码核心思路如下:
// 按 upload_id 查出所有已上传分片,按 chunk_index 排序 List<ChunkUpload> chunks = chunkUploadMapper.selectByUploadId(uploadId); chunks.sort(Comparator.comparingInt(ChunkUpload::getChunkIndex)); // 创建最终输出流 FSDataOutputStream out = fs.create(finalPath); for (ChunkUpload chunk : chunks) { Path chunkPath = new Path(chunk.getChunkTempPath()); FSDataInputStream in = fs.open(chunkPath); IOUtils.copyBytes(in, out, 4096, false); in.close(); fs.delete(chunkPath, false); // 合并后删除临时分片 } out.close();这个方案的坑在于:如果每片都走一次 HDFS 写入和删除,会产生大量临时小文件,而 HDFS 最怕的就是大量小文件。我的应对方式是限制分片数量(最大 200 片),并且合并后立刻清理临时文件,甚至在hdfs-site.xml里开了dfs.blocksize较大值来缓解区块压力。当然更专业的做法是用 HDFS 的 append 功能直接往同一个文件追加,但 append 对写并发有限制,在这个项目里不划算。
3.3 文件分享、回收站等业务功能
网盘不能只有上传下载,分享和回收站是用户感知很强的两个功能。
分享的核心是生成一段短码。我的实现是:用户选一个文件,前端调用/api/share/create,后端插入一条share_info记录,生成一个 8 位短码。短码生成方式用 UUID 的前 8 位加 Base62 编码,虽然极端情况下有碰撞可能,但对课程设计项目来说完全够用。访问分享链接时,网关把/s/{code}路由到分享服务,分享服务根据 code 查到对应的文件信息,校验是否过期,然后跳转到下载接口。
回收站的实现我建议用逻辑删除 + 定时清理,而不是物理删除。user_file表里有is_deleted和delete_time字段,用户点删除时只把is_deleted置 1;回收站列表查is_deleted = 1的记录;永久删除时才真正调 HDFS 删除文件。定时清理我用 Spring 的@Scheduled注解做,每天凌晨三点扫描超过 7 天的回收站记录:
@Scheduled(cron = "0 0 3 * * ?") public void cleanRecycleBin() { List<UserFile> expiredFiles = userFileMapper.selectDeletedBefore(new Date(System.currentTimeMillis() - 7L * 24 * 3600 * 1000)); for (UserFile userFile : expiredFiles) { // 先查引用计数,只有没有任何用户引用时才真正删 HDFS 文件 FileMetadata metadata = fileMetadataMapper.selectById(userFile.getFileId()); int refCount = userFileMapper.countByFileId(metadata.getId()); if (refCount <= 0) { storageService.deleteFile(metadata.getHdfsPath()); fileMetadataMapper.deleteById(metadata.getId()); } userFileMapper.deleteById(userFile.getId()); } }这里有个很容易忽略的细节:删除 HDFS 文件前必须检查还有没有其他用户引用它。因为多个用户可能引用同一个物理文件,某个用户从回收站彻底删除文件,不代表这个物理文件可以删。这个“引用计数”的思路和 Linux 文件系统的硬链接很像,不做这个检查就会出现“A 用户删文件导致 B 用户文件损坏”的事故。
前端展示我用的是 Vue + Element UI,目录树用parent_id递归组装,面包屑导航记录从根目录到当前目录的路径。文件图标按扩展名映射,图片和文本文件可以走预览接口(后端返回流,前端开新窗口展示),这些都是锦上添花的功能,但你做了会整体显得完整很多。
4. Hadoop 环境搭建与 Spring Cloud 整合实操
这个部分我特别想重点写,因为标题里的“Hadoop 伪分布式搭建”“从零开始安装 Hadoop”“Hadoop 集群搭建”这些关键词,恰恰就是绝大部分人卡死的地方。很多人的项目代码写完了,结果环境搭不起来,或者 Hadoop 起不来,项目直接废掉。
4.1 伪分布式 Hadoop 环境从零搭建
我在本地用 VMware 装了一台 Ubuntu 22.04 虚拟机,也可以直接装在 WSL 里,只要内存给够 4GB 以上就行。整个搭建过程分为五步,每一步都有明显坑,我挨个说清楚。
第一步:装 JDK 8,配置环境变量。Hadoop 3.x 要求 Java 8 或 11,我选 JDK 8,因为后面 SSM 项目用 JDK 8 编译最稳。装完一定要在/etc/profile里写JAVA_HOME、PATH、CLASSPATH,然后source生效,java -version验证。
第二步:配置 SSH 免密登录。Hadoop 的守护进程(NameNode、DataNode)之间需要 SSH 无密码通信。执行:
ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys ssh localhost如果ssh localhost还是要求输密码,99% 是.ssh目录权限不对,把目录改成 700。这个步骤看起来不起眼,但不配好,后面start-dfs.sh会卡在输入密码上,启动失败。
第三步:解压 Hadoop 包,推荐去官网下载稳定版 hadoop-3.3.6,解压到/opt/hadoop,然后编辑四个配置文件。core-site.xml最关键,指定 NameNode 地址:
<configuration> <property> <name>fs.defaultFS</name> <value>hdfs://localhost:9000</value> </property> </configuration>hdfs-site.xml指定副本数和目录:
<configuration> <property> <name>dfs.replication</name> <value>1</value> </property> <property> <name>dfs.namenode.name.dir</name> <value>/opt/hadoop/data/namenode</value> </property> <property> <name>dfs.datanode.data.dir</name> <value>/opt/hadoop/data/datanode</value> </property> </configuration>注意dfs.replication在伪分布式下必须设成 1,否则只有一个 DataNode 却要写 3 副本,会一直报块副本不足的告警。这个坑非常经典。
第四步:格式化 NameNode。第一次搭建必须执行hdfs namenode -format,成功标志是日志里出现successfully formatted。这个命令只能执行一次,后面如果再乱跑格式化,会导致集群 ID 不一致,DataNode 永远连不上 NameNode。
第五步:启动并在 Web UI 验证。执行start-dfs.sh,然后jps看到NameNode、DataNode、SecondaryNameNode三个进程同时存在,才算启动成功。浏览器访问http://localhost:9870(Hadoop 3.x 的 NameNode Web 端口是 9870,不是老教程里的 50070),可以看到 HDFS 浏览界面,这里能直观看到/file-storage目录下上传的文件。
整个过程中最容易卡住的三座大山:权限炸弹、格式化问题、端口占用。权限问题要用chown -R hadoop:hadoop /opt/hadoop把安装目录所有权给当前用户;格式化问题只要记住“只在第一次启动前格式化”;端口问题排查用netstat -ant | grep 9000看端口是不是被别的 Java 进程占了。
4.2 Spring Cloud 注册中心与网关配置
环境搭好之后,开始搭 Spring Cloud 微服务骨架。我用的是 Spring Boot 2.7 + Spring Cloud 2021.0.5 + Spring Cloud Alibaba 2021.0.5.0,这套版本组合比较成熟,网上资料也多。注册中心选 Nacos 而不是 Eureka,原因很简单:Nacos 同时能做注册中心和配置中心,一套服务解决两个问题,而且中文文档丰富,碰到问题容易排查。
每个服务引入公共的注册中心依赖后,在application.yml里配置:
spring: application: name: file-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 username: nacos password: nacos网关的配置稍微多一点。Spring Cloud Gateway 是一个基于 WebFlux 的网关,不能用传统 SpringMVC 那套阻塞式的写法,它的核心是路由规则。我配置了三条路由:
spring: cloud: gateway: routes: - id: user-route uri: lb://user-service predicates: - Path=/api/user/** - id: file-route uri: lb://file-service predicates: - Path=/api/file/** - id: share-route uri: lb://share-service predicates: - Path=/api/share/**, /s/**这里的lb://是关键语法,它告诉网关走负载均衡,从 Nacos 上找到对应服务实例。考虑到跨域问题,我在网关里统一配置了 CORS,后端服务不用再单独配跨域,避免前后端联调的时候被浏览器同源策略折磨。
网关还承担了全局鉴权的职责。我用一个 GlobalFilter 统一拦截所有请求,放行登录和注册接口,其它接口从请求头里取Authorization,用 JWT 工具类校验签名并解析出用户 ID,把用户 ID 放进请求头传给下游服务。这个方案避免了每个微服务都写一遍 JWT 校验代码,真正的集中治理。
4.3 SSM 整合 HDFS Java API 的踩坑点
SSM 部分其实大家都熟,Spring + SpringMVC + MyBatis 三件套在 Spring Boot 里被简化了,主要就是 Mapper 接口扫描、事务管理、统一返回值和统一异常处理。我这里重点说和 HDFS 整合时踩过的两个坑。
第一个坑是 Hadoop 依赖冲突。Hadoop 客户端会传递引入guava、jackson、protobuf这些老版本依赖,而 Spring Boot 自带新版本,两边的类冲突起来,最常见的报错是NoSuchMethodError或ClassNotFoundError。解决办法是在 pom 里排除 Hadoop 传递的依赖,只保留需要的核心 jar:
<dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>3.3.6</version> <exclusions> <exclusion> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> </exclusion> <exclusion> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </exclusion> </exclusions> </dependency>第二个坑是 Windows 下运行 Hadoop 客户端会提示缺少winutils.exe。解决办法很粗暴但有效:去 GitHub 找一个对应 Hadoop 版本的winutils压缩包,解压到某个目录,然后在代码里设置:
System.setProperty("hadoop.home.dir", "D:/hadoop-winutils");同时把bin目录里的winutils.exe和hadoop.dll复制到 JDK 的bin目录下,这招能解决大多数本地调试环境下的 Hadoop 报错。如果还不行,就老老实实把服务打成 jar 包丢到 Linux 虚拟机上跑,生产环境不会出这种问题。
第三个坑我放在这里一起说:HDFS 默认开启了权限检查,如果你用普通用户连 HDFS,写入/file-storage目录会报Permission denied。课程设计环境里最简单粗暴的处理是关闭权限检查,在hdfs-site.xml里加:
<property> <name>dfs.permissions.enabled</name> <value>false</value> </property>改完配置要重启 HDFS 才生效。这个操作在生产上当然不行,但在本地学习环境和课程演示里属于常规操作,不知道怎么关的同学经常被这个权限折磨一整天。
5. 常见问题与排查技巧实录
这个项目我前前后后调试了很久,踩了很多坑,有些坑光看报错信息根本猜不到原因。我把最有价值的排查经历整理成了三类,当作速查表分享出来。
5.1 环境类问题排查
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| jps 里有 NameNode,但 Web UI 打不开 | 防火墙拦了 9870 端口 | 宿主机用http://虚拟机IP:9870访问,关掉 ufw 或放行端口 |
| NameNode 起来了,DataNode 起不来,日志报 ClusterID 不一致 | 格式化过两次 NameNode | 删除 data 目录,重新格式化,之后绝不再格式化 |
提交 jar 包运行报ConnectException: Connection refused | fs.defaultFS 配错,或 Hadoop 没启动 | 先jps确认进程,再 ping 虚拟机 IP,最后检查 9000 端口 |
Windows 本地调 HDFS 报/tmp/hadoop-xxx/mapred不存在 | winutils 缺失 | 设置hadoop.home.dir属性并补齐 winutils.exe |
环境问题有个共同点:先看进程,再看日志,最后才改代码。很多同学一报错就先怀疑自己的 Java 代码,其实大部分环境问题通过jps和tail -100 /opt/hadoop/logs/*.log就能定位。
5.2 数据一致性与并发问题
上传大文件时,如果多个用户同时上传同一个文件,可能出现两个人同时发现“文件不存在”,然后同时写 HDFS,产生两份物理文件。解决方法是给file_metadata表的 MD5 字段加唯一索引,插入时捕获DuplicateKeyException,捕获到就说明别人先写成功了,当前请求直接改走引用逻辑。这算是一个非常经典的并发控制写法。
另一个数据一致性问题是“HDFS 文件写成功了,但 MySQL 事务回滚了”。比如用户上传完文件,创建user_file记录时因为参数错误抛了异常,MySQL 回滚了,但 HDFS 里已经多了一个没人引用的文件,成为孤儿文件。我的处理方式是:在file_metadata表里加一个status字段,上传时先写“上传中”,业务事务提交改为“可用”,然后写一个定期清理任务删掉超时未完成状态的文件记录和对应 HDFS 文件。这个思路其实就是把两个异构系统之间的操作变成最终一致,虽然简单,但很实用。
5.3 性能优化与扩展建议
项目跑通之后,性能上明显能感觉到的问题是:大文件上传慢、HDFS 小文件多、数据库连接池在高并发下不够用。我做了几处优化:
- 前端加了一个上传进度条,用 XMLHttpRequest 的
onprogress事件实时显示进度,后端配合分片接口实现断点续传,大文件上传体验提升非常明显。 - HDFS 客户端做成单例。
FileSystem.get(conf)内部是带缓存的,频繁创建会造成文件描述符泄漏,我改成在 Spring 容器里维护一个FileSystem单例 Bean。 - SQL 层面给
user_file表的(user_id, parent_id, is_deleted)建组合索引,目录树查询从几百毫秒降到几十毫秒。 - 数据库连接池从 Tomcat JDBC 换成 HikariCP,Spring Boot 默认自带,只要配置好最大连接数就行。注意 MySQL 的
max_connections也要够大,两边不匹配会导致连接池疯狂重试。
再往后扩展的话,有几个方向可以做:文件预览服务(图片缩略图、视频转码,可以引入 FFmpeg)、回收站定时清理、分享链接接入 Redis 缓存访问计数、网关层加 RateLimiter 限流、HDFS 文件加压缩(Snappy)。这些属于进阶功能,做不做取决于你的时间预算,但思路和方案都是现成的,按需实现即可。
最后再分享一个实操中体会比较深的小技巧:在开发阶段,不要把 HDFS 的地址写死在代码里。我用application.yml里的一个自定义配置项管理fs.defaultFS,本地连 Windows 的假 Hadoop 环境时改成hdfs://localhost:9000,部署到虚拟机时改成hdfs://192.168.x.x:9000,切换环境只改配置不碰代码。类似的,Nacos 地址、MySQL 地址全部外置到配置中心管理,整个系统迁移环境会从容很多。从我反复折腾的经历看,这套项目的难点从来不在某个 API 怎么调,而在于把 Hadoop、微服务、业务代码三个层次正确衔接起来,只要架构分层清晰,每一层的问题都能独立定位和解决。