1. 为什么要搭一套Hadoop生态来做就业推荐:技术选型复盘
做就业推荐系统这件事,最开始我的想法很朴素:爬点职位数据,用Python写个协同过滤,再套个Flask接口,就算完了。但真正动手之后发现,问题远没有这么简单。这里说的“就业推荐系统”,不只是给用户推荐几份职位列表那么简单。它得处理三个维度的数据:求职者的简历结构化信息(技能、工作年限、期望城市、期望薪资)、职位描述(JD文本、技能要求、薪资范围、公司属性)、还有用户在平台上的行为日志(浏览、收藏、投递、跳过)。这三类数据形态不同、量级不同、更新频率也不同。如果只用一个MySQL或者MongoDB,数据量到了一定规模之后,查询慢、计算慢、特征拼接也麻烦,更别说跑协同过滤那种全量相似度计算。
当时我给自己定的技术路线就是标题里写的那样:Python作为开发语言,Hadoop负责底层存储,Hive做数据仓库和ETL,Spark做分布式计算和特征工程,机器学习用来做召回和排序的基线,深度学习用来做排序模型。这个组合看起来“重”,但在就业推荐这个场景里,它是合理的。
先复盘一下为什么是这几个组件。
Hadoop/HDFS解决的是存储问题。 简历数据、职位快照、行为日志,这些属于典型的半结构化或非结构化数据。行为日志尤其明显,它可能是JSON、可能是CSV、可能是自定义格式的文本,一天少说几千万条。HDFS的横向扩展能力和对海量小文件不太友好但整体可控的特性,决定了它适合当底座。它在设计上就是容忍廉价机器、把数据分散存多份,单点挂了不丢数据,随便加节点就能扩容。
Hive解决的是“怎么管理这些数据”的问题。 你不能让下游的算法工程师和数据分析师直接去解析HDFS上的原始文件,那效率太低了。Hive把HDFS上的文件映射成表,提供SQL接口,让ETL、数据查询变成写SQL的事,这能节省大量人力。这个系统里,我用Hive做数仓分层,把原始数据经过清洗、标准化、特征拼接之后,形成可以直接喂给Spark训练的表。
Spark解决的是计算问题。 训练推荐模型之前,要算用户相似度、物品相似度,要做特征交叉、分组统计、负样本采样,这些任务涉及大量Shuffle和聚合操作,MapReduce跑起来太慢,Spark的基于内存的计算就合适得多。而且我后面用了PySpark,可以直接复用Python的生态,写起代码来效率高。再加上Spark SQL可以和Hive无缝衔接,读Hive表、写Hive表基本都是零成本。
机器学习和深度学习解决的是推荐效果问题。 存储和计算都是基础设施,真正决定用户点不点、投不投的是推荐算法。我当时做了两条线:一条是用机器学习模型(协同过滤、FM、逻辑回归)做召回和排序基线,保证可解释性和稳定性;另一条是用深度学习模型(DeepFM或带Attention的DNN结构)做排序,冲一下AUC。两条线并存,线上可以对比,既响应了“深度学习”这个关键词,又不至于把整个系统押在一个不好解释的黑盒上。
这套技术栈选完,我心里其实挺踏实的。它不是把热门组件堆在一起,而是每个组件都在链条上找到了自己的位置:HDFS管存、Hive管表、Spark管算、算法管推荐,Python贯彻始终。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据链路怎么搭建:从原始简历到Hive特征宽表
有了选型,接下来就是数据链路的工程化问题。这一节是很多人容易轻视但实际最耗时间的部分。你得明白,算法模型90%的时间都在跟数据打交道。数据搞得乱七八糟,模型再花哨都没用。
2.1 数据来源与采集方式
就业推荐系统的数据来源,我做的时候考虑到合规性和可获取性,没有去爬真实招聘平台的数据,而是用了两部分:一部分是公开的求职数据集(比如Kaggle上的一些简历数据集和职位数据集),另一部分是自己写程序模拟生成的行为日志。模拟数据也有讲究,不能全随机生成,那样训练出来的模型跟真实场景偏差太大。我给模拟行为加了规则倾向,比如:技能匹配度高的用户更容易投递该职位、薪资范围接近的职位更容易被收藏、同一城市的职位浏览量天然偏高。这样生成的数据有内在相关性,模型才能学到真实信号。
行为日志我用Python脚本生成,按时间序列写入Kafka,再由一个Spark Streaming或批量任务落地到HDFS。考虑到毕设或中小型项目的体量,没有踩实时流的大坑,用的最多的是每天凌晨跑一次离线批量导入。
2.2 Hive数仓分层:ODS、DWD、DWS
很多人建Hive表就是一把梭,所有数据塞一张大宽表。这种做法在小数据量下勉强能用,但一旦字段增多、来源增多,就会失控。我采用了标准的三层数仓结构:
- ODS层(原始数据层):原样存放采集到的简历数据、职位数据、行为日志,不做过多的清洗,最多做一下压缩格式转换。这一层的作用是保留全量历史,便于回溯。
- DWD层(明细数据层):对ODS的数据做清洗、去重、字段标准化、维度退化。比如把“JAVA工程师”“Java开发”“java后端”统一映射成“Java开发工程师”,把薪资“1.5万-2.5万”解析成结构化上限下限字段。这一层的产物是可分析的明细数据。
- DWS层(汇总数据层):面向业务和算法,做轻度汇总,把明细数据聚合到用户维度、职位维度的特征指标,最后形成算法直接吃的那张特征宽表。
这种做法最大的好处是:当你发现特征有问题时,可以逐层去排查,是原始数据的问题、清洗逻辑的问题、还是汇总口径的问题。小项目前期看起来是繁琐了一些,但它留了应对变化的余地。
2.3 特征宽表怎么构建:Spark SQL的实战
特征宽表是整个推荐系统的数据核心。我当时设计的宽表包含三块特征:
- 用户特征:性别、年龄区间、工作年限、期望城市、期望薪资、技能标签列表,比如“用户近30天浏览职位数”“用户投递次数”“用户活跃天数”。
- 职位特征:职位名称、城市、薪资范围、学历要求、经验要求、公司规模、技能标签。比如“职位近30天被浏览次数”“被投递次数”“投递率”。
- 用户-职位交叉特征:用户技能与职位技能重合度(这是关键的强特征)、用户期望城市与职位城市是否一致、用户期望薪资与职位薪资的匹配度、用户常搜岗位与职位类别的相似度。类似“技能重合度”这种特征,相关性极高。
构建这些特征,用PySpark SQL写了一段数据加工逻辑,核心比例如下:
python复制from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = SparkSession.builder \
.appName("employment_rec_feature_engineering") \
.enableHiveSupport() \
.getOrCreate()
# 读取Hive中的DWS层表
user_df = spark.sql("SELECT * FROM dws_user_features")
job_df = spark.sql("SELECT * FROM dws_job_features")
behavior_df = spark.sql("SELECT * FROM dws_user_job_behavior")
# 计算用户技能与职位技能重合度
def build_overlap_features(user_df, job_df):
return user_df.join(job_df, on="user_id") \
.withColumn(
"skill_overlap_count",
F.size(F.array_intersect(F.col("user_skills"), F.col("job_skills")))
) \
.withColumn(
"skill_overlap_ratio",
F.round(F.col("skill_overlap_count") / F.greatest(F.size(F.col("job_skills")), F.lit(1)), 4)
)
overlap_df = build_overlap_features(user_df, job_df)
技能重合度这个特征,后来离线评估时发现它是所有特征里重要性排前三的。也很好理解,招聘场景里,岗位和人的匹配首先看的就是技能是否对齐。这个字段在原始数据里根本没有,必须自己做交叉加工,这就是特征工程的意义。
把特征宽表落到Hive的时候,有几件事必须注意。第一,分区字段要先行设计好,一般用日期分区,一天一个分区;第二,存储格式用Parquet,列式存储跑起特征查询快很多,还天然支持压缩;第三,小文件问题要顺手处理,后面专门讲。
这样一个链路跑通之后,算法拿到的就是一张比较干净、特征丰富的用户-职位样本集,这张表可以同时用于离线训练和线上打分。数据这层的功夫下足了,后面模型的效果才有保障。
3. 推荐引擎核心:从机器学习协同过滤到深度学习排序
推荐引擎的架构,我采用的是业界最常见的“召回 + 排序”两阶段设计。召回阶段从百万级职位里快速筛出几百个候选,排序阶段再对这几十几百个候选精排,算分、排序、返回TopN。
3.1 召回层:基于Spark的ItemCF实现
召回方案的选型,我对比过UserCF(基于用户的协同过滤)和ItemCF(基于物品的协同过滤)。在就业场景里,我更推荐ItemCF。原因很实际:用户的求职意向变化快,这个月找Java开发,下个月可能想转大数据,UserCF找的是“和你相似的人看过的职位”,语义漂移比较大;ItemCF找的是“和你交互过的职位相似的职位”,结果更集中在用户当前的技能方向附近,可控性更好。而且职位数量远小于用户数量,物品相似度矩阵的计算和更新成本更低。
ItemCF在Spark上实现,核心思路就是:根据用户历史行为(浏览、投递)构造“用户-职位”共现矩阵,然后计算职位之间的余弦相似度或Jaccard相似度,再对用户的历史职位加权后汇总得到候选职位得分。简单版核心代码如下:
python复制from pyspark.ml.recommendation import ALS
# 说明:ALS属于协同过滤的矩阵分解实现,这里用来做召回候选生成
als = ALS(
maxIter=10,
regParam=0.1,
userCol="user_id",
itemCol="job_id",
ratingCol="rating",
coldStartStrategy="drop"
)
model = als.fit(train_df)
# 为每个用户召回Top200职位
user_recs = model.recommendForAllUsers(200)
加权评分有个细节:用户的不同行为要赋予不同权重。投递肯定比浏览权重高、收藏比投递权重略低但比浏览高,这样能反映用户的主动偏好。我在实操中用的权重是:浏览1分、收藏3分、投递5分。这些权重不一定要很精确,但方向必须对,后面可以调。
Spark跑ItemCF的相似度计算时,最怕的就是数据倾斜,某个热门职位跟大量用户有点击关系,会导致某个key的计算任务巨慢。后面会专门讲怎么处理。
3.2 排序层:从逻辑回归到DeepFM
召回解决了“大概可能喜欢什么”,排序则要解决“到底推哪些排在前面”。我做排序模型时做了两版,一版是机器学习基线:逻辑回归和FM模型;一版是深度学习排序:参考DeepFM的结构。
为什么排序要从LR或FM做起?因为推荐的排序任务里,特征极大多数是高基数的离散特征,比如用户ID、职位ID、技能标签ID。这类特征直接喂给DNN是不行的,必须先做Embedding化。FM天然擅长处理稀疏特征的一阶和二阶交叉,训练快、效果稳、可解释性强。它就像一个基础版,能帮你快速验证特征工程有没有做对。
DeepFM是在FM上加了深度部分,让模型能捕捉更高阶的特征交互。这是我最终的排序主力模型。整个结构是这样:
python复制# 用TensorFlow/Keras实现一个简化版DeepFM
import tensorflow as tf
from tensorflow.keras import layers
def build_deepfm(feature_columns):
# 输入层:按特征列构建
inputs = {feat.name: layers.Input(shape=(1,), name=feat.name, dtype=tf.float32) for feat in feature_columns}
# FM一阶部分
linear = layers.Dense(1)(tf.concat(list(inputs.values()), axis=-1))
# 二阶交叉部分(简化版:用FM层)
# 深度部分
deep_input = layers.Concatenate()(list(inputs.values()))
deep_x = layers.Dense(256, activation="relu")(deep_input)
deep_x = layers.BatchNormalization()(deep_x)
deep_x = layers.Dropout(0.3)(deep_x)
deep_x = layers.Dense(128, activation="relu")(deep_x)
deep_x = layers.BatchNormalization()(deep_x)
deep_x = layers.Dropout(0.3)(deep_x)
deep_out = layers.Dense(1, activation="sigmoid")(deep_x)
output = layers.Add()([linear, deep_out])
model = tf.keras.Model(inputs=inputs, outputs=output)
return model
实际训练时有一件事必须提:负样本采样。用户只看了100个职位、投了10个,剩下几十万个职位是没交互的。如果全部当负样本训练,模型会严重偏斜。我采取的办法是:对于每个训练正样本,随机采样若干未被曝光的职位作为负样本,采样比例控制在1:4到1:8之间。再对负样本做一点“困难负样本”增强,即多选一些和正样本相似但用户没点的职位,让模型学得更精细。
训练好的模型保存成HDFS上的模型文件,线上打分时用Spark加载模型,对召回的候选职位批量打分、排序。这个流程可以在批次任务里完成,实测时上千万条候选打分也就几分钟的事。
3.3 为什么没有直接端到端用深度强化学习
很多同学习惯看到“深度学习”就问:为什么不用强化学习?招聘推荐这个场景下,用户一个session内能看到的职位有限,反馈延迟也比较长,强化学习需要大量的在线交互试错,在小项目中根本跑不起来。所以我的结论是:召回+排序,先保证系统能稳定产出合理结果,再去想更高级的玩法。这个设计思路对于大多数毕业设计或新手实践者来说更有参考价值。
4. 冷启动、实时缓存与存储优化:三个真正决定系统能不能用的细节
模型架构搭好了,但真正让系统“能用”的往往是工程细节。我在这里栽过跟头,也在这一块获得了最多的成长。
4.1 新用户冷启动怎么破
就业推荐系统天然就有冷启动问题:新用户注册进来,没有任何行为数据,ItemCF召回直接失效,深度学习模型吃不到历史行为特征,输出基本是瞎猜。我当时用了一个分层冷启动策略:
- 第一层(规则兜底):新用户来了,根据他填写的期望职位、期望城市、期望薪资,直接抛SQL查“最匹配的职位TOP50”作为基础推荐流。这个逻辑不用任何模型,就是一个带权重的查询排序:技能匹配度权重0.5,城市匹配0.2,薪资匹配0.2,公司规模0.1。
- 第二层(内容相似召回):如果用户填写的技能标签不全,就利用职位之间的内容相似度做推荐。比如用户填了“Python”,我召回“Python开发”“数据分析”等标签相近的职位,这个可以提前把职位标签向量化。
- 第三层(探索投放):在用户后续的推荐流里,固定保留10%的随机探索位,让新职位和新用户的交互有机会发生。没有这10%探索,系统会越来越偏向热门职位,长尾职位永远没机会,用户也容易失去新鲜感。
实测下来,这三层策略基本能cover掉冷启动问题。用户浏览3~5个职位之后,协同过滤和深度学习模型就能逐步接棒了。
4.2 实时特征为什么要用Redis
离线训练的特征是T+1的,当天算完,第二天用。但用户点击了一个职位之后,他退出再进入推荐页,系统应该马上知道他刚看过什么、对什么感兴趣。这个“实时性”如果做不到,推荐体验会很生硬。
我在系统里引入了一个轻量的在线特征模块。用户的实时行为(点击、收藏、投递)用Flask接口接收,异步写入Kafka,同时写一份到Redis,给在线推荐打分时当实时特征用。Redis里存两类东西:一是用户最近N次行为列表,比如“最近浏览的10个职位ID”,二是用户最近一次的技能关键词集合。打分时Spark或Python进程直接读Redis,把实时行为拼进特征向量里,再喂给排序模型。
这个做法有个好处,它不改变离线训练模型,只是在特征层面加入实时信息,模型经得起训练(离线分布)和推断(在线分布)的分布漂移。
4.3 Hive和Spark的存储优化实践
存储优化是我在实际跑数据时被逼出来的,有三个点非常值得说:
第一,Hive分区与分桶。 分区字段用日期,这是常规操作。但还有一类查询是按城市或职位类别的,加分区不方便,我当时对职位表按job_category做了分桶,能显著提升join和group by这类操作的速度。分桶数量设置和文件大小有关,经验值是一个桶128MB左右。
第二,小文件合并。 这个坑太经典了。Spark写Hive表时,如果分区多、并行度大,会在每个分区下生成几百上千个几十KB的小文件,NameNode内存被大量占用,后续读表时Map数暴增,任务慢得吓人。我的处理办法是:写表前用coalesce或repartition控制输出文件数,写完后增加一步小文件合并作业,对目标分区做一次insert overwrite重写。另外一个更省事的方案是开启Spark AQE动态合并:
bash复制spark.sql.adaptive.enabled=true
spark.sql.adaptive.coalescePartitions.enabled=true
# 设置合并后的目标分区大小
spark.sql.adaptive.advisoryPartitionSizeInBytes=128MB
第三,Hive表存储格式。 建议优先选择Parquet或ORC,不要用默认的TextFile。我在项目里把DWD和DWS层核心表都换成了Parquet,配合Snappy压缩,读取速度提升非常明显。Parquet的列式存储特性对宽表尤其友好,只读用到的列时,扫描的数据量能减少80%以上。
这些细节不会写进算法论文里,但少了它们,系统跑起来就是又慢又不稳定。项目交付之前,花时间在存储优化上绝对是值得的。
5. 跑批任务时翻过的车:小文件、数据倾斜、OOM的完整排查链路
这一节价值可能比前面所有内容都高,因为真实环境里的问题,在很多教材和博客里根本找不到答案。我把自己在跑Spark+Hive任务时踩过的三个大坑记下来,顺便还原一下排查过程,供大家参考。
5.1 数据倾斜:为什么总有一个Task快不起来
现象很典型:跑一个按照职位ID做聚合的作业,Spark控制台显示99%的任务都完成了,就剩一个Task在那边转了快半小时都不结束。一开始我以为是自己代码有死循环,后来才反应过来是数据倾斜。
排查链路是这样的:
- 我先到Spark UI里看Scheduler Stage,找到那个执行时间异常长的Stage。
- 点进去看每个Executor的处理时间、Shuffle Read和Shuffle Write,发现某个Executor的Shuffle Write远大于其他Executor。
- 然后用SQL排查数据分布,确认问题所在:
sql复制-- 统计每个职位的交互量,看是否存在极热门职位
SELECT job_id, COUNT(*) AS cnt
FROM dws_user_job_behavior
WHERE dt = '2024-05-20'
GROUP BY job_id
ORDER BY cnt DESC
LIMIT 10;
结果显而易见,热门的“Java开发工程师”职位有上百万条交互数据,其他职位只有几千条。单个JobID作为Group By key时,所有数据全压到了一个ReduceTask上。
解决办法:
- 给热点key加随机前缀,打散之后再聚合。比如热门职位ID加随机数0~49,分两步聚合。
- 优化Join:把有可能导致倾斜的key事先广播出去,用Broadcast Join代替SortMergeJoin。
- 提高Shuffle并行度:原来是200个分区,改成了800个,倾斜问题虽然没根治但被分散了。
- 最根本的办法还是从数据源头解决:单独处理热点key,不参与常规聚合路径,最后再union回来。
5.2 Spark作业OOM:执行内存和存储内存的博弈
有一次跑DeepFM训练数据的特征拼接,Spark作业老是报Container killed by YARN for exceeding memory limits。刚开始我以为是数据量大到撑不住了,想着加内存,但加上去还是挂。
后来查了Spark内存管理机制,发现问题是内存配置不合理。Spark执行内存(executionMemory)和存储内存(storageMemory)是共享300MB保留内存之外的统一内存池的。默认spark.memory.fraction=0.6,意味着只有60%的堆内存用于执行和存储。当我在同一个Executor里既要缓存特征宽表DataFrame,又要做大量Shuffle聚合时,存储缓存占了内存,执行内存不够,频繁溢写到磁盘,导致GC大扫除,最后OOM。
我调整了一组参数,问题解决了:
bash复制spark.executor.memory=8g
spark.executor.memoryOverhead=2g
spark.memory.offHeap.enabled=true
spark.memory.offHeap.size=2g
spark.sql.autoBroadcastJoinThreshold=10485760
# 如果缓存数据不重用,直接关掉存储内存
spark.memory.storageFraction=0.3
核心思路是:给PySpark留足堆外内存,因为Python UDF会消耗额外开销;同时调低storageFraction,让执行内存更充足。还有一个细节,能用DataFrame API就不要写大量Python UDF,UDF的序列化开销大,性能能差出好几倍。
5.3 HDFS小文件失控之后
小文件问题前面提了一嘴,这里讲一下我完整经历的灾难。有一次跑完一个特征生产任务,发现HDFS上生成了数万个几十KB的小文件。当时没在意,结果第二天跑Hive分析时发现任务启动就花了20分钟,最后还失败了,因为Map数量实在太多了。
排查时先确认了问题来源:我那天用手动方式向Hive表插入了一个分区,但Spark的并行度设到了2000,2000个Task给同一张表写数据,每个Task写出的文件大小不一样,大部分Task写出的数据量很小,导致小文件泛滥。
解决步骤:
- 先清理:把该分区drop掉,用正确的并行度重新生成。
- 制定规范:写表前先估算输出数据量,文件数控制在“数据量/128MB”的量级。
- 开AEQ,让Spark自动合并过小分区。
- 对生产链路加了一个小文件巡检脚本,每天检查关键表的分区文件数,超过阈值就告警,触发合并任务。
这几个坑处理完之后,整个批处理链路的稳定性上了一个大台阶。以前每天跑任务前提心吊胆,后来基本可以睡个安稳觉了。这些排查思路也让我明白:大数据项目,一半以上的复杂度在处理分布式环境下资源分配、数据分布、文件组织这些“非算法”的琐事上。
6. 效果评估和几个值得记住的复盘
系统做完了,但“做完了”和“做得怎么样”是两码事。我在项目最后阶段做了一轮比较完整的效果评估,这部分对于毕设答辩和项目复盘都很有说服力。
6.1 离线指标和线上指标怎么选
离线评估我主要看这几个指标:
- 精确率Precision@K和召回率Recall@K:推荐列表里有多少是用户真正发生交互的,尤其是投递行为。
- AUC:排序模型对正负样本区分能力的整体度量。我最后DeepFM的测试集AUC在0.82左右,FM基线是0.77,提升还是比较明显的。
- NDCG@K:衡量排序的准确性,即正确的职位是否排在了更靠前的位置。这是推荐列表质量更敏感的指标。
值得强调的是,离线指标高不代表线上效果好。我做了个小规模的AB对照实验,一组用户走深度学习排序模型,一组用户走FM排序模型,主要观察CTR、投递率和7日留存这几个线上指标。刚开始线上CTR提升并不明显,但投递率有明显提升,说明深度学习模型找到的人岗匹配度确实更好。
6.2 整个项目里最值得回味的三个经验
第一,架构选型决定后续所有事情的复杂度。 一开始如果怕麻烦,只用MySQL和Scikit-learn,也许Demo能跑通,但数据量增大的时候推倒重来的成本是灾难级的。用Hadoop生态一开始学习曲线陡一点,但数据链路越走越顺。
第二,特征工程比模型重要得多。 我在这个项目里反复体会到:AUC提升最明显的不是换模型,而是加了一个好的交叉特征。比如“技能重合度”这一特征上线之后,AUC从0.78直接提升到0.81。特征决定效果上限,模型只是尽量逼近这个上限。
第三,工程稳定性是推荐系统的隐形门槛。 数据倾斜、小文件、OOM这些问题不比算法简单,它们决定了系统能不能稳定产出结果。一个算法效果再好,如果任务每天跑挂,那它也只是一个PPT系统。
项目做到后期,我把整套代码搭成了一个可复用的模板:Hive建表脚本、PySpark特征工程、训练脚本、打分脚本、Flask在线服务。以后如果要换到商品推荐、内容推荐场景,核心链路不变,换数据和特征就能快速迁移。这也算是这套系统做完之后留给我自己的一个额外收获吧。
