酒店数据可视化实战:Spark清洗、MySQL建模与ECharts大屏展示
2026/9/17 9:29:34 网站建设 项目流程

简介:面向大数据实训与课程设计场景,这套基于Spark+MySQL+ECharts的酒店度假数据可视化项目资源,完整演示了从数据统计到图表展示的落地流程,适合希望掌握内存计算框架及可视化集成的开发者参考。资源共24个文件,核心为5个Scala数据处理脚本,搭配前端HTML/CSS/JS与ECharts图表配置,另含字体、示例图片、项目总结PPT和实训报告Word文档,整个压缩包仅9.27MB,目录结构清晰,便于按模块对照学习。已有96人学习下载。通过该项目可重点理解Spark的AvgPrice、CountCity、TopN等典型聚合算子应用,以及MySQL数据存储与ECharts动态渲染的衔接方式,实训报告和PPT补充了项目设计思路、运行环境与部署说明,对酒店旅游类大数据课设、毕设或实训项目具有较高参考价值。

1. 酒店数据可视化不只是画图表:先想清楚谁在看、看什么

一套酒店度假数据可视化系统,表面上是一个大屏或一组 Dashboard,拆开之后其实是三条独立的技术链路:Spark 负责把原始订单、住宿、客源数据做清洗和聚合,MySQL 负责把聚合结果稳定地存下来供查询,Echarts 负责把查询结果转成管理者一眼能看懂的地图、折线图和占比图。三者缺一个,项目就退化成"假大屏"或者"假大数据"。

做这个系统的难点不在于 Echarts 画图,而在于数据从业务库到图表之间的建模过程——用哪些维度聚合、按什么粒度输出、如何保证指标口径一致。酒店行业的典型指标是间夜数、入住率、平均房价(ADR)、每间可售房收入(RevPAR),这些指标在不同时间粒度下计算逻辑完全不同。本文按一条可直接复现的链路来讲:数据准备与 Spark 清洗、MySQL 建模与入库、Echarts 图表与地图展示,最后给出项目报告和 PPT 里真正有用的支撑素材。

2. Spark 做数据清洗与聚合:把订单流水变成指标宽表

2.1 为什么计算层选 Spark,而不是让 MySQL 直接做聚合

先回答一个经常被问的问题:酒店数据量也就几千万行,MySQL 加索引也能 group by,为什么非要 Spark?

原因是这套系统的定位是实训和大数据技术栈展示。如果只做一次性的报表查询,MySQL 确实够用,但 Spark 的引入解决了两个实际问题。第一个是数据接入格式的多样性:上游数据可能是 CSV、JSON、业务库导出的文本,甚至多个 Excel 文件,Spark 的 DataFrame API 可以统一读入并处理 schema 推断,不需要先建表导数据。第二个是复杂计算链路的可维护性:入住率、连住天数分布、客源地聚合这类计算涉及多表 join 和多次 groupBy,在 Spark 里可以写成清晰的转换流水线,中间结果落盘或缓存,调试和维护成本远低于写一长串 SQL。

从架构上看,这里采用的是"计算与存储分离"的常见做法:Spark 集群(或本地模式)负责跑任务,MySQL 只存最终结果。这样 MySQL 的压力非常小,前端查询永远打在一个小结果集上,响应保持在百毫秒级。如果你只有单机环境,Spark 也可以用 local[*] 方式跑,不影响代码逻辑的正确性。

2.2 先用 PySpark 清洗订单流水数据

酒店订单数据一般至少包含这些字段:订单号、酒店名称、城市、入住日期、离店日期、间夜数、订单金额、预定渠道、客源地(省份)。实际拿到的数据往往是宽表或分表,第一步是统一读入并清洗。

from pyspark.sql import SparkSession from pyspark.sql.functions import col, to_date, when, count, sum, round from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType spark = SparkSession.builder \ .appName("hotel-dw-clean") \ .master("local[*]") \ .getOrCreate() schema = StructType([ StructField("order_id", StringType(), True), StructField("hotel_name", StringType(), True), StructField("city", StringType(), True), StructField("checkin_date", StringType(), True), StructField("checkout_date", StringType(), True), StructField("room_nights", IntegerType(), True), StructField("amount", DoubleType(), True), StructField("channel", StringType(), True), StructField("province", StringType(), True), ]) df_raw = spark.read.format("csv") \ .option("header", "true") \ .option("encoding", "utf-8") \ .schema(schema) \ .load("data/orders.csv") df_clean = df_raw.dropDuplicates(["order_id"]) \ .filter(col("hotel_name").isNotNull()) \ .withColumn("checkin_date", to_date(col("checkin_date"), "yyyy-MM-dd")) \ .withColumn("checkout_date", to_date(col("checkout_date"), "yyyy-MM-dd")) \ .withColumn("channel", when(col("channel") == "", "未知渠道").otherwise(col("channel"))) \ .withColumn("stay_days", (col("checkout_date").cast("long") - col("checkin_date").cast("long")) / 86400) df_clean.cache() df_clean.show(5, truncate=False)

这段代码里 dropDuplicates 用于剔除同一订单号的重复记录,这是酒店数据里最常见的脏数据来源;to_date 将字符串日期统一成日期类型,避免下游聚合时因格式不一致导致按天分组出错;stay_days 的计算使用时间戳差值,因为 checkout_date 减 checkin_date 直接相减在 DataFrame API 里需要先转成日期类型,直接转 long 再除 86400 是更稳妥的写法。

2.3 计算入住率、渠道占比、客源地分布三个核心指标

清洗完成后进入聚合阶段。酒店运营最关心的三个维度是:按日期的入住率趋势、按渠道的订单占比、按客源地的地域分布。入住率需要酒店总房间数做分母,这里假设酒店表里有 total_rooms 字段。

df_hotel = spark.read.format("csv") \ .option("header", "true") \ .load("data/hotels.csv") \ .select("hotel_name", "total_rooms") df_daily = df_clean.groupBy("checkin_date") \ .agg( count("order_id").alias("order_cnt"), sum("room_nights").alias("room_nights_total") ).join(df_hotel.select("hotel_name", "total_rooms"), how="left") \ .withColumn("occupancy_rate", round(col("room_nights_total") / (col("total_rooms") * 30) * 100, 2)) df_channel = df_clean.groupBy("channel") \ .agg(count("order_id").alias("order_cnt")) \ .withColumn("channel_ratio", round(col("order_cnt") / df_clean.count() * 100, 2)) df_province = df_clean.groupBy("province") \ .agg( count("order_id").alias("order_cnt"), sum("amount").alias("gmv") ).filter(col("province").isNotNull())

注意 occupancy_rate 的算法:用当月累计间夜数除以(总房间数乘以当月天数)。这里用 30 作为估算值,实际项目中应该根据日期范围动态计算当月天数,否则月初和月末的入住率会失真。如果你要在实训报告里体现专业度,可以把这个逻辑改成按月份计算每日可售房间总数,再求和做分母。

2.4 结果落盘为 Parquet 文件,方便后续重复调试

聚合出来的三个 DataFrame 可以先存成 Parquet 文件,这样后续不想重新跑 Spark 任务时,可以直接从 Parquet 读取结果做 MySQL 导入调试。

df_daily.write.mode("overwrite").parquet("output/daily.parquet") df_channel.write.mode("overwrite").parquet("output/channel.parquet") df_province.write.mode("overwrite").parquet("output/province.parquet")

Parquet 选型的好处是列式存储、压缩率高,而且保留了 schema 信息。如果你只是临时看数据,也可以直接 show 打印;但项目级代码建议统一落盘,配合 Spark 的 checkpoint 机制可以避免中间节点失败导致整个 DAG 重算。

提示:如果你在 Windows 上跑 PySpark,注意 Spark 的 Hadoop winutils 依赖,常见的报错是 "Could not locate executable null \ bin \ winutils.exe" 或 "Failed to locate the winutils binary"。解决方法是下载对应版本 winutils 放到 SPARK_HOME/bin 下并设置 HADOOP_HOME 环境变量,或者改用 WSL 环境。

3. MySQL 建模与对接:结果表、维度表和存储过程

3.1 用 ODS 和 ADS 两层结构组织数据库

数据可视化系统对应到 MySQL 端,只需要两张 ODS 明细表和三张 ADS 聚合表。ODS 层存清洗后的订单明细,用于审计和回溯;ADS 层存 Spark 聚合结果,直接服务报表查询。不建议把原始业务库几十个字段全量迁过来,MySQL 只承担结果存储和简单查询职责。

CREATE DATABASE IF NOT EXISTS hotel_dw DEFAULT CHARSET utf8mb4; CREATE TABLE ods_order_detail ( order_id VARCHAR(64) PRIMARY KEY, hotel_name VARCHAR(128), city VARCHAR(64), checkin_date DATE, checkout_date DATE, room_nights INT, amount DECIMAL(10,2), channel VARCHAR(32), province VARCHAR(32), stay_days INT, etl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE ads_daily_occupancy ( stat_date DATE PRIMARY KEY, order_cnt INT, room_nights_total INT, occupancy_rate DECIMAL(5,2) ) ENGINE=InnoDB; CREATE TABLE ads_channel_ratio ( channel VARCHAR(32) PRIMARY KEY, order_cnt INT, channel_ratio DECIMAL(5,2) ) ENGINE=InnoDB; CREATE TABLE ads_province_gmv ( province VARCHAR(32) PRIMARY KEY, order_cnt INT, gmv DECIMAL(12,2) ) ENGINE=InnoDB;

表结构设计的核心原则是:ADS 层表的主键对应 Echarts 图表的维度字段。比如地图数据源是省份维度,主键就是 province;折线图数据源是日期维度,主键就是 stat_date。这样前端查询时不需要再做复杂聚合,一个简单 SELECT 就能拿到图表所需的数据格式。

表名服务图表主键数据来源
ads_daily_occupancy入住率折线图stat_dateSpark 按日期聚合
ads_channel_ratio渠道占比饼图channelSpark 按渠道聚合
ads_province_gmv客源地地图provinceSpark 按省份聚合

3.2 Spark 通过 JDBC 写入 MySQL 的参数设置

Spark 写 MySQL 的常用方式是通过 JDBC DataFrame Writer。这里有几个参数直接影响写入性能和数据正确性。

df_daily.write \ .mode("overwrite") \ .format("jdbc") \ .option("url", "jdbc:mysql://localhost:3306/hotel_dw?useSSL=false&serverTimezone=Asia/Shanghai") \ .option("dbtable", "ads_daily_occupancy") \ .option("user", "root") \ .option("password", "your_password") \ .option("driver", "com.mysql.cj.jdbc.Driver") \ .option("batchsize", "1000") \ .option("truncate", "true") \ .save()

mode("overwrite") 在 JDBC 写入时会先删除目标表数据再写入;配合 truncate 参数可避免 DDL 操作直接 drop 表导致表结构丢失。batchsize 设为 1000 是多数 MySQL 实例的稳定值,调大会提升吞吐但可能触发 max_allowed_packet 报错。写完后建议执行 ANALYZE TABLE 更新统计信息,保证前端查询走索引。

如果你要追加每日增量数据,把 mode 改成 append 即可。但要注意,Spark JDBC 的 append 模式不会做主键冲突处理,重复跑任务会造成数据重复。稳妥做法是:先 DELETE 当天分区数据,再执行 append 写入,这可以用一条 SQL 在存储过程里完成。

3.3 用存储过程封装刷新逻辑

Spark 任务跑完后,MySQL 端可以写一个存储过程做统一的刷新和校验,同时生成一张"最后更新时间"配置表,方便前端展示数据是否新鲜。

DELIMITER // CREATE PROCEDURE sp_refresh_ads() BEGIN DELETE FROM ads_daily_occupancy WHERE stat_date = CURDATE(); -- 实际场景中,Spark 写入完成后会调用此存储过程 -- 这里只做数据量校验,防止空结果覆盖正常数据 IF (SELECT COUNT(*) FROM ads_daily_occupancy) = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ADS data empty, rollback needed'; END IF; END // DELIMITER ;

存储过程在可视化项目里不承担核心计算任务,它更适合做三件事:刷新数据之前清空目标分区、检查数据量是否异常、记录调度日志。真正的聚合逻辑留在 Spark 里,MySQL 存储过程不要写复杂业务计算,否则你会面临两套计算引擎口径不一致的维护噩梦。

提示:写入 MySQL 时最容易踩的坑是时区问题。url 参数里必须带 serverTimezone=Asia/Shanghai,否则日期字段写入后可能少 8 小时。另外如果你的 MySQL 版本较新,建议使用 com.mysql.cj.jdbc.Driver 替代旧的 com.mysql.jdbc.Driver,新版连接器已经移除了旧驱动类。

4. Echarts 展示层:从 SQL 查询到大屏图表的完整链路

4.1 接口层用 Flask 返回 JSON 数据

前端 Echarts 本身不关心数据从哪来,它只消费 JSON 数组。这里提供一个轻量 Python Flask 服务,从 MySQL 读取 ADS 表并返回标准格式。选 Flask 的原因是和 PySpark 同属 Python 技术栈,实训项目中减少一套语言切换成本。

from flask import Flask, jsonify import pymysql app = Flask(__name__) def query_db(sql): conn = pymysql.connect(host="localhost", user="root", password="your_password", database="hotel_dw", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor) try: with conn.cursor() as cursor: cursor.execute(sql) return cursor.fetchall() finally: conn.close() @app.route("/api/daily") def api_daily(): data = query_db("SELECT stat_date, occupancy_rate FROM ads_daily_occupancy ORDER BY stat_date") return jsonify({"code": 0, "data": data}) @app.route("/api/channel") def api_channel(): data = query_db("SELECT channel, channel_ratio FROM ads_channel_ratio") return jsonify({"code": 0, "data": data}) @app.route("/api/province") def api_province(): data = query_db("SELECT province, gmv FROM ads_province_gmv") return jsonify({"code": 0, "data": data}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)

接口返回的字段名直接对应 Echarts 需要的维度列和数值列。比如 province 接口返回 [{province: "浙江", gmv: 12345}] 这种结构,前端 map 类型图表可以直接绑定 name 和 value。这种字段对齐设计能在项目里省掉大量前端数据格式转换代码。

4.2 中国地图:visualMap 分段与省份数据绑定

热词里出现"echarts 中国地图"和"echarts 地图 9 段图变 10 段图",这正好是地图可视化的关键细节。ECharts 5 之后官方不再内置地图 GeoJSON,需要自己引入 china.json 文件。

// 引入地图 GeoJSON 数据 fetch('/static/china.json') .then(res => res.json()) .then(geoJson => { echarts.registerMap('china', geoJson); const chart = echarts.init(document.getElementById('mapChart')); chart.setOption({ tooltip: { trigger: 'item' }, visualMap: { min: 0, max: 50000, splitNumber: 5, inRange: { color: ['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'] } }, series: [{ type: 'map', map: 'china', roam: true, label: { show: true, fontSize: 10 }, data: provinceData.map(item => ({ name: item.province, value: item.gmv })) }] }); });

splitNumber 设置的是分段个数,默认是 5 段,改成 10 就是"9 段图变 10 段图"的动作。实际项目中,分段数不是越多越好,要看数值分布。如果省份之间的 GMV 差异很大,用 continuous 模式或者让 visualMap 的 max 取数据最大值而非固定值会更合理。这里还有一个隐藏细节:省份名称必须和 GeoJSON 里的 name 完全一致,比如"内蒙古"不能写成"内蒙古自治区",否则地图不会渲染该区域。前端可以在数据加载后用 provinceData.filter 检查匹配情况。

4.3 折线图、饼图、象形柱图的配置要点

三张图表对应三个指标,配置上有各自的注意事项。折线图用于入住率趋势,x 轴是日期,如果日期跨月,xAxis 的 type 设成 time 类型可以自动处理刻度稀疏问题,不会出现一堆挤在一起的标签。

// 入住率趋势折线图 option = { tooltip: { trigger: 'axis' }, grid: { left: 40, right: 20, top: 40, bottom: 30 }, xAxis: { type: 'time' }, yAxis: { type: 'value', name: '入住率(%)', min: 0, max: 100, axisLabel: { formatter: '{value}%' } }, series: [{ type: 'line', smooth: true, symbol: 'circle', symbolSize: 6, data: dailyData.map(item => [item.stat_date, item.occupancy_rate]), areaStyle: { opacity: 0.15 } }] };

这里把 data 直接组织成 [date, value] 的元组数组,配合 xAxis type:'time',ECharts 会自动识别日期字符串并排布刻度。areaStyle 的浅色半透明填充让趋势图更有数据大屏的观感,同时不会遮挡折线本身。

饼图用于渠道占比。渠道数量一般在 5-8 个之间,全部展示即可;如果渠道超过 10 个,可以把占比小于 3% 的合并成"其他",保证饼图可读性。象形柱图适合展示各酒店的营收对比,热词相关检索里有"echarts 柱形异形图",ECharts 的 pictorialBar 图表类型可以做到,用酒店门牌的 SVG 路径做柱体,比普通柱状图更有场景感。

// 渠道占比饼图 option = { tooltip: { trigger: 'item' }, legend: { orient: 'vertical', left: 'left' }, series: [{ type: 'pie', radius: ['40%', '70%'], itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 }, data: channelData.map(item => ({ name: item.channel, value: item.channel_ratio })) }] };

radius 的数组写法表示内半径和外半径,形成环形饼图,中间可以放总订单数或总营收的展示区域。borderRadius 和 borderColor 是 ECharts 5 的样式特性,能让饼图边缘变得更精致,适合直接截图放进实训报告。

提示:ECharts 地图相关常见报错是 GeoJSON 加载失败后图表空白,控制台显示 "There is a chart instance already initialized" 之类。解决办法是每次绘制前调用 chart.dispose() 重新实例化,或者用 chart.clear() 清除旧配置。

5. 指标校验与报告素材提取:让实训报告和 PPT 有内容可写

5.1 用 Spark UI 和 Explain 验证聚合逻辑正确性

Spark 任务跑完后,不要急着看图表,先用两个方法验证结果。第一个是 DataFrame 的 explain 方法,打印物理执行计划,确认 join 的 key 没有歧义,groupBy 的列和预期一致。第二个是直接查询 MySQL 数据量和 Spark 输出结果做比对,确保没有丢数据。

df_daily.explain(mode="formatted")

在实训报告中贴这张执行计划截图,同时附上一段文字说明为什么要加 cache:df_clean 被下游三个聚合任务复用,不加 cache 会导致每条聚合重新读源数据、重新执行清洗逻辑。使用 Spark UI 可以清楚看到每个 stage 的输入数据量、shuffle 规模和耗时,这些截图都是报告中的加分素材。

5.2 前端效果自检清单

图表全部完成后,按下面的清单验收:地图省份名称是否正确匹配、鼠标悬浮 tooltip 是否显示完整字段名、折线图的日期刻度是否出现重叠、饼图占比总和是否等于 100%、数据刷新后页面是否有缓存需要强制更新。其中省份名称匹配问题最隐蔽,建议在控制台打印一份 GeoJSON 自带的 name 列表,与数据库里的省份字段做差集,一次性找全不匹配的省份名称。

5.3 实训报告的数据支撑:从 ADS 表导出统计摘要

实训报告和 PPT 需要的不是代码,而是结果截图和指标解读。可以从 MySQL 直接导出这张统计表作为报告附录素材:数据总量、覆盖省市数、最热门的三个客源地省份、渠道占比排名、入住率最高和最低的日期、平均房价走势。这些信息用三条简单 SQL 就能拿到:

SELECT COUNT(*) AS total_rows FROM ods_order_detail; SELECT province, gmv FROM ads_province_gmv ORDER BY gmv DESC LIMIT 3; SELECT stat_date, occupancy_rate FROM ads_daily_occupancy ORDER BY occupancy_rate DESC LIMIT 1;

报告写作时,把 Spark 执行日志中的关键耗时数据(如 total time for all the tasks)和对应数据量记录下来,形成一张"10 万行数据清洗耗时多少秒、100 万行数据耗时多少秒"的对比表。这种可量化的性能数据比任何文字描述都有说服力,也是 PPT 上最有技术含量的页码。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询