最近不少人在做大数据方向的课程设计或者毕业设计,选来选去还是共享单车这个题材最经典——数据量够大、场景够真实、指标够直观,而且正好能把 Hadoop、Spark、Hive 这一整套离线数仓技术栈串起来,最后再用 Flask 做可视化大屏把成果展示出来。我前阵子就把这套系统从头到尾完整搭了一遍,从环境部署到指标计算再到前端图表联动,踩了不少坑,也沉淀下来一整套能直接照搬的实操方案。这篇就把整个项目的架构设计、数据链路、核心代码和调优经验一次讲清楚。
这套系统本质上做的事情是:把共享单车的原始骑行订单数据接入 HDFS,通过 Hive 完成数据清洗和分层建模,再用 Spark 做跨天、跨区域的聚合统计,最终由 Flask 提供 JSON 接口,前端用 ECharts 渲染出运营大盘。它解决的痛点是“数据有了但看不出业务规律”——比如早晚高峰的潮汐效应、不同区域的热度差异、节假日租还车波动,这些单看 CSV 文件根本摸不清,但落到数仓里一算,图表一画,结论就自己浮出来了。
适合谁来参考?如果你是正在做大数据课设、准备大数据面试项目、或者刚入门数仓想找个完整案例练手的开发者,这篇的内容密度足够你少走一个月的弯路。
1. 项目整体设计与技术选型
1.1 为什么是 Hadoop + Spark + Hive 这套组合
很多人在选型阶段就卡住了,总觉得框架越多越好,其实不是。我最终定下 Hadoop + Spark + Hive + Flask 这个组合,不是因为它“看起来高大上”,而是每一层都有不可替代的职责。
Hadoop 的 HDFS 是整个系统的存储底座。共享单车原始数据一天少说几百万条,CSV 文件动辄几个 GB,这已经超出了单机 Excel 和 MySQL 的舒适区。HDFS 把文件切片分布式存储,天然支持超大文件和高吞吐读取,而且后续 Hive 和 Spark 都要直接读 HDFS 上的数据,存储层统一用 Hadoop 能省掉大量数据搬运的功夫。
Hive 承担数据仓库的建模职责。它本身不存储数据,只是把 SQL 翻译成 MapReduce 或者 Spark 任务去跑 HDFS 上的文件。我用 Hive 做 ODS、DWD、DWS 三层建模,把原始数据清洗成干净的明细层,再聚合出业务可以直接查的结果层。这一步的核心价值是:让数据从“能用”变成“好用”,分析师不需要每次写一堆 filter 才能拿到数据。
Spark 是真正的计算引擎。Hive 原始默认的 MapReduce 引擎跑几百万条数据聚合要等十几分钟,换成 SparkSQL 之后,同样逻辑一分钟以内就能出结果。Spark 基于内存计算,DAG 调度器能把复杂的多阶段任务拆成并行执行的算子,性能和 MapReduce 完全不在一个量级。再加上它天然兼容 Hive 的元数据,SparkSQL 可以直接读 Hive 表,不需要额外衔接。
Flask 负责把计算结果暴露成 HTTP 接口。它轻量、灵活,写一个 API 几行代码搞定,渲染模板和静态文件也方便,做可视化项目的后端再合适不过。
1.2 整体数据流和各模块职责划分
这套系统的数据流转路径非常清晰,从下往上五层:
最底层是原始数据层,共享单车平台导出的骑行订单 CSV,包含车辆编号、用户ID、租车时间、还车时间、租车站点、还车站点、骑行时长、骑行距离、金额等字段。这些文件按天切割,每天一个或者多个文件。
数据进入 HDFS 之后,Hive 在上面建外部表,完成 ODS 层原始数据映射。ODS 层基本不做任何加工,字段类型和原始文件保持一致,保留最细粒度的数据,方便后续排查问题。
接着是 DWD 层清洗明细。我在这一层做去重、空值过滤、时间字段规范化、经纬度异常值剔除,把脏数据挡在数仓外面。DWD 层还会做维度退化,比如把站点编号、区域编号冗余进事实表,减少后续聚合的 join 次数。
再往上 DWS 层按业务主题做汇总。比如“按小时租车量统计表”、“按区域热度统计表”、“按天气维度骑行量统计表”,这些表是直接服务可视化接口的。DWS 之所以单独建一层,是为了避免前端每次查询都对全量明细做一遍 group by,那样响应时间会很难看。
最上面的应用层就是 Flask + ECharts。Flask 从 DWS 结果表里查数据,转成 JSON 返回,前端大屏按时刷新。这里有个实践细节:把 DWS 结果表导出到 MySQL,Flask 直接从 MySQL 查询,比每次都走 Hive 查询速度快一个数量级。可视化场景对响应延迟敏感,没必要每次都动用 Hadoop 集群。
2. 数据准备与 Hive 数仓分层建模
2.1 共享单车原始数据格式和导入 HDFS 的方式
共享单车订单数据一般是 CSV 格式,我用的这批样例数据字段结构大概是这样的:
bike_id,user_id,start_time,end_time,start_lat,start_lng,end_lat,end_lng,start_station,end_station,duration,distance,amount实操时先建 HDFS 目录,然后把 CSV 文件上传进去:
hdfs dfs -mkdir -p /data/sharerbike/ods/2024-01-01 hdfs dfs -put order_20240101.csv /data/sharerbike/ods/2024-01-01/这里注意两点。第一,按日期建目录这是个好习惯,为后面 Hive 分区表做准备,查询的时候只扫对应分区,能省掉大量 IO 开销。第二,不要直接把文件丢在 /data 根目录下,命名空间乱了后面找数据会怀疑人生。
2.2 ODS 层建表:保持原样,不做加工
ODS 层建的 Hive 外部表,本质上是给 HDFS 上的 CSV 文件挂一个“关系型视图”。我用的建表语句如下:
CREATE EXTERNAL TABLE ods_bike_order( bike_id STRING, user_id STRING, start_time STRING, end_time STRING, start_lat DOUBLE, start_lng DOUBLE, end_lat DOUBLE, end_lng DOUBLE, start_station STRING, end_station STRING, duration INT, distance DOUBLE, amount DOUBLE ) PARTITIONED BY (dt STRING) ROW FORMAT DELIMITED FIELDS TERMINATED BY ',' STORED AS TEXTFILE LOCATION '/data/sharerbike/ods';然后执行分区修复命令,让 Hive 元数据感知到 HDFS 上新增的日期目录:
MSCK REPAIR TABLE ods_bike_order;这步容易踩坑:很多新手建完表直接 select 发现查不到数据,就是忘了 MSCK REPAIR TABLE。因为 Hive 的元数据不会自动扫描 HDFS 上新出现的分区目录,必须手动同步一下。
ODS 层设计原则就是“能不动就不动”。原始字段全保留,类型尽量宽松,时间先不转格式,统一用字符串存着。这样即使后续发现下游算法需要某些原始字段,还能从 ODS 层重新加工,不至于因为提前裁剪字段导致数据不可追溯。
2.3 DWD 层清洗:脏数据和空值的处理细节
DWD 层是数据质量的关卡。共享单车原始数据里面经常出现这类脏数据:骑行时间为负数、经纬度为零、站点名为空、同一订单出现多次记录。这些不处理干净,统计出来的高峰时段和热门区域完全是错的。
我建 DWD 表时做了四层清洗:
第一层去重,用 ROW_NUMBER() 按 bike_id + start_time 分组,只保留每组第一条。因为共享单车平台偶尔会重复上报订单,不去重的情况下租车量会被虚高统计。
第二层过滤异常值,骑行时长小于 30 秒的记录直接丢弃,这类数据一般是用户扫码后马上取消的无效订单;经纬度为 0 或者超出城市合理范围的记录也剔除,防止后面做区域聚合时出现“幽灵坐标点”。
第三层时间格式化,把 start_time、end_time 从字符串转成 TIMESTAMP 类型,并且抽取小时、星期、是否节假日等时间维度字段。这样下游按小时聚合时直接用 hour 字段 group by,不需要每次都对字符串做函数转换。
第四层做维度退化,把站点区域信息直接并入订单表。我有一张独立的站点维度表,在 DWD 层通过 start_station 关联站点区域字段,把 region_name 冗余进事实表,后续聚合不再需要额外的 join 操作。
2.4 DWS 层结果表:为可视化场景定制聚合指标
DWS 层是我跟可视化需求反复对齐之后建的几张表,每张表对应大屏上的一个图表模块。
小时骑行趋势表,核心字段有 dt、hour、order_cnt、总骑行时长、平均骑行时长。它驱动大屏上的“24小时租车量趋势图”,能直观看出早晚高峰,共享单车典型的双驼峰曲线就是这个表算出来的效果。
区域热度表,核心字段有 dt、region_name、order_cnt、active_user_cnt。它驱动“城市热力区域 Top10”排行,展示哪个区域的车流转最猛。
站点流量表,核心字段有 dt、station_name、borrow_cnt、return_cnt、net_flow(借还净差)。它驱动“站点借还差异榜单”,能直接看出哪些站点是“早高峰-晚上净流出”,哪些是“晚高峰-早上净流入”,这个指标对调度运维非常有价值。
建表语句大致长这样:
CREATE TABLE dws_hour_order_cnt( dt STRING, hour INT, order_cnt BIGINT, total_duration BIGINT, avg_duration DOUBLE ) PARTITIONED BY (dt STRING) STORED AS PARQUET;DWS 层选择 Parquet 列式存储,查询性能比 TextFile 强太多。聚合任务是重 IO 类型的计算,Parquet 按列读取,只需要扫描用到的字段,不会把整行数据全部加载到内存。
3. Spark 核心计算逻辑与性能优化
3.1 SparkSQL 读 Hive 表的前置配置
Spark 要直接读 Hive 表,需要把 Hive 的元数据地址告诉 Spark。我用的 Spark 版本是 3.x,提交任务时加上了 hive-site.xml 配置:
spark-submit \ --class BikeAnalysis \ --master yarn \ --deploy-mode client \ --executor-memory 4g \ --num-executors 4 \ --conf spark.sql.warehouse.dir=hdfs:///user/hive/warehouse \ --conf spark.hive.metastore.uris=thrift://node01:9083 \ bike-analyse.jar注意这个 thrift 地址是 Hive metastore 服务的连接串。需要先在集群上启动 metastore 服务:
nohup hive --service metastore > /opt/hive/logs/metastore.log 2>&1 &这个坑我踩过一次:直接启动 hive 命令行可以跑 SQL,但 Spark 读 Hive 表时一直报 “Table not found”,就是因为 metastore 服务没独立启动,Spark 找不到元数据入口。
3.2 基于 Scala 编写 Spark 聚合任务
我最终实现的几个核心分析任务都写在了一个 Scala 工程里面,通过传入参数切换不同的分析逻辑。以小时趋势统计为例,核心代码逻辑如下:
import org.apache.spark.sql.SparkSession object HourTrendAnalysis { def main(args: Array[String]): Unit = { val spark = SparkSession.builder() .appName("ShareBikeHourTrend") .enableHiveSupport() .getOrCreate() import spark.implicits._ val result = spark.sql( """ |SELECT dt, hour, COUNT(*) AS order_cnt, | SUM(duration) AS total_duration, | AVG(duration) AS avg_duration |FROM dwd_bike_order_detail |WHERE dt = '2024-01-01' |GROUP BY dt, hour |ORDER BY hour """.stripMargin) result.write .mode("overwrite") .format("parquet") .partitionBy("dt") .saveAsTable("dws_hour_order_cnt") spark.stop() } }实际工作中还有一个非常实用的技巧:如果在清洗和聚合逻辑里频繁用到自定义时间函数,可以在 SparkSQL 里注册 UDF。比如判断某个时间是否是节假日:
val isHoliday = udf((time: String) => { // 维护一个节假日列表,判断时间是否在里面 holidaySet.contains(time.substring(0, 10)) }) spark.udf.register("is_holiday", isHoliday)这样在 SQL 里直接写WHERE is_holiday(start_time) = true就能过滤出节假日订单,比起每次写一长串复杂表达式干净得多。
3.3 数据倾斜处理:聚合场景的典型问题
做共享单车数据分析时最容易出现的性能问题是数据倾斜。比如“区域热度统计”任务,某些核心商圈的订单量可能是其他区域的几十倍,Spark 按区域分组时会把大量数据分配到同一个分区,导致某个 executor 累死、其他 executor 空闲,整个任务卡在最后几个 stage。
排查数据倾斜最直接的方法是看 Spark UI 上各 task 的处理时长:正常情况下各 task 时长相差不大,倾斜时会出现一个 task 运行时间远大于其他 task,而且对应的 shuffle read 数据量明显偏高。
我处理这次数据倾斜用了两招。第一招是给倾斜的 key 加随机前缀:
val saltedDf = df.withColumn( "region_salt", when($"region_name".isin("核心商圈A"), concat(rand(), lit("_"), $"region_name")) .otherwise($"region_name") )这样核心商圈的记录会被随机分散到多个分区,然后再做一次去前缀的聚合。虽然多了一步操作,但整体任务执行时间从十几分钟降到了两分钟以内,效果非常明显。
第二招是调整 Spark 的 shuffle 分区数。默认 executors 数量 × 200 的分区数在一些场景下过多,导致每个分区的数据量很小、task 调度开销占比高。我把spark.sql.shuffle.partitions调成了和集群核数匹配的值,比如集群共 16 核就设 32,任务调度反而更轻快。
3.4 Spark 任务写入 Hive 结果表的小文件问题
每次跑完 Spark 聚合任务,HDFS 上会生成大量小文件,尤其是按天分区写入的场景。小文件的危害在于:后续查询时 NameNode 要维护更多的元数据,任务调度时每个文件就是一个 InputSplit,文件多了调度开销指数级上升。
我当时通过coalesce()控制输出文件数量。DWS 表数据量不大,每个分区一个文件足够:
result.coalesce(1) .write .mode("overwrite") .format("parquet") .partitionBy("dt") .saveAsTable("dws_hour_order_cnt")需要注意coalesce()和repartition()的区别。coalesce()会尽量不触发 shuffle,只是合并分区,适合这里减少输出文件数量的场景;repartition()会重新 shuffle 数据,适合需要改变分区数量且要重新分布数据的场景。
4. Flask 后端接口设计与性能调优
4.1 DWS 结果数据落地 MySQL 的理由
Spark 计算完结果后,我第一步是把 Hive 的 DWS 结果表同步到 MySQL。这不是多此一举,而是响应速度和运维成本综合考虑后的选择。
Flask 通过 thrift 接口访问 Hive 也是一个方案,但每次查询都要起一个 YARN 任务,哪怕只是查一张 1000 行的小结果表也要等任务调度,响应时间轻松到秒级甚至分钟级。而 MySQL 对这类几十万行以内的汇总表,单条查询基本是毫秒级响应,服务几十个前端请求毫无压力。可视化大屏要求高频刷新,频繁起 YARN 任务既不现实,也会拖垮集群资源。
数据同步我直接用 Python 脚本实现,从 Hive 查出来再写入 MySQL:
import pymysql from pyhive import hive # 连接 Hive 查询 DWS 结果 hive_conn = hive.Connection(host='node01', port=10000, username='hive') hive_cursor = hive_conn.cursor() hive_cursor.execute("SELECT dt, hour, order_cnt, total_duration, avg_duration FROM dws_hour_order_cnt WHERE dt='2024-01-01'") rows = hive_cursor.fetchall() # 写入 MySQL mysql_conn = pymysql.connect(host='localhost', user='root', password='123456', database='bike_analysis') mysql_cursor = mysql_conn.cursor() for row in rows: mysql_cursor.execute( "INSERT INTO hour_order_cnt(dt, hour, order_cnt, total_duration, avg_duration) VALUES(%s,%s,%s,%s,%s)", row ) mysql_conn.commit()这里有个实用细节:同步任务用crontab每天凌晨定时执行,把前一天的数据从 Hive 同步到 MySQL。大屏展示不需要实时数据,T+1 的更新频率完全够用。如果确实需要小时级更新,把 cron 周期改成每小时执行一次即可。
4.2 Flask 接口层的路由设计与 JSON 返回格式
Flask 项目我按照模块拆分了蓝图(Blueprint),每个图表模块对应一个路由文件。整体目录结构大概是这样:
flask_app/ ├── app.py ├── blueprints/ │ ├── trend.py │ ├── hot_zone.py │ └── station_flow.py └── utils/ └── db.py小时趋势接口的完整代码:
# app.py from flask import Flask, jsonify from blueprints.trend import trend_bp from blueprints.hot_zone import hot_zone_bp from blueprints.station_flow import station_flow_bp app = Flask(__name__) app.register_blueprint(trend_bp, url_prefix='/api/trend') app.register_blueprint(hot_zone_bp, url_prefix='/api/hot_zone') app.register_blueprint(station_flow_bp, url_prefix='/api/station_flow') if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)# blueprints/trend.py from flask import Blueprint, jsonify from utils.db import get_connection trend_bp = Blueprint('trend', __name__) @trend_bp.route('/hour') def hour_trend(): conn = get_connection() cursor = conn.cursor() cursor.execute( "SELECT hour, order_cnt, avg_duration FROM hour_order_cnt WHERE dt='2024-01-01' ORDER BY hour" ) rows = cursor.fetchall() data = [ {"hour": row[0], "order_cnt": row[1], "avg_duration": row[2]} for row in rows ] cursor.close() conn.close() return jsonify({"code": 0, "data": data})接口设计遵循一个原则:前端需要什么结构,后端就返回什么结构,不要让前端拿到数据后做二次加工。比如小时趋势图需要[{"hour": 8, "order_cnt": 15230}, ...],后端就直接返回这个格式,前端拿到数组往 ECharts 里面一填就能渲染。
4.3 Flask 连接 MySQL 的性能瓶颈与连接池改造
Flask 项目刚跑起来的时候用的是最简单的方式:每次请求都新建一个 MySQL 连接,用完关掉。开发调试没毛病,但压力测试时问题出来了——并发 50 个请求时,MySQL 报Too many connections,而且每次新建连接都要经过 TCP 握手、认证,耗时全耗在网络往返上了。
改成连接池之后情况明显好转。我用DBUtils.PooledDB封装了一个连接池:
from dbutils.pooled_db import PooledDB import pymysql POOL = PooledDB( creator=pymysql, maxconnections=20, mincached=5, maxcached=10, blocking=True, host='localhost', port=3306, user='root', password='123456', database='bike_analysis', charset='utf8mb4' ) def get_connection(): return POOL.connection()设置maxconnections=20是综合考虑了 MySQL 默认连接上限和 Flask 并发压力的折中。连接池启动时预先建好 5 个连接,用完了归还池子而不是关闭,请求高峰期连接可以复用,速度提升非常明显。
4.4 Redis 缓存热点数据,减轻数据库压力
大屏项目还有一个特征:多个页面共享同一份聚合数据,而且数据本身是 T+1 更新的——也就是说同一个小时内,十次请求查出来的结果完全一样。这种情况下每次查数据库属于严重的重复劳动。
我引入了 Redis 做缓存层,把 DWS 查询结果缓存到内存,设置一个合理的过期时间:
import redis import json r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True) @trend_bp.route('/hour') def hour_trend(): cache_key = 'trend:hour:2024-01-01' cached_data = r.get(cache_key) if cached_data: return jsonify({"code": 0, "data": json.loads(cached_data)}) conn = get_connection() cursor = conn.cursor() cursor.execute( "SELECT hour, order_cnt, avg_duration FROM hour_order_cnt WHERE dt='2024-01-01' ORDER BY hour" ) rows = cursor.fetchall() data = [{"hour": row[0], "order_cnt": row[1], "avg_duration": row[2]} for row in rows] cursor.close() conn.close() r.setex(cache_key, 1800, json.dumps(data)) return jsonify({"code": 0, "data": data})过期时间设 1800 秒,也就是半小时。因为底层数据是每天更新,半小时的缓存时间既保证了数据不会太陈旧,又能大幅降低数据库的查询压力。实际压测下来,加了 Redis 缓存后接口平均响应时间从 80ms 降到了 5ms 以内。
5. 可视化大屏实现与前后端联调细节
5.1 大屏图表的选型与布局逻辑
可视化前端我用的是 ECharts,主要原因是它生态成熟、图表类型全、社区示例丰富,而且对用户完全免费。做数据大屏特别依赖地图和热力图能力,ECharts 通过 geo 组件可以方便地绘制区域热力图,这一点比传统的 Chart.js、Highcharts 体验更顺。
大屏整体布局我按照“左-中-右”的三栏设计,适配 1920×1080 分辨率。左侧栏放区域热度 Top10 的横向柱状图和站点借还差异榜;中间最显眼的区域放城市租车热力地图,因为它是整个大屏的视觉中心;右侧栏放 24 小时趋势折线图和骑行距离分布直方图。顶部是标题和核心指标卡片,显示当日总订单量、活跃用户数、平均骑行时长这些关键数字。
5.2 使用 ECharts 配置核心图表
小时趋势折线图的配置是可视化里最有代表性的一个。通过 X 轴展示小时,Y 轴展示订单量,用面积图渐变效果突出高峰期曲线:
const chart = echarts.init(document.getElementById('trendChart')) fetch('/api/trend/hour') .then(res => res.json()) .then(res => { const hours = res.data.map(item => item.hour + ':00') const orderCnt = res.data.map(item => item.order_cnt) chart.setOption({ tooltip: { trigger: 'axis' }, grid: { top: 30, right: 30, bottom: 30, left: 60 }, xAxis: { type: 'category', data: hours }, yAxis: { type: 'value', name: '订单量' }, series: [{ name: '订单量', type: 'line', data: orderCnt, smooth: true, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: 'rgba(64, 158, 255, 0.6)' }, { offset: 1, color: 'rgba(64, 158, 255, 0.1)' } ]) } }] }) })这里要用fetch获取后端数据后在前端做组装,而不是把 JSON 数据硬编码到页面里。前后端分离的好处是数据更新后刷新页面就是新数据,不用重新改代码。
5.3 大屏自动刷新机制的实现
大屏一般会挂在展厅或者监控室,不可能有人盯着刷新按钮。所以需要实现自动刷新机制,用setInterval定时拉取最新数据:
function loadTrendData() { fetch('/api/trend/hour') .then(res => res.json()) .then(res => { if (res.code === 0) { trendChart.setOption({ xAxis: { data: res.data.map(item => item.hour + ':00') }, series: [{ data: res.data.map(item => item.order_cnt) }] }) } }) } loadTrendData() setInterval(loadTrendData, 30000)刷新时间设 30 秒,兼顾实时性和后端压力。如果刷新太快,Flask 接口和 MySQL 都扛不住无意义的查询;太慢了,大屏数据时效性又打折。30 秒是实际项目里最常见的折中值。
5.4 峰值时刻联动查看:图表点击交互的实践
纯展示型大屏对运营的价值有限,让大屏“能动”起来才是关键。我实现了一个联动交互:点击区域热度 Top10 榜单中的某个区域,右侧的站点流量榜自动切换为该区域下各站点的借还数据,同时中间地图的视角也会聚焦到对应区域。
实现方式是在前端给柱状图注册 click 事件:
hotZoneChart.on('click', function(params) { const regionName = params.name fetch(`/api/station_flow/region?region_name=${encodeURIComponent(regionName)}`) .then(res => res.json()) .then(res => { stationChart.setOption({ yAxis: { data: res.data.map(item => item.station_name) }, series: [{ data: res.data.map(item => item.net_flow) }] }) }) })后端对应的 Flask 接口/api/station_flow/region接收region_name参数,返回该区域下所有站点的借还差数据。这个交互逼着我在后端对站点流量表做了 region 维度的索引,查询时从 70ms 优化到了 15ms。
这个交互看起来简单,但实际做下来对系统的提升很大。运营人员不需要自己脑补“热力最高的区域到底是哪些站点贡献的”,鼠标一点,数据直接呈现在面前。这种“总览-下钻”的交互路径也是可视化大屏区别于普通报表的价值所在。
6. 项目部署、性能调优与常见问题排查
6.1 集群环境资源和配置参考
我搭建这套系统用的是 3 台节点的测试集群,每台机器 8 核 CPU、16GB 内存、200GB 磁盘,操作系统是 CentOS 7.9。软件版本方面:Hadoop 3.3.4、Hive 3.1.3、Spark 3.3.2、Flask 2.2.5、MySQL 8.0。
这个配置在真实企业里算是入门级别的资源,但跑几百 GB 级别的共享单车数据已经完全够用了。资源分配上有个小建议:NameNode 和 ResourceManager这类管理节点占用资源少,尽量跟数据节点分开部署;Spark 的 executor 内存不要贪多,每个 executor 4GB、总共 4 个 executor 的方式在测试集群上最稳。
6.2 从 Hive 到 MySQL 的数据同步调度方案
我用的同步方案是 Airflow 来编排整个任务流。任务分为三个阶段:
第一是数据接入任务,检查当天 HDFS 上是否上传了对应日期的 CSV 文件,如果存在就触发后续任务。 第二是 Hive ETL 任务,执行 DWD 层清洗和 DWS 层聚合的 SQL 脚本,这一步在 Hive 上生成当天的结果表。 第三是数据导出任务,用 Python 脚本把 DWS 结果同步到 MySQL。
这种方式比 crontab 硬调度多了一个依赖感知的能力:上游任务失败,下游任务不会盲目执行,避免出现“MySQL 里写入了半天的脏数据但自己还不知道”的情况。
6.3 常见问题排查速查表
把整个项目跑通的过程中,我遇到了一堆问题,有些问题报错信息非常误导人,光看日志根本找不到原因。整理一张速查表,给后来者参考:
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
Spark 读取 Hive 表报Table not found | metastore 服务未独立启动 | 启动hive --service metastore,Spark 配置中指定 thrift 地址 |
| HDFS 文件已经上传但 Hive 查询无数据 | 外部表未同步分区信息 | 执行MSCK REPAIR TABLE 表名 |
| 聚合任务长时间卡在最后几个 stage | 数据倾斜 | 倾斜 key 加随机前缀,调整spark.sql.shuffle.partitions |
Flask 接口报Too many connections | 每次请求新建 MySQL 连接未复用 | 用PooledDB连接池替换直接连接 |
| 大屏数据刷新很慢 | 前端直接查 Hive | DWS 结果落到 MySQL,Flask 从 MySQL 读取 |
| Spark 写入结果表后小文件过多 | 默认输出分区数过多 | 写入前coalesce(1)控制文件个数 |
| Hive SQL 查询非常慢 | 默认 MapReduce 引擎 | 设置SET hive.execution.engine=spark |
6.4 容器化部署的思路与注意点
最后提一下我把 Flask 服务容器化的经验。项目在交付时为了统一环境,我用 Docker 把 Flask 应用打成了镜像,Dockerfile 大致是:
FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple COPY . . EXPOSE 5000 CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]这里有个值得留意的细节:本地开发调试app.run()没有问题,但部署到容器里一定要换成 gunicorn 这类生产级 WSGI 服务器。Flask 自带的开发服务器是单进程的,无法利用多核 CPU,并发稍高就会出现请求排队。
容器化部署时还要注意网络模式的配置。Flask 容器要访问宿主机上的 MySQL 和 Redis,直接用localhost是不通的。我在启动容器时加了--network=host参数让容器共享宿主机网络,或者在配置项里把数据库地址改成宿主机 IP,这类问题在容器化初期特别容易踩。
还有一点,多 workers 模式下要注意 Redis 缓存和 MySQL 连接池的配置。每个 worker 进程都会各自初始化连接池,连接数会被放大。我当时设置了PooledDB的maxconnections=5,4 个 worker 总共 20 个连接,刚好在 MySQL 的连接上限之内。
最终把整个系统跑通的那一刻,你会在 24 小时趋势图上清楚地看到早高峰和晚高峰两个峰值,在热力地图上看到城市核心区域的颜色明显比郊区深,这种从原始数据到业务洞察的完整链路,才是这个项目最让人有成就感的地方。整个过程中踩过的坑、调过的参、改过的代码,也是面试时最能体现真实项目经验的内容。