每年一到毕设季,“大数据+推荐系统”永远是最热门的选题方向。酒店推荐系统更是典型中的典型:业务场景直白、数据链路完整、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", encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return {"completed": []} def save_progress(progress): with open("progress.json", "w", encoding="utf-8") as f: json.dump(progress, f, ensure_ascii=False, indent=2) def fetch_hotel_page(city, page): url = f"https://example.com/hotels/{city}/{page}" headers = { "User-Agent": random.choice(UA_POOL), "Referer": f"https://example.com/hotels/{city}", "Accept": "text/html,application/xhtml+xml,*/*;q=0.8" } resp = requests.get(url, headers=headers, timeout=10) 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 的连接串里加上 characterEncoding=UTF-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], seed=42) als = ALS( maxIter=15, regParam=0.05, userCol="user_id", itemCol="hotel_id", ratingCol="rating", coldStartStrategy="drop" ) model = als.fit(train) predictions = model.transform(test) evaluator = RegressionEvaluator(metricName="rmse", labelCol="rating", predictionCol="prediction") rmse = evaluator.evaluate(predictions) print(f"RMSE = {rmse}") userRecs = model.recommendForAllUsers(10) userRecs.write.mode("overwrite").saveAsTable("dwd_user_recommendations")这段代码直接能跑,但有两个地方要留意。一个是 coldStartStrategy 必须设为 drop,否则遇到训练集里没出现过的酒店 ID,ALS 会默认预测值取反,给你整出一堆负数推荐结果,排查起来特别迷惑。另一个是本地调试时 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() { List<CityCountVO> 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 连接串没有指定时区和字符集参数,启动时会一直报连接错误,非常恼人。连接串里带上 useSSL=false 和 characterEncoding=utf8,能省掉不少莫名其妙的报错。
6.2 Spark 跑 ALS 时的 OOM 与内存调优
ALS 在毕设数据量下本来不应该爆内存,但如果你把整个 SparkSession 的 executor 内存配得不对,照样 OOM。我当时踩过一次很典型的坑:默认配置下 Spark 启动的 executor 内存很小,数据加载和 shuffle 阶段直接堆溢出。
调优的核心思路不是盲目加内存,而是先搞清楚瓶颈在哪。ALS 训练阶段主要是迭代矩阵分解,需要在内存里缓存用户和物品的因子矩阵。我最后的做法是执行器内存调到 4G,Hive 读取时做投影精简,只取 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 做一下,再加一个简单的用户注册和登录流程,整套系统的完整度会再上一个台阶。