去年帮一位学弟救火毕业设计,题目就是“基于SpringBoot+大数据爬虫Hadoop+智能AI大模型的兼职聚合与个性化推荐平台”。一眼看过去,SpringBoot、Hadoop、爬虫、AI大模型四个关键词堆在一起,表面像个技术全家桶,但真正拆解下来,这套系统其实是一条非常完整的数据链路:爬虫负责把分散的兼职信息抓下来,Hadoop负责存储和离线统计,SpringBoot对外提供API和业务能力,最后AI大模型在数据基础上做个性化推荐。整个项目既有业务场景,又有工程深度,做出来放在简历里也非常能打。这篇博文我就把这个项目的整体设计、核心实现、以及那些在文档里根本不会写的坑,完整梳理一遍,给你一个可以直接套用的模板。
1. 项目整体设计:从业务痛点倒推技术选型
1.1 兼职聚合到底要解决什么问题
先想清楚业务场景。兼职市场需求一直存在,但信息极度分散:综合性招聘平台有自己的兼职频道,垂直兼职网站有独立App,本地生活社区、校园论坛、公众号也会不定期发一些零散的招人信息。对找兼职的学生和上班族来说,逐个平台去翻效率太低,而且不同平台的信息格式、更新频率、真实度都不一样。一个聚合平台的价值就在这里:把分散的岗位信息统一采集、清洗、结构化,让用户在一个入口就能看到全网可用的兼职机会。
这个需求听起来简单,落地却有讲究。聚合平台不是简单把数据堆在一个列表里,还要考虑信息过期的问题。兼职岗位的生命周期很短,今天挂出来的服务员、发单员、展会协助,可能一周后就招满了。所以在做技术方案的时候,就必须把“增量采集”和“过期下架”作为核心功能来设计,而不是一次性爬完就完事。这也是为什么架构里需要一个定时任务调度器来驱动爬虫周期运行。
1.2 技术栈的分工逻辑:每一个组件都有明确职责
这个项目技术栈多,但如果把它们当成一个流水线来看,逻辑就非常清晰。爬虫(我用Python实现)负责数据采集,产出的是半结构化、带各种噪声的原始数据;Hadoop负责对海量原始数据进行存储和离线分析,跑出统计结果和数据快照;SpringBoot作为后端主框架,负责用户管理、职位查询、行为上报这些在线业务,同时承担定时任务的调度入口;AI大模型负责推荐引擎,把用户的偏好和职位描述做语义匹配,输出个性化的职位列表。
整条数据流是:定时调度触发爬虫 → 原始数据先落临时存储 → 写入HDFS → MapReduce离线清洗和统计 → 结构化结果写入MySQL → 用户通过SpringBoot API访问职位数据 → 用户行为(浏览、收藏、投递)记录回流 → 推荐服务读取行为数据和职位特征 → 大模型计算打分 → API返回推荐列表。
这套链路设计下来,每一个技术组件都找到了它不可替代的位置,用这种思路去做答辩时解释技术选型,也远比你背“因为××技术很流行”要有说服力得多。
1.3 为什么用Hadoop而不是只用MySQL
很多人会问,只有一万多条数据,MySQL完全能存,为什么要绕一圈Hadoop?这个问题在答辩时也几乎必问。我的理解是:Hadoop在这个项目里承担的是“离线数据仓库”的角色。爬虫采集的原始数据量级虽然不大,但格式很脏,JSON、HTML片段、半截描述混在一起,不适合直接喂给业务数据库。把这些原始数据放在HDFS里,既保留了最底层的原始现场,又能用MapReduce批量处理成干净的结构化数据。另一方面,平台如果后续扩充数据源,每天新增几万条原始记录,MySQL直接扛业务读写就会吃力,但HDFS不需要管索引和事务,横向扩展也容易。从学习和演示的角度来说,Hadoop跑通一条完整的“落地→清洗→分析→回读”链路,本身就是这个项目最有含金量的部分。
2. 数据采集层:爬虫模块的设计与数据治理
2.1 采集目标与页面解析策略
爬虫第一步是确定数据源。我用的是几个公开招聘平台的兼职频道和两个垂直兼职站点,全部是公开信息页面。需要注意,爬虫只抓取公开可访问的列表页和详情页,不涉及需要登录才能看到的非公开信息,更不碰用户个人数据,这个边界从一开始就要在代码里明确下来,除了合规问题,也避免平台方的法律风险。
解析方案用的是Requests + XPath。Requests负责发请求拿HTML,XPath负责定位页面节点。比如职位列表页的每条记录,通常都包在一个特定的div或li节点里,通过XPath路径就能精确抽取标题、公司、薪资、地点这些字段。XPath的核心逻辑可以这样写:
import requests from lxml import html def parse_job_list(page_html): tree = html.fromstring(page_html) items = tree.xpath('//div[contains(@class, "job-item")]') jobs = [] for item in items: title = item.xpath('.//span[contains(@class, "job-title")]/text()') company = item.xpath('.//div[contains(@class, "company-name")]/a/text()') salary = item.xpath('.//span[contains(@class, "salary")]/text()') if title and company and salary: jobs.append({ "title": title[0].strip(), "company": company[0].strip(), "salary": salary[0].strip(), "source_url": item.base_url }) return jobs解析策略上有一个经验:先抓列表页,拿到每条职位的详情页链接,再并发请求详情页补充描述、标签等信息。但详情页请求量大,频率控制不好容易被封,所以我的做法是列表页正常抓,详情页只对前N条做增量补充,剩下的字段用规则从列表页信息中扩展。
2.2 数据清洗与结构化存储
原始数据里能直接扔进数据库的很少。薪资字段是最典型的例子,“200-300元/天”“面议”“15元/小时”“月薪4K-6K”各种格式都有,必须统一解析成最低值和最高值字段。地点字段也要归一化,把“北京朝阳望京”“朝阳区”统一映射到城市和区域的标准化格式。标签抽取使用关键词库加简单规则匹配,比如描述里出现“日结”就打上“日结”标签,出现“学生”就打上“学生兼职”标签。
清洗后的数据模型大概是这样:
| 字段 | 说明 | 示例 |
|---|---|---|
| job_id | 唯一标识,URL哈希生成 | 8f3a2c9e |
| title | 职位名称 | 展会现场协助 |
| company | 公司名称 | 某某文化传媒 |
| salary_min | 最低日薪 | 200 |
| salary_max | 最高日薪 | 300 |
| location | 标准化城市/区域 | 北京-朝阳 |
| tags | 逗号分隔标签 | 日结,学生,短期 |
| publish_time | 抓取到的发布时间 | 2025-06-12 |
| source | 来源平台标识 | 58 |
| source_url | 原始链接 | https://... |
清洗之后的数据分成两条路:一条写入MySQL供在线业务查询,一条保留原始格式传入HDFS,作为后续离线分析的输入。这里是整个项目数据流中承上启下的一环,写代码的时候不要把清洗逻辑和采集逻辑写在一起,采集程序只负责产出原始数据,清洗任务单独跑,这样即便解析规则变化也不需要重新抓取。
2.3 反爬应对与采集稳定性保障
爬虫最怕的就是跑两天被限制访问。我的经验是优先做好“伪装”和“节流”。User-Agent随机轮换是必须的,最好维护一个常见的浏览器UA池;每次请求间隔控制在2到5秒随机,既不会太慢,也不会触发频率限制。如果设计目标是一万条数据,以单线程加随机延时的速度,大概需要几个小时跑完,完全可以接受,不必追求高并发。
代码里还需要做失败重试机制。网络超时、页面结构临时调整、服务器返回错误码,都是爬虫家常便饭。重试两次、间隔递增是比较稳妥的方案。采集过程中我用了简单的断点记录:每爬完一个列表页就把当前页码记录到本地文件,进程意外终止后可以接着跑,不用从头开始,这个细节在实际调试中省了很多时间。
3. 大数据处理层:Hadoop在项目里的真实角色
3.1 HDFS存储与MapReduce离线分析
Hadoop在毕设项目里最常见的误区是想让它“全干”,既做存储又做实时查询,结果什么都做不好。这个项目里我给它定的角色非常明确:HDFS负责保存爬虫采集的原始数据文件和系统运行日志,MapReduce负责跑离线统计分析。统计分析的任务也很具体:统计各城市职位数量并排序、统计各薪资区间的职位分布、统计各标签的频次Top20、按周统计新增职位趋势。这些统计结果输出到HDFS的指定目录,SpringBoot再通过HDFS API读取结果文件,提供给前端做可视化展示。这个设计让Hadoop真正参与到业务中,而不是装个环境跑个wordcount就结束。
3.2 伪分布式环境搭建与配置细节
Hadoop搭建版本选的是3.3.x,直接跑伪分布式模式,这样一台机器就能完整运行NameNode、DataNode、ResourceManager和NodeManager四个核心进程。装好JDK,配置JAVA_HOME后,核心工作都在四个配置文件和三个启动步骤上:
core-site.xml里设置HDFS文件系统的访问地址:
<configuration> <property> <name>fs.defaultFS</name> <value>hdfs://localhost:9000</value> </property> </configuration>hdfs-site.xml里设置副本数为1(伪分布式只有一个DataNode,默认3会一直报副本缺失告警):
<configuration> <property> <name>dfs.replication</name> <value>1</value> </property> <property> <name>dfs.namenode.name.dir</name> <value>/home/hadoop/data/namenode</value> </property> <property> <name>dfs.datanode.data.dir</name> <value>/home/hadoop/data/datanode</value> </property> </configuration>yarn-site.xml里把资源调度和节点管理器的地址配好:
<configuration> <property> <name>yarn.nodemanager.aux-services</name> <value>mapreduce_shuffle</value> </property> <property> <name>yarn.resourcemanager.hostname</name> <value>localhost</value> </property> </configuration>启动顺序是大坑:第一次启动前必须先执行 hdfs namenode -format 格式化,然后依次用 start-dfs.sh 和 start-yarn.sh 启动。很多人格式化之前没注意配置文件已经改好,启动后NameNode一直起不来,其实八成是core-site.xml的地址和格式化时的默认配置不一致导致的。启动完成后用 jps 命令检查进程,能看到四个核心进程就说明环境没问题。
3.3 清洗后的Hadoop落地流程
爬虫采集的原始JSON文件,我通过HDFS命令行工具直接上传:
hdfs dfs -mkdir -p /data/job/raw hdfs dfs -put ./raw_jobs_20250612.json /data/job/raw/然后提交MapReduce任务做清洗统计。MapReduce任务在纯Java环境下写起来比较啰嗦,但Hadoop生态支持更轻量的方案,比如直接用Hive或者PySpark。不过考虑到这个项目本身就是SpringBoot为主,我更推荐用Hadoop自带的Streaming接口,用Python直接写Mapper和Reducer,代码量小、读起来也直观。
Mapper端按城市维度做计数:
#!/usr/bin/env python3 import sys for line in sys.stdin: data = line.strip().split(",") if len(data) >= 5: city = data[3] print(f"{city}\t1")Reducer端做汇总:
#!/usr/bin/env python3 import sys city_count = {} for line in sys.stdin: city, count = line.strip().split("\t") city_count[city] = city_count.get(city, 0) + int(count) for city, count in sorted(city_count.items(), key=lambda x: x[1], reverse=True)[:20]: print(f"{city}\t{count}")运行命令:
hadoop jar /path/to/hadoop-streaming.jar \ -input /data/job/raw/ \ -output /data/job/stat_city \ -mapper mapper.py \ -reducer reducer.py \ -file mapper.py -file reducer.py注意:输出目录不能预先存在,否则MapReduce会报错。这是Hadoop的一个常见限制,每次跑之前先删掉上一次的输出目录,或者用动态时间戳命名输出路径。
4. 推荐引擎设计:AI大模型如何实现个性化推荐
4.1 从传统推荐到大模型增强的路线选择
推荐模块是整份设计里加分最大的部分。传统做法是协同过滤或者基于内容的标签匹配,但这类方法有明显的瓶颈:用户偏好和职位描述都是短文本、稀疏矩阵,协同过滤在用户行为数据不够时几乎无法生效。所以我在项目里走了“传统推荐打底,大模型增强排序”的混合路线。
具体做法是先用内容匹配从职位库里召回一个候选集(比如80条),再调用AI大模型对候选集进行细粒度打分排序。这样既避免了大模型逐个扫描全量职位的性能问题,也解决了协同过滤冷启动收到干扰的问题。这个设计在答辩时讲出来,技术深度明显高一个层次。
4.2 用户画像与行为特征工程
用户画像的数据来源有三个:注册时的主动选择、行为记录、以及推荐后的反馈。注册表单里让用户选城市、意向岗位类型、期望薪资区间、可工作时段,这是最干净的偏好来源。行为记录包括浏览、收藏、投递三种,权重各不相同:投递权重最高,收藏次之,浏览最低。用户向量由这些行为触达的职位向量累加获得。职位向量则来自职位标签的One-Hot编码加描述文本的语义向量拼接。这个设计即使不用大模型,单做协同过滤也具备一定的推荐合理性,大模型是在这个基础上做增强。
4.3 大模型接入的三种实现方案
我实测下来有三条路线可以走,成本和效果各有取舍。
方案一:调用云端API。将职位描述和用户偏好拼接成提示词,让模型输出一个打分。这种方式效果最好,但每次请求都有网络耗时和费用。适合原型演示,不适合高并发场景。我当时用的逻辑是:先用传统算法筛出Top50,再调用API对这50条做精排,控制在每次请求只处理一批,用户体感基本无感。
方案二:本地部署开源模型。在本地跑一个参数量较小的模型(例如Qwen系列或ChatGLM系列的小版本),用于文本向量化和意图理解。该方案免费、离线可用,但需要一台配置过得去的机器,至少在16GB内存以上才能流畅运行7B级别的模型。模型加载后常驻内存,通过HTTP接口提供服务。
方案三:折中方案,只把大模型用于“推荐理由生成”和“语义扩展”。推荐排序用传统算法,大模型只负责给每条推荐结果生成一句个性化的推荐理由,比如“根据你最近浏览的展会协助类岗位,推荐这个朝阳区的短期活动执行职位”。这样大模型的调用频次大幅降低,响应时间可控,但用户在页面上能直观感受到“AI推荐”的存在感,产品体验提升明显。
我在正式版里实现的是方案三,同时在后台预留了方案一的接口。核心打分逻辑可以这样简化描述:
// 推荐服务伪代码 public List<Job> recommend(Long userId, int topN) { // 1. 召回:基于标签匹配取候选集 List<Job> candidates = jobService.matchByTags(userId, 80); // 2. 粗排:基于行为加权打分 candidates.sort((a, b) -> scoreByBehavior(userId, b) - scoreByBehavior(userId, a)); List<Job> topK = candidates.subList(0, Math.min(50, candidates.size())); // 3. 精排:大模型语义评分 List<Job> ranked = llmService.rerank(topK, userProfile.get(userId)); return ranked.subList(0, Math.min(topN, ranked.size())); }精排的提示词模板大致长这样:
你是兼职推荐助手,请根据用户偏好描述和职位信息,为每个职位给出0到100的匹配分。 用户偏好:{user_profile} 职位列表: 1. {job_1_info} 2. {job_2_info} 请只输出每项的匹配分数,格式为:编号:分数模型返回后再将分数解析出来排序。这里有个细节:大模型输出格式不稳,解析时最好用正则提取数字,而不是指望它严格按格式输出。我一开始让模型直接返回JSON,结果偶尔出现括号、中文描述混进去,解析直接报错,改成纯数字格式后稳定了很多。
5. SpringBoot服务端与系统集成
5.1 后端分层与核心API设计
SpringBoot端我采用了标准的Controller-Service-Mapper三层结构。核心API集中在五个接口:用户注册登录、职位搜索、职位详情、推荐列表、行为上报。职位搜索接口支持多条件组合筛选,包括城市、岗位类型、薪资区间、标签等:
@RestController @RequestMapping("/api/jobs") public class JobController { @Autowired private JobService jobService; @GetMapping("/search") public Result<PageResult<JobVO>> search(JobQuery query) { // query包含city、tag、salaryMin、salaryMax、page、size PageResult<JobVO> page = jobService.search(query); return Result.success(page); } @GetMapping("/detail/{jobId}") public Result<JobVO> detail(@PathVariable String jobId) { return Result.success(jobService.getById(jobId)); } }行为上报接口用了异步处理,用户点击职位、收藏职位、投递职位三个行为都调用同一个接口,后端把行为写入消息表后立即返回,通过线程池异步刷新用户画像,避免写行为日志拖慢主流程。这个设计虽然简单,但体现了对高并发场景的基本意识。
5.2 定时任务驱动爬虫与大数据流程
SpringBoot是整个系统的调度中枢。我配置了一个定时任务,每天凌晨2点启动采集流程:
@Component public class DataSyncTask { // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * ?") public void runCrawler() { // 1. 调用Python爬虫脚本采集增量数据 ProcessBuilder pb = new ProcessBuilder("python3", "/opt/crawler/main.py"); Process process = pb.start(); process.waitFor(); // 2. 对采集结果做清洗入库 crawlerDataService.cleanAndImport(); // 3. 将原始数据上传HDFS hdfsService.upload("/data/job/raw/"); // 4. 提交MapReduce统计任务 mapReduceService.submitStatJob(); } }用ProcessBuilder调Python脚本有一个坑:Python脚本的print输出如果量大,会阻塞进程缓冲。我用脚本重定向输出到日志文件解决了。SpringBoot项目里执行外部脚本时,一定要设置好工作目录和超时时间,不然脚本卡住会一直占着定时任务线程。
5.3 前端集成与部署细节
前端用Vue + Element UI + ECharts,ECharts用来展示MapReduce统计出来的可视化数据。开发阶段前后端分离,接口走代理访问SpringBoot。正式部署时直接把Vue项目打包后的dist目录复制到SpringBoot的resources/static下,这样就是一个单体应用,部署简单,也不需要单独配Nginx。
这里要注意Vue Router的History模式问题:打包放进SpringBoot后,如果用户直接刷新某个子路由页面,会出现404。解决方法是写一个路由转发规则,把非API路径的请求都转发到index.html。还有一个跨域问题迎刃而解的细节:打包后资源走的是同源,不需要再配CORS,但本地联调阶段还是要配允许跨域,所以我在项目里保留了两种环境的配置,用Spring profile切换,开发环境允许跨域,生产环境关闭。
6. 常见问题与排查技巧实录
6.1 爬虫采集零数据或字段缺失
爬虫最容易踩的坑就是页面改版导致XPath失效,表现是程序不报错,但解析出来都是空列表。排查方法是先单独打印一个页面的HTML,确认目标节点的class名称是否发生变化。另一个常见问题是页面内容用了JavaScript动态渲染,Requests直接请求拿不到数据。遇到这种页面,要么换数据接口(很多站点前端会调后端JSON API),要么用Selenium或Playwright做渲染,但后者开销大,尽量优先找页面内嵌的JSON数据。
6.2 Hadoop进程起不来或频繁失联
NameNode启动失败九成是格式化时机不对。第一次格式化前,检查dfs.namenode.name.dir配置的目录是否存在且为空;如果格式化后修改了core-site.xml的端口或路径,必须删除原数据目录再重新格式化,否则会出现ClusterID不一致。DataNode进程起不来的另一个原因是磁盘空间不足,HDFS传输超过1GB的任务时,默认会占大量临时空间,建议把临时目录指到磁盘空间充足的路径,在hdfs-site.xml里配置dfs.datanode.data.dir时选择大分区。
6.3 大模型服务响应超时
本地部署模型首次请求会加载权重,可能要等十几秒甚至几十秒。实际使用时我做了三件事:启动时预热模型、接口超时设置到30秒以上、推荐接口本身增加了缓存。缓存策略是:同一用户一天内的推荐结果先查缓存,只有用户行为发生变化时才重新计算。实测下来推荐接口平均响应时间从8秒降到200毫秒以内。
6.4 MySQL查询慢与推荐冷启动
一万条数据本身不多,但如果搜索接口没有索引,性能也会很难看。我在city、tag、salary_min三个字段上建了联合索引,分页查询性能立刻上来。推荐冷启动问题主要靠热门职位兜底:新用户没有行为数据时,推荐列表先按城市匹配和发布时间倒序排,再放大模型生成一段“为你精选热门兼职”的开场文案,用户在视觉上不会觉得推荐是空的。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 爬虫跑一会就被限流 | 请求频率过高 | 增加随机延时,降低单次任务采集量 |
| XPath解析为空 | 页面改版或模块加载 | 单页调试,查看当前页面DOM结构 |
| NameNode启动失败 | 数据目录异常或配置不一致 | 清空数据目录,重新格式化 |
| YARN任务卡住 | 资源不足或Queue配置错误 | 检查内存配置,降低Map/Reduce数量 |
| 大模型首请求慢 | 模型加载 | 服务启动时预热,增加超时时间 |
| 推荐的职位不是用户想要的 | 用户画像稀疏 | 结合行为反馈,降低非活跃标签权重 |
| 前端刷新404 | History路由未处理 | 添加转发规则,重定向index.html |
| JVM内存溢出 | 爬虫数据批量处理过大 | 分批处理,适当调大Xmx参数 |
整条链路跑通之后,这个项目就不是几个技术栈的简单拼接,而是一个能自圆其说的数据应用系统。我个人实际操作中最大的体会是:这类多技术栈项目,最难的不是某个单独模块,而是数据在各模块之间如何顺滑流动。爬虫产出的数据是否符合Hadoop输入格式,MapReduce输出的统计结果能否被数据库消费,用户行为能否及时回流到推荐模型——这些才真正花时间。建议你自己做的时候,先把一条最小链路跑通:一次采集、一次清洗、一次HDFS落地、一次统计分析、一次推荐召回,然后再横向扩展数据源和优化细节,整个系统复杂度的提升就会非常自然。