ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Hadoop的图书推荐系统设计与实现:MapReduce协同过滤与HDFS实战部署

基于Hadoop的图书推荐系统设计与实现:MapReduce协同过滤与HDFS实战部署 简介这是一个基于 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/javaMapper、Reducer、Driver 三个层次推荐计算的执行主体sql/book.sqlbooks、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 MapperLongWritable, 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 ReducerText, Text, Text, Text { private ListString[] list new ArrayList(); Override protected void reduce(Text key, IterableText 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 端拿到结果后批量执行 insertOverride protected void reduce(Text key, IterableText 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 \ --columnsuser_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 namefs.defaultFS/name valuehdfs://localhost:9000/value /property property namehadoop.tmp.dir/name value/home/hadoop/hadoop_tmp/value /property /configurationhdfs-site.xml 里最关键的是副本数configuration property namedfs.replication/name value1/value /property property namedfs.namenode.name.dir/name value/home/hadoop/hdfs/name/value /property property namedfs.datanode.data.dir/name value/home/hadoop/hdfs/data/value /property /configurationdfs.replication一定要写 1伪分布式只有一台机器副本数设成 3 的话 DataNode 会一直报块不满足。hadoop.tmp.dir也要改出系统默认的 /tmp否则系统清理临时文件时NameNode 的持久化目录会被连带清掉第二天起来集群直接起不来。mapred-site.xml 指定计算框架configuration property namemapreduce.framework.name/name valueyarn/value /property /configuration配置文件配好之后第一次启动前必须先格式化 NameNodehdfs namenode -format这一步我要多提醒一句格式化会生成新的 cluster ID如果之后因为配置错误再次格式化DataNode 保留的还是旧 ID就会报 cluster ID mismatch。我在这里浪费过半天时间后来养成的习惯是格式化之前先把 name 和 data 两个目录做备份出了状况恢复备份而不是反复格式化赌运气。3.3 启动、传数据、提交作业三条命令串起来集群启动顺序固定先 HDFS 再 YARNstart-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 topN10 \ -D mapreduce.job.reduces2-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里只有 DataNodeNameNode 进程根本没出现日志里写着 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_idbook_idrating110151102421014210253101231033就凭这几行运行第一轮 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 图书推荐系统的源码包真正值钱的地方不在于算法有多复杂而在于能帮你把“数据 → 计算 → 存库 → 演示”的闭环完整跑下来。希望拿到资源后的你也能顺着这条线推一遍少踩我踩过的坑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进