Spark+ECharts搭建招聘数据大屏:从清洗到可视化完整实战

Spark+ECharts搭建招聘数据大屏:从清洗到可视化完整实战 简介这是一份基于Spark与ECharts实现的前程无忧招聘网站数据大屏分析源码包主要面向大数据或Web可视化方向的课程设计、期末大作业及自学实践场景。项目围绕招聘数据采集、清洗分析与可视化展示展开包含Python爬虫脚本、可视化分析Notebook、前端大屏页面及配套项目说明难度适中源码均已本地编译可运行评审分95分以上内容经助教老师审定可直接作为学习参考或二次开发基础。包内共48个文件以JavaScript、Python、CSV、CSS及地图文件为主其中JS负责ECharts图表交互Python完成数据爬取与预处理CSV保存清洗后的岗位、城市、薪资等数据HTML与CSS搭建大屏页面另附使用说明、字体及地图配置文件整体约10.14MB目录结构清晰便于按模块查阅。通过该项目可以了解从爬虫抓取到数据清洗、再到ECharts可视化的完整流程涉及城市岗位分布、学历经验薪资关联、关键词热词等分析维度图表类型丰富适合快速掌握数据大屏项目的开发思路。目前已有169人浏览学习需要完成类似课设或想上手Spark可视化项目的读者可参考使用。1. 招聘网站数据分析这事为什么大家都用 Spark ECharts 搭大屏手里有一批前程无忧的岗位明细几十万行Excel 打开要转圈筛选条件一多就卡死。这时候想按城市看岗位分布、按薪资区间看机会多少、按发布时间看招聘热度最顺手的方案就是把明细丢给 Spark 做离线聚合再把聚合结果交给 ECharts 渲染成大屏。这里 Spark 负责扛住数据量、快速出指标ECharts 负责把指标变成中国地图、饼图、折线图这些一眼能看懂的可视化组件。这个组合不需要重型中间件一台开发机就能跑通也是招聘数据分析类项目里最主流的做法。适合准备做数据大屏交付、或者想搞清 Spark 离线分析结果如何被前端消费的工程师往下看。2. 招聘数据大屏的字段设计与 Spark 清洗聚合从原始明细到可绘制指标2.1 大屏指标怎么定先有指标才有图表常见的大屏不是先画图表再找数据而是先定业务指标。招聘数据大屏的指标一般围绕岗位供给展开哪些城市在招人、薪资给到什么区间、学历和经验卡得严不严、最近一个月招聘热度在涨还是跌。把这些指标落到图表上映射关系大致如下指标图表形式需要的字段全国岗位数量分布中国地图城市、岗位数薪资水平分布饼图薪资区间、岗位数招聘热度趋势折线图发布时间、岗位数学历要求分布横向柱状图学历要求、岗位数热门岗位 TOP10排行列表职位名称、岗位数整体数据概况KPI 数字卡岗位总数、公司数、城市数指标定完之后Spark 的分析维度也就定了。每个指标背后都是一次 groupBy 聚合区别只在于分组字段不同城市一个分组、薪资区间一个分组、发布时间按天截断后一个分组。所以 Spark 清洗的核心任务是把源数据里杂乱的字段整理成可以稳定分组的形态。2.2 用 Spark DataFrame 完成字段清洗与标准化招聘网站导出的原始数据通常长这样职位名称、公司名称、城市、薪资、学历要求、经验要求、发布时间。薪资字段是最麻烦的它经常是15-20K·14薪或者8千-1.2万这种混合字符串不处理没法做数值聚合。我这里用 PySpark 演示因为 Python 上手快写聚合逻辑也比 Scala 直观。假设数据在data/job_detail.csvfrom pyspark.sql import SparkSession, functions as F from pyspark.sql.types import StructType, StructField, StringType, IntegerType spark SparkSession.builder \ .appName(job_analysis_clean) \ .master(local[*]) \ .getOrCreate() # 显式声明 schema避免 Spark 对 CSV 字段类型做错误推断 schema StructType([ StructField(job_name, StringType(), True), StructField(company, StringType(), True), StructField(city, StringType(), True), StructField(salary, StringType(), True), StructField(education, StringType(), True), StructField(experience, StringType(), True), StructField(publish_time, StringType(), True), ]) df spark.read \ .option(header, True) \ .option(encoding, UTF-8) \ .schema(schema) \ .csv(data/job_detail.csv) print(f原始数据量: {df.count()}) df.show(5, truncateFalse)这段代码里有两个值得注意的参数。local[*]表示使用本机所有可用 CPU 核招聘数据分析这种量级在本地跑完全够没必要连集群UTF-8编码选项必须显式指定因为国内导出的 CSV 经常是 GBK不指定的话中文城市名会乱码后面 groupBy 出来的结果全是乱码分组。接下来处理薪资和发布时间。薪资字段里混着单位、区间、薪月数处理思路是先把15-20K拆成上下限再取中值作为该岗位的平均月薪带万的要先换算成 Kdef parse_salary_avg(s): 把 15-20K·14薪 转成数值型平均月薪单位 K if not s: return None s s.replace(·, -).replace(×, -) # 统一单位为 K if 万 in s: s s.replace(万, K) s s.replace(K, K, 1) # 占位实际按数字换算 # 简单处理1.5-2万 - 15-20K import re nums re.findall(r[\d.], s) if len(nums) 2: low float(nums[0]) * 10 high float(nums[1]) * 10 return (low high) / 2 else: import re nums re.findall(r\d, s) if len(nums) 2: return (float(nums[0]) float(nums[1])) / 2 return None # 注册成 UDF作用于 DataFrame 的每一行 udf_salary_avg F.udf(parse_salary_avg, double) df_clean df \ .filter(F.col(city).isNotNull()) \ .filter(F.col(publish_time) ! ) \ .withColumn(salary_avg, udf_salary_avg(F.col(salary))) \ .withColumn(publish_date, F.to_date(F.col(publish_time), yyyy-MM-dd)) \ .dropna(subset[salary_avg, publish_date]) df_clean.cache()这里做了三件关键事情。dropna(subset...)把薪资解析失败或时间格式不对的行删掉因为这些行进不了任何聚合publish_date从时间戳里截断到天为后面按天统计趋势做准备cache()把这个清洗后的中间结果缓存进内存因为后面多个指标的聚合都要反复扫描这份数据不缓存的话每个指标都要重新解析一遍原始 CSV。关于 UDF 的性能这里要说明一下。用F.udf写 Python 函数每条数据都要经过 JVM 和 Python 进程之间的序列化传输数据量超过千万行时会有明显开销。招聘数据分析这种几十万到几百万行的场景完全没压力但如果以后数据量上来建议用 Spark SQL 内置的regexp_extract加when表达式改写把逻辑下推给 Spark 原生执行。2.3 批量聚合按城市 / 职位 / 薪资分桶的 DataFrame 写法与参数清洗完成后大屏需要的每个指标就是一次groupBy。城市维度直接分组计数薪资维度需要先做分桶把连续的薪资值切成0-10K10-20K这样的离散区间饼图才画得出来# 指标1城市岗位数输出格式对齐 ECharts 地图的 [{name, value}] city_agg df_clean \ .groupBy(city) \ .count() \ .withColumnRenamed(count, value) \ .withColumnRenamed(city, name) \ .select(name, value) # 指标2薪资分桶用 when 表达式做区间映射 salary_agg df_clean \ .withColumn(salary_bucket, F.when(F.col(salary_avg) 10, 0-10K) .when(F.col(salary_avg) 20, 10-20K) .when(F.col(salary_avg) 30, 20-30K) .when(F.col(salary_avg) 50, 30-50K) .otherwise(50K以上)) \ .groupBy(salary_bucket) \ .count() \ .withColumnRenamed(count, value) \ .withColumnRenamed(salary_bucket, name) \ .select(name, value) # 指标3按天发布的岗位趋势 trend_agg df_clean \ .groupBy(publish_date) \ .count() \ .withColumnRenamed(count, value) \ .withColumnRenamed(publish_date, date) \ .orderBy(date)三个聚合的逻辑完全一样先指定分组字段再计数最后把字段重命名成前端好消费的名字。withColumnRenamed这一步不是可有可无的它决定了 JSON 输出里 key 的名字如果前端约定用name和value那这里就必须对齐否则要么前端改代码要么后端做字段映射多一层没必要的转换。聚合任务跑完后准备输出。这里有一个经常被忽略的参数spark.sql.shuffle.partitions。默认值是 200意味着每个 groupBy 产生的 shuffle 结果会分成 200 个分区文件。招聘数据量小200 个分区大部分是空的输出到磁盘会出现一堆只有几行的小文件。调小这个值再输出spark.conf.set(spark.sql.shuffle.partitions, 4) city_agg.write.mode(overwrite).json(output/agg_city) salary_agg.write.mode(overwrite).json(output/agg_salary) trend_agg.write.mode(overwrite).json(output/agg_trend)mode(overwrite)保证重复跑任务时不会因为输出目录已存在而报错。输出格式用 JSON是因为前端 fetch 可以直接消费不需要额外做序列化或类型转换。到这里Spark 侧的工作已经完成三份聚合 JSON 落在output/目录下接下来就是前端大屏的活了。3. 用 ECharts 搭建招聘数据大屏的前端图表中国地图、饼图、折线图组合3.1 大屏布局与图表选型数据大屏的视觉套路已经非常固定深色渐变背景、发光边框、KPI 数字卡、对称布局。技术上不必上重型框架一个 HTML 文件用 CSS Grid 切出三列布局就够用——中间放中国地图左侧放折线图和热门岗位列表右侧放饼图和学历分布柱状图顶部一排 KPI 数字卡。图表选型有个基本原则一个图表只回答一个问题。城市越多、颜色越深的省代表岗位机会越多这是中国地图薪资分布看哪一段占比最高这是饼图招聘热度看趋势这是折线图。不要在一个图表里堆三个维度的信息大屏不是报表观众只有十秒钟的注意力。具体到这份项目里建议的图标选型如下区域图表类型数据来源中央主图中国地图visualMap 色阶城市岗位聚合左下折线图招聘趋势按天发布岗位数右下饼图薪资分布薪资分桶聚合左上横向柱状图学历要求学历分组计数右侧边栏热门岗位 TOP10 列表职位名称计数排序3.2 中国地图的图与数据格式ECharts 从 5.x 开始不再内置中国地图的 geoJSON 数据需要自己准备一份china.json文件。这份文件是各省的边界坐标数据ECharts 官方仓库的历史版本里能找得到网上也有大量现成的简化版找一份省级精度的就够用不需要市县级。地图初始化的代码骨架如下import * as echarts from echarts import chinaJson from ../assets/china.json // 注册地图数据china 是自定义的坐标系名称 echarts.registerMap(china, chinaJson) const mapChart echarts.init(document.getElementById(mapChart)) fetch(/api/city.json) .then(res res.json()) .then(data { mapChart.setOption({ tooltip: { trigger: item, formatter: params ${params.name}: ${params.value || 0} 个岗位 }, visualMap: { min: 0, max: 5000, left: left, orient: vertical, text: [高, 低], inRange: { // 颜色从浅到深低值浅色、高值深色 color: [#e0f3f8, #74add1, #4575b4] } }, series: [{ type: map, map: china, roam: false, label: { show: false }, data: data }] }) })这里有几个关键点。第一registerMap必须写在init之后、setOption之前顺序反了图表会空白。第二visualMap的min和max需要根据实际数据量调整数据最大值是 800max 设成 5000那所有省份颜色都会偏浅看不出差异先用一条 Spark 聚合任务算出最大值再回头填这个参数经验值max取最大值的 1.2 倍比较合适。第三地图数据里某个省份没有岗位时前端拿到的 value 是undefinedtooltip 里显示成 undefined 个岗位 很丑所以要params.value || 0兜底。3.3 用 Promise 统一封装异步数据加载大屏上同时有五个图表就要同时发五六个请求。最直观但不推荐的做法是在每个图表的初始化函数里各自 fetch这样请求是串行发出的首屏渲染时间等于所有请求时间之和。更好的做法是用Promise.all把所有接口并行拉取。async function loadAllDashboardData() { const [city, salary, trend, edu, topJob] await Promise.all([ fetch(/api/city.json).then(r r.json()), fetch(/api/salary.json).then(r r.json()), fetch(/api/trend.json).then(r r.json()), fetch(/api/edu.json).then(r r.json()), fetch(/api/top_job.json).then(r r.json()), ]) return { city, salary, trend, edu, topJob } } // 大屏入口 loadAllDashboardData().then(allData { initMap(allData.city) initSalaryPie(allData.salary) initTrendLine(allData.trend) initEduBar(allData.edu) initTopJobList(allData.topJob) })用Promise.all的核心收益是并发而不是语法上的精简。五个请求如果串行每个 100ms总耗时 500ms并发后只需要 100ms 左右。在大屏这种信息密度高的场景里用户对首屏时间的感知非常明显。另外把数据加载和图表初始化拆成两个函数将来接入 WebSocket 做实时刷新时只需要重新调loadAllDashboardData再走一遍init*系列结构上不用大改。这里还要注意一个渲染性能问题。ECharts 默认使用 Canvas 渲染大屏上的每个图表都是独立的 Canvas 实例。五个图表同时挂载页面切换标签页再回来时有些图表会变成空白原因是浏览器限制了后台标签页的 requestAnimationFrame。规避办法是在图表初始化后调用一次chart.resize()或者在页面可见性变化事件里统一执行 resize。这个坑在演示大屏时特别容易踩投影仪切信号源回来地图区域白了一块很影响交付效果。4. 打通 Spark 与 ECharts接口层设计与 JSON 数据契约4.1 数据契约先行后端返回什么结构Spark 聚合出的结果最终要变成前端能直接用的数据中间隔着一层数据契约。所谓契约就是后端返回的 JSON 结构长什么样、字段叫什么、值是什么类型前后端共同遵守。ECharts 的series.data最常见的消费格式是对象数组字段名约定为name和value。接口路径返回结构示例/api/city.json[{name, value}, ...][{name:北京, value:1200}]/api/salary.json[{name, value}, ...][{name:20-30K, value:350}]/api/trend.json[{date, value}, ...][{date:2024-03-01, value:80}]/api/edu.json[{name, value}, ...][{name:本科, value:600}]/api/top_job.json[{name, value}, ...][{name:Java开发, value:99}]这个表格就是前后端沟通的全部内容。任何一端的实现发生变化只要最终产出符合这个契约另一方就不需要动代码。Spark 那边做的withColumnRenamed(count, value)操作本质上就是在对齐这个契约。4.2 用 Spark 的 JSON 输出对接前端Spark 写 JSON 有两种方式区别在于数据量。第一种是DataFrame.write.json()把结果按分区写到目录里每个分区一个文件文件名还带一长串随机前缀。第二种是collect()之后用 Python 原生的json.dump落盘适合最终结果很小的场景。招聘数据大屏的聚合结果通常只有几十行到几百行我倾向于用第二种方式省去处理分区文件的麻烦def save_to_json(df, output_path): 把聚合后的 DataFrame 转成 list 再写 JSON保证单文件输出 data df.collect() records [row.asDict() for row in data] with open(output_path, w, encodingutf-8) as f: json.dump(records, f, ensure_asciiFalse, indent2) print(f写出 {len(records)} 条 - {output_path}) save_to_json(city_agg, output/agg_city.json) save_to_json(salary_agg, output/agg_salary.json) save_to_json(trend_agg, output/agg_trend.json)这里要注意row.asDict()的行为Spark 的Row对象转成 Python dict 后字段顺序和数据帧里select的顺序一致所以如果前端要求name在前、value在后中间章聚合时就要用select(name, value)显式排好顺序。另外json.dump的ensure_asciiFalse参数必须写否则中文会被转成\u5317\u4eac虽然 JSON 解析没问题但人眼排查数据时非常痛苦。collect 的开销也值得说清楚。DataFrame 的所有数据会先汇总到 Driver 节点再转成 Python 对象如果聚合结果有几十万行Driver 内存会吃紧甚至 OOM。这也是为什么前面聚合完要把spark.sql.shuffle.partitions调小的原因之一。大屏场景的结果集是这个量级的千分之一collect 是最简单可靠的方案。4.3 本地联调的最小实践Spark 输出的是静态 JSON 文件前端大屏需要的是 HTTP 接口。最小可行方案是用 FastAPI 起一个静态服务把 JSON 文件直接映射成接口不需要数据库、不需要中间件from fastapi import FastAPI from fastapi.responses import FileResponse, JSONResponse import json app FastAPI() # 大屏页面 app.get(/) def dashboard(): return FileResponse(dashboard.html) # 各图表的 JSON 接口直接读 Spark 写出的文件 app.get(/api/{name}) def read_json(name: str): import os file_map { city: output/agg_city.json, salary: output/agg_salary.json, trend: output/agg_trend.json, } target file_map.get(name) if not target or not os.path.exists(target): return JSONResponse({error: data not found}, status_code404) with open(target, r, encodingutf-8) as f: return json.load(f)一个文件就完成了接口层。FastAPI 启动后访问http://localhost:8000就能看到大屏Spark 重跑聚合任务后刷新页面就能看到新数据。这个开发闭环是改数据 → 重跑任务 → 刷新页面三步循环比搭一套完整 Web 服务省事得多。如果大屏页面和接口不在同一台机器上或者页面是直接双击打开的file://协议浏览器会拦截跨域请求。有两个解决方向一是给 FastAPI 加上CORSMiddleware允许本地跨域二是更推荐的做法始终用http://localhost:8000访问页面不走文件协议。后者从根上避免了跨域问题也符合大屏系统的最终部署方式。5. 大屏交付前的三个验证方法与一个容易翻车的坑5.1 数据量一致性验证大屏上线前最怕的不是图表丑而是数据对不上。验证方法不复杂拿城市维度举例把所有城市的岗位数加起来应该等于原始数据的行数。写一个小脚本# 原始 CSV 行数去掉表头 wc -l data/job_detail.csv # 聚合后所有城市岗位数之和用 Python 快速验证 python3 -c import json data json.load(open(output/agg_city.json)) print(城市岗位总数:, sum(item[value] for item in data)) 两个数字不一致说明清洗阶段有数据被误删或者重复计算。常见的原因是薪资字段解析失败导致丢行可以回到第 2 章dropna那一步把被删掉的样本打出来看看是哪些格式没覆盖到。5.2 地图数据空值验证中国地图的另一个坑是省份名称不一致。Spark 聚合出的城市名是北京上海ECharts 地图里的 name 也是北京上海一般能对上但有些生僻地名或者带省市后缀的写法地图上完全匹配不到视觉上表现为某个省份颜色异常浅或者根本不上色。验证方式是打开浏览器控制台看 ECharts 的 warn 日志它会明确提示data item name 不存在于地图中逐个修正即可。5.3 大屏长时间运行的稳定性数据大屏通常要在演示现场开一整天ECharts 图表长时间不动会出现 Canvas 内存缓慢增长。一个有效的兜底策略是定时重绘// 每 10 分钟重新拉取数据并刷新图表避免长时间挂机白屏 setInterval(() { loadAllDashboardData().then(allData { initMap(allData.city) initSalaryPie(allData.salary) initTrendLine(allData.trend) }) }, 10 * 60 * 1000)这里刷新的是图表数据而不是整个页面因为整页刷新会带来一次明显的闪白演示时很掉价。定时重绘的间隔不宜太短数据源本身是 Spark 离线跑出来的一小时更新一次就算高频了。本文还有配套的精品资源点击获取