简介:这是一个基于 Hadoop 的图书推荐系统完整源码包,随附数据库相关文件,面向正在学习大数据开发、希望掌握 Hadoop 分布式计算与推荐系统搭建的开发者;项目围绕图书推荐场景,集成了前端页面、后端服务与分布式数据处理流程,可直接用于课程设计、毕业设计或工程入门实践。压缩包共 346 个文件,整体约 6.57MB,其中 37 个 Java 文件承担推荐逻辑与数据清洗,Vue、页面脚本与样式文件构成可视化界面,配置文件与数据集用于环境启动和样例输入,Hadoop 运行输出文件则可帮助对照源码验证处理流程;资源还附带数据库文件与依赖库,整体目录按前端、后端、配置与运行输出分区,便于快速定位、按模块阅读调试,也能帮助理解推荐系统的输入格式、任务提交方式以及工程化组织方式。目前已有 3182 人学习下载,尤其适合需要完成 Hadoop 图书推荐系统大作业、毕业设计,或想快速搭建可运行推荐原型的开发者。
1. 基于 Hadoop 图书推荐系统源码+数据库.zip:这套大数据课设项目到底能拿来干什么
在高校的课程设计里,“图书推荐系统”和“Hadoop 平台”几乎是常年霸榜的两个选题,偏偏很多人把这两个分开做:要么写个普通的 SSH 网站,要么搭个 Hadoop 环境跑完 wordcount 就收工。而基于 Hadoop 图书推荐系统源码+数据库.zip 这套资源,是把两件事合成了一件:用真实的评分数出来了,通过 MapReduce 离线计算用户之间的相似度,最后产出 Top-N 图书推荐。它不是花架子,而是把大数据课设里最常被追问的三个环节——HDFS 存储、MapReduce 并行计算、MySQL 结果落库——完整串成了一条可以现场演示的链路。适合哪些人?准备交 Hadoop 课程设计、毕业设计里涉及推荐模块、或者想从 wordcount 再往上走一步的同学。我会按拆包的顺序来:系统设计讲清楚,再说环境怎么搭、坑在哪,最后给出让答辩更稳的验证方法和调参建议。
2. 拆包看架构:用户协同过滤在 HDFS 上是怎么分两轮算出来的
2.1 压缩包里的四样东西:源码、SQL、数据、说明各自管什么
把 zip 解压之后,先不要急着双击 README,先铺开目录看一眼整体结构。一个合格的课设工程一般至少包含四块:Java 源码目录、数据库脚本目录、示例数据文件、部署说明。多数基于 Hadoop 图书推荐系统的压缩包也是这种组织方式,只是目录命名会因为导师要求、IDE 版本略有出入。
我一般按下面这个清单对应:
| 目录 / 文件 | 内容 | 在系统里的角色 |
|---|---|---|
| src/main/java | Mapper、Reducer、Driver 三个层次 | 推荐计算的执行主体 |
| sql/book.sql | books、users、ratings 建表脚本 | MySQL 侧的数据模型 |
| data/ratings.csv | 用户评分示例数据 | 上传到 HDFS 的原始输入 |
| README.md | 版本选型与启动步骤 | 排查依赖问题的依据 |
注意 ratings.csv 的列格式几乎决定了代码里 map 阶段的切分方式。如果评分文件里混入了中文字段,而 Mapper 里只做了简单的line.split(","),后续强转类型时大概率抛异常。建议拿到压缩包后先看一眼它的前几行,确认有没有表头、分隔符是逗号还是制表符。大多数课设包用的是 user_id,book_id,rating 三列,不带表头,但也有例外。
head -5 data/ratings.csv这套数据库脚本里一般包含三张表:books(图书信息)、users(用户基础信息)、ratings(用户评分记录)。评分表是核心,字段通常就是 user_id、book_id、rating,外加一个 update_time 之类的冗余字段。要特别留意的是,建表脚本里大概率不含数据,数据要么写在独立的 CSV 里,要么被拼成 INSERT 语句放在脚本末尾。这一步不确认清楚,后边作业提交后没有输出时会很懵——八成是数据根本不在 HDFS 上。
2.2 两轮 MapReduce:相似度 Job 和推荐 Job 的代码拆解
整个推荐系统用的是典型的基于用户的协同过滤(UserCF),一句话讲就是:如果 A 和 B 给同一批图书打过相近的分数,那么 B 看过而 A 没看过的书,A 大概率也喜欢。落到工程上分两个阶段:第一阶段把评分表转换为用户对之间的相似度;第二阶段依据相似度预测目标用户对每本未读书的评分,取 Top-N。
第一个 Job 的 Mapper 要做的很直接:把评分表的一行映射为 book_id -> user_id:rating。这里的思路反直觉但很关键——并不是直接以用户为 key,而是以图书为 key。因为协同过滤的分子需要计算“两个用户在多少本书上有共同打分”,只有把同一本书的评分路由到同一个 reduce 里,才能一次性拿到所有在这本书上打过分的用户组合。核心代码大概是这样的:
public static class BookUserMapper extends Mapper<LongWritable, Text, Text, Text> { private Text outKey = new Text(); private Text outValue = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String[] fields = value.toString().split(","); if (fields.length < 3) { // 过滤空行和异常行,防止后面类型转换直接崩掉 return; } // 假设数据格式:userId,bookId,rating outKey.set(fields[1].trim()); outValue.set(fields[0].trim() + ":" + fields[2].trim()); context.write(outKey, outValue); } }这里fields.length < 3的前置过滤值得多说一句:CSV 里经常有空行或表头,如果不加这个判断,Double.parseDouble一遇到非数字字符就会抛 NumberFormatException,整个 Task 会反复失败重试。另外这里用Text字符串拼接,而不是自定义 Writable 对象,是因为课程设计阶段优先保证流程正确,自定义 Writable 还得处理序列化协议,对新手不友好,也没什么收益。
Reducer 侧做用户两两组合,逻辑也比较固定:
public static class UserPairReducer extends Reducer<Text, Text, Text, Text> { private List<String[]> list = new ArrayList<>(); @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { // 注意:Iterable 只能遍历一次,必须当场拷贝到 List 里 list.clear(); for (Text val : values) { String[] up = val.toString().split(":"); list.add(up); } // 同一本 bookId 下,所有用户两两配对,输出评分乘积 for (int i = 0; i < list.size(); i++) { for (int j = i + 1; j < list.size(); j++) { double r1 = Double.parseDouble(list.get(i)[1]); double r2 = Double.parseDouble(list.get(j)[1]); context.write( new Text(list.get(i)[0] + ":" + list.get(j)[0]), new Text(String.valueOf(r1 * r2))); } } } }这里的list用成员变量配合clear是为了减少对象分配,但有一个隐忧:你在迭代 values 时,如果只遍历一次、当场拷贝,没问题;如果先遍历一次做了别的统计,又想回过头再遍历一次,拿到的就是空集合。这是 MapReduce 里非常经典的坑,我看到过不止一个同学在这里翻车,把同一个 values 拿出来循环两次,结果第二个循环什么都拿不到。
第二个 Job 的主要任务是把第一个 Job 输出的 userA:userB -> 乘积按用户对聚合,同时结合每个用户的评分平方和,算出归一化后的余弦相似度。关于归一化,很多简化版课设代码会直接把乘积累加当相似度,但这是不严谨的:两个只看过 3 本书的用户和两个看过 50 本书的用户,累加值天然就差很多,不除以模长的话,结果完全不可比。所以更完整的实现里,第一个 Job 应该顺带统计每个用户的评分平方和,第二个 Job 在 reduce 阶段做一次分母运算,得到真正的余弦相似度。
推荐阶段用相似用户集合对候选图书的评分做加权平均,输出 userId,bookId,score 三个字段。这部分代码通常放在 RecommendReducer 里,Driver 阶段再通过MultipleOutputs把结果写到独立的推荐目录。
2.3 落库方式:Reducer 直连数据库还是离线导入
Hadoop 计算完的结果落在 HDFS 的 part 文件里,但课设验收要看 MySQL,所以最后一定有个落库动作。最常见的两种做法各有适用场景。
第一种是 Reducer 里直接写 JDBC。它的实现很直观,reduce 端拿到结果后批量执行 insert:
@Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PWD); PreparedStatement ps = conn.prepareStatement( "insert into recommend_result(user_id, book_id, score) values (?,?,?)"); for (Text val : values) { String[] kv = val.toString().split("\t"); ps.setInt(1, Integer.parseInt(kv[0])); ps.setString(2, kv[1]); ps.setDouble(3, Double.parseDouble(kv[2])); ps.addBatch(); } ps.executeBatch(); conn.close(); }每次 reduce 调用都创建一个新连接,这个代价在课设规模下可以忽略,但如果 reducer 并行度一高,数据库连接数会被打满。更常见的做法是把 Connection 的初始化挪到setup()里复用,或者用一个小配置的数据库连接池把连接缓存起来,几千条结果完全够用。不过实话说,课设答辩时老师更关心你有没有说清楚这条链路,而不是你用的是不是连接池。
第二种是离线导入,也是我通常给学员推荐的路径:先hdfs dfs -getmerge把 part 文件拉回本地,再用 mysqlimport 导进去。
hdfs dfs -getmerge /output/recommend /tmp/recommend.tsv mysqlimport --local --fields-terminated-by='\t' \ -u root -p123456 \ --columns=user_id,book_id,score \ bookdb /tmp/recommend.tsv这里有两个坑位想说清楚。mysqlimport 是按文件名推断目标表的,文件必须重命名成和表名一致,否则会报找不到表;另外--fields-terminated-by='\t'里的转义符在 shell 中要写成'\t'而不是"\t",否则转义不生效,数据会全部挤到第一列。如果你不想被这两个细节折磨,直接用离线导入加一个简单的 LOAD DATA 也行,原理一样。
3. 环境搭建到作业提交:伪分布式是这样一步步跑通的
3.1 前置准备:JDK、winutils、免密登录
在动手之前先回答一个实际问题:如果开发机是 Windows,要跑通这套基于 Hadoop 的源码,第一步不是改代码,而是确认三个前置条件。
JDK 尽量装 8。Hadoop 3.x 在 JDK 8 和 11 下比较稳,上到 17 会带出一堆反射相关的异常,这些异常和推荐算法本身没关系,纯属于环境自找麻烦。装好后在命令行输入java -version确认,不要只看 IDE 右上角的配置,IDE 的 Project Structure 与系统 JAVA_HOME 不一致,很容易导致 Maven 打包时选错编译级别。
Windows 上跑 hadoop jar 或者直接在 IDEA 里调 Driver 的 main 方法时,会频繁遇到Failed to locate the winutils binary in the hadoop binary path。解决办法是下载对应 Hadoop 版本的 winutils.exe,放进解压目录的 bin 下,然后在代码里设置:
System.setProperty("hadoop.home.dir", "D:\\hadoop-3.3.4");这个报错只影响本地调试,不影响 jar 包提交到集群。但建议提前处理,不然每次 IDEA 里跑一个 main 都得先应付它。
如果是用 Linux 虚拟机跑 Hadoop,还有一个隐藏门槛是 SSH 免密登录。start-dfs.sh启动时会通过 SSH 连接 localhost,如果没有配过免密,会一直卡在输入密码的交互处,脚本根本没法自动化。
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如果不再提示输入密码,说明配置成功。网上很多文章会顺手把 Zookeeper 整合也放进伪分布式流程里,那是为高可用集群准备的,伪分布式阶段完全用不到,别把环境复杂度往上堆。
3.2 写对三份配置文件:core-site、hdfs-site、mapred-site
能跑通一个伪分布式集群,最小化配置只需要三份文件。
core-site.xml 里固定 NameNode 地址和临时目录:
<configuration> <property> <name>fs.defaultFS</name> <value>hdfs://localhost:9000</value> </property> <property> <name>hadoop.tmp.dir</name> <value>/home/hadoop/hadoop_tmp</value> </property> </configuration>hdfs-site.xml 里最关键的是副本数:
<configuration> <property> <name>dfs.replication</name> <value>1</value> </property> <property> <name>dfs.namenode.name.dir</name> <value>/home/hadoop/hdfs/name</value> </property> <property> <name>dfs.datanode.data.dir</name> <value>/home/hadoop/hdfs/data</value> </property> </configuration>dfs.replication一定要写 1,伪分布式只有一台机器,副本数设成 3 的话 DataNode 会一直报块不满足。hadoop.tmp.dir也要改出系统默认的 /tmp,否则系统清理临时文件时,NameNode 的持久化目录会被连带清掉,第二天起来集群直接起不来。
mapred-site.xml 指定计算框架:
<configuration> <property> <name>mapreduce.framework.name</name> <value>yarn</value> </property> </configuration>配置文件配好之后,第一次启动前必须先格式化 NameNode:
hdfs namenode -format这一步我要多提醒一句:格式化会生成新的 cluster ID,如果之后因为配置错误再次格式化,DataNode 保留的还是旧 ID,就会报 cluster ID mismatch。我在这里浪费过半天时间,后来养成的习惯是格式化之前先把 name 和 data 两个目录做备份,出了状况恢复备份,而不是反复格式化赌运气。
3.3 启动、传数据、提交作业:三条命令串起来
集群启动顺序固定,先 HDFS 再 YARN:
start-dfs.sh start-yarn.sh jpsjps应看到 NameNode、DataNode、SecondaryNameNode、ResourceManager、NodeManager 五个进程。少一个都别急着提交作业,先去看对应日志。日志路径在$HADOOP_HOME/logs/下,格式是hadoop-<用户>-<守护进程>-<主机>.log,比终端里的报错信息完整得多。
数据上传用常规的hdfs dfs命令:
hdfs dfs -mkdir -p /input/recommend hdfs dfs -put /home/hadoop/dataset/ratings.csv /input/recommend/ hdfs dfs -ls -R /input/recommend本地文件路径尽量别带中文,HDFS 路径也建议全英文,否则 map 阶段读文件可能遇到编码兼容问题。另外这条链路里要确认数据文件确实进了 HDFS 默认分块,而不是只上传到了本地当前目录。
作业提交有两种方式:开发调试阶段可以直接在 IDEA 里跑 Driver 的 main,但要把框架切到本地模式,避免本地调试也去占用 YARN 资源;答辩演示阶段更推荐打 jar 包提交到 YARN,这样能现场展示 Application 的运行日志。
mvn clean package -DskipTests hadoop jar target/book-recommend-1.0.jar \ com.recommend.job.RecommendDriver \ -D input=/input/recommend \ -D output=/output/recommend \ -D topN=10 \ -D mapreduce.job.reduces=2-D参数是用来往 Driver 里传自定义配置项的,Driver 侧通过conf.getInt("topN", 10)读取。这样同一个 jar 包就能在提交时动态调整推荐数量,不需要重新编译。mapreduce.job.reduces建议显式指定,如果不设,默认只起一个 reducer 拉取所有 map 输出,数据量稍大就会明显变慢。
提交后到浏览器打开localhost:8088,能看到一个 Application 从 ACCEPTED 变成 RUNNING 再到 FINISHED。如果一两分钟内直接 FAILED,别急着上网搜,先看日志里是不是ClassNotFoundException。很多情况是内部类被注册成 Mapper 或 Reducer,但打 fat jar 时没有把内部类对应的包完整打进去。提交前用mvn clean package重新打包,别用 IDEA 默认打的 thin jar。
4. 避坑指南:Hadoop 图书推荐系统常见的四个翻车场景
4.1 NameNode 启动失败:报错里全是 cluster ID mismatch
现象:格式化后再次启动集群,jps里只有 DataNode,NameNode 进程根本没出现,日志里写着 Failed to load FSImage,或者 Cluster ID 不匹配。
原因:格式化动作默认生成新的 cluster ID,这个 ID 同时写进 NameNode 的元数据目录和 DataNode 的 data 目录。多次格式化后两边不一致,DataNode 会拒绝向 NameNode 注册。
解决:最直接的办法是同时清掉 name 和 data 两个目录再格式化一次,但前提是已经确认 HDFS 里的数据都导出了。我更推荐动手前先备份 name 目录,实在没法恢复的时候再走清理这一步。
# 清理前确认已有作业结果都已导出 rm -rf /home/hadoop/hdfs/name /home/hadoop/hdfs/data hdfs namenode -format4.2 map 阶段全失败:CSV 里混入了表头或脏数据
现象:作业提交后很快失败,打开 Application 日志,所有 map task 报的都是同一个错误,NumberFormatException: For input string: "rating"。
原因:数据文件带了 CSV 表头,第一行“rating”被当成数字去解析,直接在Double.parseDouble崩了。
解决:在 Mapper 入口加字符串检查是治标,更稳的做法是在上传数据前就清掉表头:
sed -i '1d' ratings.csv处理完后用head -3 ratings.csv确认首行确实是数据。如果你不想动原始文件,也可以在 Mapper 里判断第一个字段是否纯数字,不是就return。
4.3 推荐结果全是热门书:这个“命中”是假象
现象:代码跑通了,推荐列表也有 10 本书,但全库最热的那几本固定在前排,换哪个用户结果都差不多。
原因:数据集太稀疏。两个人有共同打分的书极少,相似度的分子接近 0,分母也接近 0,归一化后所有相似度都在同一个噪音水平。热门书因为看过的人多,更容易进入候选集合,最后占满整个 Top-N。
解决:这是推荐系统本身的冷启动问题,不是一行代码能修的。课设场景下做三件事:第一,给演示数据多造一些评分记录,让每个用户至少有 20 条评分;第二,把相似度阈值从 0 提到 0.4 以上,过滤掉弱关系;第三,在候选生成阶段显式排除目标用户已经读过的书。如果这些都不管用,就在冷启动用户上直接按平均分排序兜底,别硬套协同过滤。
4.4 落库后主键冲突:同一个用户被写了两条推荐结果
现象:mysqlimport 执行成功,但查询 recommend_result 表发现 user_id 有重复,好几条明显是同一个用户 ID 的重复记录。
原因:reduce 输出是 userId,bookId,score,但 Top-N 在 reduce 内部用 TreeMap 做排序,TreeMap 的 key 只包含 userId,导致同一用户的多本候选书被压缩成一本。还有可能是落库文件重复导入,之前的旧数据没清干净。
解决:最简单的方法是把结果表主键设计成 (user_id, book_id) 联合主键,并在导入前先清空目标表:
mysql -u root -p123456 bookdb -e "TRUNCATE TABLE recommend_result;"然后再执行之前那条 mysqlimport。如果还是有重复,检查 reduce 输出是否真的带了不同的 bookId。
5. 答辩前的最后一步:用小样本验证推荐算法,再调两个参数
5.1 手工验证:少样本就能把链路算明白
拿到这套源码后,与其在几万条 demo 数据上瞎看,不如人工构造一份小数据去验证整个链路对不对。比如下面这个三行评分表:
| user_id | book_id | rating |
|---|---|---|
| 1 | 101 | 5 |
| 1 | 102 | 4 |
| 2 | 101 | 4 |
| 2 | 102 | 5 |
| 3 | 101 | 2 |
| 3 | 103 | 3 |
就凭这几行,运行第一轮 MapReduce,用户 1 和用户 2 的共同评分是 101 和 102,乘积累加后除以模长,相似度应该很高,大概接近 0.97;用户 1 和用户 3 只共同评了 101,相似度应该在 0.4 上下。然后检查第二轮推荐输出:用户 3 对 102 的预测分应该在 3 分以上,明显高于他给 101 打的 2 分,这个方向就对了。
这种手算是给自己买的一颗定心丸。程序跑通后,光看“有输出”不代表结果对,很多落库错乱后的输出乍一看也像模像样。把每个阶段的中间文件拉出来核一遍,远比盯着最终列表猜靠谱。
5.2 两个值得调的参数:相似度阈值与 Top-N
调参时重点看两处:相似度阈值和推荐数量。相似度阈值决定谁有资格成为邻居,太高的话冷启动用户邻居太少,推荐列表填不满;太低的话列表里会混进大量弱关系噪音。我一般从 0.5 起步,再根据生成结果里相似用户的数量做微调。
Top-N 决定最终展示个数,课设演示用 10 个比较常规。但要注意,数据集稀疏时 Top-N 越大,末尾几本越可能是随机排序。与其展示随机结果,不如把 Top-N 调小一点,让每一条推荐都能在相似用户那里找到依据,并且上手就查数据里面的“用户相似度分数”。
5.3 把日志和中间结果准备好,现场就不慌
最后分享一个习惯:我跑这套系统时,都会强制保留 YARN 的日志文件和 HDFS 中间输出目录。答辩现场最容易被追着问“相似度到底怎么算出来的”,这时直接把中间结果文件打开,指着一行乘积说“这是分子,除以这个用户的评分平方和再开方就是相似度”,比任何口头解释都有说服力。
从那以后,我每次调完推荐参数,第一件事就是先把中间两个 Job 的输出验证一遍,再谈推荐列表好不好看。这套基于 Hadoop 图书推荐系统的源码包,真正值钱的地方不在于算法有多复杂,而在于能帮你把“数据 → 计算 → 存库 → 演示”的闭环完整跑下来。希望拿到资源后的你,也能顺着这条线推一遍,少踩我踩过的坑。
本文还有配套的精品资源,点击获取