1. 项目立项前必须想清楚的事:租赁系统为什么要搭上Hadoop
1.1 物品租赁的业务流程和核心痛点
物品租赁系统的业务模型其实很直观。用户注册登录后浏览可租物品,查看物品详情、租金、押金和库存状态,然后下单租赁,租期结束后归还并结算费用,中间还涉及物品损坏赔偿、续租、超期处理等分支流程。管理员则需要维护物品信息、审核订单、处理归还和统计运营数据。
这类系统的核心痛点其实集中在三个方面。第一是海量非结构化数据的存储,比如物品图片、用户上传的凭证照片、租赁合同的电子扫描件,这些文件如果直接塞进MySQL或者存在服务器本地磁盘,扩展性会很差,单机磁盘满了就得人工迁移;第二是运营数据的分析,当租赁订单积累到几十万条以后,按品牌、按品类、按时段去统计热门租赁物品,用普通SQL也能跑,但会越来越慢,而且这类统计分析本来就不是在线事务该干的事;第三是数据可靠性,文件如果只存一份在本地,磁盘损坏意味着直接丢失,而租赁合同这种数据丢了是很麻烦的。
这个选题把Spring Boot和Hadoop放在一起,本质上就是要把"业务开发"和"大数据存储与计算"两条线打通。Spring Boot负责对外提供REST API、处理用户请求、管理业务事务,Hadoop负责承接文件存储和离线统计。
1.2 Hadoop在项目中的真实定位,不是凑技术栈
很多同学做这类系统,最容易犯的毛病是把Hadoop硬塞进来,结果整个系统里Hadoop只干了一件事——存了一个文本文件,然后大吹特吹"大数据分布式存储"。说实话,答辩老师一眼就能看穿。
在这个项目里,Hadoop的定位应该落在两个具体场景上。第一个是HDFS作为物品图片和合同文件的分布式存储介质,利用其多副本机制保证文件不丢失,同时解决单机磁盘容量的天花板问题。第二个是用MapReduce做离线统计分析,比如每月跑一次"热门租赁物品Top10""各品类租赁时长均值""用户租赁频次分布"这类报表,结果写入MySQL供管理端查询展示。
这样设计的好处是,Hadoop不再是摆设,它在系统里真的有活干,而且干的活和它的特性完全匹配。分布式存储解决文件可靠性和容量问题,离线计算解决统计分析性能问题,这两个都是Hadoop的强项。
1.3 功能需求范围界定
作为一个课程设计或毕业设计级别的项目,功能范围必须有所取舍,不能什么都做。我当时界定的核心功能如下:
- 用户端:注册登录、物品浏览与搜索、物品详情、下单租赁、在线归还申请、我的租赁记录、押金管理
- 管理端:物品管理(含图片上传)、用户管理、订单审核与处理、归还确认与费用结算、运营统计报表
- 统计模块:基于MapReduce的离线分析任务,输出热门物品、租赁时长、用户活跃度等指标
这个范围覆盖了一个完整业务闭环,同时给Hadoop留出了明确的应用接口,工作量上也符合一个人独立完成的情况。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统架构与数据流向:Spring Boot和Hadoop各自管什么
2.1 技术选型的完整思路
技术栈选型这件事,最怕的就是"因为大家都在用所以我也用"。我当时的思考逻辑是这样的:
Spring Boot不用多说,Java生态下做Web系统最主流的选择,起步依赖省心、自动配置方便、和前端Vue对接也顺畅。MyBatis-Plus负责数据库操作,比原生MyBatis少写很多样板代码,分页、条件构造器都是现成的。MySQL存业务表数据,这是在线事务处理的主力。Hadoop则承担两块,HDFS存文件,MapReduce跑离线统计。
选Hadoop而不是别的,核心原因有两个:一是这是课程设计/毕设的明确要求,技术点必须覆盖;二是从数据的自然流向来看,租赁系统确实存在文件存储和离线统计的需求,HDFS加MapReduce的组合在这个体量下完全够用。如果项目规模再大一些,可以考虑引入Hive写SQL做分析,但那会额外增加元数据管理和HiveServer2的复杂度,在毕设阶段容易失控。
2.2 系统分层与模块划分
系统的整体结构分成四层:
- 表现层:Vue或Thymeleaf页面,通过HTTP调用后端REST API
- 控制层:Spring Boot Controller,负责参数校验、鉴权、请求转发
- 业务层:Service接口与实现类,承载核心业务逻辑,包括租赁状态流转、费用计算、文件上传下载
- 数据层:MyBatis-Plus操作MySQL,Hadoop FileSystem API操作HDFS
Hadoop相关的代码独立放在一个模块里,比如hadoop-service,内部封装了文件上传下载、目录创建、文件删除、MapReduce任务提交等方法。这样做的目的是隔离变化,万一后续换存储方案,业务层代码不用动。
2.3 一次租赁操作背后数据如何流转
我以"用户下单租赁一个物品"为例,把数据流转完整串一遍:
用户在页面上看到一把电钻,点击"立即租赁",系统执行以下操作:
订单表插入一条租赁订单记录,状态为"待审核",同时锁定物品库存数量,防止超租。如果租赁协议要求上传身份证照片或签收凭证,用户上传的文件会被写入HDFS的/image/items目录或/contracts目录下,MySQL的附件表中记录文件路径和大小。管理员在后台看到待审核订单,确认物品可用后点击通过,订单状态变为"租赁中",租期开始计算。到期后用户申请归还,管理员验收物品、计算租金和可能的押金扣款,订单状态变为"已完成"。
整个流程中,MySQL管的是结构化业务数据,HDFS管的是文件本身。两者靠"路径字符串"和"附件表"完成关联,逻辑非常清晰。
3. 环境搭建与集成实战:从Hadoop伪分布式到Spring Boot客户端
3.1 Hadoop伪分布式搭建的关键配置
本地开发最省事的方式是搭Hadoop伪分布式模式,也就是单机运行NameNode、DataNode、ResourceManager、NodeManager这些进程,模拟分布式环境。这个过程我踩了不少坑,把关键点整理出来。
core-site.xml里要配置默认文件系统和HDFS主节点的地址:
xml复制<configuration>
<property>
<name>fs.defaultFS</name>
<value>hdfs://localhost:9000</value>
</property>
</configuration>
hdfs-site.xml里要指定副本数和NameNode的元数据目录:
xml复制<configuration>
<property>
<name>dfs.replication</name>
<value>1</value>
</property>
<property>
<name>dfs.namenode.name.dir</name>
<value>/usr/local/hadoop/data/namenode</value>
</property>
<property>
<name>dfs.datanode.data.dir</name>
<value>/usr/local/hadoop/data/datanode</value>
</property>
</configuration>
这里有个非常容易踩的坑:首次启动前必须执行hdfs namenode -format,但切记不要反复格式化,格式化会清空NameNode的元数据,如果DataNode的数据目录还在,两者对不上就会报Incompatible namespaceID / clusterID错误。我第一次不懂,反复格式化之后DataNode一直起不来,排查了半天才发现是集群ID不一致。
3.2 Spring Boot集成Hadoop的依赖与配置
Spring Boot项目集成Hadoop客户端,核心是引入hadoop-client依赖,注意版本要和集群端对齐:
xml复制<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-client</artifactId>
<version>3.3.6</version>
</dependency>
配置文件application.yml中设置HDFS连接参数:
yaml复制hadoop:
name-node: hdfs://localhost:9000
user: root
fs:
default-fs: hdfs://localhost:9000
这里有个关键细节:Hadoop客户端默认会用当前系统的用户名去访问HDFS,本地开发时Windows的用户名和Linux集群上的用户对不上,就会出现Permission denied。解决办法是在代码中设置用户:
java复制System.setProperty("HADOOP_USER_NAME", "root");
Configuration conf = new Configuration();
conf.set("fs.defaultFS", "hdfs://localhost:9000");
FileSystem fs = FileSystem.get(conf);
还有一种做法是直接在FileSystem.get时指定UserGroupInformation,用代理用户获取FileSystem实例,但毕设阶段用System.setProperty的方式最省事。
3.3 集成过程中最常见的三个坑
第一个坑是依赖冲突。Spring Boot 2.x自身带了jackson、guava等库,Hadoop客户端也依赖这些库,版本不一致会直接抛NoSuchMethodError或ClassNotFoundException。我当时在pom里排除了Spring Boot自带的guava:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
但这种全局排除容易误伤,更稳妥的方式是在hadoop-client的依赖里排除和Spring Boot明显冲突的项,然后手动引入统一版本的guava、commons-lang3、jackson。
第二个坑是Windows本地开发时缺少native库。Hadoop在Windows下跑需要winutils.exe和hadoop.dll,不然会报Failed to locate the winutils binary in the Hadoop binary directory。解决方式有两个:一是下载对应版本的winutils放到HADOOP_HOME的bin目录下;二是干脆用Docker在Linux容器里跑Hadoop服务端,本地只连远程端口。我当时用的是后者,省去了很多native库的问题。
第三个坑是HDFS端口不通。NameNode的RPC端口是9000,DFS客户端连的是这个;NameNode的HTTP页面端口是9870(Hadoop 3.x)或50070(Hadoop 2.x),网页能开不代表RPC能通。排查的时候要先netstat确认9000端口在监听,再用hdfs dfs -ls /命令行直接验证连通性,命令行通了再怀疑Java代码的问题。
4. 核心功能实现:物品管理、租赁流程与HDFS文件操作
4.1 物品管理模块:业务表与HDFS的关联设计
物品管理分两部分:一部分是物品结构化信息,比如名称、品牌、租金单价、押金、库存,这些放在MySQL的item表里;另一部分是物品图片、使用说明书PDF等文件,存到HDFS的/image/items/目录下,MySQL的item表里只存一个url字段指向HDFS路径。
上传流程我用代码走一遍:
java复制@PostMapping("/admin/item/upload")
public Result uploadImage(@RequestParam("file") MultipartFile file,
@RequestParam("itemId") Integer itemId) {
if (file.isEmpty()) {
return Result.error("文件不能为空");
}
// 校验文件类型,只允许jpg/png
String originalFilename = file.getOriginalFilename();
String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
if (!Arrays.asList(".jpg", ".jpeg", ".png").contains(ext.toLowerCase())) {
return Result.error("仅支持jpg/png格式");
}
// 生成HDFS存储路径,按物品ID分目录
String hdfsPath = "/image/items/" + itemId + "/" + System.currentTimeMillis() + ext;
hadoopService.uploadFile(file.getInputStream(), hdfsPath);
// 更新物品表的图片路径
itemService.updateImageUrl(itemId, hdfsPath);
return Result.success("上传成功", hdfsPath);
}
这里有一个容易疏忽的点:上传到HDFS时,如果目标目录不存在,FileSystem的create方法会直接报错,要先调用mkdirs创建父目录。我在hadoopService.uploadFile里封装了这段逻辑:
java复制public void uploadFile(InputStream in, String hdfsPath) {
Path path = new Path(hdfsPath);
if (!fs.exists(path.getParent())) {
fs.mkdirs(path.getParent());
}
try (FSDataOutputStream out = fs.create(path, true)) {
IOUtils.copyBytes(in, out, 4096, true);
}
}
多级目录的好处是避免单目录下文件数量过多,HDFS的NameNode把所有目录和文件元数据都存在内存里,文件数量越大内存压力越大。按itemId分目录,再按时间戳命名文件,既有语义又能分散。
4.2 租赁与归还流程的状态机设计
租赁订单的状态流转是整个系统的业务核心,我用一个状态字段来描述:
| 状态编码 | 状态含义 | 允许的下一状态 |
|---|---|---|
| 0 | 待审核 | 1 租赁中 / 4 已取消 |
| 1 | 租赁中 | 2 待归还 / 5 超期 |
| 2 | 待归还(已申请归还) | 3 已完成 |
| 3 | 已完成 | 无 |
| 4 | 已取消 | 无 |
| 5 | 超期 | 2 待归还 |
状态流转的实现要点有两个。一是所有的状态变更都要记录到order_log表,包括操作人、操作时间、变更前后状态和备注,这样答辩的时候可以拿出"审计追踪"这个亮点。二是超期状态不能靠用户操作触发,要有一个定时任务每天扫描一次租赁中且结束时间已过的订单,批量把状态改成超期并计算违约金金额。
租赁费用计算我采用的是简单的按日计费模型:应付租金=租金单价乘以租赁天数,押金在订单完成后原路退回。超期部分按租金的1.5倍每天计算违约金。这些规则看起来简单,但写代码的时候我会把计算逻辑单独抽成一个RentCalculator类,而不是散落在Service里,方便写单元测试。
4.3 MapReduce统计:热门物品排行与租赁分析
这是整个项目里最能体现Hadoop价值的部分。我用MapReduce实现了一个统计任务,输入是HDFS上导出的订单数据,输出是各物品租赁次数的统计结果。
先明确数据来源。MySQL里的订单表数据需要先导出成文本文件放到HDFS上,比如每行格式为"订单ID,物品ID,租赁天数,租金,租赁日期"。我在项目里写了一个DataExportJob,定时把满足条件的订单数据从MySQL查到并写入HDFS的/dw/order_input目录,格式是Tab分隔的纯文本。
MapReduce的Mapper负责切分数据:
java复制public class RentCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
private Text itemId = new Text();
private IntWritable one = new IntWritable(1);
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
String[] fields = value.toString().split("\t");
if (fields.length < 2) return;
// fields[0]是订单ID,fields[1]是物品ID
itemId.set(fields[1]);
context.write(itemId, one);
}
}
Reducer把每个物品的租赁次数累加起来,输出到HDFS的/dw/rent_count目录:
java复制public class RentCountReducer 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);
}
}
任务提交入口通过ToolRunner运行:
java复制public class RentCountJob {
public static void main(String[] args) throws Exception {
Configuration conf = new Configuration();
conf.set("fs.defaultFS", "hdfs://localhost:9000");
Job job = Job.getInstance(conf, "rent count");
job.setJarByClass(RentCountJob.class);
job.setMapperClass(RentCountMapper.class);
job.setCombinerClass(RentCountReducer.class);
job.setReducerClass(RentCountReducer.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(IntWritable.class);
FileInputFormat.addInputPath(job, new Path("/dw/order_input"));
FileOutputFormat.setOutputPath(job, new Path("/dw/rent_count"));
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
跑完之后,管理端的统计接口会读取/dw/rent_count目录下的part-r-00000文件,把结果解析出来展示成排行榜。这里注意,输出目录如果已经存在,MapReduce任务会直接报错,要么每次运行前删除上一次的结果目录,要么在输出路径上加上时间戳。
除了热门物品排行,我额外加了一个"租赁时长分析"任务,统计每个物品的平均租赁天数,这需要Map阶段把租赁天数字段提取出来,用自定义的AverageWritable在Reducer里做累加求均值。这个任务其实已经能看出MapReduce处理聚合计算的通用思路了。
5. 数据库设计与HDFS目录规划的配合
5.1 MySQL表设计:HDFS只存该存的东西
系统核心表我设计成这么几张:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| user | 用户表 | id, username, password, phone, role |
| item | 物品表 | id, name, brand, rent_price, deposit, stock, image_url |
| category | 物品分类表 | id, name |
| order_info | 租赁订单表 | id, user_id, item_id, begin_time, end_time, status, total_amount, deposit_amount |
| attachment | 附件文件表 | id, order_id, file_path, file_name, file_size, upload_time |
| order_log | 订单状态流转日志表 | id, order_id, pre_status, after_status, operator, operate_time, remark |
这里最关键的设计决策是:附件表单独拆出来,不直接挂在order_info表上。原因是HDFS上的文件路径可能会因为迁移而改变,如果耦合在订单表里,改路径就得改业务表;而拆成附件表后,一个订单对应多条附件记录,文件路径和业务数据解耦,维护起来更灵活。
MySQL和HDFS之间的数据一致性,我采用的是"业务事务优先、文件最终一致"的补偿策略。举个具体场景:用户在租赁申请时上传身份证照片,先调用HadoopService上传文件拿到路径,再插入附件表记录和订单记录。如果附件表插入失败,说明业务异常,HDFS上那个文件就成了孤儿文件,需要定时任务扫描附件表,把HDFS上未被引用的文件清理掉。反过来,如果上传失败就直接返回错误,业务不进入下一步。
5.2 HDFS目录结构与文件命名规范
HDFS上的目录规划我遵循了简单清晰的原则:
code复制/image/items/{itemId}/{timestamp}.jpg 物品图片
/contracts/{orderId}/{timestamp}.pdf 租赁合同扫描件
/dw/order_input/order_data_{date}.txt 每日订单数据导出
/dw/rent_count/ 热门物品排行结果
/dw/avg_duration/ 租赁时长统计结果
文件命名用时间戳加后缀,避免重名。这里我踩过一个小坑:文件重名的问题。第一次写上传逻辑时直接用原始文件名存到HDFS,结果用户上传两张同名照片,后者把前者覆盖了。所以文件路径里一定要加时间戳或UUID,保证唯一性。
5.3 文件元数据的一致性维护
我设计了一个后台定时任务,每天凌晨检查一遍HDFS上的文件是否都有对应的附件表记录。具体做法是:调用FileSystem.listStatus遍历指定目录下的文件,和attachment表中的file_path做比对,找出未被引用的文件,写入日志供管理员确认后再删除。
这个做法在答辩时是加分项,它说明你考虑了"分布式存储带来的数据孤岛问题",而不是简简单单把文件丢上去就不管了。
6. 配套文档的写作思路:毕设文档怎么写才不被怼
6.1 文档结构安排与篇幅分配
这个项目标题里带"文档+源码",说明文档是交付物的重要组成部分。我根据自己的写作经验,把文档分成六章:
| 章节 | 内容 | 篇幅建议 |
|---|---|---|
| 第1章 绪论 | 研究背景、国内外现状、研究内容、论文结构 | 8~12页 |
| 第2章 相关技术 | Spring Boot概述、Hadoop架构与HDFS原理、MapReduce编程模型、MyBatis-Plus | 10~15页 |
| 第3章 系统分析 | 可行性分析、业务流程分析、功能需求分析、非功能需求分析 | 10~15页 |
| 第4章 系统设计 | 系统架构设计、功能模块设计、数据库设计、HDFS目录设计、核心接口设计 | 20~30页 |
| 第5章 系统实现 | 每个功能模块的实现细节,贴关键代码并解释 | 25~35页 |
| 第6章 系统测试 | 测试环境、测试用例、测试结果、性能与可靠性分析 | 10~15页 |
这里要重点提醒一个常见问题:千万不要把文档写成代码的堆砌。第5章贴代码时,不是整段整段往上贴,而是只贴核心代码片段,并解释这段代码解决什么问题、为什么这么写。答辩老师真正想看的不是你会贴代码,而是你会讲清楚自己做了什么设计决策。
6.2 关键图表和核心图怎么画
文档里至少要有三张图直接决定答辩观感:
第一张是系统架构图,用分层的方式画出用户端、Spring Boot后端、MySQL、HDFS、MapReduce以及它们之间的交互关系。这张图要让人一眼看出"这是一个前后端分离、大数据存储与业务系统结合的架构"。
第二张是租赁业务时序图,从用户下单开始到归还完成,一张图把一个完整业务流程的时间顺序画清楚。这是答辩的时候最容易被提问的图,因为老师会顺着时序图追问每个环节的实现细节。
第三张是HDFS数据流图,画清楚物品图片从浏览器上传、经Spring Boot接收、写入HDFS、再到浏览器访问的完整链路。这张图直接呼应选题中Hadoop的部分,必画。
画图工具我推荐ProcessOn或者draw.io,不怕画得丑,就怕不画。很多同学文档里全是文字,答辩时一张图都没有,老师听完根本不知道系统长什么样,印象分直接扣一大截。
6.3 答辩时的常见问题与准备
答辩老师针对这个选题最爱问的问题,我提前列一下:
- "为什么选择Hadoop而不是直接用服务器本地磁盘存文件?"最优回答是围绕"可靠性、扩展性、容量"三个词展开,说明HDFS多副本机制和横向扩展能力。
- "MapReduce跑一次统计要多久?和直接用SQL查有什么区别?"这个问题考察的是你对离线计算和实时查询差异的理解。要承认MapReduce延迟高、适合批量分析,同时说明选择它的原因是订单数据量大时纯SQL统计会拖垮数据库性能,把分析工作分流到Hadoop集群上。
- "HDFS上的文件如果丢了怎么办?"可以回答NameNode没有副本信息或DataNode宕机时的恢复机制,也可以引申到项目里如何通过校验MD5保证文件完整性。
- "你的系统支持多少并发?"这个要实事求是,说清楚用了多线程和连接池做基础优化,但毕竟单机伪分布式,不追求高性能,重点在功能完整性和技术栈覆盖。
这些问题本身不复杂,关键是提前准备,别等到台上被问住了才现场编。
我在实际做这个项目的过程中,最大的体会是:Hadoop在毕设项目里的分量,不在于用了多高深的技术,而在于你把它的特性落到了系统的哪个真实场景里。文件存HDFS、分析用MapReduce,两个点只要做扎实了,整个项目的技术含量就立住了。文档部分也顺理成章,因为每一个模块都有真实的代码和数据流支撑,不存在写不出来硬编的情况。如果你也正在做类似的选题,建议把重心放在"业务和Hadoop的边界划分"上,这个想清楚了,后面环境、编码、文档都会顺很多。最后再分享一个小技巧:Hadoop环境装好之后,一定要用hdfs dfs -ls这种命令把集群状态摸一遍再动代码,命令行通了你才有底气说集群环境没问题,不然后面调了一整天的"代码Bug"其实全是环境问题。
