大数据酒店推荐系统实战:从爬虫采集到Spark推荐与可视化
每年一到毕设季“大数据推荐系统”永远是最热门的选题方向。酒店推荐系统更是典型中的典型业务场景直白、数据链路完整、Hadoop、Spark、Hive 这三件套都能名正言顺地落进项目里再做一版可视化撑住答辩现场。我当时选这道题倒不是为了堆技术名词而是想用一个完整项目把“数据从哪里来、存到哪里、怎么算、怎么展示”整条链路彻底跑通而不是零散地写几个 demo 交差。这篇文章把这套系统的完整做法拆开讲一遍从爬虫采集、Hive 建仓、Spark 训练推荐模型到 SpringBoot 接口和可视化大屏联调连带着毕设过程中踩过的坑一起记录。如果你正在做类似题目或者想用分布式技术栈完成一个真正有业务闭环的小项目这篇应该能省掉不少瞎折腾的时间。默认你至少会一点 Python 或者 SQL如果连 Hive 都没听说过也能跟着代码走一遍不算难。1. 这个项目到底在做什么选题逻辑与技术栈取舍1.1 为什么偏偏是“酒店推荐系统”毕设选题有个核心逻辑题目要能同时照顾到业务、算法、工程三块而且每块都得能让评委看见。纯做爬虫太单薄纯调参训练模型又不像计算机专业的活儿。酒店推荐系统正好踩在中间——数据采集是爬虫的活数据管理是数仓的活推荐结果是算法的活可视化是前后端的活一个题目把四件事全包了。更重要的是酒店场景做推荐有天然优势。用户对酒店的选择受价格、星级、位置、评分、评论内容等因素共同影响数据特征丰富做协同过滤也好、做规则匹配也好都有足够的解释空间。相比电影推荐、商品推荐这种已经被讲烂的场景酒店数据还带着地理位置和价格区间这些可可视化的属性做出来的图表在答辩现场很能打。1.2 Hadoop、Spark、Hive 三件套各自干哪一棒很多人的第一个疑问是就一个几万条数据的毕设项目用得着上 Hadoop 全家桶吗我当时的想法是毕设评审判的不是你的数据量有多大而是你有没有真正理解并应用分布式框架解决问题的思路。如果只是单机 Python 脚本分析 CSV那和普通 Web 开发没区别技术含量提不上去。三个组件的分工是这样的组件职责对应项目中的角色Hadoop HDFS底层分布式存储存放爬虫采集的原始酒店数据、评论数据Hive离线数仓与数据清洗把原始数据清洗成规整的维度表和事实表生成评分宽表Spark分布式计算引擎基于清洗后的数据跑 ALS 推荐算法产出 TopN 推荐结果简单说Hadoop 负责“放”Hive 负责“洗”Spark 负责“算”。数据从爬虫落地之后先进 HDFS再用 Hive SQL 做清洗和统计最后把评分数据交给 Spark 训练推荐模型推荐结果写回数据库供后端的 Web 接口查询。1.3 整体数据流向与各层产出我当时设计的完整链路是爬虫采集原始 HTML 和 JSON 数据 - 解析清洗后写入 HDFS - Hive 建外部表加载原始数据 - ETL 生成酒店维度表、评分事实表 - Spark 读取评分数据训练 ALS 模型 - 推荐结果写回 MySQL - SpringBoot 提供 REST API - ECharts 渲染可视化页面。这条链路每一层都有明确的产出物。爬虫层产出的是每天的原始数据文件Hive 层产出的是可直接查询的业务表Spark 层产出的是每个用户对应的酒店推荐列表Web 层产出的是可视化指标和推荐结果展示。答辩的时候顺着这条链路讲逻辑非常顺评委不容易打断你。2. 数据管道第一棒酒店爬虫的落地细节与反爬处理2.1 爬虫要采集哪些字段以及为什么要采集这些字段爬虫是整个项目的数据源头但很多人一上来就埋头写代码结果爬到一半发现字段不够用。我在开始采集之前先把后面所有环节需要的字段列了一张清单这是非常关键的一步。酒店基础信息表需要这些字段酒店 ID、名称、所属城市、商圈、地址、经纬度、星级、价格区间、评分、评论数、封面图 URL。评分事实表需要用户 ID、酒店 ID、评分等级、评论内容、评论时间、入住房型。这些字段每一条都是后面计算和可视化直接要用的比如城市字段要拿来做地图分布评分和评论数要做排行价格要做区间分布。爬虫脚本用的是最常见的组合requests 发送请求BeautifulSoup 解析 HTML正则表达式提取 JSON 字段。因为酒店列表数据通常就嵌在页面 JS 变量里解析成 JSON 比逐个节点提取要快得多也不容易漏字段。2.2 反爬策略与断点续爬的实操写法做爬虫的人都知道反爬处理是真正的大头。酒店平台对高频访问的封禁很严格我自己测试的时候发现同一个 IP 连续请求超过一定次数就会开始出现验证码页面。所以爬虫里必须包含这几个策略随机 User-Agent、随机休眠、失败重试、以及最重要的断点续爬。随机 User-Agent 只是最基础的伪装还要带上 Referer 字段模拟从首页点击进入列表页的操作路径。休眠时间别固定固定了就有规律range(1, 4) 的随机秒数比较自然。断点续爬我用的是最朴素也最可靠的办法已爬取的城市和页码记录在一个 progress.json 文件里启动时先加载跳过已经完成的分页。这样做还有一个好处就是爬虫中途挂掉不需要从头再来对毕设这种需要反复调试数据的项目来说非常实用。import requests import json import time import random from bs4 import BeautifulSoup UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36 ] def load_progress(): try: with open(progress.json, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {completed: []} def save_progress(progress): with open(progress.json, w, encodingutf-8) as f: json.dump(progress, f, ensure_asciiFalse, indent2) def fetch_hotel_page(city, page): url fhttps://example.com/hotels/{city}/{page} headers { User-Agent: random.choice(UA_POOL), Referer: fhttps://example.com/hotels/{city}, Accept: text/html,application/xhtmlxml,*/*;q0.8 } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() return resp.text上面这段只是一个最小的骨架实际跑的时候还要在外面套一层重试机制。我的做法是某个页面连续失败三次就放弃这一页并记录到 failed.json最后单独排查失败页面防止一个页面卡死整个流程。2.3 数据入库从解析结果到 HDFS爬虫解析完的数据最初是一行行 JSON攒够一定量就写成一个文件。这里有一个容易忽略的点文件名要带有城市和时间信息比如 hotel_beijing_20250101.json这样后面往 HDFS 上传和 Hive 建分区表都会方便很多。上传 HDFS 的命令很简单hdfs dfs -mkdir -p /data/hotel/raw hdfs dfs -put hotel_*.json /data/hotel/raw/但我建议先用一行简短的 Python 脚本把每条数据的字段对齐字段缺失的直接丢弃。别小看这一步真实爬下来的数据永远比你想的脏有些条目没有价格有些地址为空如果不提前清洗后面 Hive 查询出来的统计数字根本没法看。另外爬虫采集要遵守基本的网络规范只采集公开的页面信息、不涉及个人隐私数据、控制请求频率这也是毕设文档里值得写一笔的内容。3. Hive 数仓实践表结构设计、分区策略与 ETL 清洗3.1 原始数据表的形态外部表还是内部表爬虫数据进了 HDFS 之后第一件事是建 Hive 表把数据映射出来。这里涉及一个选择外部表还是内部表。对毕设项目来说我建议用外部表。外部表删除表结构并不会删掉 HDFS 上的数据文件万一建表字段顺序写错了调整表结构的时候不会把原始数据弄丢操作上安全很多。建表的时候要特别注意 Serde。原始 JSON 文件可以先用 JSON Serde 直接映射但我实际操作下来发现爬虫导出的数据如果已经转成了 CSV用 OpenCSVSerde 反而更稳定字段顺序一目了然排查问题方便。CREATE EXTERNAL TABLE ods_hotel_raw ( id STRING, name STRING, city STRING, district STRING, address STRING, longitude DOUBLE, latitude DOUBLE, star_level STRING, price DOUBLE, score DOUBLE, comment_count INT ) ROW FORMAT SERDE org.apache.hadoop.hive.serde2.OpenCSVSerde WITH SERDEPROPERTIES ( separatorChar ,, quoteChar \ ) STORED AS TEXTFILE LOCATION /data/hotel/raw;这里还有一个看着不起眼但特别坑的细节CSV 文件里的空值。如果原始 CSV 里价格字段是空的OpenCSVSerde 会把它读成字符串类型的空值后续做算术运算就会返回 NULL非常容易导致统计结果不对。所以 ETL 时一定要统一做空值转换。3.2 数仓分层与酒店维度表、评分事实表毕设项目中数仓不需要做很复杂的多层架构但至少要体现分层思想。我做了两层ODS 层放原始数据DWD 层放清洗后的业务表。DWD 层建了两张核心表一张是酒店维度表一张是评分事实表。酒店维度表存储酒店的静态属性每个酒店一条记录。评分事实表存储用户对酒店的每一次评分行为用于后续协同过滤。事实表单独建表而不是和酒店信息混在一起是因为推荐算法只需要用户 ID、酒店 ID、评分三个字段提前拆分可以减少 Spark 加载数据时的 IO。分区是另一个要考虑的问题。酒店数据量对 Hive 来说非常小按天分区会产生大量小文件查询反而变慢。我更建议按城市分区每个城市的数据放在一个分区里这样既符合业务上按城市查看酒店的习惯也能让可视化模块的统计查询走分区裁剪效率更好。3.3 清洗逻辑与中文乱码的几个坑清洗的核心逻辑就是把脏数据抹平。我当时在 ETL SQL 里做的事情包括去重、补默认值、统一格式、剔除异常值。去重按酒店 ID 和评论 ID 进行价格为空时用城市均价替代经纬度为 0 的标记为无效但保留记录评分超过 5 分的当成数据错误剔除。这里要重点说中文乱码问题。酒店名称、商圈、地址基本都是中文Hive 表如果建表时没指定字符集很容易出现乱码或者中文变成问号的情况。在 Hive 的连接串里加上 characterEncodingUTF-8建表语句里也可以对相关字段做规范。如果发现已经写入的数据乱码了最省事的办法不是写 update 改而是回头检查原始 JSON 文件的编码重跑一遍加载流程毕竟数据量不大重跑成本很低。推荐评分事实表生成之后可以顺手跑几条 SQL 检查清洗结果比如按城市统计酒店数量、按星级统计平均评分这样能确认清洗过程没有把数据搞丢。4. 推荐引擎的实现思路ALS 协同过滤与冷启动补位4.1 为什么选 ALS协同过滤在酒店场景下的适配性推荐算法可选的不少基于用户的协同过滤、基于物品的协同过滤、矩阵分解、甚至深度学习模型。毕设项目里我选了 Spark MLlib 里的 ALS也就是交替最小二乘矩阵分解。原因很实际ALS 在 Spark 里有现成实现代码量少训练速度快而且效果容易解释。ALS 的核心思想是把“用户-酒店”评分矩阵分解成两个低维矩阵一个是用户特征矩阵一个是酒店特征矩阵。用户对某个酒店的评分预测值就是这两个低维矩阵对应向量的点积。这种思路天然适合捕获用户对不同酒店的偏好差异而且 Spark 的分布式实现能并行处理矩阵分解正好体现分布式计算的价值。4.2 评分数据怎么来从显式评分到隐式反馈真实酒店平台的数据里显式的用户评分并不好采集。为了把推荐算法跑起来我做了两层处理。第一层把爬下来的评论星级直接作为用户对酒店的评分这是最天然的显式反馈。第二层对于没有评论行为的用户用浏览行为构造隐式反馈比如用户搜索酒店时停留较久或者反复查看的酒店赋一个较低的默认评分。构造完评分数据之后用户 ID、酒店 ID、评分分成三列存成一张 Hive 表。在数据量不大的情况下ALS 的效果很大程度上取决于评分数据的稠密程度。为了让模型不至于太稀疏我做了个折中对评分次数少于 5 的用户做了过滤只保留有一定行为记录的用户。虽然损失了一部分用户量但模型质量会明显提升答辩时讲这个取舍也很加分。4.3 模型训练与 TopN 推荐的落地代码训练代码用 PySpark 写逻辑非常短。加载 Hive 表数据切分训练集和测试集ALS 训练然后为所有用户生成 Top10 推荐列表。from pyspark.sql import SparkSession from pyspark.ml.recommendation import ALS from pyspark.ml.evaluation import RegressionEvaluator spark SparkSession.builder \ .appName(HotelRecommenderTrain) \ .enableHiveSupport() \ .config(spark.executor.memory, 4g) \ .config(spark.sql.shuffle.partitions, 20) \ .getOrCreate() ratings spark.sql(SELECT user_id, hotel_id, rating FROM dwd_rating_table WHERE rating IS NOT NULL) train, test ratings.randomSplit([0.8, 0.2], seed42) als ALS( maxIter15, regParam0.05, userColuser_id, itemColhotel_id, ratingColrating, coldStartStrategydrop ) model als.fit(train) predictions model.transform(test) evaluator RegressionEvaluator(metricNamermse, labelColrating, predictionColprediction) rmse evaluator.evaluate(predictions) print(fRMSE {rmse}) userRecs model.recommendForAllUsers(10) userRecs.write.mode(overwrite).saveAsTable(dwd_user_recommendations)这段代码直接能跑但有两个地方要留意。一个是 coldStartStrategy 必须设为 drop否则遇到训练集里没出现过的酒店 IDALS 会默认预测值取反给你整出一堆负数推荐结果排查起来特别迷惑。另一个是本地调试时 spark.sql.shuffle.partitions 别设太大小数据集上设了大会有一堆空 task日志刷得让人心烦。模型评估的部分RMSE 只是其中一项指标。答辩时更直观的展示方式是抽几个用户把他的历史入住酒店和模型推荐酒店摆在一起做对比评委一眼就能看懂推荐系统干了什么。4.4 冷启动与热度推荐兜底ALS 有一个致命问题冷启动。新用户没有任何评分记录模型根本没法给他做推荐。新酒店也是没被评分在训练过程中学不到它的特征向量。毕设项目不能用“无法推荐”这四个字交差所以我在 ALS 推荐结果之外加了一个兜底逻辑。对于无历史行为的用户直接用城市维度的热度推荐按照“一周内评论数增量”加权“酒店评分”生成热门酒店榜单。新酒店则用同商圈、同星级、相似价格区间的酒店属性做规则匹配输出替代推荐。这套混合策略解释起来也很顺ALS 负责个性化热度榜单负责冷启动兜底规则匹配负责新物品探索。三种策略拼起来推荐接口在任何输入条件下都有返回结果功能上无懈可击。5. 可视化不是画个图SpringBoot 接口与 ECharts 联调5.1 可视化指标的选择哪些图最能说明问题可视化环节最容易犯的错是盲目堆图表。当时和我一起做项目的同学就做了十几个图结果答辩现场演示时评委根本没耐心看完。我最后砍到六个核心图表每个图对应一个业务问题图表类型数据内容说明什么问题地图各城市酒店数量分布数据覆盖范围与地域分布柱状图各星级酒店平均价格价格与星级的关联关系饼图价格区间占比酒店供给结构散点图评分与评论数分布酒店热度与口碑的关系排行榜评分 Top10 酒店城市优质酒店推荐词云评论关键词用户关注点与偏好每个图背后都有一条对应的 Hive SQL 统计逻辑可视化的架子就搭起来了。词云用的评论关键词是从评论表中高频词汇提取出来的不仅好看还能作为推荐算法的补充说明。5.2 后端接口设计查询逻辑与返回结构可视化前端不直接查数据库所有数据都通过 SpringBoot 接口返回。接口设计遵循一个原则前端要什么后端就给什么前端只负责渲染。比如城市酒店数量分布接口返回的 JSON 就是 城市名数量 的列表价格区间统计接口返回的就是区间名称和对应酒店数。RestController RequestMapping(/api) public class HotelStatController { Autowired private HotelStatService statService; GetMapping(/city/count) public Result cityCount() { ListCityCountVO list statService.getCityCount(); return Result.success(list); } GetMapping(/hotel/top) public Result hotelTop(RequestParam(defaultValue 10) int limit) { return Result.success(statService.getTopHotels(limit)); } }后端查数据的逻辑建议直接用 MyBatis 写 SQL而不是用复杂的 ORM 封装。因为统计类查询的 SQL 本身就比较复杂各种 group by 和 order by 叠在一起直接写 SQL 反而更清晰。返回结构统一用一个 Result 包装类前端根据 code 和 data 渲染出错时弹提示交互上比较完整。5.3 前端地图、柱状图、词云的联调细节ECharts 渲染数据本身很简单真正的坑在联调阶段。第一个坑是跨域。前后端如果不在一起部署SpringBoot 必须配置跨域支持否则前端请求接口直接被浏览器拦截。我用的最简单方案是写一个配置类允许所有来源访问毕设环境够用但如果线上部署还是要收紧跨域范围。第二个坑是地图组件需要注册中国地图的 GeoJSON 数据。ECharts 5 之后不再内置地图需要单独引入地图数据文件。这一步当时卡了我挺长时间因为网上很多教程的引入方式已经过时了。正确做法是下载对应 GeoJSON 文件用 echarts.registerMap 注册后再配置 map 类型的 series。fetch(/json/china.json) .then(res res.json()) .then(geoJson { echarts.registerMap(china, geoJson); myChart.setOption({ tooltip: {}, visualMap: { min: 0, max: 1000, left: 20, bottom: 20 }, series: [{ name: 酒店数量, type: map, map: china, roam: true, data: cityData }] }); });第三个坑是图表容器宽高问题。ECharts 的容器如果没有明确高度渲染出来的图会是一片空白。初始化之前一定要确保容器 div 有确定的宽度和高度不然排查半天也不知道图去哪了。6. 最容易翻车的环境与集群问题内存、版本与数据倾斜6.1 版本兼容矩阵与虚拟机资源规划毕设的大数据环境基本都在虚拟机里搭版本选择往往决定后面要踩多少坑。我当时用的组合是 Hadoop 3.3.x、Spark 3.2.x、Hive 3.1.x搭配 JDK 8。这套组合整体兼容性比较稳网上相关的资料也多遇到问题容易搜到答案。虚拟机资源配置上如果电脑内存是 16G虚拟机建议分配 8G三台虚拟机每台给 2.5G 左右另外留一部分给本机跑 IDE 和浏览器。三节点集群不用每个组件都开全内存不够的话HDFS 的 SecondaryNameNode 可以先不启用Yarn 的 NodeManager 也可以控制资源上限。Hive 在集群上跑还需要额外启动 Metastore 服务这里有个细节Metastore 的数据库我用的 MySQL如果 MySQL 连接串没有指定时区和字符集参数启动时会一直报连接错误非常恼人。连接串里带上 useSSLfalse 和 characterEncodingutf8能省掉不少莫名其妙的报错。6.2 Spark 跑 ALS 时的 OOM 与内存调优ALS 在毕设数据量下本来不应该爆内存但如果你把整个 SparkSession 的 executor 内存配得不对照样 OOM。我当时踩过一次很典型的坑默认配置下 Spark 启动的 executor 内存很小数据加载和 shuffle 阶段直接堆溢出。调优的核心思路不是盲目加内存而是先搞清楚瓶颈在哪。ALS 训练阶段主要是迭代矩阵分解需要在内存里缓存用户和物品的因子矩阵。我最后的做法是执行器内存调到 4GHive 读取时做投影精简只取 user_id、hotel_id、rating 三列别把评论字段也一起 load 进来。spark SparkSession.builder \ .appName(HotelRecommenderTrain) \ .config(spark.executor.memory, 4g) \ .config(spark.driver.memory, 2g) \ .config(spark.memory.fraction, 0.8) \ .config(spark.sql.shuffle.partitions, 20) \ .getOrCreate()另外一个容易被忽略的内存配置项是 spark.memory.fraction。Spark 统一内存管理中默认只有部分内存给执行和存储使用如果这个比例太低缓存 ALS 中间数据时可用内存会不够。调到 0.8 对毕设场景足够了。6.3 数据倾斜与后续扩展思路数据倾斜在我这个项目里表现得很明显热门城市的评论数据量可能是冷门城市的几十倍计算时个别节点任务跑很久其他节点闲着。因为按城市分区Hive 的统计查询在热门城市分区上负载明显偏高。解决办法有两个思路一个是在 Spark 做 join 或聚合时对倾斜 key 加随机前缀打散数据另一个是调整逻辑避免把热点数据堆在一个 reducer 上。毕设项目数据量小倾斜对整体效率影响不大但答辩时如果被问到“生产环境数据量大怎么办”能说出加盐打散、两阶段聚合这些方案是会加分的。做完这个项目我个人最大的体会是毕设的价值不在代码量而在整条链路的贯通感。从网页上一条条酒店信息到 HDFS 里的数据文件到 Hive 里的业务宽表再到 Spark 模型产出的推荐列表最后变成浏览器里的一张张图表每一个环节都在解决上一环节留下的问题。很多同学做完这一套之后再去准备面试聊大数据组件时心里是很踏实的因为你真的知道数据是怎么一步步流过去的。如果时间还充裕可以接着把推荐结果的在线缓存用 Redis 做一下再加一个简单的用户注册和登录流程整套系统的完整度会再上一个台阶。