前段时间帮A同学调试一个毕业设计,题目是“hadoop+Spark+django基于Hive的公共交通系统数据分析”,附带源码、文档和可视化大屏。这类项目在各类资源网站上一抓一大把,标题都很诱人,但真正能跑通、能讲清楚、能在答辩现场扛住追问的,真的不多。原因很简单:Hadoop、Spark、Hive、Django这四个组件,单独拎一个出来都不算难,难的是把它们串成一条完整的数据链路,过程中会碰到版本兼容、资源分配、中文编码、权限配置等一堆组合拳。这篇文章不卖源码,也不贴那些看着光鲜的截图,只讲我从零组装这套系统时的架构思路、建模方法、核心代码和踩坑记录,给正在开工或调试同类项目的同学一个可复用的参考。
1. 这套技术栈到底在干什么:先搞清楚每个组件的角色
1.1 为什么要把Hadoop、Spark、Hive、Django拧在一起
很多人拿到这个题目后的第一反应是:这几个词哪个单独用我都听过,但为什么要同时用?是不是纯为了凑技术栈?说实话,这类题目确实有凑技术栈的成分,但从业务角度也能自圆其说。
公共交通系统的核心数据是刷卡记录、GPS轨迹和线路站点信息。以一座中等城市为例,一天的刷卡记录少则几十万条,多则上千万条,这还没算每辆车的GPS坐标。传统单机MySQL在千万级数据上做聚合查询,基本是跑几分钟起步,在线路客流、站点拥挤度这类分析场景里根本撑不住。于是需要Hadoop体系里的HDFS来做分布式存储,把海量原始数据先存下来。Hive则在这层数据之上提供类SQL的查询能力,降低写MapReduce的门槛。Spark负责更复杂的清洗、关联和聚合计算,速度比原生Hive查询快不少。Django是纯应用层,负责把分析结果通过Web接口暴露出去,最后在浏览器里渲染成可视化大屏。
这四者的关系可以理解成:Hadoop是仓库,Hive是仓库管理员,Spark是加工车间,Django是前台。没有仓库,数据没地方放;没有管理员,取货很麻烦;没有加工车间,原菜没法直接上桌;没有前台,做好的菜客人看不到。这样一拆,整个项目就不再是技术名词堆砌,而是一条逻辑完整的业务流水线。
1.2 数据流转全景:从刷卡记录到可视化大屏
我基于项目实际运行过程,把数据流整理成了六步,每一步都有明确的输入输出:
- 原始刷卡日志按天落盘,上传到HDFS的指定目录,例如
/data/ods/tap_record/dt=2024-10-01/。 - Hive建立ODS层外部表,直接映射这个目录,做到数据只存一份,Hive只管读。
- Spark作业读取ODS层表,完成清洗和转换,写入Hive的DWD层和DWS层。
- 聚合结果通过Spark的JDBC写入MySQL中的ADS结果表,这一步服务的是查询性能。
- Django后端从MySQL读取结果表,对外提供JSON格式的REST API。
- 前端可视化大屏定时请求这些API,用ECharts等图表库渲染出客流趋势、站点热度、线路满载率等图表。
这套链路的关键在于:Django永远不直连Hive,大屏永远不直连MySQL做复杂查询。每一层都只和它相邻的一层打交道,出了问题只需要查一小段,不会牵一发动全身。
1.3 版本搭配:最容易在第一步就翻车的地方
版本兼容是这类多组件项目最容易翻车的点。我在最初搭建时用的是Hadoop 3.3.6、Spark 3.4.1、Hive 3.1.3、Django 3.2 LTS、MySQL 8.0.34,这个组合整体跑下来比较稳。Java版本建议用JDK 8或者JDK 11,Spark 3.4在高版本JDK上的兼容性一般,没必要给自己添堵。
一个常见的错误是随意混搭版本,比如Spark 2.x配Hadoop 3.x,编译级别不同,提交作业时会出现各种NoSuchMethodError。另一个注意点是Spark连接Hive时,需要把hive-site.xml放到Spark的conf目录下,否则SparkSession找不到Hive的元数据库连接信息。这些细节单独看都不难,但叠加在一起就足够让人排查一个下午。下面给出一张我最终确定的版本对照表,方便直接参考。
| 组件 | 版本 | 说明 |
|---|---|---|
| Hadoop | 3.3.6 | 稳定,NameNode管理简单 |
| Hive | 3.1.3 | 与Hadoop 3.x兼容良好 |
| Spark | 3.4.1 | 支持Hive集成和动态资源分配 |
| Django | 3.2 LTS | 长期支持,生态成熟 |
| MySQL | 8.0.34 | 存储ADS结果表和业务元数据 |
| JDK | 1.8 | 兼容性最佳 |
| Python | 3.8+ | PySpark和Django都适用 |
2. 数据准备阶段:没有公交数据怎么办
2.1 公共交通数据到底长什么样
做数据项目最怕的就是没有数据。网上能下载到真实公交数据集的渠道很少,而且大多脱敏不彻底。我的做法是先了解这类数据的标准结构,再用脚本自己造一份看起来真实的数据集。
公共交通数据通常由三张核心表组成。线路信息表包含线路编号、线路名称、起点站、终点站、首班时间、末班时间。站点信息表包含站点编号、站点名称、经度、纬度、所属区域。刷卡记录表是最主要的事实表,包含卡号、线路编号、站点编号、刷卡时间、支付金额、卡类型、车辆编号。如果项目涉及GPS轨迹,还有一张轨迹表,记录车辆在某个时间点的经纬度和速度。
刷卡记录表的字段设计直接决定后面所有统计口径,所以一开始就要想清楚。比如"客流"到底怎么定义:是按刷卡次数算,还是按进站人次算?在不同城市的地铁和公交系统中口径不一样。做这类项目时,推荐在文档里提前定义好:一个乘客一次上车刷卡记为一条客流,换乘不算新增客流。这样后面写SQL和Spark逻辑时才不会自相矛盾。
2.2 用脚本生成一份看着真实的模拟数据集
没有真实数据,就自己造。造数据不是瞎随机,要符合公交运行的物理规律。我的造数脚本基于三个规则:第一,早高峰出现在7点到9点,晚高峰出现在17点到19点,客流是平峰期的四到六倍;第二,热点站点的客流服从长尾分布,地铁换乘站和商圈站的刷卡量远高于普通小区站;第三,同一条线路上,相邻站点的上下车量应该有一定连续性,不能出现前一站爆满、下一站瞬间为零的离奇情况。
Python造数脚本主要生成刷卡记录表,核心代码如下所示。为了方便后面演示,生成的数据量我建议控制在50万到100万条之间,既能体现大数据处理的效果,又不会让单机开发环境跑太久。
import random import csv from datetime import datetime, timedelta random.seed(42) def gen_tap_records(date_str, count=600000): routes = [f"R{i:03d}" for i in range(1, 61)] stations = [f"S{i:03d}" for i in range(1, 501)] cards = [f"C{i:08d}" for i in range(1, 50001)] # 早晚高峰权重函数:7-9点和17-19点概率放大 def time_prob(hour): if 7 <= hour <= 9 or 17 <= hour <= 19: return 5.0 if 6 <= hour <= 22: return 1.0 return 0.15 hour_weights = [time_prob(h) for h in range(24)] total_weight = sum(hour_weights) with open(f"tap_record_{date_str}.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["card_id", "route_id", "station_id", "tap_time", "pay_amount", "card_type"]) for _ in range(count): hour = random.choices(range(24), weights=hour_weights, k=1)[0] minute = random.randint(0, 59) second = random.randint(0, 59) tap_time = datetime.strptime(date_str, "%Y-%m-%d").replace( hour=hour, minute=minute, second=second ) amount = round(random.uniform(1.0, 5.0), 2) card_type = random.choice(["普通卡", "学生卡", "老年卡", "月票"]) writer.writerow([ random.choice(cards), random.choice(routes), random.choice(stations), tap_time.strftime("%Y-%m-%d %H:%M:%S"), amount, card_type, ]) if __name__ == "__main__": gen_tap_records("2024-10-15", 600000)生成之后还要做一个重要动作:故意往里面注入一部分脏数据。比如把百分之二的记录时间格式改成2024/10/15 08:21:33,把百分之零点五的记录station_id设为-1,再随机重复几条刷卡记录。这样做的目的是让后面的清洗流程有的放矢,在答辩现场也能展示出数据质量处理的价值。
2.3 清洗逻辑:哪些脏数据必须处理
数据清洗不是把所有异常都删掉,而是要分清楚哪些能修、哪些只能丢弃。我在这个项目里整理了五类常见脏数据及其处理方式:
- 时间格式不统一:统一解析字符串,转成
timestamp类型,无法解析的直接丢弃。 - 关键字段缺失:卡号或站点编号为空的记录,这类数据占比较低,直接过滤。
- 重复刷卡:同一卡号、同一线路、同一站点、一分钟内出现两次,保留第一条,第二条标记为重复并剔除。
- 不合理时间:刷卡时间早于当天首班或晚于末班,按线路表关联后过滤。
- 金额异常:金额为0或超过单程票价上限的,按异常值处理。
清洗逻辑看似琐碎,但它是整个项目中最能体现工程素养的地方。到答辩时,别只讲我写了什么SQL,要讲“我发现了哪些数据问题,用什么样的规则去解决,处理后数据量变化了多少”,这才是评委愿意听的内容。
3. Hive里的数据仓库建模:不要让Spark直接读原始日志
3.1 ODS-DWD-DWS三层模型的作用
很多同学拿到Hive之后,直接建一张大宽表就开始跑SQL。这种做法在小数据量下没问题,但数据量上来之后,每次查询要扫描全量数据,慢且浪费资源。我在这套项目里采用的是标准数仓分层思路:ODS、DWD、DWS,外加一张给大屏直接用的ADS层。
ODS层就是原始数据层,表结构和原始文件保持一致,我不对它做任何加工。DWD层是清洗明细层,负责把ODS里的脏数据过滤掉,把字段类型转换成标准格式,同时按分区组织。DWS层是汇总层,按照线路、站点、时间等维度预先聚合好明细数据。ADS层是应用层,面向具体业务需求,比如大屏要看的“某线路每小时的客流趋势”,直接在ADS层出结果。
用厨房来类比:ODS层是从菜市场买回来的带泥蔬菜,DWD层是择菜、洗菜、切菜,DWS层是把切好的菜配成半成品套餐,ADS层是直接端上桌的成品菜。每一层各司其职,改动任何一层都不会影响其他层。
3.2 建表DDL:三层表的字段设计
ODS层表我建的是外部表,因为原始文件已经落在HDFS上,外部表在删除表时不会误删数据,这对数据安全很重要。建表语句如下:
CREATE EXTERNAL TABLE IF NOT EXISTS ods_tap_record ( card_id STRING, route_id STRING, station_id STRING, tap_time STRING, pay_amount DOUBLE, card_type STRING ) PARTITIONED BY (dt STRING) ROW FORMAT DELIMITED FIELDS TERMINATED BY ',' STORED AS TEXTFILE LOCATION '/data/ods/tap_record';DWD层表则采用Parquet列式存储,并对字段类型做了标准化。Parquet在压缩率和查询性能上都明显优于纯文本,尤其适合Spark后续的列裁剪。建表语句如下:
CREATE TABLE IF NOT EXISTS dwd_tap_detail ( card_id STRING, route_id STRING, station_id STRING, tap_time TIMESTAMP, tap_hour INT, pay_amount DOUBLE, card_type STRING ) PARTITIONED BY (dt STRING) STORED AS PARQUET;DWS层则按统计分析的主题建表。比如线路客流汇总表,粒度是“线路+日期+小时”,主要字段包括总客流、平均满载率、高峰时段等。ADS层进一步把大屏需要的指标物化成一张单表,字段包括日期、线路编号、线路名称、小时、客流量、上下车热门站点等信息,大屏接口可以一条SQL直接查出来,连JOIN都不用做。
3.3 分区策略与小文件问题
分区策略上,我采用按天分区,因为公交数据天然按天产生,历史分区不会变化,查询时也能快速裁剪数据。如果希望更精细地支持某一天的某个小时,可以在表里加一个hour字段,而不是把每个小时都做成一个分区。分区数量太多会导致NameNode元数据压力增大,反而拖慢整体性能。
在向分区表写入数据时,优先使用动态分区方式,通过一条SQL把数据插到正确分区里。动态分区相关的参数需要提前打开:
SET hive.exec.dynamic.partition=true; SET hive.exec.dynamic.partition.mode=nonstrict;小文件问题是我实际遇到过的大坑。前面生成的原始文件如果切得太碎,Spark写入时会产生大量几十KB的小文件,后续每次查询光打开文件就耗费大量时间。解决方法是:写入前按分区重新分区,写入时控制每个分区的文件数量,必要时用coalesce控制输出文件数,保证单个分区的Parquet文件在128MB左右,小文件问题会改善非常明显。
4. Spark作业开发:哪些计算必须交给Spark
4.1 为什么不用Hive SQL硬算,还要加一个Spark
很多人在这个项目里很困惑:Hive已经能写SQL了,为什么还要上Spark?我用同一个聚合任务做了对比测试:从DWD层算“每个线路每个小时的上车客流”,Hive跑一遍大约需要7分钟,Spark作业在同样资源下大约2分钟跑完。如果你的集群只有一台机器,这个差距可能被资源抢占掩盖掉,但只要数据量超过几百万条,Spark的优势就很明显。
另外,Hive SQL在处理复杂ETL时特别别扭。比如要做多表关联后,再根据站点经纬度做空间匹配,或者对轨迹数据进行窗口滑动的计算,用Hive写要套好几层子查询,逻辑难以维护。Spark里可以直接用DataFrame API或写Python逻辑,代码的可读性和复用性都更好。所以我的定位是:简单查询和报表用Hive跑,复杂ETL和聚合用Spark跑,各取所长。
4.2 一个完整的Spark作业:线路客流TopN与站点高峰时段统计
我在项目里写了一个核心Spark作业,逻辑并不复杂,但覆盖了完整的数据处理流程:读取DWD表、按维度和时间粒度聚合、关联线路和站点维度、输出到ADS结果表。下面给出简化后的核心代码:
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder \ .appName("traffic_analysis_etl") \ .enableHiveSupport() \ .getOrCreate() dt = "2024-10-15" # 1. 读取DWD层明细 df = spark.sql(f""" SELECT route_id, station_id, tap_hour, card_id FROM dwd_tap_detail WHERE dt = '{dt}' """) # 2. 按线路+小时聚合客流 route_hour_flow = df.groupBy("route_id", "tap_hour") \ .agg(F.countDistinct("card_id").alias("passenger_flow")) \ .orderBy("route_id", "tap_hour") # 3. 按站点+小时聚合上下车热度 station_hour_flow = df.groupBy("station_id", "tap_hour") \ .agg(F.count("card_id").alias("station_flow")) \ .orderBy("station_id", "tap_hour") # 4. 找出每条线路高峰时段 rank_df = route_hour_flow.withColumn( "rn", F.row_number().over( window = F.Window.partitionBy("route_id").orderBy(F.desc("passenger_flow")) ) ) top_peak = rank_df.filter(F.col("rn") == 1) # 5. 结果写入Hive ADS层 top_peak.write.mode("overwrite").insertInto("ads_route_peak_hour")这里有一个特别容易被忽略的细节:countDistinct和count的语义区别。客流人数应该用countDistinct(card_id),因为同一个人在一个小时内可能刷卡多次,不能重复计算。而站点流量统计中,每一次刷卡代表一次站点事件,用count就够。很多源码里直接对刷卡记录做count,计算出来的所谓客流其实是刷卡次数,这两者在答辩时很容易被抓漏洞。
4.3 Spark读写Hive的路径与参数细节
Spark连接Hive时,需要满足三个条件:Spark的conf目录下有hive-site.xml,Spark的jars目录里有MySQL驱动(用于连接Hive元数据库),以及enableHiveSupport()被显式调用。很多同学在本地IDE里运行半天报找不到spark.sql.warehouse.dir,都是因为少了hive-site.xml。
写Hive表时,saveAsTable和insertInto是有本质区别的。saveAsTable会重建目标表,改变表结构;insertInto只追加数据,保留表原有定义。在数仓场景里,表结构由Hive统一管理,Spark作业只负责算数据,所以我全部使用write.mode("overwrite").insertInto()这种方式。如果目标表是全量覆盖,可以先TRUNCATE TABLE再insertInto,避免旧数据残留。
4.4 YARN提交与资源调优
Spark作业写完后,要提交到YARN上运行。我使用的提交命令是这个形态:
spark-submit \ --master yarn \ --deploy-mode cluster \ --driver-memory 2g \ --executor-memory 4g \ --executor-cores 2 \ --num-executors 4 \ --conf spark.sql.shuffle.partitions=200 \ --conf spark.memory.fraction=0.6 \ traffic_analysis_etl.py资源参数的设定不能盲目照搬。如果集群只有一台机器16GB内存,配置4个executor各4GB,再加上NodeManager本身的开销,很可能会因为容器内存超限直接把作业Kill掉。我在单机测试环境里的经验是:executor内存控制在2GB到3GB之间,executor数量不超过2个,配合spark.sql.shuffle.partitions调小到100,可以稳定跑完百万级数据。
如果发现某个任务一直卡在最后的聚合阶段,而且某个Executor的GC时间异常高,大概率是数据倾斜。解决办法有几种:把倾斜的线路编号加随机前缀后分桶聚合,再合并结果;或者对较小的维度表做广播,避免Shuffle。这个问题的排查思路会在后面专门展开。
5. Django后端与可视化大屏:别让Django直连Hive
5.1 为什么Django要读MySQL而不是直接连Hive
这是我在最初设计时考虑过的问题,最后结论很明确:绝对不要让Django直连Hive。原因是Hive的查询延迟太高,在线交互场景下用户根本等不起。Hive定位是批处理引擎,一条简单查询也要经历SQL编译、任务调度、容器申请等过程,秒级响应几乎不可能。而可视化大屏需要频繁刷新数据,每次刷新等十几秒,演示效果会非常差。
更现实的问题是Django的ORM完全不认识Hive,没有现成的数据库后端。虽然可以写原生SQL,通过PyHive连接HiveServer2,但那只是能用,不是好用。所以我最终采用的做法是:把Hive/Spark算好的聚合结果同步到MySQL,Django只做MySQL的读取和接口封装。MySQL里的表结构很简单,都是按日期、线路、站点预先聚合成行记录,一条不带JOIN的SQL就能查出来,大屏接口响应时间控制在200毫秒以内。
下面给出一张方案对比表,方便从工程角度理解这个取舍。
| 方案 | 响应速度 | 开发复杂度 | 适用场景 |
|---|---|---|---|
| Django直连Hive | 秒级以上 | 高,需自研ORM后端 | 离线报表,不适合交互 |
| Django直连Impala | 百毫秒到秒 | 高,依赖Impala服务 | 交互查询但基建复杂 |
| 结果同步MySQL后Django读取 | 百毫秒内 | 低,ORM原生支持 | 大屏和接口,最推荐 |
5.2 用Spark把聚合结果写入MySQL
把结果同步到MySQL,最直接的做法就是在Spark作业的最后阶段,用DataFrame的write.jdbc方法一次性写入。这样省掉Sqoop之类的额外组件,数据管道更短,出错时也好排查。代码如下:
mysql_url = "jdbc:mysql://localhost:3306/traffic_db?useSSL=false&rewriteBatchedStatements=true&serverTimezone=Asia/Shanghai" mysql_properties = { "user": "root", "password": "your_password", "driver": "com.mysql.cj.jdbc.Driver", "batchsize": "1000" } result_df.write.jdbc( url=mysql_url, table="ads_route_flow", mode="overwrite", properties=mysql_properties )这里有几个细节值得注意。rewriteBatchedStatements=true会显著提升大批量写入MySQL的性能,实测写入速度能提高好几倍。mode="overwrite"对表而言是删掉重建,所以在业务上要接受这个语义;如果表里有外键或被其他服务依赖,就要改成先TRUNCATE再append的方式。写入之前还要保证MySQL表字段类型和Spark侧一致,尤其是时间字段,建议统一为DATETIME或VARCHAR(19),避免转换出错。
5.3 Django REST接口设计:大屏要什么,就给什么
Django在这个项目里的任务很简单,就是一个只读的API服务。我先在models.py里定义了对应MySQL结果表的模型,字段与大屏需求一一对应:
class RouteFlow(models.Model): date = models.DateField(db_column="dt") route_id = models.CharField(max_length=16) route_name = models.CharField(max_length=64) hour = models.IntegerField() passenger_flow = models.IntegerField() avg_load_factor = models.FloatField() class Meta: db_table = "ads_route_flow" unique_together = ("date", "route_id", "hour")视图层使用Django的JsonResponse手动构造JSON,未额外引入DRF。因为大屏只需要两类接口:一是某日所有线路的分时客流,二是某线路的站点热度排名。前者用于渲染柱状图和折线图,后者用于渲染地图和列表。视图代码如下所示:
import json from django.http import JsonResponse from .models import RouteFlow def route_flow_api(request): dt = request.GET.get("date", "2024-10-15") rows = RouteFlow.objects.filter(date=dt).values( "route_name", "hour", "passenger_flow" ).order_by("route_name", "hour") data = {} for r in rows: data.setdefault(r["route_name"], []).append({ "hour": r["hour"], "flow": r["passenger_flow"], }) return JsonResponse({"code": 0, "data": data})接口设计只有一个原则:大屏需要什么结构,接口就返回什么结构。不要把所有数据一次性堆给前端,让前端再去做二次聚合,这样会把服务器压力转移到浏览器。接口字段名尽量用驼峰或下划线统一,前后端约定好后别随意改动。
5.4 可视化大屏:布局、图表和定时刷新
大屏是整个项目最直观的成果,也是答辩时最抓眼球的部分。我采用的是经典的1920乘1080分辨率布局:顶部是项目标题和日期选择器,左侧放客流概览和线路排行,中间放站点客流分布或线路热力区域,右侧放高峰时段分布和满载率趋势。这张布局不需要复杂的框架,一个HTML页面配合ECharts就足够。
ECharts的使用核心在于把Django接口返回的数据映射到图表的series。以线路客流柱状图为例,前端请求/api/route-flow/?date=2024-10-15,拿到每个线路全天总客流后,用以下方式渲染:
$.get("/api/route-flow/?date=2024-10-15", function (res) { const routes = Object.keys(res.data); const flows = routes.map(route => { return res.data[route].reduce((s, item) => s + item.flow, 0); }); routeChart.setOption({ xAxis: { data: routes }, series: [{ type: "bar", data: flows }] }); });大屏刷新我用的是setInterval定时轮询,每30秒请求一次接口。不建议用WebSocket自行实现实时推送,课程设计或毕业设计场景里,轮询已经足够,而且实现更简单稳定。有一点要特别注意:页面离开时必须清除定时器,否则页面在后台一直发请求,会白白消耗MySQL连接资源。可以为页面指定一个date参数,演示时切换日期就能看到不同日期的数据变化,比让用户等待实时数据生动得多。
6. 从调试到演示:我踩过的坑和排查链路
6.1 中文乱码:Hive、MySQL、Django三端都要统一字符集
这个项目里中文字符出现在线路名称、站点名称、卡类型等字段上,乱码几乎是每个新手都会撞上的问题。我在调试时遇到的现象是:Hive里用SELECT查询显示正常,但Spark写入MySQL后,Django接口读出来全是问号。排查链路是这样的:
先查MySQL表字符集,发现是默认的latin1,肯定不对。于是把所有表改成utf8mb4,并确认连接串里带了characterEncoding=utf8。再查Spark写入时有没有指定编码,JDBC连接串里加上useUnicode=true&characterEncoding=utf8。最后查Hive建表时的注释字段是否乱码,这个问题出在Hive元数据库连接串上,需要在hive-site.xml里设置:
<property> <name>javax.jdo.option.ConnectionURL</name> <value>jdbc:mysql://localhost:3306/hive_metastore?createDatabaseIfNotExist=true&characterEncoding=utf8</value> </property>Django那边也要在settings.py里设置USE_TZ = False和数据库连接的OPTIONS,让Django明确按UTF-8读写MySQL。三端都统一后,中文显示才正常。这个问题的排查顺序是:前端先看浏览器编码,再看Django响应头,再查MySQL连接和表,最后查Spark写入参数。从下往上逐层排查,能少走不少弯路。
6.2 Spark内存溢出与YARN资源争抢:一次完整排查过程
有一次我在跑全量数据时,作业刚提交不到两分钟,YARN页面就显示Application被杀掉,日志里看不到具体报错,只有一句Container killed by ApplicationMaster。刚开始我还以为是代码问题,反复检查DataFrame逻辑,后来又以为是网络问题,折腾了很久。
后来我打开YARN的ResourceManager日志和容器日志,看到一个关键指标:某个Executor的物理内存使用量持续超过申请值,触发了Container强制回收。这其实是两件事叠加的结果:一是spark.executor.memory申请了4GB,但spark.memory.offHeap.enabled没有关闭,堆外内存占用了额外空间;二是数据倾斜导致某个Executor上堆积了过多的Key,局部数据和计算压力远超其他节点。
修复方案分两步。第一步,在spark-submit里明确设置spark.executor.memoryOverhead=1g,给堆外内存预留空间。第二步,给容易倾斜的线路字段加盐,即先按加盐字段聚合一轮,再去掉盐聚合第二轮,把热点数据分摊到多个分区。加盐后的伪代码如下:
salt_df = df.withColumn("salt", (F.rand() * 10).cast("int")) aggregated = salt_df.groupBy("route_id", "salt", "tap_hour") \ .agg(F.countDistinct("card_id").alias("cnt")) final_result = aggregated.groupBy("route_id", "tap_hour") \ .agg(F.sum("cnt").alias("passenger_flow"))这是我在实际项目里最值钱的一次排错经历。它说明:Spark作业报OOM,不一定真的只是内存配置不够,很可能是数据分布不均,也可能是堆外内存被忽略。排查时要先看Spark UI中每个Executor的Shuffle读写量,数据量差距超过一个数量级,基本就可以确认是倾斜。
6.3 大屏数据不刷新的三处常见原因
大屏页面搭好后,最尴尬的场景是演示时图表一直是空的。我在联调阶段遇到过三次,每一次原因都不同。
第一次是接口直接报跨域错误。Django服务跑在8000端口,大屏页面由另一个简单HTTP服务托管在8080端口,浏览器拦截了跨域请求。解决方法是在Django中间件里加跨域响应头,或者开发阶段直接让Django同时托管页面,省去跨域问题。第二次是MySQL查询速度太慢,接口超时。打开发现前端还是按原始明细表查询,没有走ADS结果表,数据量一上去,单条查询就卡住。解决方案是核对Django模型对应的表名,确保是ADS层聚合表,并在date、route_id字段上建立联合索引。第三次是浏览器控制台报setInterval引用错误,仔细一查是定时器回调里用了未定义的变量,属于低级错误,但排查时容易忽略。
我建议在联调阶段,先用浏览器开发者工具的Network面板确认接口返回了JSON,再确认JSON结构是否和ECharts配置匹配,最后才看图表渲染代码。这个次序能快速定位大部分“图表不显示”的问题。
6.4 项目答辩演示时的建议
最后聊一下怎么把这个项目在答辩现场稳妥地演示出来。很多同学准备的演示数据是全量数据,现场要跑几十分钟的Spark作业,时间根本不够用。我的建议是提前准备一份10万到20万条记录的小数据集,所有聚合结果预先算好并写入MySQL。演示当天打开大屏,直接展示结果,再把其中的核心SQL和Spark代码讲清楚,最后现场跑一个只处理单天数据的小作业,控制在两分钟以内完成,效果会好很多。
还要检查Hadoop集群的关键进程是否都在运行。NameNode、DataNode、ResourceManager、NodeManager、Hive Metastore、MySQL服务,一个都不能少。我列了一个简单的检查清单,演示前两小时按顺序过一遍:
jps查看Java进程,确认NameNode和ResourceManager是否存活。- 访问Hadoop Web UI和YARN Web UI,确认集群不处于SafeMode。
- 用
beeline连接Hive,执行一条简单SELECT,确认Hive Metastore正常。 - 启动Django,用浏览器访问大屏页面,确认接口返回数据。
- 在MySQL里执行一条
SELECT COUNT(*),确认结果表数据量正确。
这套流程走下来,基本能把现场翻车的概率降到最低。我个人在多次演示中最大的体会是:这套系统真正的价值不在于用了多少热门框架,而在于数据从采集到展示的每个环节是否都能讲清楚、能自圆其说。把数据建模口径、清洗规则、Spark调优逻辑这些细节吃透,比所谓“完美运行”的截图要有说服力得多。