
每年九月初招生办和教务处的老师手里都压着一批新生数据。学号、姓名、性别、出生日期、生源地、高考成绩、录取专业——这些数据分散在招生系统、学籍系统、宿舍系统里格式五花八门有的导出是Excel有的得从接口拉有的甚至还在纸质表里。这正是我这次做hadoopSparkdjango高校新生数据可视化分析系统的初衷把分散、杂乱的新生数据收拢起来用Hadoop存、用Spark算、用Django出接口、用ECharts做可视化大屏让招生办老师打开大屏就能看清今年新生性别比例如何、生源从哪里来、录取分数集中在哪里。这套东西做完之后源码、文档、调试记录、可视化大屏四样都齐了。如果你正在做大数据课程设计或者刚学完Hadoop和Spark想找一个能把全套流程串起来的项目练手这套系统的设计思路和踩坑过程应该能给你省下不少时间。我会把选型逻辑、环境搭建、数据清洗、Spark统计、Django接口、大屏对接以及调试过程中遇到的实际问题全部摊开来讲。1. 为什么新生数据要用大数据这套东西来玩1.1 单看一年数据Excel其实够用先说句实在话如果只是统计一届几千个新生的性别、省份、专业分布一个Excel透视表十分钟就搞定了完全没有必要上Hadoop和Spark。但实际场景没这么简单。第一数据源从来不是一个文件。招生办导出的新生名单是一个CSV学籍系统拉过来的是JSON接口宿舍分配表又是一份Excel再加上历年数据的累积三年、五年的数据堆在一起单机Excel打开都卡。第二这类课程设计或者毕业设计往往要求你完整展示大数据处理链路分布式存储和分布式计算是必须出现的考核点。第三也是最重要的一点新生数据只是整个校园大数据的一小块入口后面还要接在校成绩、图书借阅、一卡通消费、门禁记录数据量会从几万条涨到几亿条那时候才是HDFS和Spark真正发挥作用的场景。所以这个项目的定位很明确数据规模不算大但处理链路是完整的、真实的大数据链路。别小看这个定位很多同学项目做完只会点运行问他数据在哪些环节流转、每个组件为什么存在完全答不上来。面试官最烦的就是这种。1.2 HDFS、Spark、Django各自的活这个系统里三个核心组件的分工极其清晰组件职责类比Hadoop HDFS分布式存储原始数据与清洗后的数据仓库Spark分布式计算负责清洗与聚合统计加工车间Django提供REST接口业务查询与权限控制前台的接待员MySQL Redis存聚合结果、做接口缓存方便翻看的账本ECharts大屏图表渲染汇报大厅的展示屏这里有一条很容易踩的边界不要让Django直接去读HDFS上的文件也不要让Spark去承担Web层的实时查询。Django读HDFS非常别扭Spark每次跑Job又有调度开销两者都不适合做高频的页面查询。我的做法是让Spark把最终聚合结果写回MySQLDjango只查MySQL接口响应基本都在几十毫秒以内。数据量再大一些可以在中间加一层Redis缓存或者把聚合结果落到ClickHouse这类OLAP引擎里思路是一样的计算层和展示层解耦。2. 环境搭建的取舍伪分布式还是真集群Local模式还是Yarn2.1 Hadoop伪分布式的搭建顺序这个项目我是在Ubuntu 20.04上跑的JDK用的8u202Hadoop选择3.3.x版本。不是说Hadoop 2.x不行而是3.x解决了大量老版本的NameNode性能问题而且从课程设计到生产项目的迁移路径更平滑。伪分布式的搭建顺序我整理成了固定的几步创建hadoop用户配置SSH免密登录ssh-keygen -t rsa然后把公钥写到authorized_keys里。安装JDK在~/.bashrc里设置JAVA_HOME和PATH。解压Hadoop到指定目录在hadoop-env.sh里显式指定JAVA_HOME。配置core-site.xmlfs.defaultFS设为hdfs://localhost:9000hadoop.tmp.dir指向一个独立的临时目录。配置hdfs-site.xml把dfs.replication设为1dfs.namenode.name.dir和dfs.datanode.data.dir分别指定。执行hdfs namenode -format初始化NameNode。执行start-dfs.sh和start-yarn.sh启动HDFS与Yarn。用jps检查节点进程正常情况下可以看到NameNode、DataNode、SecondaryNameNode、ResourceManager、NodeManager五个进程。很多人在伪分布式上栽跟头原因就两个一是/etc/hosts里hostname解析不对NameNode起不来二是复制因子还是默认的3单节点反复写入会把磁盘塞满。伪分布式一定要记得把replication改成1这是和真集群最不一样的地方。如果你做的是更正式一点的集群环境那就得考虑HA了。NameNode要做主备切换依赖Zookeeper来协调选主JournalNode负责同步edit log这套配置比伪分布式复杂一个量级但原理其实就一句话让两个NameNode共享同一份命名空间Zookeeper在Active挂掉之后把Standby扶正。这个项目里我不建议一上来就搞HA先把单点跑通再去看hadoop和zookeeper整合的官方文档会容易得多。2.2 Spark的运行模式与内存配置Spark的部署方式我没有做成集群而是直接在伪分布式的Hadoop基础上用Yarn模式提交。原因很简单Spark Standalone还要再维护一套Master/Worker和项目里的HDFS割裂而Yarn本来就是Hadoop自带的资源调度器把Spark应用提交到Yarn上Spark的executor由Yarn分配整条链路的资源管理是统一的。开发阶段为了快速调试我在IDE或者Notebook里直接用local[*]模式跑SparkSession逻辑调通之后再用spark-submit提交。切换方式就是master参数# 本地调试 spark.masterlocal[*] # 提交到Yarn spark-submit --master yarn --deploy-mode client \ --driver-memory 1g --executor-memory 1g \ --num-executors 2 \ --jars /opt/mysql-connector-j-8.0.33.jar \ analytics.py内存配置这里要单独强调。spark.executor.memory和spark.driver.memory不是越大越好在课程设计的单机环境里给executor 1g、driver 1g通常就够了。真正容易出问题的是shuffle那部分的配置Spark默认spark.sql.shuffle.partitions是200如果你的数据只有几千行200个分区意味着绝大部分task是空跑执行计划开销反而大。我习惯在创建SparkSession时显式把分区数压小比如20耗时立刻降下来。还有PySpark的版本匹配问题。我一开始用的Python 3.10配PySpark 3.2跑起来各种莫名其妙的方法找不到后来查官方文档才发现3.2对Python 3.10的支持还不完整换成Python 3.8或者升级PySpark到3.3才稳定。这类版本兼容问题报错信息往往很不直观遇到先检查版本组合是个好习惯。2.3 Django侧的环境准备Django这边的准备就常规多了。我习惯用虚拟环境隔离依赖python3 -m venv venv source venv/bin/activate pip install django djangorestframework django-cors-headers pymysql redis django-admin startproject freshman_web cd freshman_web python manage.py startapp api数据库直接用MySQL建库的时候注意字符集。新生数据里全是中文如果建表默认用了latin1后面Django写入和查询都会出现问号所以建库语句我固定这么写CREATE DATABASE freshman CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;Django的DATABASES配置里把默认引擎指向MySQL用pymysql做驱动在项目的__init__.py里加一行pymysql.install_as_MySQLdb()这个就是Django连MySQL的标准姿势。整个项目的源码我按四块组织hadoop_config放环境配置文件spark_jobs放清洗和分析任务freshman_web是Django后端frontend_dashboard是大屏前端配套的文档目录里放环境安装文档、数据字典、接口说明和部署说明调试日志单独放在debug_logs里。这样结构清晰别人接手项目时不至于摸不着头脑。3. 数据从哪来、怎么洗、怎么存3.1 数据字典与多源合并这个系统处理的新生数据我按照高校招生的常见字段设计了一张宽表字段名类型说明示例student_idstring学号全局唯一20240901001namestring姓名张伟genderstring性别男 / 女birth_datestring出生日期2006-03-15origin_provincestring生源地省份河南gaokao_scoreint高考总分623majorstring录取专业计算机科学与技术high_schoolstring毕业中学郑州一中ethnicstring民族汉族first_choicestring是否第一志愿录取是 / 否真实数据基本来自三个地方招生办导出的CSV注意Windows下Excel导出的CSV默认是GBK编码还带BOM头学籍系统的JSON接口以及宿舍分配用的Excel。第一步不是直接写Spark而是先用Python脚本或者DataFrame把这些数据源扫一遍确认每个文件的字段名和字段顺序再决定怎么合并。学籍系统那边用JSON接口给的Spark侧用spark.read.json直接读就行字段名对得上就完事。这一步很多人忽略导致后面Spark读出来对不上列名排查起来非常痛苦。3.2 清洗规则编码、日期、去重清洗环节我定了六条规则每条都是在处理真实数据时被逼出来的编码统一所有源文件转成UTF-8CSV读取时显式指定encodingutf-8。日期统一birth_date全部转成yyyy-MM-dd非法日期直接标为null并记入异常清单。省份标准化把新疆维吾尔自治区这样的全称映射成新疆把广西壮族自治区映射成广西保证统计口径一致。年龄计算以2024年9月1日为统一基准日计算入学时的周岁年龄。去重以student_id为准保留更新时间最新的一条记录重复学号写入重复记录日志。缺失值处理student_id和name缺失的直接剔除gaokao_score缺失的保留但不参与分数统计避免把0分当成真实成绩算进平均分。这六条规则用Spark写起来不复杂核心就是DataFrame的transform链from pyspark.sql import SparkSession from pyspark.sql.functions import col, to_date, datediff, lit spark SparkSession.builder \ .appName(freshman_clean) \ .config(spark.sql.shuffle.partitions, 10) \ .getOrCreate() raw spark.read.csv(hdfs://localhost:9000/user/freshman/raw/year2024/, headerTrue, encodinggbk) clean raw \ .withColumn(birth_date, to_date(col(birth_date), yyyy-MM-dd)) \ .withColumn(age, (datediff(lit(2024-09-01), col(birth_date)) / 365).cast(int)) \ .dropDuplicates([student_id]) \ .filter(col(student_id).isNotNull() col(name).isNotNull()) clean.write.mode(overwrite) \ .option(encoding, utf-8) \ .csv(hdfs://localhost:9000/user/freshman/clean/year2024/)注意这里读取raw用的是gbk因为源文件是招生办那边Windows导出的清洗之后统一写成utf-8。如果你拿到的是正经的UTF-8文件把encoding改成utf-8就行。age的计算用了除以365的简化方式严格一点可以用months_between除以12但对于课程设计统计年龄分布来说够用了。3.3 HDFS目录分层设计HDFS上的目录不是随便建的我按raw / clean / result三层来分/user/freshman/raw/year2024/原始数据保留现场不修改。/user/freshman/clean/year2024/清洗后的宽表分析任务的输入。/user/freshman/result/Spark聚合结果最终回写MySQL的来源。按年份分区的原因很直接后面做历年新生对比时只需要扫描对应年份的分区不用全量读。这也是Hive和Spark SQL里最常见的分区策略养成习惯对以后做数仓很有帮助。如果你要考虑跨集群备份或者数据迁移可以顺便了解hadoop distcp命令它本质上就是MR任务支持增量同步和带宽限制生产环境迁数据基本都靠它但这个项目用不上知道有这么个东西就行。4. Spark统计口径与核心分析指标4.1 指标怎么定先想清楚给谁看写Spark代码之前我先花了两天时间把指标口径和展示方式列成一张表。这一步的价值被很多人低估——技术再强统计口径错了大屏上展示的东西就是错的这种错是最难救的。分析维度统计口径展示形式性别比例男/女人数及占比环形饼图年龄结构按出生年份或年龄分组统计人数柱状图生源地分布按省份统计人数取Top10柱状图 / 中国地图专业分布各专业人数及录取分数极值柱状图 / 表格高中来源按毕业中学统计人数取Top20横向柱状图录取分数最高分、最低分、平均分数字卡片 分段分布民族构成各民族人数饼图这里面有几个口径容易踩坑比如年龄有人按自然年算有人按周岁算。高校新生的标准算法是截至入学当年9月1日已满周岁所以基准日固定为9月1日不能拿系统当前时间算否则每年看的岁数都在变。再比如平均分千万不能用含缺失值的列直接avgSpark的avg会忽略null如果缺失值被填成0平均值直接被拉低一两百分这种错误在可视化大屏上一眼看不出来但一核对源数据就穿帮。4.2 DataFrame Spark SQL的组合写法分析代码我是DataFrame API和Spark SQL混着用的。复杂联合逻辑用DataFrame API更清晰简单分组查询直接写SQL更省事。这里把几个核心指标拆开说一下。from pyspark.sql import SparkSession spark SparkSession.builder \ .appName(freshman_analytics) \ .config(spark.sql.shuffle.partitions, 20) \ .getOrCreate() clean_df spark.read.csv( hdfs://localhost:9000/user/freshman/clean/year2024/, headerTrue, encodingutf-8, inferSchemaTrue )性别比例gender_result clean_df.groupBy(gender).count() \ .withColumnRenamed(count, cnt) \ .orderBy(gender) gender_result.show() # --------- # |gender|cnt| # --------- # | 男 | 456| # | 女 | 544| # ---------省份Top10直接上Spark SQLclean_df.createOrReplaceTempView(freshman) province_top10 spark.sql( SELECT origin_province AS province, COUNT(*) AS cnt FROM freshman GROUP BY origin_province ORDER BY cnt DESC LIMIT 10 )录取分数统计就是上面那个坑的对应代码score_stats spark.sql( SELECT major, COUNT(gaokao_score) AS sample_cnt, MIN(gaokao_score) AS min_score, MAX(gaokao_score) AS max_score, ROUND(AVG(gaokao_score), 1) AS avg_score FROM freshman GROUP BY major )count(gaokao_score)只统计非空值这样sample_cnt就能当有效样本量用避免被缺失值误导。把这些结果合并成一份JSON或者直接回写前端大屏就有数据源了。4.3 聚合结果怎么落库Spark算出来的结果规模很小性别就两行、省份最多三十多行、专业也就几十行完全没必要留在HDFS上给Django查。我直接用DataFrame的jdbc写入MySQLgender_result.write.format(jdbc) \ .option(url, jdbc:mysql://localhost:3306/freshman?useUnicodetruecharacterEncodingutf8) \ .option(driver, com.mysql.cj.jdbc.Driver) \ .option(dbtable, summary_gender) \ .option(user, root) \ .option(password, your_password) \ .mode(overwrite) \ .save()mode(overwrite)意味着每次重算直接覆盖旧表这对新生数据这种算一次基本不变的场景是合适的。如果是每天都在更新的数据建议改成分区覆盖或者先删后插避免出现重复统计。另外记得在spark-submit时通过--jars带上MySQL驱动jar包否则会报ClassNotFound这一步很容易漏。5. Django后端与可视化大屏的对接细节5.1 接口设计聚合结果表直接查Spark把数据写进MySQL之后Django这边的活就轻了。我在Django里建了对应的模型比如summary_gender、summary_age、summary_province、summary_major然后用Django REST Framework写只读接口。接口设计非常直白接口路径返回内容/api/overview/总人数、男女比、平均分、专业数等核心数字/api/gender/性别分布/api/age/年龄分布/api/province/?top10生源地TopN/api/major/专业人数分布/api/score/各专业分数统计/api/school/毕业高中Top20/api/ethnic/民族构成写接口的代码非常简单# api/views.py from rest_framework.decorators import api_view from rest_framework.response import Response from .models import SummaryGender api_view([GET]) def gender_view(request): rows SummaryGender.objects.values(gender, cnt) return Response(list(rows))这里有个设计原则Django永远不做实时聚合。数据清洗、聚合统计全部交给SparkDjango只查已经算好的结果。道理很简单Spark任务的调度启动要好几秒而Web接口要求毫秒级响应实时聚合等于每次刷新页面都触发一次分布式计算谁也扛不住。5.2 大屏布局与ECharts渲染方案可视化大屏我按1920x1080设计整体是深色科技风。布局分成三部分顶部一条大标题和核心数字卡片左侧放性别环形图、年龄柱状图、民族饼图中间放中国地图加悬浮的省份排名右侧放生源地Top10、专业分布和高中Top20。ECharts这块我用的V5版本项目不复杂不需要封装统一的图表组件直接在HTML里初始化就行// 拉取性别接口并渲染环形图 fetch(/api/gender/) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(genderChart)); chart.setOption({ tooltip: { trigger: item }, series: [{ type: pie, radius: [40%, 70%], data: data.map(item ({ name: item.gender, value: item.cnt })) }] }); });地图用中国地图的GeoJSON注册到ECharts里visualMap把省份人数映射成颜色深浅配合tooltip可以做到鼠标悬停显示某个省的人数。省名和Spark清洗后的河南这种简称保持一致否则地图匹配不上这个在联调时最容易踩。大屏适配我用的方案是固定设计稿尺寸渲染然后用CSS transform的scale做整体缩放。优点是布局不用考虑响应式缺点是缩放后dom实际尺寸不变点击事件偏移的问题需要处理。课程设计级别的项目用这个方案足够了不用上rem或者postcss-px-to-viewport那套复杂方案。5.3 大屏性能与缓存别把接口打崩大屏是面向领导展示的刷新频繁而且通常挂在固定的大电视上。为了不让每次刷新都去查MySQL我给聚合接口加了Redis缓存缓存时间设为600秒。第一次请求查库之后的请求直接读缓存。Django这边可以用cache_page装饰器from django.views.decorators.cache import cache_page api_view([GET]) cache_page(60 * 10) def overview_view(request): ...如果大屏同时展示7、8个图表前端一次性发七八个请求即使加了缓存也会有首屏慢的问题。更好的做法是提供一个批量接口 /api/dashboard/all把大屏需要的所有数据打包成一份JSON返回前端只请求一次首屏渲染会快很多。这个优化我放在后期做的效果立竿见影首屏时间从原来4-5秒降到1秒以内。另外如果以后要把这个系统开放给不同学院用就得考虑行级和列级的数据权限学院A只能看到本院新生的统计教务管理员能看到全校。开源的方案里Hue、Superset这类工具自带行列权限设计Django层面也可以用中间件按用户角色过滤接口数据。这个属于进阶需求当前项目里我只留了接口预留参数没做完整实现。6. 调试实录跑通整套流程遇到的那些坑6.1 HDFS的坑二次格式化与安全模式第一次跑通流程后我因为改了hadoop.tmp.dir想重新初始化一下直接把data目录删了重新hdfs namenode -format结果DataNode启动报错日志里关键的提示是Incompatible namespaceIDs。原因很好理解格式化只清了NameNode的命名空间DataNode自己的current目录里还留着旧的namespaceID两边对不上。排查过程是逐步推进的先看jps发现DataNode进程确实是起来的再看logs/hadoop-hadoop-datanode-xxx.log看到namespaceID不兼容的报错然后定位到是data目录残留导致的。解决办法是把NameNode和DataNode的current目录都删掉重新format再启动就正常了。这种排查思路比答案本身更重要——先确认进程在不在再看日志关键词再分析原因最后动手清数据。还有一个高频问题是NameNode进入SafeMode。症状是上传文件时报Cannot create directory Name node is in safe mode原因是块报告数量不够或者刚启动还没上报完。我一般是先看50070端口的管理页面确认块数量然后hdfs dfsadmin -safemode leave如果经常卡在safe mode就要检查datanode是不是没起来、磁盘是不是满了。伪分布式下磁盘满最常见的原因是replication没改成1一块数据写三份很快就满。6.2 Spark执行层的坑分区数、中文乱码与数据倾斜Spark这块我踩过三个坑每一个都有代表性。第一个是shuffle分区数。我用默认配置跑过一次全量统计数据本身只有几千行但是Spark默认spark.sql.shuffle.partitions200执行计划里生成了200个分区大部分分区是空的任务调度开销占了总耗时的一大半。后来在SparkSession.builder里显式设置config(spark.sql.shuffle.partitions, 20)耗时立刻降了60%以上。这个参数是Spark调优里最容易被忽视的记住一个原则分区数大致等于数据量除以单分区处理能力小数据集就用小分区数。第二个是中文乱码。第一次清洗完MySQL里存进去的全是问号一开始以为是连接串没配characterEncoding查了之后发现源头在Spark读取CSV时没指定encoding。原始文件是GBKSpark默认按UTF-8读中文全变乱码。改成option(encoding, gbk)之后问题解决。这个坑特别容易出现在Windows导出的Excel数据上。第三个是数据倾斜严格说这个项目的数据量碰不到明显的倾斜但组队跑更大数据集时遇到过按省份统计有的省考生人数是其他省的好几倍导致某个task处理时间特别长整个Job被拖慢。解法是两阶段聚合先加盐打散再按真实key聚合或者把热点key拆出来单独处理。这块建议理解原理即可真正练手还是得靠大一点的数据集。6.3 Django与前端联调的坑跨域、时间序列化和并发Django后端跑在8000端口大屏页面如果放到另一个端口或者纯静态服务里第一个碰到的问题就是跨域。我直接配置了django-cors-headers在settings里加上CORS_ALLOWED_ORIGINS列表前端fetch就正常了。如果不加这个库自己写中间件处理OPTIONS预检请求也能实现但没必要重复造轮子。第二个坑是datetime字段的序列化。Django ORM查出来的时间字段是datetime对象直接丢给Response会报TypeError。解决办法简单粗暴在接口里先格式化或者用Django REST Framework的Serializer搭配DateTimeField它内置了JSON序列化。不要自己写一个手动的str()转换容易漏。第三个是联调阶段的接口并发问题。大屏页面挂起来之后每10秒刷新一次数据如果缓存没做好MySQL的连接数会被打满。我最初没加Redis缓存测试时7个图表同时刷新Django的数据库连接池直接爆掉页面开始报500。后来把所有大屏接口都套了cache_page又把7个接口合成了/dashboard/all这一个问题才彻底解决。最后再说点实际操作中的体会。这套系统做完表面上是把Hadoop、Spark、Django、ECharts串成了一条流水线但真正值钱的是你对这条流水线每一层的理解HDFS为什么要这样分目录Spark的分区参数为什么这么配Django为什么只做查询不做聚合。面试官问大数据项目时他关心的不是你会不会点运行而是你能不能讲清楚数据从源头到展示大屏经过了哪几步、每一步做了什么、出了问题怎么排查。建议你把这个项目重跑三遍第一遍照着文档跑通第二遍关掉文档凭记忆跑第三遍故意制造一个故障比如删掉DataNode的current目录再自己修好。三遍下来这套系统的每一个环节才真正是你的。后面再扩展的话可以在这个骨架上接入在校成绩、一卡通消费、图书馆借阅数据做成校园大数据平台思路完全一致只是数据规模和指标维度翻了几倍而已。大数据学习路线走到这里存储、计算、调度、后端、可视化这条主链路算是真正打通了。