最近有不少同学来问我期末或者毕业设计想做一个“大数据分析 可视化大屏”方向的项目选来选去都绕不开hadoop spark django python这个经典组合。我把自己做的一个滴滴出行大数据分析与可视化系统完整复盘一篇从 Hadoop 伪分布式搭建、Spark 数据清洗、Django 后端接口到 ECharts 大屏展示结合源码和文档调试过程把能直接抄作业的步骤、参数和踩过的坑都列出来。如果你正好被这个题目命中或者在找一套能边做边讲清楚原理的大数据项目这篇应该能帮你省不少时间。这个项目的本质不复杂用 HDFS 存放原始订单数据用 Spark 做分布式清洗和聚合统计把统计结果写到 MySQL然后用 Django 提供 JSON 接口最后在浏览器里呈现一个可自动刷新的可视化大屏。整个链路就是离线数仓里最典型的“采集 - 存储 - 计算 - 展示”流程但把每一步都落到工程实现后你已经能清楚讲出 Hadoop、Spark、Django 各自负责什么这也是答辩时最容易被问到的地方。1. 项目整体设计与技术选型1.1 这个滴滴出行分析系统到底做了什么我们先别急着写代码先把题目拆开看。滴滴出行数据分析核心是处理网约车订单数据。一个订单通常包含订单号、城市编号、上车点经纬度、下车点经纬度、开始时间、结束时间、金额、里程、业务类型、司机 ID 等字段。我的做法是拿一批脱敏后的模拟订单也可以理解成公开的网约车订单样例作为数据源规模上做了几万到几十万条目的是让 Spark 的分布式算力“有得发挥”又不至于让你在本地机跑不动。系统要回答的问题很实际一天里面什么时段订单最多哪些城区订单量最大平均客单价是多少凌晨和早晚高峰的车辆利用率有何差异等等。最终通过大屏把这些统计结果展示出来图表包括订单量时段趋势折线图、区域订单分布柱状图、城市热力图、核心指标数字滚动卡片等。用这个题目做课程设计也好毕业设计也好最大的优势是它“麻雀虽小五脏俱全”你会接触到大数据环境安装、分布式文件存储、Spark 统计分析、Web 后端接口、前端 ECharts 可视化每层都是速成项目里最能出效果的部分。1.2 为什么偏偏是 Hadoop Spark Django Python先把技术栈的“为什么”说清楚不然答辩时容易被一句话噎住。Hadoop负责分布式存储。原始 CSV 数据放在 HDFS 上体现的是“大数据先落到分布式文件系统”的常识。哪怕你的实际数据量只有几十万条也要故意走一遍 HDFS让评委看到你理解分布式存储的意义。Spark负责分布式计算。用 PySpark 读取 HDFS 上的数据做数据清洗、去重、过滤、聚合。为什么用 Spark 而不是 Pandas因为 Pandas 是单机内存计算而这个项目的展示重点就是“大数据技术栈”用 Spark 才能把分布式计算落到实际代码里。Django负责 Web 后端和 API 接口。它自带 ORM、Admin、模板系统写起来比 Flask 结构清晰而且很多同学在课程作业里学过 Django上手阻力小。Python把整个链路串起来的胶水语言。数据清洗用 PySpark后端开发也用 Python前后端语言统一维护成本低。可视化大屏本质上是前端展示层用 ECharts 绘制图表。选它不是因为 ECharts 有多高深而是图表类型丰富、文档齐全、效果炫酷特别适合“汇报场景”。可以对照下面这个分层表格看系统结构层次组件作用数据存储层HDFS / MySQL原始订单存 HDFS聚合统计结果存 MySQL数据计算层SparkPySpark分布式清洗、过滤、聚合后端应用层Django提供 REST API读取 MySQL 统计数据前端展示层ECharts HTML/JS大屏布局、图表渲染、定时刷新有人会问为什么实时性不强还要套这么复杂架构其实这里做的是离线批次分析不是实时流处理。你用 Spark 跑一次统计可以只跑几分钟然后 Django 从 MySQL 读取结果展示。如果非要做成实时刷新只需要在大屏上用 setInterval 定时请求后端接口重新拉数据就行这就是本项目的“动态”含义。2. 环境搭建与数据预处理实战2.1 从零搭建 Hadoop 伪分布式与 Spark 环境这个环节是很多人的第一个泥坑。我建议用虚拟机和原生安装方式尽量别用 Docker因为课程设计答辩时老师经常问“你的 Hadoop 是怎么装的”你要是没手工配过会露怯。下面给一条我验证过的通路。前置条件一台 Linux 机器或虚拟机内存至少 4G推荐 8G。装好 JDK 1.8配置环境变量export JAVA_HOME/usr/local/jdk1.8 export PATH$PATH:$JAVA_HOME/bin然后是 Hadoop 伪分布式安装。以 Hadoop 3.3.x 为例把 hadoop 解压到/usr/local/hadoop后修改这些配置文件core-site.xml里指定 NameNode 地址和临时目录property namefs.defaultFS/name valuehdfs://localhost:9000/value /property property namehadoop.tmp.dir/name value/usr/local/hadoop/tmp/value /propertyhdfs-site.xml里设置副本数为 1property namedfs.replication/name value1/value /property然后配置 SSH 免密登录格式化 NameNode再启动ssh-keygen -t rsa -P -f ~/.ssh/id_rsa cat ~/.ssh/id_rsa.pub ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys /usr/local/hadoop/bin/hdfs namenode -format /usr/local/hadoop/sbin/start-dfs.sh /usr/local/hadoop/sbin/start-yarn.sh启动后用jps命令应该能看到 NameNode、DataNode、SecondaryNameNode、ResourceManager、NodeManager 这五个进程。如果缺进程先看/usr/local/hadoop/logs下的日志九成是 JAVA_HOME 没配对或者端口被占用。Spark 的安装相对轻松。下载带 Hadoop 版本的 Spark 包比如spark-3.3.2-bin-hadoop3解压以后配置export SPARK_HOME/usr/local/spark export PATH$PATH:$SPARK_HOME/bin单机学习模式可以直接用local[*]来跑不用启动 Standalone 集群。但为了讲清楚 Spark 集群概念建议也把 Master 和 Worker 启起来$SPARK_HOME/sbin/start-master.sh $SPARK_HOME/sbin/start-worker.sh spark://localhost:7077启动后浏览器访问http://localhost:8080就能看到 Spark 的 Web UI里面会显示 Worker 资源信息这也是答辩时能截图展示的实打实的证据。如果你要做 Hadoop 高可用集群那就得加上 ZooKeeper常规做法是配置 ZK ensemble 和 JournalNode。不过课程设计阶段伪分布式已经够用不要为了凑内容硬上 HA 集群会把整个开发周期拖长一倍。2.2 手把手用 Spark 清洗滴滴出行数据数据清洗是整个项目里最体现“大数据分析”内核的部分。我的数据源是一份 CSV 订单文件字段类似这样order_id, city_id, vehicle_type, start_time, end_time, start_lng, start_lat, dest_lng, dest_lat, price, distance, pay_type先用 PySpark 建一个 SparkSession从 HDFS 上读取 CSVfrom pyspark.sql import SparkSession from pyspark.sql.functions import col, hour, count, sum as _sum, round spark SparkSession.builder \ .appName(DidiOrderAnalysis) \ .master(local[*]) \ .getOrCreate() df spark.read \ .option(header, True) \ .option(inferSchema, True) \ .csv(hdfs://localhost:9000/user/didi/data/didi_orders.csv)读进来以后先看结构和数量df.printSchema() df.show(5) print(total raw rows:, df.count())清洗有几步不能省删除完全重复的行用order_id做dropDuplicates过滤掉经纬度异常的记录比如经度超出 70~140、纬度超出 15~55过滤掉价格为负或者里程为 0 的脏数据把start_time转为时间戳并提取小时字段方便后面按时段聚合示例代码df_clean df.dropDuplicates([order_id]) \ .filter(col(start_lng).between(70, 140)) \ .filter(col(start_lat).between(15, 55)) \ .filter(col(price) 0) \ .filter(col(distance) 0) \ .withColumn(hour_of_day, hour(col(start_time))) df_clean.cache() print(clean rows:, df_clean.count())洗完之后开始做几个核心统计按小时订单量hourly_stat df_clean.groupBy(hour_of_day) \ .agg(count(order_id).alias(order_count), round(_sum(price), 2).alias(total_amount)) \ .orderBy(hour_of_day)按城市 ID 的订单量city_stat df_clean.groupBy(city_id) \ .agg(count(order_id).alias(order_count), round(_sum(price), 2).alias(total_amount)) \ .orderBy(col(order_count).desc())把结果写回 MySQL后面 Django 直接查表city_stat.write.mode(overwrite) \ .format(jdbc) \ .option(url, jdbc:mysql://localhost:3306/didi_db?useUnicodetruecharacterEncodingutf8) \ .option(dbtable, city_order_stat) \ .option(user, root) \ .option(password, 123456) \ .option(driver, com.mysql.cj.jdbc.Driver) \ .save()写 MySQL 前一定要先把对应的表建好或者用 Spark 自动建表。我这里更推荐先在 MySQL 里手动建表指定好字段类型和字符集不然经常出现中文乱码或者字段类型长得太随意的情况。3. Django 后端与接口开发3.1 Django 项目初始化和数据模型设计后端这块我用 Django 4.x Python 3.9数据库直接连 MySQL。先用虚拟环境把 Django 装好python -m venv venv source venv/bin/activate pip install django pymysql cryptography创建项目和应用django-admin startproject didi_web cd didi_web python manage.py startapp api在settings.py里注册api应用并配置 MySQLINSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, api, ] DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: didi_db, USER: root, PASSWORD: 123456, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, }, } }别忘了在api/__init__.py或项目配置里让 PyMySQL 冒充 MySQLdbimport pymysql pymysql.install_as_MySQLdb()数据模型和 Spark 写出来的统计表一一对应。我在api/models.py里写了两个模型类一个按小时统计一个按城市统计from django.db import models class OrderHourStat(models.Model): hour_of_day models.IntegerField(verbose_name小时) order_count models.BigIntegerField(verbose_name订单数) total_amount models.FloatField(verbose_name总金额) class Meta: db_table hour_order_stat verbose_name 小时订单统计 class CityOrderStat(models.Model): city_id models.IntegerField(verbose_name城市ID) order_count models.BigIntegerField(verbose_name订单数) total_amount models.FloatField(verbose_name总金额) class Meta: db_table city_order_stat verbose_name 城市订单统计这里有个小技巧db_table直接指定成 Spark 写入 MySQL 时用的那张表名Django 就不需要再用 migrate 生成新表只要确保 MySQL 里表已经存在Django 直接读就行。这种方式适合“数据由 Spark 计算Web 只做展示”的项目结构。3.2 编写查询接口并避开 ORM 的删除坑接口设计要围绕大屏需要的数据来我做了三个主要接口/api/summary/返回总订单量、总金额、平均客单价、覆盖城市数/api/trend/返回 0~23 时订单量和金额趋势/api/city/返回各城市订单量和金额排名在api/views.py里写一个通用查询函数from django.http import JsonResponse from django.db.models import Sum, Count from .models import OrderHourStat, CityOrderStat def trend(request): rows list(OrderHourStat.objects.all().order_by(hour_of_day)) data [ { hour: r.hour_of_day, order_count: r.order_count, total_amount: r.total_amount, } for r in rows ] return JsonResponse({data: data}) def city(request): rows list(CityOrderStat.objects.all().order_by(-order_count)[:20]) data [ { city_id: r.city_id, order_count: r.order_count, total_amount: r.total_amount, } for r in rows ] return JsonResponse({data: data})api/urls.py里配置路由from django.urls import path from . import views urlpatterns [ path(summary/, views.summary), path(trend/, views.trend), path(city/, views.city), ]这里特别提醒一个 Django ORM 的坑也是很多新手翻车的地方查询并删除对象时不要无条件使用filter().delete()。比如你想删掉某个城市 ID 的统计记录操作前一定要确认过滤条件# 危险写法 CityOrderStat.objects.filter(city_id100).delete() # 安全写法 rows CityOrderStat.objects.filter(city_id100) print(rows.count()) # 先看数量是不是预期值 if rows.exists(): rows.delete()如果删除的是单条记录用city_stat CityOrderStat.objects.get(pk1)但要注意get()查不到对象会抛 DoesNotExist更稳妥的是first()city_stat CityOrderStat.objects.filter(pk1).first() if city_stat: city_stat.delete()之所以强调这个坑是因为删除操作一旦条件写宽会把整张表的数据抹掉。我在调试时手滑过一次五分钟的前端页面数据全空了最后只能重新跑 Spark 任务白等十几分钟。如果你要更实时一点的效果可以往 Django Channels WebSocket 方向扩展后端有数据变化时主动推送前端。但我个人建议项目初期用前端轮询就够了一个setInterval30 秒请求一次接口简单稳定演示也够用。4. 可视化大屏设计与实现4.1 大屏布局与核心图表选择大屏我采用 1920×1080 的深色底版背景用深蓝渐变避免花哨突出数据本身。整体分成五个区域顶部居中系统标题“滴滴出行大数据分析平台”顶部左侧核心指标卡片总订单量、总成交金额、平均客单价、订单量环比顶部右侧时段高峰提醒或指标排名中间主体城市订单热力地图配合全国地图散点底部左侧24 小时订单趋势折线图底部右侧城市订单量 TOP10 柱状图前端不用框架直接一个 HTML 页面引入 ECharts用 CSS grid 或 flex 做栅格布局。为了响应式我给 root 容器设置了百分比宽度在大屏和普通屏幕上都能自动撑开。核心部分是用 ECharts 初始化图表。比如底部左侧的折线图var trendChart echarts.init(document.getElementById(trendChart)); function loadTrend() { fetch(/api/trend/) .then(response response.json()) .then(json { var data json.data; trendChart.setOption({ tooltip: { trigger: axis }, grid: { left: 10%, right: 10%, top: 30%, bottom: 20% }, xAxis: { type: category, data: data.map(d d.hour 时) }, yAxis: { type: value, name: 订单量 }, series: [{ name: 订单量, type: line, smooth: true, areaStyle: { opacity: 0.3 }, data: data.map(d d.order_count) }] }); }); }底部右侧柱状图同理注意加载时如果数据为空要先chart.clear()否则旧图形会闪烁。4.2 大屏数据刷新与地图热力联动可视化大屏不能做一次请求就完事要模拟“动态监控”效果。我用两个setInterval分别刷新指标和图表function loadAll() { loadSummary(); loadTrend(); loadCity(); } setInterval(loadAll, 30000); loadAll();还要处理窗口尺寸变化window.addEventListener(resize, function () { if (trendChart) trendChart.resize(); if (cityChart) cityChart.resize(); if (mapChart) mapChart.resize(); });地图热力图部分需要用到 ECharts 的地图组件。城市 ID 映射到具体的城市名时我在后端做了一层解码把 city_id 替换为中文名称。如果没有地理坐标数据也可以用最简单的散点图按经纬度标记订单热点只需要把数据格式转成[lng, lat, value]数组。var mapChart echarts.init(document.getElementById(mapChart)); function loadMap() { fetch(/api/heatmap/) .then(response response.json()) .then(json { mapChart.setOption({ geo: { map: china, roam: true, itemStyle: { areaColor: #0f1b35, borderColor: #3f6fd1 } }, series: [{ type: scatter, coordinateSystem: geo, data: json.data.map(d [d.lng, d.lat, d.value]), symbolSize: function (val) { return Math.min(30, val / 100); }, itemStyle: { color: #ffd24d, shadowBlur: 10, shadowColor: #ffd24d } }] }); }); }如果你用的城市名字分散在全国各地需要确保后端返回的 map 数据里包含name, value字段ECharts map 组件会自动匹配 geo 坐标。整个大屏做下来最花时间的并不是图表配置而是数据格式的对接。Django 返回 JSON 的字段名JavaScript 里怎么取必须在写接口前就定清楚。我建议在后端直接统一返回{data: [...]}这样的结构前端 JS 只用取一次json.data后面不管图表多复杂都不会乱。5. 常见问题与踩坑记录5.1 Hadoop 和 Spark 环境里最容易翻的五个车Namenode 格式化后重复启动报错不要反复执行hdfs namenode -format。每次格式化会生成新的 clusterId和 DataNode 不一致时DataNode 就起不来。正确做法是第一次格式化后如果后续要重新开始删除 Hadoop 临时目录/usr/local/hadoop/tmp再格式化或者直接改 hdfs-site.xml 里的dfs.namenode.name.dir和dfs.datanode.data.dir。Spark 内存不足如果你的数据量稍微大一点默认 1G 内存很容易 OOM。启动时加参数spark-submit --driver-memory 2g --executor-memory 2g your_script.py如果只是本地调试直接在 SparkSession 里配置spark SparkSession.builder \ .appName(DidiOrderAnalysis) \ .master(local[*]) \ .config(spark.driver.memory, 2g) \ .getOrCreate()Hadoop Web UI 访问不了 50070/9870先检查防火墙再确认core-site.xml里fs.defaultFS用的是 localhost 还是局域网 IP。考试机器上如果端口没起来八成是 ssh 免密配置有问题用ssh localhost测一下。HDFS 文件名乱码或中文路径问题HDFS 支持中文路径但很多终端编码不统一导致显示乱码建议所有目录名用英文别拿中文给文件起名。如果做 Hadoop 高可用与 ZooKeeper 整合最佳实践是三台节点都装 ZooKeeper先启动 ZK再启动 JournalNode然后格式化 NameNode。很多教程让你只配一台 ZK启动后 ZK 单点无意义还容易让你误以为 HA 成功。课程设计不做 HA 就不用碰这些避免浪费时间。5.2 Django 和前端静态资源显示不出来的终极解决方案很多人在 VSCode 里写 HTML 图片标签时明明放了图片Django 就是显示不了。先说原因Django 开发环境对静态文件有特殊处理不能直接img src/static/img/logo.png必须用{% static %}模板标签。在settings.py里配置STATIC_URL /static/ STATICFILES_DIRS [ BASE_DIR / static, ]然后在模板里{% load static %} img src{% static img/logo.png %} altlogo还需要确认static目录下有img子目录。调试时可以先打开浏览器的 Network 面板看图片请求返回的是 404 还是 200。404 一般就是路径问题200 但图片显示不了可能是缓存CtrlF5 强制刷新即可。另外Django 的 Debug 模式下如果 URL 路由写成了path(, views.index)模板能正常加载但如果你用了django.contrib.staticfiles还需要在urls.py最后加from django.conf import settings from django.conf.urls.static import static urlpatterns static(settings.STATIC_URL, document_rootsettings.STATICFILES_DIRS[0])5.3 统计指标对不上或查询极慢怎么办如果你发现大屏上的折线图数据和自己用 Excel 算出来的不一样先从源头排查。我的习惯是Spark 每做一步清洗都用count()打印一次行数聚合结果先show(10)查看再写入 MySQL。宁可多打几次日志也不要等到大屏挂了再回来找。查询慢的问题大多是 Django ORM 对统计表查询时没用索引。比如OrderHourStat按小时查询给字段加索引很有效class OrderHourStat(models.Model): hour_of_day models.IntegerField(db_indexTrue, verbose_name小时)如果是几十万行数据走 SQL 直接EXPLAIN看看扫描行数通常加一个普通索引就够了。还有一个小问题Spark 的时间戳和 Django 的时区不一致容易导致按小时统计偏 8 小时。解决方法是清洗时统一设置时区spark.conf.set(spark.sql.session.timeZone, Asia/Shanghai)别看这个配置不起眼我一开始没设置统计出来的白天高峰期全变成了凌晨最后核对半天才发现是 UTC 和北京时间差了一个时区。最后说一个我自己的体会这种大而全的项目最忌讳一上来就想要“完美架构”。很多人卡在 Hadoop 环境三四个星期还没跑到 Django心态就崩了。我的建议是先把后端用本地 CSV 跑通再用 Flask 或者 Django 做一版单机演示最后才接 HDFS 和 Spark。环境装上之后数据流越顺你的逻辑才越清楚。等这套流程跑完你会发现“Hadoop 负责存、Spark 负责算、Django 负责给数据、大屏负责好看”这句话比任何教程都刻在脑子里。