
简介压缩包内为基于SpringBoot与Spark的共享单车数据存储系统毕业设计资料包含完整论文与前后端工程源码适合计算机科学与技术、人工智能等相关专业学生用于课题实践与技术学习。项目以SpringBoot构建后端服务结合Spark分布式计算处理骑行数据并集成HDFS存储大规模日志实现数据查询、分析与调度优化。资源共374个文件以Java源码、Vue前端、SVG图标、JavaScript脚本及XML配置为主另含论文文档、SQL脚本和运行批处理整体约17.8MB。已有51人学习浏览可下载后参考README或论文快速启动项目。通过系统源码可掌握分布式系统设计、RESTful API开发、Spark数据处理及前后端分离等技能同时提供运维脚本与配置备份便于调试与二次开发。资源仅用于学习交流请勿商用。1. 这个标题到底在做什么用 Spark 做存储为什么还要 SpringBoot 来凑共享单车每天产生的轨迹点、订单记录和车辆状态上报随便一个中型城市就能堆出上亿条数据。MySQL 在这种量级下要么分库分表拆到运维崩溃要么慢查询拖垮整个业务后台。这个标题给的方案本质上是把「存储系统」拆成三层SpringBoot 负责对外接口和业务编排Spark 负责数据清洗与批量落盘底层存储则交给 HBase/HDFS 这类分布式列式存储。标题里的「数据存储系统」不是指 Spark 本身存数据而是指用 Spark 把数据高效地写进真正的存储引擎里。这个组合是典型的毕设和企业初级数据平台选题适合两类人一是需要快速搭出一套能演示、能答辩的完整系统的学生二是想在现有 SpringBoot 业务边上加一个离线数据通道的后端工程师。接下来我不讲概念直接拆架构、给表结构、贴可跑的代码最后把最常见的几个翻车现场列出来。2. 架构选型先立住Spark 不是存储引擎HBase/HDFS 才是落盘底层2.1 责任边界计算归 Spark存储归 HBase入口归 SpringBoot把标题拆开看SpringBoot、Spark、数据存储系统三个词各管一段。SpringBoot 的价值在于它是业务系统的门面对外暴露 REST 接口收单车上报数据对内把查询请求转发给底层存储。Spark 的价值在于它不负责存数据而是负责「怎么把数据高效写进去」——清洗、转换、分区、去重这些脏活累活都在 Spark 阶段完成。真正把数据落到磁盘的是存储引擎本身常见选择是 HBase靠 HDFS 做持久化。这套分工有个直接好处写入链路和查询链路可以走不同通道。单车上报数据量大、频率高适合走「消息堆积 Spark 批量落 HBase」的异步链路查询单笔订单、统计某个区域车辆分布这类请求走 SpringBoot 直连 HBase 的同步链路。两边互不阻塞这是单一 MySQL 方案做不到的。2.2 三种存储方案对比HDFS 文件、HBase 宽表、MySQL 备份存储层选型是这套系统最容易被低估的决策点。我见过不少项目把 Spark 清洗完的数据直接写成 Parquet 文件丢到 HDFS 上这能跑通但查询侧很痛苦——你要查某辆车的订单记录得用 Spark 重新扫一遍全量数据每次查询都是分钟级。HDFS 文件适合「离线分析」而不是「在线存储系统」标题既然叫存储系统就意味着要有随机查询能力。HBase 是这套架构里最顺手的落点。它天然支持亿级行数下的随机读写RowKey 设计好了以后查一辆车一天的所有轨迹点就是一次 Scan毫秒到十毫秒级。MySQL 在这个架构里不是替代品而是降级方案——可以用它存订单的摘要信息和用户账户数据HBase 存全量轨迹和明细两张表靠 order_id 关联。提示如果你的系统查询模式主要是「按订单号查详情」HBase 是首选如果主要是「按时间范围做聚合报表」Parquet Spark SQL 其实更省事。先想清楚查询场景再选存储别为了用 HBase 而用 HBase。2.3 决定写这套系统前先回答三个问题第一个问题数据量真的到了需要 Spark 的程度吗如果一天只有几万条骑行记录一台 MySQL 加索引绰绰有余引入 Spark 和 HBase 只会让系统变重。第二个问题你手上有多少台机器Spark 的本地模式local[*]能完成开发和演示但生产环境至少需要一套小型集群这个标题里涉及的存储系统如果跑在单机上你要明确说出「这是简化部署」而不是「这就是全部」。第三个问题谁来维护这套系统Spark 任务失败要有人看日志重跑HBase RegionServer 挂了要有人处理这是长期成本不是写完代码就结束的事。这三个问题在论文答辩和项目评审里几乎必然被问到提前想清楚答案比临时编理由要好得多。3. 数据模型与表设计共享单车订单、轨迹和车辆状态该存成什么样3.1 实体边界哪张表管订单哪张表管轨迹哪张表管车况共享单车业务里最核心的三个实体是订单、轨迹和车辆状态。订单表记录一次骑行从开锁到关锁的完整信息字段包括 order_id、user_id、bike_id、start_time、end_time、start_location、end_location、费用等。轨迹表记录骑行过程中周期性上报的 GPS 点字段包括 order_id、timestamp、longitude、latitude、speed。车辆状态表记录车辆的实时状态和上报数据字段包括 bike_id、status、battery、last_report_time、location。在 HBase 里这三类数据对应三张表。订单表用 order_id 做 RowKey一次骑行一行轨迹表用「order_id 反转 时间戳」做 RowKey这样同一个订单的所有轨迹点在物理上相邻Scan 一个订单的轨迹非常快车辆状态表用 bike_id 做 RowKey一行就是一辆车的最新状态。注意车辆状态表不需要保留历史版本靠 HBase 的列族版本机制控制即可。3.2 HBase 列族设计一个列族走天下还是拆成两个HBase 的列族设计原则是「宁少勿多」。官方建议一个表最多两到三个列族因为列族过多会导致 Region 内多个文件同时写flush 和 compaction 的压力成倍增加。对于共享单车场景我一般建议两张表各用一个列族就够订单表用 info 列族把订单所有字段都放进去轨迹表也用一个 info 列族每行一个 GPS 点。车辆状态表稍微特殊一点可以用 info 和 metrics 两个列族前者放静态信息如车辆型号、投放区域后者放动态状态如电量、速度、位置。提示不要为了「看起来规范」把每个字段单独设一个列族。列族数量直接影响 RegionServer 的内存占用和写入性能一个列族能解决的事不要拆成三个。列族内字段无需预定义这是 HBase 和关系型数据库最大的区别。你在写入时指定列名即可比如 put orders, order_001, info:user_id, u_10001这条数据自动落进 info 列族。设计表结构时只需要做两件事确定 RowKey 结构确定列族归属。3.3 RowKey 与分区设计先定热点再定拼接顺序RowKey 设计是 HBase 方案的核心决定了系统性能的上限和下限。共享单车场景最常见的翻车姿势是直接用自增 ID 或 bike_id 做 RowKey——看数据分布会觉得没问题但写入时所有请求都打在同一个 Region 上RegionServer 忙的忙死闲的闲死。正确做法是先把访问模式列出来。对共享单车系统来说最高频的查询是「查某辆车某天产生的所有记录」和「查某个订单的完整轨迹」。前者适合用「bike_id 反转 日期 时间戳」做 RowKey后者适合用「order_id 反转 时间戳」做 RowKey。反转是为了打散顺序递增的 ID避免所有新数据都写到最后那个 Region。具体设计如下订单表 RowKey 取「order_id 反转 骑行开始时间戳」比如 order_20241021_001 反转成 100_120241002这样一个自然时间段内产生的订单可以比较均匀地分布在多个 Region。轨迹表 RowKey 取「order_id 反转 上报时间戳」保证同一个订单的轨迹点连续排列同时利用时间戳后缀天然有序。车辆状态表则直接取 bike_id因为车辆状态表的写入频率远低于前两者且单行更新场景对热点的敏感度低很多。每张表都建议在创建时指定预分区用 RowKey 的前缀做 split 点避免后续 Region 分裂带来的性能抖动。4. 把最小闭环跑起来Spark 清洗入 HBaseSpringBoot 负责调度和查询4.1 环境准备本机伪分布集群与版本对齐开发这套系统最容易卡住的地方不是代码逻辑而是环境版本。SpringBoot 生态和 Spark 生态各自迭代速度很快两个框架的依赖在同一个进程里相遇时经常互相踩脚。我的建议是先定版本再写代码。以 Java 8 为基础SpringBoot 用 2.7.xSpark 用 3.3.x Scala 2.12HBase 用 2.4.xHadoop 用 3.3.x。这套组合是经过大量项目验证的稳定搭配。如果用了 SpringBoot 3.x 会遇到 Jakarta EE 命名空间迁移的问题Spark 的某些依赖会跟着出兼容性错误那些坑不是不能解决但没必要在毕设阶段给自己加戏。本机跑最小闭环时Hadoop 以伪分布模式启动HBase 以 standalone 模式启动即可。按顺序执行以下命令# 1. 配置 SSH 免密登录Hadoop 伪分布需要 ssh-keygen -t rsa -P -f ~/.ssh/id_rsa cat ~/.ssh/id_rsa.pub ~/.ssh/authorized_keys # 2. 启动 Hadoop HDFS 和 YARN $HADOOP_HOME/sbin/start-dfs.sh $HADOOP_HOME/sbin/start-yarn.sh jps # 确认 NameNode、DataNode、ResourceManager 已启动 # 3. 启动 HBase依赖上面的 HDFS $HBASE_HOME/bin/start-hbase.sh $HBASE_HOME/bin/hbase shell这里有个容易忽略的点HBase 的 hbase-site.xml 必须配置 hbase.rootdir 指向 Hadoop 的 HDFS 地址比如 hdfs://localhost:9000/hbase。很多人第一次启动 HBase 时没配这项结果 HBase 默认写到本地文件系统虽然能跑但失去了分布式存储的意义。启动完成后用 jps 能看到 HMaster 和 HRegionServer 两个进程缺一个都说明启动失败。4.2 Spark 读 JSON 清洗后写 HBase完整代码与参数说明环境就绪后建表并写 Spark 作业脚本如下# 在 HBase shell 中执行建表 预分区 create bike_order, {NAME info}, {SPLITS [1,4,7,a,d,f]} create bike_track, {NAME info}, {SPLITS [1,4,7,a,d,f]} create bike_status, {NAME info, VERSIONS 1}预分区的 split 点按十六进制字符划分目的是让 RowKey 前缀分布到六个 Region 上。Spark 作业读 JSON 原始上报文件清洗后写入 HBase。完整代码如下import org.apache.hadoop.hbase.HBaseConfiguration import org.apache.hadoop.hbase.client.Put import org.apache.hadoop.hbase.io.ImmutableBytesWritable import org.apache.hadoop.hbase.mapreduce.TableOutputFormat import org.apache.hadoop.mapreduce.Job import org.apache.spark.sql.SparkSession object BikeDataLoader { def main(args: Array[String]): Unit { val spark SparkSession.builder() .appName(BikeOrderLoader) .master(local[*]) // 本地调试提交集群时改为 yarn .config(spark.serializer, org.apache.spark.serializer.KryoSerializer) .getOrCreate() import spark.implicits._ // 读取原始上报 JSON模拟路径hdfs://localhost:9000/data/bike_report/ val raw spark.read.json(hdfs://localhost:9000/data/bike_report/) val cleaned raw .filter($bike_id.isNotNull $order_id.isNotNull) .dropDuplicates(order_id, event_time) // 按订单去重 .select(order_id, bike_id, user_id, start_time, end_time, start_lng, start_lat, end_lng, end_lat, fee) // 构造 HBase 的 Put 对象RowKey order_id 反转 开始时间戳 val hbaseData cleaned.map { row val orderId row.getString(0) val startTs row.getString(3) val reversedKey orderId.reverse _ startTs val put new Put(org.apache.hadoop.hbase.util.Bytes.toBytes(reversedKey)) put.addColumn(Bytes.toBytes(info), Bytes.toBytes(bike_id), Bytes.toBytes(row.getString(1))) put.addColumn(Bytes.toBytes(info), Bytes.toBytes(user_id), Bytes.toBytes(row.getString(2))) put.addColumn(Bytes.toBytes(info), Bytes.toBytes(start_time), Bytes.toBytes(startTs)) put.addColumn(Bytes.toBytes(info), Bytes.toBytes(end_time), Bytes.toBytes(row.getString(4))) put.addColumn(Bytes.toBytes(info), Bytes.toBytes(fee), Bytes.toBytes(row.getString(9))) (new ImmutableBytesWritable(), put) } // 写入 HBase走 MapReduce 输出格式 val conf HBaseConfiguration.create() conf.set(TableOutputFormat.OUTPUT_TABLE, bike_order) conf.set(hbase.zookeeper.quorum, localhost:2181) val job Job.getInstance(conf) job.setOutputFormatClass(classOf[TableOutputFormat[ImmutableBytesWritable]]) hbaseData.saveAsNewAPIHadoopDataset(job.getConfiguration) spark.stop() } }这段代码的要点有三个。清洗逻辑里先过滤空字段再去重是因为 GPS 上报数据常出现重复推送不先去重的话 HBase 里会出现同一订单的多条脏数据。RowKey 用 order_id 反转而不是原值是为了避免订单 ID 递增导致写入全部打到最后一个 Region。写入用 TableOutputFormat 走批量提交比逐条调用 HBase API 快一个量级这是生产环境的标准姿势。注意这段代码里的 conf.set(hbase.zookeeper.quorum, localhost:2181) 这个参数。如果 HBase 客户端连不上集群多半是这项没配或者配错了地址。本地伪分布配 localhost 即可集群环境要换成 Zookeeper 所在节点列表。4.3 SpringBoot 集成 Spark 的两种姿势进程内 vs spark-submitSpringBoot 和 Spark 的集成方式有两种选型依据是「任务规模」和「部署形态」。第一种是进程内集成SpringBoot 启动时创建 SparkSession接口接收到触发指令后直接调用 Spark API 执行作业。这种方式的优点是代码简单无需额外的任务调度组件适合数据量千万级以内、对响应时间不敏感的场景。缺点是 Spark 的 Driver 端内存和 SpringBoot 的 Tomcat 线程共享同一个 JVM堆内存压力大且 Spark 作业长任务会阻塞 Web 服务的响应线程。第二种是外部提交方式SpringBoot 只负责接收 http 请求然后把任务参数写入数据库或消息队列由独立的调度器调用 spark-submit 提交作业。这种方式资源隔离好Spark 跑在独立进程中崩溃不影响 Web 服务。代价是多了一套调度组件要维护对毕设或小型项目来说偏重。我建议中小项目选第一种但做一层薄封装。核心代码如下Service public class SparkJobService { private SparkSession spark; PostConstruct public void init() { this.spark SparkSession.builder() .appName(BikeStoreService) .master(local[2]) .config(spark.sql.shuffle.partitions, 4) .getOrCreate(); } public void triggerCleanAndStore(String date) { // date 形如 20241021按需触发某一批数据的清洗和写入 String inputPath hdfs://localhost:9000/data/bike_report/ date /; // 构造 Dataset 并执行写入逻辑同 4.2 节中的 Spark 作业主体 // 这里只做触发封装具体业务逻辑由 SparkJobService 内部方法实现 } PreDestroy public void cleanup() { if (spark ! null) { spark.stop(); } } }SpirngBoot 进程里常驻一个 SparkSession 时spark.sql.shuffle.partitions 这个参数要特别关注。默认值 200 意味着每次 shuffle 产生 200 个分区文件在小数据量下会带来额外的调度开销调到 4 或 8 就够了。还有一点SparkSession 初始化开销很大要保证全局单例不要每次请求都 new 一个不然后端服务迟早被撑着。提示进程内集成时务必在 SpringBoot 的配置里把 Spark 的日志级别调成 WARN。Spark 默认用 log4j 输出大量 INFO 日志会和 SpringBoot 的日志体系混在一起刷屏排错时连自己的输出都找不到。4.4 查询链路一张单车订单要跨几层才能回到前端数据落进 HBase 之后查询链路的设计直接决定使用者体验。最简单的方式是 SpringBoot 使用 HBase 的 Java 客户端直接查询。关键代码如下RestController RequestMapping(/api/order) public class OrderController { Autowired private HBaseQueryService queryService; GetMapping(/{orderId}) public OrderVO getOrder(PathVariable String orderId) { // 拼接 RowKey与写入端保持同一规则 String reversedKey new StringBuilder(orderId).reverse().toString(); return queryService.queryOrderByRowKey(bike_order, reversedKey); } }查询这个环节有个常见认知差HBase 的 Java API 走的是 RPC不是 JDBC。也就是说SpringBoot 依赖里要引入 hbase-client而不是 hbase-jdbc后者主要用于 Apache Phoenix 场景。引入的坐标和版本要与服务端一致比如 HBase 2.4.x 对应 hbase-client 2.4.x版本不一致会出现 RPC 协议不兼容的报错这是联调阶段高频翻车点。查询链路完整走一遍是浏览器或小程序发起请求 → SpringBoot Controller 接收并做参数校验 → Service 层拼接 RowKey → HBase Client 发 RPC 到 RegionServer → RegionServer 返回 Result → Service 层将 Result 转成 VO 返回前端。这条链路上每一步都有超时控制的位置HBase 客户端默认连接超时 30 秒建议在配置里调低到 5 秒避免 RegionServer 假死时接口长时间挂起。5. Spark 与 SpringBoot 联调的避坑记录五个翻车现场5.1 依赖冲突Spark 的 Scala 版本把 SpringBoot 的 Jackson 打崩现象SpringBoot 服务启动时直接报 NoSuchMethodError指向 jackson-databind 的某个方法或者 JsonProcessingException 频繁出现。原因Spark 3.x 内部依赖的 Jackson 版本和 SpringBoot 2.x 默认管理的 Jackson 版本不一致。Maven 在解析依赖时按「最近优先」原则选版本Spark 传递进来的低版本 Jackson 覆盖了 SpringBoot 需要的版本运行时调不到对应方法就崩。解决在 pom.xml 中显式声明 Jackson 版本把 SpringBoot 的依赖管理强制抬高。同时将 Spark 的 jackson-module-scala 排除掉因为非序列化场景用不到。显式声明后两个框架各取所需冲突消失。dependencyManagement dependencies dependency groupIdcom.fasterxml.jackson/groupId artifactIdjackson-bom/artifactId version2.13.5/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement5.2 写热点单车 ID 做 RowKey半个集群闲着一个 RegionServer 被打满现象集群 6 个 RegionServer写入时只有一个节点 CPU 飙高其他节点全是绿的。HBase 监控页面里某个 Region 的写请求数是其他 Region 的几十倍。原因bike_id 通常是递增数字直接做 RowKey 时空前缀都一样HBase 按 RowKey 字典序分区所有新数据全部落到最后一个 Region。这就是最典型的写热点问题。解决RowKey 加盐。在 bike_id 前拼一个哈希前缀比如取 bike_id 的哈希值对 6 取余得到一个 0-5 的前缀。这样同一辆车的记录会被分散到 6 个 Region 上代价是同辆车的查询会跨 Region但 HBase 的 Scan 天然支持跨 Region 聚合对毫秒级查询影响不大。加盐之后需要再拼时间戳才能保证时间有序拼接顺序是「盐值 bike_id 日期 时间戳」。5.3 SparkSession 与 Tomcat 抢堆内存接口偶发性超时现象SpringBoot 启动正常前几次接口调用正常运行一段时间后接口偶发性卡顿GC 日志显示 Full GC 频繁。原因SparkSession 常驻在 SpringBoot 进程里Driver 端会分配执行内存spark.driver.memory这部分内存和 Tomcat 的堆区相互挤压。当 Spark 作业刷数据时内存暴涨Tomcat 线程得不到内存接口就卡住。解决给 Spark 单独设置内存上限同时把 SpringBoot 的堆内存调大。建议 JVM 参数设为 -Xmx2g -XX:MaxMetaspaceSize512mSpark 的 driver.memory 设为 1g。如果机器内存不够就对 Spark 作业做串行化处理限制同时只能跑一个任务避免多个作业叠加吃内存。另一个思路是干脆不要进程内集成改用 spark-submit 方式一劳永逸地隔离。5.4 扫描 HBase 不带边界全表 Scan 把 RegionServer 拖垮现象查询某辆车的轨迹时接口响应 30 秒以上RegionServer 日志出现大量 BlockCache miss其他表的读写也变慢。原因代码里执行 Scan 时没设置 StartRow 和 StopRow默认全表扫描。比如查某辆车的轨迹RowKey 结构是「盐值 bike_id 日期 时间戳」正确做法是设置 StartRow 为 bike_id 前缀StopRow 为 bike_id 前缀加一个终止符。没有边界时 HBase 会把表中所有行都扫一遍在亿级数据下这是灾难。解决养成写 Scan 必带边界的习惯代码如下。Scan scan new Scan(); // 只扫描 bikeId 为 b_10086 的所有行 scan.setStartRow(Bytes.toBytes(0_b_10086_)); scan.setStopRow(Bytes.toBytes(0_b_10086_ \u0000)); scan.setCaching(1000);setCaching 也是一个容易忽略的参数控制在一次 RPC 中取多少行数据回客户端。默认值太小会导致多次网络往返太大会占用客户端内存。1000 是一个比较稳妥的起点。5.5 恰巧碰到 SpringBoot 版本太高HBase 客户端连不上现象SpringBoot 版本是 3.x项目能正常启动但调用 HBase 时报 ConnectionLossException或者 ZooKeeper 客户端报 SessionExpiredException。原因SpringBoot 3.x 基于 Jakarta EE内置的依赖管理对 ZooKeeper/HBase 客户端的传递依赖做了版本覆盖HBase 客户端与 3.x 不兼容。HBase 2.4.x 官方客户端设计时没有适配 Jakarta底层 Java 包名变更导致连接异常。解决如果已经用 SpringBoot 3.x最省事的方案是把 HBase 访问层独立成一个微服务或用 Apache Phoenix 走 JDBC 协议绕开客户端兼容问题。如果项目还在设计阶段直接用 SpringBoot 2.7.x。这个坑是典型的「版本号玄学」——不是代码写错而是生态脱节越早锁定版本越好。我自己的习惯是先把 SpringBoot 版本钉死再谈 Spark 和 HBase大版本不互相试探。6. 进阶用 BulkLoad 绕过写放大先做小批量验证再上全量常规写入路径是 Spark 生成 Put 对象后通过 HBase 的 RPC 逐条写入每个 Put 都要走 WAL 写日志、更新 MemStore、触发 flush 三个步骤写放大明显。当积累到几千万条历史订单需要回灌时更合理的方案是 HFile BulkLoad在 Spark 中直接生成 HFile 文件再由 HBase 把 HFile 整体加载到 Region 里跳过写入路径。这个方案的速度优势通常在 5 倍以上是数据迁移场景的标准做法。BulkLoad 的关键步骤是先在 HBase 里建好和 RowKey 分布匹配的预分区表然后让 Spark 按同样的分区规则输出 HFile。如果建表时的分区边界和 HFile 的分区键不一致load 阶段会触发大量 Region 分裂性能反而更差。做完 BulkLoad 后要跑一遍核对脚本对比 HBase 里的行数和源数据条数两个数字对不上就要检查 RowKey 是否重复覆盖。我在做这类数据系统时有个习惯任何数据管道在上全量前先抽取一小批样本比如 10 万条跑通全链路比对 HBase 查询结果和源数据的一致性。样本验证通过后再放宽到全量宁可多花一小时做小批量验证也不要在全量跑完后才发现表结构设计错位。这个小习惯帮我避过多次返工。另外任务跑完后用 HBase shell 执行 scan bike_order, {LIMIT 5} 快速肉眼验证一下数据形态再用 count 命令核对总量。这套验证流程简单直接适合压轴收尾。希望帮到你。本文还有配套的精品资源点击获取