简介这是一份基于Spark的共享单车数据分析完整项目覆盖前端与后端代码面向计算机专业毕业设计学生及需要项目实战练习的学习者适用于毕设、课程设计或期末大作业场景。项目旨在解决Spark环境下共享单车数据的采集、清洗、存储、分析与可视化展示等全流程问题能帮助读者建立完整的数据分析项目开发思路。项目包含265个文件其中以185个XML配置、21个Vue组件、6个Java与4个Scala源码文件为主辅以HTML/SCSS页面、JS交互逻辑和Properties等资源压缩包约9.15MB目录结构清晰便于按前端展示、后端服务、数据处理等模块检索与二次开发。目前已有384人学习下载。资源提供全部源码及CSV数据文件经严格调试确保可运行并含有HBase、Spark相关处理逻辑与完整的前后端交互实现读者可参考它快速搭建同类型数据分析系统节省从零编码与调试的时间。1. 拿到“Spark共享单车数据分析”题目先别急着写代码如果你手上这份是带前后端完整代码的毕业设计项目包大概率你在面对的是这样一道题拿到一批共享单车的骑行订单数据用 Spark 做清洗和统计后端提供查询接口前端用大屏把指标展示出来。这类题在本科毕设里出现频率极高因为它一套链路把大数据、后端、前端全串起来了答辩时每一层都能讲出东西老师也容易给分。但很多人拿到这种包第一反应是“能跑就行”结果一跑就卡在环境上Spark 起不来、端口被占、前端跨域报错三小时过去连数据都没看见。这篇文章不会带你逐行读那个 zip 里的代码而是把这条链路拆开讲清楚Spark 在里边到底算什么、后端怎么把结果接出去、前端怎么把指标画出来。我的建议是先理解再动手因为你答辩时被问得最多的不是“怎么跑的”而是“为什么这么设计”。2. 先搭好技术底座Spark 在单车数据链路上到底承担什么活2.1 为什么是 Spark 而不是 Pandas数据量、时间窗口与批处理节奏很多人在选题时会犹豫共享单车数据撑死几十万条用 Pandas 一把梭不香吗答案是香但答辩不香。毕设里选 Spark 的价值不在“数据量大到单机跑不动”而在“你用了一套分布式计算框架来处理时空数据”。几十万条订单数据在 Pandas 里 groupby 一下可能只要几百毫秒但换成 Spark 你要先启动 JVM、创建 SparkSession、提交任务光初始化就得好几秒纯粹比速度肯定是输的。但换成真实的业务语境就不一样了。共享单车企业每天全国范围会产生千万级甚至亿级的骑行记录每一笔包含车辆编号、用户 ID、开锁时间、关锁时间、起点经纬度、终点经纬度、骑行时长、骑行距离。这种体量下Pandas 的内存模型会先崩而 Spark 的弹性分布式数据集可以按分区把数据摊到多台机器上算。更重要的是Spark SQL 能让分析师用标准 SQL 直接查询这正好对接后端接口和前端报表的需求——你算好的结果直接注册成临时视图后端再来查。我一般会在答辩 PPT 里放一张三行对比表单机内存处理、传统数据库聚合、Spark 分布式计算分别对应“能跑但会崩”“索引优化但吞吐有限”“水平扩展但引入集群开销”。老师看到你能说清楚这个选型逻辑比看到你贴十页代码有用得多。需要注意的是毕设环境通常是一台 8G 内存的笔记本我们就按本地模式local[*]来搭不用真的去搞三台机器的集群——那部分放到最后一章说。2.2 单机也能跑spark-shell 里完成清洗、去噪、聚合的完整代码拿到原始 CSV 数据后第一件事不是算指标而是清洗。共享单车原始数据里常见的脏数据有这么几类时间字段格式不统一有的带时区有的不带、经纬度越界超出城市范围或者直接是 0、骑行时长异常负数或者超过 24 小时、起点终点相同且时长极短的无效订单可能是调试数据。清洗这件事在 Spark 里做非常顺手因为 DataFrame 的 API 是声明式的过滤条件写出来代码本身就是文档。启动 Spark 交互环境然后贴下面这段代码。注意这个 zip 包里的数据格式不一定和我下面代码里的列名完全一致但清洗思路是通用的。import org.apache.spark.sql.SparkSession import org.apache.spark.sql.functions._ val spark SparkSession.builder() .appName(BikeSharing Cleaner) .master(local[*]) .getOrCreate() val df spark.read .option(header, true) .option(inferSchema, true) .csv(data/raw/trips.csv) val cleaned df .filter(col(start_time).isNotNull col(end_time).isNotNull) .filter(col(start_lat).between(30.5, 31.5) col(start_lng).between(120.5, 122.5)) .filter(col(duration_sec) 60 col(duration_sec) 86400) .withColumn(start_date, to_date(col(start_time), yyyy-MM-dd HH:mm:ss)) .withColumn(start_hour, hour(col(start_time))) .withColumn(weekday, date_format(col(start_time), EEEE)) cleaned.write.mode(overwrite).parquet(data/clean/trips.parquet)这段代码干了四件事。前三个 filter 在做数据去噪第一行去掉时间为空的记录第二行把经纬度限定在某个城市范围内这里我写的是杭州的粗略范围你要按数据来源城市改第三行把骑行时长限定在 1 秒到 24 小时之间。后两个 withColumn 在做特征提取把字符串时间转成日期类型、拆出小时和星期这两个字段后面聚合要用。最后落盘成 Parquet 格式列式存储后续查询比 CSV 快一个数量级。参数说明master(local[*])表示用本机所有 CPU 核心并行跑[*]也可以改成具体数字如local[2]限制两个核心内存紧张时这样更稳。inferSchema让 Spark 自动推断列类型但如果数据里有混入脏字符推断会失败这时宁可先用字符串读进来再 cast。Parquet 落盘时用mode(overwrite)重复跑清洗任务不会报“文件已存在”的错误。2.3 聚合口径怎么定小时粒度、站点粒度与日期粒度的取舍清洗干净之后才进入这个项目的核心——聚合。共享单车数据分析的经典指标无非是这几类全城总骑行量随时间的变化趋势、各站点的借还热度排行、高峰时段的潮汐现象早高峰住宅区借出多、办公区还入多、平均骑行时长和骑行距离的分布。指标的粒度决定了你前端能画出什么样的图也决定了后端接口的返回结构所以这一步要先想清楚再动手。我的建议是至少算出三张结果表对应三个粒度的需求。第一张按天聚合看长期趋势前端用折线图第二张按小时聚合横轴是 0 到 23 点看一天内的双峰曲线前端用柱状图或面积图第三张按站点聚合统计每个站点的借出量和还入量前端用散点图或地图热力叠加。下面这段代码把前两张表一次算出来。val dailyStats cleaned .groupBy(start_date) .agg( count(*).alias(trip_count), avg(duration_sec).alias(avg_duration), sum(duration_sec).alias(total_duration) ) .orderBy(start_date) val hourlyStats cleaned .groupBy(start_hour) .agg( count(*).alias(trip_count), avg(duration_sec).alias(avg_duration) ) .orderBy(start_hour) dailyStats.show(10) hourlyStats.show(24)这里用了一个要点groupBy之后用agg同时算多个聚合值避免多次扫数据。count(*)统计的是记录数而不是某一列的非空值数avg(duration_sec)算平均骑行时长。如果以后要加“站点粒度”的表只要把groupBy的字段换成start_station_id就行聚合模式完全一样。算完之后建议把结果写回一张 MySQL 表或者导出成 JSON 文件后面后端接口直接读这个结果而不是让后端每次请求都去触发一次 Spark 任务——那样响应时间会慢到前端超时。Spark 跑完聚合之后整个项目的“数据底座”就稳了。到这里为止你已经完成了选题里最体现技术含量的部分后面后端和前端的工作量再大也只是在消费这批计算结果。3. 后端接口层把 Spark 算出的结果安全地交给前端3.1 前后端分离的接口约定数据字典与时间戳格式先谈拢这个 zip 包既然配了完整前后端那必然是前后端分离的架构Vue 项目跑在 5173 端口Spring Boot 后端跑在 8080前端通过 axios 发 HTTP 请求拿 JSON。前后端分离的项目实战里最怕的不是某一端写不出来而是两端各写各的联调的时候字段名对不上、时间格式解析不了、分页参数叫法不一致改来改去一天就没了。我的习惯是在写代码之前先定一份接口文档哪怕只是写在 README 里的一段 JSON 示例。拿“按小时骑行量”这个接口举例约定好请求路径是/api/v1/stats/hourly响应格式是下面这样前端拿到的响应里code固定为 0 表示成功data里是数组每一项包含hour和tripCount两个字段。{ code: 0, message: success, data: [ { hour: 0, tripCount: 320 }, { hour: 1, tripCount: 180 } ] }这里有两个细节值得注意。第一时间字段不要用字符串 2025-06-01 传给前端再让前端解析直接传时间戳或者拆好的数字就行前端展示时自己格式化避免时区问题。第二字段命名统一用驼峰后端 Java 里天然是驼峰前端 JavaScript 也习惯驼峰不要一个用trip_count一个用tripCount然后到处做映射转换。定好约定之后后端写 Controller、前端写页面就是各干各的不用来回对字段。3.2 Spring Boot 读取 Spark 结果Controller、Service 与 VO 三层怎么切后端的技术栈通常是 Spring Boot因为你后面要用 Java 写接口而 Spark 的计算结果已经落到了 Parquet 文件或者 MySQL 表里。这里需要注意架构上的一个取舍后端到底是每次请求都去读 Parquet 文件还是启动时把数据加载到内存缓存如果读 Parquet那后端项目里要引入 Spark 依赖启动一个轻量 SparkSession 来读文件这在毕设里是完全可行的但会拖慢启动速度如果读 MySQL那后端就是一个普通的 CRUD 工程逻辑更简单也更好答辩。我建议用第二种把 Spark 当“离线计算引擎”把 MySQL 当“在线服务存储”。Spark 算完结果后通过 JDBC 写入 MySQL后端只负责查 MySQL。这样做的好处是前后端联调时后端可以独立启动不依赖 Spark 环境部署的时候也只需要一个 Java 进程。下面的代码是 Controller 层的写法。RestController RequestMapping(/api/v1/stats) public class StatsController { Autowired private StatsService statsService; GetMapping(/hourly) public ApiResponseListHourlyStatVO getHourlyStats() { ListHourlyStatVO list statsService.getHourlyStats(); return ApiResponse.success(list); } }Controller 层做三件事定义路由、接收参数、调用 Service。具体的查询逻辑放在 Service 层比如getHourlyStats()内部执行一条SELECT start_hour, trip_count FROM hourly_stats ORDER BY start_hour把结果映射成 VO 对象列表。VO 里只放前端要的字段不要直接把数据库实体类返回回去——数据库表里可能有多余字段比如id、created_at返回出去既浪费流量又暴露表结构。这是分层最常见也最容易忽略的意义。参数说明RequestMapping是类级别的路径前缀GetMapping(/hourly)组合成完整路径。ApiResponseT是你自己定义的统一包装类结构就是前面约定的code、message、data三件套。用泛型来包装不同类型的 data前端就不用为每个接口单独写一套解析逻辑。3.3 跨域与空数据联调阶段最容易翻车的两个点后端写完之后前端 axios 一请求浏览器控制台直接红了。最常见的报错是CORS policy翻译成人话就是你前端页面跑在 5173 端口后端接口在 8080 端口浏览器认为这是两个不同的源默认不允许跨端口互相访问。解决办法不在前端用代理虽然开发模式下代理也能绕过去而是后端显式声明允许跨域。Spring Boot 里加一个配置类即可代码很短但几乎是每个前后端分离项目必写的。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }这段代码的含义是允许任意来源的请求、任意 HTTP 方法GET/POST/PUT/DELETE、任意请求头访问所有接口。开发阶段这样配最省事但如果是生产环境addAllowedOriginPattern(*)建议换成前端实际的部署地址比如http://your-frontend-domain避免任何网站都能调你的接口。/**表示对所有路径生效。第二个坑是空数据问题。Spark 清洗后如果没筛出任何记录或者 MySQL 表还没写入前端图表会拿不到data数组ECharts 直接渲染空白不说还可能因为读不到data[0].hour而报错。后端的处理办法是Service 层查出来是空列表也不要返回null返回一个空数组Collections.emptyList()前端拿到空数组就显示“暂无数据”的占位图。这个约定要写在接口文档里双方都遵守联调就能少很多无意义的排查时间。4. 前端可视化用 Vue3 加 ECharts 把指标落到大屏4.1 大屏布局要什么先定指标再定图别急着画格子前端部分是很多人眼中的“面子工程”但恰恰是答辩时老师第一眼看到的东西。你 Spark 算得再漂亮如果前端展示是一堆数字堆在页面上老师会觉得你没做可视化反过来图表选得合适、布局清爽哪怕算法复杂度一般印象分也会高很多。共享单车数据分析的大屏核心指标就四个总骑行量、活跃站点数、高峰时段、平均骑行时长。我建议的布局是顶部一排三个指标卡总骑行次数、平均骑行时长、活跃站点数中间左侧放“24 小时骑行量趋势”柱状图中间右侧放“TOP10 热门站点”横向条形图中下方放“各站点借还分布”散点图或者地图。这个布局的好处是信息层级清楚从左到右、从上到下对应“宏观总量-时间趋势-空间分布”答辩时你可以顺着这个顺序把分析结论讲完。布局用 CSS Grid 就能实现不用引额外的 UI 库。大屏设计的一个细节是所有图表的背景色保持透明让大屏的整体底色透过图表显示出来画面才统一。ECharts 默认的白色背景在这个场景下会显得非常突兀。4.2 Vue3 连接后端axios 封装、时间参数传递与图表更新逻辑Vue3 连接后端这件事核心就是一个 axios 调用加一段 ECharts 渲染逻辑。axios 建议封装成一个公共模块设置好baseURL和超时时间这样所有页面请求后端时不用重复写全路径。封装之后页面里调用接口的代码可以写得非常干净。下面这段代码是一个完整的 Vue3 组件片段它做的事是组件挂载时请求后端/api/v1/stats/hourly拿到数据后塞给 ECharts 渲染柱状图。template div refchartRef classchart/div /template script setup import { ref, onMounted } from vue import * as echarts from echarts import request from /utils/request const chartRef ref(null) let chartInstance null const renderChart (data) { chartInstance echarts.init(chartRef.value) chartInstance.setOption({ xAxis: { type: category, data: data.map(item item.hour :00) }, yAxis: { type: value, name: 骑行次数 }, series: [{ type: bar, data: data.map(item item.tripCount), itemStyle: { color: #3b82f6 } }] }) } onMounted(async () { const res await request.get(/stats/hourly) renderChart(res.data) }) /script逻辑上注意三点。第一echarts.init要传入一个真实的 DOM 元素所以用ref绑定模板里的div并且必须在onMounted之后再初始化因为挂载之前 DOM 还不存在。第二request是封装好的 axios 实例.get(/stats/hourly)里的路径会自动拼接上baseURL的/api/v1前缀所以这里路径只写后半截。第三ECharts 的setOption是全量覆盖还是增量更新是有讲究的——如果后续要定时刷新数据应该用chartInstance.setOption(option, true)第二个参数传true表示完全覆盖旧配置防止旧数据残留。参数说明xAxis.data是横轴类目数组我把小时数字拼成8:00这样的格式展示上更直观。series.data是数值数组与横轴一一对应。如果后端某个hour没数据前端map出来的数组会变短图表就会错位所以后端聚合时最好保证 0 到 23 点每个小时都有记录没有的补 0。4.3 图表的业务表达热力图、时变曲线和站点排行的选择依据图表不是越多越好而是每个图都要能回答一个业务问题。共享单车数据分析里最经典的三个问题分别是这个城市一天什么时候骑行的人最多对应 24 小时柱状图或面积图、哪些站点是真正的热门枢纽对应 TOP10 条形图、工作日和周末的骑行模式有什么差别对应对比折线图。我做这个项目时踩过的一个思维误区是为了展示技术把 ECharts 所有图表类型都堆上去雷达图、漏斗图、仪表盘全都画一遍。实际上一张雷达图在“共享单车”这个场景里很难找到合理的业务含义画出来只是显得很炫但答辩老师一问“这张图说明什么商业问题”答不上来就是减分项。我们有的图表选项里scatter适合表达站点经纬度分布heatmap适合表达“工作日各小时”双维度交叉分析但如果没有对应的问题就不用硬塞。后端设计接口的时候可以专门留一个对比接口给“工作日 vs 周末”的曲线比如日期的weekday字段取前五个工作日和一个周末日的数据分两组聚合。这比在接口里传一堆复杂参数更直观也更好答辩。5. 避坑Spark 加单车数据项目里我踩过的五个坑5.1 脏坐标把聚合结果全部带偏现象跑完站点热度统计发现排名第一的“站点”经纬度是 (0, 0)前端地图上这个点直接画到非洲去了整个散点图的坐标系被拉崩。原因原始数据里有一部分开锁记录没有正常上报 GPS经纬度默认填了 0。我一开始只过滤了isNotNull没过滤数值范围结果空值没了0 值全留下了。解决清洗阶段必须加范围过滤按城市实际经纬度框一个矩形比如杭州就限定北纬 30.5 到 31.5、东经 120.5 到 122.5。超出范围的记录要么丢弃要么标记为“未知区域”绝对不能进聚合。这个坑排在第一因为它直接影响所有下游结果。5.2 Spark 本地跑 OOM几十万条数据也能内存爆掉现象spark-shell启动后跑聚合报java.lang.OutOfMemoryError: Java heap space任务直接中断。原因Spark 默认的 driver 内存只有 1G本地模式下 driver 同时承担了执行任务的角色groupBy产生的大量 shuffle 数据全压在内存里。而且我用collect()把结果全部拉取到 driver 端进一步加重了内存压力。解决启动时显式指定内存。我的建议是spark-shell --driver-memory 4g --executor-memory 4g。代码层面聚合完的结果用write落盘不要一上来就collect()真要看预览用show(10)就够了。如果数据实在大到单机扛不住再考虑把master从local[*]改成集群地址这个放到下一章说。5.3 时间字段时区错乱按天聚出来的数据少一天现象明明数据里2025-06-01 23:30:00这条记录存在按start_date分组后6 月 1 日的数据却少了一大截6 月 2 日反而多了很多。原因Spark 默认的spark.sql.session.timeZone是 UTC而数据源里的时间是北京时间UTC8。Spark 读取时把字符串按会话时区解释存储和分组时又按 UTC 算结果当地时间 23:30 被归到了隔天的日期里。解决SparkSession 初始化时设置时区。spark.conf.set(spark.sql.session.timeZone, Asia/Shanghai)设置完之后重新跑清洗和聚合日期分组就对了。这个坑很容易被忽略因为只差 8 个小时但按天聚合时恰好卡在日界线上数据缺失非常明显。5.4 前端请求后端跨域接口在 Postman 里好好的浏览器里就挂现象后端接口用 Postman 请求完全正常返回 JSON 也正确。但前端 Vue 项目里 axios 一调就报CORS error整个页面图表空白。原因Postman 是桌面应用不受浏览器同源策略限制。浏览器里前端运行在localhost:5173后端在localhost:8080端口不同即视为跨域。后端没有返回 CORS 响应头浏览器就直接拦截了响应。解决在后端加一个 CORS 配置类或者更轻量的方式是在 Controller 类上加CrossOrigin(origins http://localhost:5173)注解。加完配置后重启后端再刷新前端页面即可。开发环境用全局配置无所谓但生产部署时建议把origins限制成你自己的前端域名。5.5 JSON 序列化把空值丢给前端图表渲染报错现象前端控制台报错Cannot read property tripCount of null或者图表只显示一部分柱子缺的柱子没有占位。原因后端返回的data数组里混入了null元素。原因是数据库里某些小时段没有任何骑行记录Spark 端聚合的结果本身就没这一行后端按 0 到 23 遍历补数据时补进去的null没有转成0或者0L。解决这是个约定问题后端在组装 VO 时统一处理缺的数据补0而不是null。同一时刻前端渲染时也做一次防御判断data.map(item item.tripCount || 0)双保险。这个坑虽然不致命但排查起来费时间前后端一推一拖半天就过去了。6. 部署收尾从 IDE 一键跑到 Spark 集群参数怎么改才不翻车前面所有环节都在本机单机模式跑通之后最后一步是考虑怎么把项目部署到更真实的集群环境。很多毕设到这里就直接提交了但如果你想把“分布式”这个亮点做实至少要把 Spark 任务的提交方式从 IDE 里跑改成spark-submit命令行提交。这一步的改动不大但参数配置经常让人翻车我给出一个可以直接参考的命令模板。spark-submit \ --class com.example.BikeCleaningJob \ --master yarn \ --deploy-mode cluster \ --driver-memory 4g \ --executor-memory 4g \ --executor-cores 2 \ --num-executors 4 \ bike-analysis.jar \ hdfs://your-namenode:8020/data/raw/trips.csv \ hdfs://your-namenode:8020/data/clean/trips.parquet参数含义这里逐个说清楚--master yarn表示提交到 YARN 集群毕设里如果没有真正的多机集群至少可以用 Spark Standalone 模式的spark://node01:7077代替效果类似。--deploy-mode cluster表示 driver 运行在集群内如果你的提交机器还要承担其他任务可以改成client模式让 driver 留在本地。内存参数要和集群实际资源匹配executor-cores 2配合num-executors 4一共是 8 个虚拟核如果你的机器只有 4 核 8G要相应调低否则任务一提交集群立刻资源不足任务一直卡在 ACCEPTED 状态。还有一个点是从本地文件系统换到 HDFS 之后的路径适配。单机跑的时候代码里写的路径是data/raw/trips.csv在集群模式下要改成 HDFS 路径而且需要先把原始数据文件上传到 HDFS 上。这个步骤很多人会漏导致任务提交成功但读取文件直接FileNotFoundException看着像是权限问题实际上是路径写错了。关于数据库连接因为后端要读 MySQL部署时要注意 JDBC URL 不能写localhost要写成 MySQL 所在机器的内网 IP并且确认 MySQL 用户的访问权限不局限于localhost。前置这些事做完项目从本机到集群才算真正换了环境。我的习惯是每换一个环境先跑通最小的“读取-打印条数”任务确认数据能读、能写再跑完整链路这样出了问题能快速定位是环境问题还是代码问题。这套链路走完之后你会发现Spark 也好、Spring Boot 也好、Vue 的 ECharts 也好每层拿出来单独看都不复杂真正有价值的是你能把各层的坑提前踩掉并且说清楚。希望这些经验能帮到你少走我走过的那些弯路。本文还有配套的精品资源点击获取